compiling project documentation
This commit is contained in:
@@ -1,512 +0,0 @@
|
|||||||
# Implementation Roadmap
|
|
||||||
## Multi-Tenant Ecommerce Platform - Complete Development Guide
|
|
||||||
|
|
||||||
**Last Updated**: October 11, 2025
|
|
||||||
**Project Status**: Slice 1 In Progress (~75% complete)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Documentation Structure
|
|
||||||
|
|
||||||
Your complete vertical slice documentation is organized as follows:
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/slices/
|
|
||||||
├── 00_slices_overview.md ← Start here for overview
|
|
||||||
├── 00_implementation_roadmap.md ← This file - your guide
|
|
||||||
├── 01_slice1_admin_vendor_foundation.md
|
|
||||||
├── 02_slice2_marketplace_import.md
|
|
||||||
├── 03_slice3_product_catalog.md
|
|
||||||
├── 04_slice4_customer_shopping.md
|
|
||||||
└── 05_slice5_order_processing.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Quick Start Guide
|
|
||||||
|
|
||||||
### For Current Development (Slice 1)
|
|
||||||
1. ✅ Read `01_slice1_admin_vendor_foundation.md`
|
|
||||||
2. ✅ Review what's marked as complete vs. in-progress
|
|
||||||
3. ⏳ Focus on vendor login and dashboard pages
|
|
||||||
4. ⏳ Complete testing checklist
|
|
||||||
5. ⏳ Deploy to staging
|
|
||||||
|
|
||||||
### For Future Slices
|
|
||||||
1. Complete current slice 100%
|
|
||||||
2. Read next slice documentation
|
|
||||||
3. Set up backend (models, schemas, services, APIs)
|
|
||||||
4. Build frontend (Jinja2 templates + Alpine.js)
|
|
||||||
5. Test thoroughly
|
|
||||||
6. Move to next slice
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Current Status Overview
|
|
||||||
|
|
||||||
### Slice 1: Multi-Tenant Foundation (75% Complete)
|
|
||||||
|
|
||||||
#### ✅ Completed
|
|
||||||
- Backend database models (User, Vendor, Role, VendorUser)
|
|
||||||
- Authentication system (JWT, bcrypt)
|
|
||||||
- Admin service layer (vendor creation with owner)
|
|
||||||
- Admin API endpoints (CRUD, dashboard)
|
|
||||||
- Vendor context middleware (subdomain + path detection)
|
|
||||||
- Admin login page (HTML + Alpine.js)
|
|
||||||
- Admin dashboard (HTML + Alpine.js)
|
|
||||||
- Admin vendor creation page (HTML + Alpine.js)
|
|
||||||
|
|
||||||
#### ⏳ In Progress
|
|
||||||
- Vendor login page (frontend)
|
|
||||||
- Vendor dashboard page (frontend)
|
|
||||||
- Testing and debugging
|
|
||||||
- Deployment configuration
|
|
||||||
|
|
||||||
#### 📋 To Do
|
|
||||||
- Complete vendor login/dashboard
|
|
||||||
- Full testing (see Slice 1 checklist)
|
|
||||||
- Documentation updates
|
|
||||||
- Staging deployment
|
|
||||||
|
|
||||||
### Slices 2-5: Not Started
|
|
||||||
|
|
||||||
All future slices have complete documentation ready to implement.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗓️ Development Timeline
|
|
||||||
|
|
||||||
### Week 1: Slice 1 - Foundation ⏳ CURRENT
|
|
||||||
**Days 1-3**: ✅ Backend complete
|
|
||||||
**Days 4-5**: ⏳ Frontend completion
|
|
||||||
|
|
||||||
**Deliverable**: Admin can create vendors, vendors can log in
|
|
||||||
|
|
||||||
### Week 2: Slice 2 - Marketplace Import
|
|
||||||
**Days 1-3**: Backend (CSV import, Celery tasks, MarketplaceProduct model)
|
|
||||||
**Days 4-5**: Frontend (import UI with Alpine.js, status tracking)
|
|
||||||
|
|
||||||
**Deliverable**: Vendors can import products from Letzshop CSV
|
|
||||||
|
|
||||||
### Week 3: Slice 3 - Product Catalog
|
|
||||||
**Days 1-3**: Backend (Product model, publishing, inventory)
|
|
||||||
**Days 4-5**: Frontend (product management, catalog UI)
|
|
||||||
|
|
||||||
**Deliverable**: Vendors can manage product catalog
|
|
||||||
|
|
||||||
### Week 4: Slice 4 - Customer Shopping
|
|
||||||
**Days 1-3**: Backend (Customer model, Cart, public APIs)
|
|
||||||
**Days 4-5**: Frontend (shop pages, cart with Alpine.js)
|
|
||||||
|
|
||||||
**Deliverable**: Customers can browse and shop
|
|
||||||
|
|
||||||
### Week 5: Slice 5 - Order Processing
|
|
||||||
**Days 1-3**: Backend (Order model, checkout, order management)
|
|
||||||
**Days 4-5**: Frontend (checkout flow, order history)
|
|
||||||
|
|
||||||
**Deliverable**: Complete order workflow, platform ready for production
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Technology Stack
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- **Framework**: FastAPI (Python 3.11+)
|
|
||||||
- **Database**: PostgreSQL + SQLAlchemy ORM
|
|
||||||
- **Authentication**: JWT tokens + bcrypt
|
|
||||||
- **Background Jobs**: Celery + Redis/RabbitMQ
|
|
||||||
- **API Docs**: Auto-generated OpenAPI/Swagger
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **Templating**: Jinja2 (server-side rendering)
|
|
||||||
- **JavaScript**: Alpine.js v3.x (15KB, CDN-based)
|
|
||||||
- **CSS**: Custom CSS with CSS variables
|
|
||||||
- **AJAX**: Fetch API (vanilla JavaScript)
|
|
||||||
- **No Build Step**: Everything runs directly in browser
|
|
||||||
|
|
||||||
### Why This Stack?
|
|
||||||
- ✅ **Alpine.js**: Lightweight reactivity without build complexity
|
|
||||||
- ✅ **Jinja2**: Server-side rendering for SEO and performance
|
|
||||||
- ✅ **No Build Step**: Faster development, easier deployment
|
|
||||||
- ✅ **FastAPI**: Modern Python, async support, auto-docs
|
|
||||||
- ✅ **PostgreSQL**: Robust, reliable, feature-rich
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Implementation Checklist
|
|
||||||
|
|
||||||
Use this checklist to track your progress across all slices:
|
|
||||||
|
|
||||||
### Slice 1: Foundation
|
|
||||||
- [x] Backend models created
|
|
||||||
- [x] Authentication system working
|
|
||||||
- [x] Admin service layer complete
|
|
||||||
- [x] Admin API endpoints working
|
|
||||||
- [x] Vendor context middleware working
|
|
||||||
- [x] Admin login page created
|
|
||||||
- [x] Admin dashboard created
|
|
||||||
- [x] Admin vendor creation page created
|
|
||||||
- [ ] Vendor login page created
|
|
||||||
- [ ] Vendor dashboard page created
|
|
||||||
- [ ] All tests passing
|
|
||||||
- [ ] Deployed to staging
|
|
||||||
|
|
||||||
### Slice 2: Marketplace Import
|
|
||||||
- [ ] MarketplaceProduct model
|
|
||||||
- [ ] MarketplaceImportJob model
|
|
||||||
- [ ] CSV processing service
|
|
||||||
- [ ] Celery tasks configured
|
|
||||||
- [ ] Import API endpoints
|
|
||||||
- [ ] Import UI pages
|
|
||||||
- [ ] Status tracking with Alpine.js
|
|
||||||
- [ ] All tests passing
|
|
||||||
|
|
||||||
### Slice 3: Product Catalog
|
|
||||||
- [ ] Product model complete
|
|
||||||
- [ ] Inventory model complete
|
|
||||||
- [ ] Product service layer
|
|
||||||
- [ ] Publishing logic
|
|
||||||
- [ ] Product API endpoints
|
|
||||||
- [ ] Product management UI
|
|
||||||
- [ ] Catalog browsing
|
|
||||||
- [ ] All tests passing
|
|
||||||
|
|
||||||
### Slice 4: Customer Shopping
|
|
||||||
- [ ] Customer model
|
|
||||||
- [ ] Cart model
|
|
||||||
- [ ] Customer service layer
|
|
||||||
- [ ] Cart service layer
|
|
||||||
- [ ] Public product APIs
|
|
||||||
- [ ] Shop homepage
|
|
||||||
- [ ] Product detail pages
|
|
||||||
- [ ] Shopping cart UI
|
|
||||||
- [ ] Customer registration/login
|
|
||||||
- [ ] All tests passing
|
|
||||||
|
|
||||||
### Slice 5: Order Processing
|
|
||||||
- [ ] Order model
|
|
||||||
- [ ] OrderItem model
|
|
||||||
- [ ] Order service layer
|
|
||||||
- [ ] Checkout logic
|
|
||||||
- [ ] Order API endpoints
|
|
||||||
- [ ] Checkout UI (multi-step)
|
|
||||||
- [ ] Customer order history
|
|
||||||
- [ ] Vendor order management
|
|
||||||
- [ ] Email notifications
|
|
||||||
- [ ] Payment integration (Stripe)
|
|
||||||
- [ ] All tests passing
|
|
||||||
- [ ] Production ready
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Each Slice Must Include
|
|
||||||
|
|
||||||
### Backend Checklist
|
|
||||||
- [ ] Database models defined
|
|
||||||
- [ ] Pydantic schemas created
|
|
||||||
- [ ] Service layer implemented
|
|
||||||
- [ ] API endpoints created
|
|
||||||
- [ ] Exception handling added
|
|
||||||
- [ ] Database migrations applied
|
|
||||||
- [ ] Unit tests written
|
|
||||||
- [ ] Integration tests written
|
|
||||||
|
|
||||||
### Frontend Checklist
|
|
||||||
- [ ] Jinja2 templates created
|
|
||||||
- [ ] Alpine.js components implemented
|
|
||||||
- [ ] CSS styling applied
|
|
||||||
- [ ] API integration working
|
|
||||||
- [ ] Loading states added
|
|
||||||
- [ ] Error handling added
|
|
||||||
- [ ] Mobile responsive
|
|
||||||
- [ ] Browser tested (Chrome, Firefox, Safari)
|
|
||||||
|
|
||||||
### Documentation Checklist
|
|
||||||
- [ ] Slice documentation updated
|
|
||||||
- [ ] API endpoints documented
|
|
||||||
- [ ] Frontend components documented
|
|
||||||
- [ ] Testing checklist completed
|
|
||||||
- [ ] Known issues documented
|
|
||||||
- [ ] Next steps identified
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Development Workflow
|
|
||||||
|
|
||||||
### Starting a New Slice
|
|
||||||
|
|
||||||
1. **Read Documentation**
|
|
||||||
```bash
|
|
||||||
# Open the slice markdown file
|
|
||||||
code docs/slices/0X_sliceX_name.md
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Set Up Backend**
|
|
||||||
```bash
|
|
||||||
# Create database models
|
|
||||||
# Create Pydantic schema
|
|
||||||
# Implement service layer
|
|
||||||
# Create API endpoints
|
|
||||||
# Write tests
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Set Up Frontend**
|
|
||||||
```bash
|
|
||||||
# Create Jinja2 templates
|
|
||||||
# Add Alpine.js components
|
|
||||||
# Style with CSS
|
|
||||||
# Test in browser
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Test Thoroughly**
|
|
||||||
```bash
|
|
||||||
# Run backend tests
|
|
||||||
pytest tests/
|
|
||||||
|
|
||||||
# Manual testing
|
|
||||||
# Use testing checklist in slice docs
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Deploy & Demo**
|
|
||||||
```bash
|
|
||||||
# Deploy to staging
|
|
||||||
# Demo to stakeholders
|
|
||||||
# Gather feedback
|
|
||||||
```
|
|
||||||
|
|
||||||
### Daily Development Flow
|
|
||||||
|
|
||||||
**Morning**
|
|
||||||
- Review slice documentation
|
|
||||||
- Identify today's goals (backend or frontend)
|
|
||||||
- Check testing checklist
|
|
||||||
|
|
||||||
**During Development**
|
|
||||||
- Follow code patterns from slice docs
|
|
||||||
- Use Alpine.js examples provided
|
|
||||||
- Keep vendor isolation in mind
|
|
||||||
- Test incrementally
|
|
||||||
|
|
||||||
**End of Day**
|
|
||||||
- Update slice documentation with progress
|
|
||||||
- Mark completed items in checklist
|
|
||||||
- Note any blockers or issues
|
|
||||||
- Commit code with meaningful messages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Alpine.js Patterns
|
|
||||||
|
|
||||||
### Basic Component Pattern
|
|
||||||
```javascript
|
|
||||||
function myComponent() {
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
data: [],
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
init() {
|
|
||||||
this.loadData();
|
|
||||||
},
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
async loadData() {
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
this.data = await apiClient.get('/api/endpoint');
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error.message;
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Template Usage
|
|
||||||
```html
|
|
||||||
<div x-data="myComponent()" x-init="init()">
|
|
||||||
<div x-show="loading">Loading...</div>
|
|
||||||
<div x-show="error" x-text="error"></div>
|
|
||||||
<div x-show="!loading && !error">
|
|
||||||
<template x-for="item in data" :key="item.id">
|
|
||||||
<div x-text="item.name"></div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Directives
|
|
||||||
- `x-data` - Component state
|
|
||||||
- `x-init` - Initialization
|
|
||||||
- `x-show` - Toggle visibility
|
|
||||||
- `x-if` - Conditional rendering
|
|
||||||
- `x-for` - Loop through arrays
|
|
||||||
- `x-model` - Two-way binding
|
|
||||||
- `@click` - Event handling
|
|
||||||
- `:class` - Dynamic classes
|
|
||||||
- `x-text` - Text content
|
|
||||||
- `x-html` - HTML content
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Key Resources
|
|
||||||
|
|
||||||
### Documentation Files
|
|
||||||
- `00_slices_overview.md` - Complete overview
|
|
||||||
- `01_slice1_admin_vendor_foundation.md` - Current work
|
|
||||||
- `../quick_start_guide.md` - Setup guide
|
|
||||||
- `../css_structure_guide.txt` - CSS organization
|
|
||||||
- `../css_quick_reference.txt` - CSS usage
|
|
||||||
- `../12.project_readme_final.md` - Complete README
|
|
||||||
|
|
||||||
### External Resources
|
|
||||||
- [Alpine.js Documentation](https://alpinejs.dev/)
|
|
||||||
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
|
||||||
- [Jinja2 Documentation](https://jinja.palletsprojects.com/)
|
|
||||||
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Common Pitfalls to Avoid
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- ❌ Forgetting vendor isolation in queries
|
|
||||||
- ❌ Not validating vendor_id in API endpoints
|
|
||||||
- ❌ Skipping database indexes
|
|
||||||
- ❌ Not handling edge cases
|
|
||||||
- ❌ Missing error handling
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- ❌ Not handling loading states
|
|
||||||
- ❌ Not displaying error messages
|
|
||||||
- ❌ Forgetting mobile responsiveness
|
|
||||||
- ❌ Not testing in multiple browsers
|
|
||||||
- ❌ Mixing vendor contexts
|
|
||||||
|
|
||||||
### General
|
|
||||||
- ❌ Skipping tests
|
|
||||||
- ❌ Not updating documentation
|
|
||||||
- ❌ Moving to next slice before completing current
|
|
||||||
- ❌ Not following naming conventions
|
|
||||||
- ❌ Committing without testing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Quality Gates
|
|
||||||
|
|
||||||
Before moving to the next slice, ensure:
|
|
||||||
|
|
||||||
1. **All Features Complete**
|
|
||||||
- All user stories implemented
|
|
||||||
- All acceptance criteria met
|
|
||||||
- All API endpoints working
|
|
||||||
|
|
||||||
2. **All Tests Pass**
|
|
||||||
- Backend unit tests
|
|
||||||
- Backend integration tests
|
|
||||||
- Frontend manual testing
|
|
||||||
- Security testing (vendor isolation)
|
|
||||||
|
|
||||||
3. **Documentation Updated**
|
|
||||||
- Slice documentation current
|
|
||||||
- API docs updated
|
|
||||||
- Testing checklist completed
|
|
||||||
|
|
||||||
4. **Code Quality**
|
|
||||||
- Follows naming conventions
|
|
||||||
- No console errors
|
|
||||||
- No security vulnerabilities
|
|
||||||
- Performance acceptable
|
|
||||||
|
|
||||||
5. **Stakeholder Approval**
|
|
||||||
- Demo completed
|
|
||||||
- Feedback incorporated
|
|
||||||
- Sign-off received
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 Success Metrics
|
|
||||||
|
|
||||||
### After Slice 1
|
|
||||||
- ✅ Admin can create vendors
|
|
||||||
- ✅ Vendors can log in
|
|
||||||
- ✅ Vendor isolation works
|
|
||||||
- ✅ Context detection works
|
|
||||||
|
|
||||||
### After Slice 2
|
|
||||||
- ✅ Vendors can import CSVs
|
|
||||||
- ✅ Background processing works
|
|
||||||
- ✅ Import tracking functional
|
|
||||||
|
|
||||||
### After Slice 3
|
|
||||||
- ✅ Products published to catalog
|
|
||||||
- ✅ Inventory management working
|
|
||||||
- ✅ Product customization enabled
|
|
||||||
|
|
||||||
### After Slice 4
|
|
||||||
- ✅ Customers can browse products
|
|
||||||
- ✅ Shopping cart functional
|
|
||||||
- ✅ Customer accounts working
|
|
||||||
|
|
||||||
### After Slice 5
|
|
||||||
- ✅ Complete checkout workflow
|
|
||||||
- ✅ Order management operational
|
|
||||||
- ✅ **Platform production-ready!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Ready to Start?
|
|
||||||
|
|
||||||
### Current Focus: Complete Slice 1
|
|
||||||
|
|
||||||
**Your immediate next steps:**
|
|
||||||
|
|
||||||
1. ✅ Read `01_slice1_admin_vendor_foundation.md`
|
|
||||||
2. ⏳ Complete vendor login page (`templates/vendor/login.html`)
|
|
||||||
3. ⏳ Complete vendor dashboard (`templates/vendor/dashboard.html`)
|
|
||||||
4. ⏳ Test complete admin → vendor flow
|
|
||||||
5. ⏳ Check all items in Slice 1 testing checklist
|
|
||||||
6. ⏳ Deploy to staging
|
|
||||||
7. ⏳ Demo to stakeholders
|
|
||||||
8. ✅ Move to Slice 2
|
|
||||||
|
|
||||||
### Need Help?
|
|
||||||
|
|
||||||
- Check the slice documentation for detailed implementation
|
|
||||||
- Review Alpine.js examples in the docs
|
|
||||||
- Look at CSS guides for styling
|
|
||||||
- Test frequently and incrementally
|
|
||||||
- Update documentation as you progress
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Pro Tips
|
|
||||||
|
|
||||||
1. **Work Incrementally**: Complete one component at a time
|
|
||||||
2. **Test Continuously**: Don't wait until the end to test
|
|
||||||
3. **Follow Patterns**: Use the examples in slice documentation
|
|
||||||
4. **Document as You Go**: Update docs while code is fresh
|
|
||||||
5. **Ask for Reviews**: Get feedback early and often
|
|
||||||
6. **Celebrate Progress**: Each completed slice is a milestone!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
If you need assistance:
|
|
||||||
- Review the slice-specific documentation
|
|
||||||
- Check the testing checklists
|
|
||||||
- Look at the example code provided
|
|
||||||
- Refer to the technology stack documentation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Ready to build an amazing multi-tenant ecommerce platform?**
|
|
||||||
|
|
||||||
**Start with**: `01_slice1_admin_vendor_foundation.md`
|
|
||||||
|
|
||||||
**You've got this!** 🚀
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,808 +0,0 @@
|
|||||||
# Slice 2: Marketplace Product Import
|
|
||||||
## Vendor Imports Products from Letzshop
|
|
||||||
|
|
||||||
**Status**: 📋 NOT STARTED
|
|
||||||
**Timeline**: Week 2 (5 days)
|
|
||||||
**Prerequisites**: Slice 1 complete
|
|
||||||
|
|
||||||
## 🎯 Slice Objectives
|
|
||||||
|
|
||||||
Enable vendors to import product catalogs from Letzshop marketplace via CSV files.
|
|
||||||
|
|
||||||
### User Stories
|
|
||||||
- As a Vendor Owner, I can configure my Letzshop CSV URL
|
|
||||||
- As a Vendor Owner, I can trigger product imports from Letzshop
|
|
||||||
- As a Vendor Owner, I can view import job status and history
|
|
||||||
- The system processes CSV data in the background
|
|
||||||
- As a Vendor Owner, I can see real-time import progress
|
|
||||||
|
|
||||||
### Success Criteria
|
|
||||||
- [ ] Vendor can configure Letzshop CSV URL (FR, EN, DE)
|
|
||||||
- [ ] Vendor can trigger import jobs manually
|
|
||||||
- [ ] System downloads and processes CSV files
|
|
||||||
- [ ] Import status updates in real-time (Alpine.js)
|
|
||||||
- [ ] Import history is properly tracked
|
|
||||||
- [ ] Error handling for failed imports
|
|
||||||
- [ ] Products stored in staging area (MarketplaceProduct table)
|
|
||||||
- [ ] Large CSV files process without timeout
|
|
||||||
|
|
||||||
## 📋 Backend Implementation
|
|
||||||
|
|
||||||
### Database Models
|
|
||||||
|
|
||||||
#### MarketplaceProduct Model (`models/database/marketplace_product.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MarketplaceProduct(Base, TimestampMixin):
|
|
||||||
"""
|
|
||||||
Staging table for imported marketplace products
|
|
||||||
Products stay here until vendor publishes them to catalog
|
|
||||||
"""
|
|
||||||
__tablename__ = "marketplace_products"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
|
||||||
import_job_id = Column(Integer, ForeignKey("marketplace_import_jobs.id"))
|
|
||||||
|
|
||||||
# External identifiers
|
|
||||||
external_sku = Column(String, nullable=False, index=True)
|
|
||||||
marketplace = Column(String, default="letzshop") # Future: other marketplaces
|
|
||||||
|
|
||||||
# Product information (from CSV)
|
|
||||||
title = Column(String, nullable=False)
|
|
||||||
description = Column(Text)
|
|
||||||
price = Column(Numeric(10, 2))
|
|
||||||
currency = Column(String(3), default="EUR")
|
|
||||||
|
|
||||||
# Categories and attributes
|
|
||||||
category = Column(String)
|
|
||||||
brand = Column(String)
|
|
||||||
attributes = Column(JSON, default=dict) # Store all CSV columns
|
|
||||||
|
|
||||||
# Images
|
|
||||||
image_urls = Column(JSON, default=list) # List of image URLs
|
|
||||||
|
|
||||||
# Inventory
|
|
||||||
stock_quantity = Column(Integer)
|
|
||||||
is_in_stock = Column(Boolean, default=True)
|
|
||||||
|
|
||||||
# Status
|
|
||||||
is_selected = Column(Boolean, default=False) # Ready to publish?
|
|
||||||
is_published = Column(Boolean, default=False) # Already in catalog?
|
|
||||||
published_product_id = Column(Integer, ForeignKey("products.id"), nullable=True)
|
|
||||||
|
|
||||||
# Metadata
|
|
||||||
language = Column(String(2)) # 'fr', 'en', 'de'
|
|
||||||
raw_data = Column(JSON) # Store complete CSV row
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
vendor = relationship("Vendor", back_populates="marketplace_products")
|
|
||||||
import_job = relationship("MarketplaceImportJob", back_populates="products")
|
|
||||||
published_product = relationship("Product", back_populates="marketplace_source")
|
|
||||||
|
|
||||||
# Indexes
|
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_marketplace_vendor_sku', 'vendor_id', 'external_sku'),
|
|
||||||
Index('ix_marketplace_selected', 'vendor_id', 'is_selected'),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### MarketplaceImportJob Model (`models/database/marketplace.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MarketplaceImportJob(Base, TimestampMixin):
|
|
||||||
"""Track CSV import jobs"""
|
|
||||||
__tablename__ = "marketplace_import_jobs"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
|
||||||
|
|
||||||
# Job details
|
|
||||||
marketplace = Column(String, default="letzshop")
|
|
||||||
csv_url = Column(String, nullable=False)
|
|
||||||
language = Column(String(2)) # 'fr', 'en', 'de'
|
|
||||||
|
|
||||||
# Status tracking
|
|
||||||
status = Column(
|
|
||||||
String,
|
|
||||||
default="pending"
|
|
||||||
) # pending, processing, completed, failed
|
|
||||||
|
|
||||||
# Progress
|
|
||||||
total_rows = Column(Integer, default=0)
|
|
||||||
processed_rows = Column(Integer, default=0)
|
|
||||||
imported_count = Column(Integer, default=0)
|
|
||||||
updated_count = Column(Integer, default=0)
|
|
||||||
error_count = Column(Integer, default=0)
|
|
||||||
|
|
||||||
# Timing
|
|
||||||
started_at = Column(DateTime, nullable=True)
|
|
||||||
completed_at = Column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
# Error handling
|
|
||||||
error_message = Column(Text, nullable=True)
|
|
||||||
error_details = Column(JSON, nullable=True)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
vendor = relationship("Vendor", back_populates="import_jobs")
|
|
||||||
products = relationship("MarketplaceProduct", back_populates="import_job")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pydantic Schemas
|
|
||||||
|
|
||||||
#### Import Schemas (`models/schema/marketplace.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pydantic import BaseModel, HttpUrl
|
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class MarketplaceImportCreate(BaseModel):
|
|
||||||
"""Create new import job"""
|
|
||||||
csv_url: HttpUrl
|
|
||||||
language: str = Field(..., regex="^(fr|en|de)$")
|
|
||||||
marketplace: str = "letzshop"
|
|
||||||
|
|
||||||
class MarketplaceImportJobResponse(BaseModel):
|
|
||||||
"""Import job details"""
|
|
||||||
id: int
|
|
||||||
vendor_id: int
|
|
||||||
marketplace: str
|
|
||||||
csv_url: str
|
|
||||||
language: str
|
|
||||||
status: str
|
|
||||||
total_rows: int
|
|
||||||
processed_rows: int
|
|
||||||
imported_count: int
|
|
||||||
updated_count: int
|
|
||||||
error_count: int
|
|
||||||
started_at: Optional[datetime]
|
|
||||||
completed_at: Optional[datetime]
|
|
||||||
error_message: Optional[str]
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class MarketplaceProductResponse(BaseModel):
|
|
||||||
"""Marketplace product in staging"""
|
|
||||||
id: int
|
|
||||||
vendor_id: int
|
|
||||||
external_sku: str
|
|
||||||
title: str
|
|
||||||
description: Optional[str]
|
|
||||||
price: float
|
|
||||||
currency: str
|
|
||||||
category: Optional[str]
|
|
||||||
brand: Optional[str]
|
|
||||||
stock_quantity: Optional[int]
|
|
||||||
is_in_stock: bool
|
|
||||||
is_selected: bool
|
|
||||||
is_published: bool
|
|
||||||
image_urls: List[str]
|
|
||||||
language: str
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service Layer
|
|
||||||
|
|
||||||
#### Marketplace Service (`app/services/marketplace_service.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import List, Dict, Any
|
|
||||||
import csv
|
|
||||||
import requests
|
|
||||||
from io import StringIO
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from models.database.marketplace_product import MarketplaceProduct
|
|
||||||
from models.database.marketplace import MarketplaceImportJob
|
|
||||||
|
|
||||||
class MarketplaceService:
|
|
||||||
"""Handle marketplace product imports"""
|
|
||||||
|
|
||||||
async def create_import_job(
|
|
||||||
self,
|
|
||||||
vendor_id: int,
|
|
||||||
csv_url: str,
|
|
||||||
language: str,
|
|
||||||
db: Session
|
|
||||||
) -> MarketplaceImportJob:
|
|
||||||
"""Create new import job and start processing"""
|
|
||||||
|
|
||||||
# Create job record
|
|
||||||
job = MarketplaceImportJob(
|
|
||||||
vendor_id=vendor_id,
|
|
||||||
csv_url=csv_url,
|
|
||||||
language=language,
|
|
||||||
marketplace="letzshop",
|
|
||||||
status="pending"
|
|
||||||
)
|
|
||||||
db.add(job)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(job)
|
|
||||||
|
|
||||||
# Trigger background processing
|
|
||||||
from tasks.marketplace_import import process_csv_import
|
|
||||||
process_csv_import.delay(job.id)
|
|
||||||
|
|
||||||
return job
|
|
||||||
|
|
||||||
def process_csv_import(self, job_id: int, db: Session):
|
|
||||||
"""
|
|
||||||
Process CSV import (called by Celery task)
|
|
||||||
This is a long-running operation
|
|
||||||
"""
|
|
||||||
job = db.query(MarketplaceImportJob).get(job_id)
|
|
||||||
if not job:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Update status
|
|
||||||
job.status = "processing"
|
|
||||||
job.started_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Download CSV
|
|
||||||
response = requests.get(job.csv_url, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# Parse CSV
|
|
||||||
csv_content = StringIO(response.text)
|
|
||||||
reader = csv.DictReader(csv_content)
|
|
||||||
|
|
||||||
# Count total rows
|
|
||||||
rows = list(reader)
|
|
||||||
job.total_rows = len(rows)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Process each row
|
|
||||||
for idx, row in enumerate(rows):
|
|
||||||
try:
|
|
||||||
self._process_csv_row(job, row, db)
|
|
||||||
job.processed_rows = idx + 1
|
|
||||||
|
|
||||||
# Commit every 100 rows
|
|
||||||
if idx % 100 == 0:
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
job.error_count += 1
|
|
||||||
# Log error but continue
|
|
||||||
|
|
||||||
# Final commit
|
|
||||||
job.status = "completed"
|
|
||||||
job.completed_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
job.status = "failed"
|
|
||||||
job.error_message = str(e)
|
|
||||||
job.completed_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
def _process_csv_row(
|
|
||||||
self,
|
|
||||||
job: MarketplaceImportJob,
|
|
||||||
row: Dict[str, Any],
|
|
||||||
db: Session
|
|
||||||
):
|
|
||||||
"""Process single CSV row"""
|
|
||||||
|
|
||||||
# Extract fields from CSV
|
|
||||||
external_sku = row.get('SKU') or row.get('sku')
|
|
||||||
if not external_sku:
|
|
||||||
raise ValueError("Missing SKU in CSV row")
|
|
||||||
|
|
||||||
# Check if product already exists
|
|
||||||
existing = db.query(MarketplaceProduct).filter(
|
|
||||||
MarketplaceProduct.vendor_id == job.vendor_id,
|
|
||||||
MarketplaceProduct.external_sku == external_sku
|
|
||||||
).first()
|
|
||||||
|
|
||||||
# Parse image URLs
|
|
||||||
image_urls = []
|
|
||||||
for i in range(1, 6): # Support up to 5 images
|
|
||||||
img_url = row.get(f'Image{i}') or row.get(f'image_{i}')
|
|
||||||
if img_url:
|
|
||||||
image_urls.append(img_url)
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Update existing product
|
|
||||||
existing.title = row.get('Title') or row.get('title')
|
|
||||||
existing.description = row.get('Description')
|
|
||||||
existing.price = float(row.get('Price', 0))
|
|
||||||
existing.stock_quantity = int(row.get('Stock', 0))
|
|
||||||
existing.is_in_stock = existing.stock_quantity > 0
|
|
||||||
existing.category = row.get('Category')
|
|
||||||
existing.brand = row.get('Brand')
|
|
||||||
existing.image_urls = image_urls
|
|
||||||
existing.raw_data = row
|
|
||||||
existing.import_job_id = job.id
|
|
||||||
|
|
||||||
job.updated_count += 1
|
|
||||||
else:
|
|
||||||
# Create new product
|
|
||||||
product = MarketplaceProduct(
|
|
||||||
vendor_id=job.vendor_id,
|
|
||||||
import_job_id=job.id,
|
|
||||||
external_sku=external_sku,
|
|
||||||
marketplace="letzshop",
|
|
||||||
title=row.get('Title') or row.get('title'),
|
|
||||||
description=row.get('Description'),
|
|
||||||
price=float(row.get('Price', 0)),
|
|
||||||
currency="EUR",
|
|
||||||
category=row.get('Category'),
|
|
||||||
brand=row.get('Brand'),
|
|
||||||
stock_quantity=int(row.get('Stock', 0)),
|
|
||||||
is_in_stock=int(row.get('Stock', 0)) > 0,
|
|
||||||
image_urls=image_urls,
|
|
||||||
language=job.language,
|
|
||||||
raw_data=row,
|
|
||||||
is_selected=False,
|
|
||||||
is_published=False
|
|
||||||
)
|
|
||||||
db.add(product)
|
|
||||||
job.imported_count += 1
|
|
||||||
|
|
||||||
def get_import_jobs(
|
|
||||||
self,
|
|
||||||
vendor_id: int,
|
|
||||||
db: Session,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 20
|
|
||||||
) -> List[MarketplaceImportJob]:
|
|
||||||
"""Get import job history for vendor"""
|
|
||||||
return db.query(MarketplaceImportJob).filter(
|
|
||||||
MarketplaceImportJob.vendor_id == vendor_id
|
|
||||||
).order_by(
|
|
||||||
MarketplaceImportJob.created_at.desc()
|
|
||||||
).offset(skip).limit(limit).all()
|
|
||||||
|
|
||||||
def get_marketplace_products(
|
|
||||||
self,
|
|
||||||
vendor_id: int,
|
|
||||||
db: Session,
|
|
||||||
import_job_id: Optional[int] = None,
|
|
||||||
is_selected: Optional[bool] = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100
|
|
||||||
) -> List[MarketplaceProduct]:
|
|
||||||
"""Get marketplace products in staging"""
|
|
||||||
query = db.query(MarketplaceProduct).filter(
|
|
||||||
MarketplaceProduct.vendor_id == vendor_id,
|
|
||||||
MarketplaceProduct.is_published == False # Only unpublished
|
|
||||||
)
|
|
||||||
|
|
||||||
if import_job_id:
|
|
||||||
query = query.filter(MarketplaceProduct.import_job_id == import_job_id)
|
|
||||||
|
|
||||||
if is_selected is not None:
|
|
||||||
query = query.filter(MarketplaceProduct.is_selected == is_selected)
|
|
||||||
|
|
||||||
return query.order_by(
|
|
||||||
MarketplaceProduct.created_at.desc()
|
|
||||||
).offset(skip).limit(limit).all()
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
|
|
||||||
#### Marketplace Endpoints (`app/api/v1/vendor/marketplace.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.post("/import", response_model=MarketplaceImportJobResponse)
|
|
||||||
async def trigger_import(
|
|
||||||
import_data: MarketplaceImportCreate,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Trigger CSV import from marketplace"""
|
|
||||||
service = MarketplaceService()
|
|
||||||
job = await service.create_import_job(
|
|
||||||
vendor_id=vendor.id,
|
|
||||||
csv_url=str(import_data.csv_url),
|
|
||||||
language=import_data.language,
|
|
||||||
db=db
|
|
||||||
)
|
|
||||||
return job
|
|
||||||
|
|
||||||
@router.get("/jobs", response_model=List[MarketplaceImportJobResponse])
|
|
||||||
async def get_import_jobs(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get import job history"""
|
|
||||||
service = MarketplaceService()
|
|
||||||
jobs = service.get_import_jobs(vendor.id, db, skip, limit)
|
|
||||||
return jobs
|
|
||||||
|
|
||||||
@router.get("/jobs/{job_id}", response_model=MarketplaceImportJobResponse)
|
|
||||||
async def get_import_job(
|
|
||||||
job_id: int,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get specific import job status"""
|
|
||||||
job = db.query(MarketplaceImportJob).filter(
|
|
||||||
MarketplaceImportJob.id == job_id,
|
|
||||||
MarketplaceImportJob.vendor_id == vendor.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not job:
|
|
||||||
raise HTTPException(status_code=404, detail="Import job not found")
|
|
||||||
|
|
||||||
return job
|
|
||||||
|
|
||||||
@router.get("/products", response_model=List[MarketplaceProductResponse])
|
|
||||||
async def get_marketplace_products(
|
|
||||||
import_job_id: Optional[int] = None,
|
|
||||||
is_selected: Optional[bool] = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get products in marketplace staging area"""
|
|
||||||
service = MarketplaceService()
|
|
||||||
products = service.get_marketplace_products(
|
|
||||||
vendor.id, db, import_job_id, is_selected, skip, limit
|
|
||||||
)
|
|
||||||
return products
|
|
||||||
```
|
|
||||||
|
|
||||||
### Background Tasks
|
|
||||||
|
|
||||||
#### Celery Task (`tasks/marketplace_import.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from celery import shared_task
|
|
||||||
from app.core.database import SessionLocal
|
|
||||||
from app.services.marketplace_service import MarketplaceService
|
|
||||||
|
|
||||||
@shared_task(bind=True, max_retries=3)
|
|
||||||
def process_csv_import(self, job_id: int):
|
|
||||||
"""
|
|
||||||
Process CSV import in background
|
|
||||||
This can take several minutes for large files
|
|
||||||
"""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
service = MarketplaceService()
|
|
||||||
service.process_csv_import(job_id, db)
|
|
||||||
except Exception as e:
|
|
||||||
# Retry on failure
|
|
||||||
raise self.retry(exc=e, countdown=60)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 Frontend Implementation
|
|
||||||
|
|
||||||
### Templates
|
|
||||||
|
|
||||||
#### Import Dashboard (`templates/vendor/marketplace/imports.html`)
|
|
||||||
|
|
||||||
```html
|
|
||||||
{% extends "vendor/base_vendor.html" %}
|
|
||||||
|
|
||||||
{% block title %}Product Import{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div x-data="marketplaceImport()" x-init="loadJobs()">
|
|
||||||
<!-- Page Header -->
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>Marketplace Import</h1>
|
|
||||||
<button @click="showImportModal = true" class="btn btn-primary">
|
|
||||||
New Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Import Configuration Card -->
|
|
||||||
<div class="card mb-3">
|
|
||||||
<h3>Letzshop CSV URLs</h3>
|
|
||||||
<div class="config-grid">
|
|
||||||
<div>
|
|
||||||
<label>French (FR)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
x-model="vendor.letzshop_csv_url_fr"
|
|
||||||
class="form-control"
|
|
||||||
readonly
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>English (EN)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
x-model="vendor.letzshop_csv_url_en"
|
|
||||||
class="form-control"
|
|
||||||
readonly
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>German (DE)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
x-model="vendor.letzshop_csv_url_de"
|
|
||||||
class="form-control"
|
|
||||||
readonly
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted mt-2">
|
|
||||||
Configure these URLs in vendor settings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Import Jobs List -->
|
|
||||||
<div class="card">
|
|
||||||
<h3>Import History</h3>
|
|
||||||
|
|
||||||
<template x-if="jobs.length === 0 && !loading">
|
|
||||||
<p class="text-muted">No imports yet. Start your first import!</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="jobs.length > 0">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Language</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Progress</th>
|
|
||||||
<th>Results</th>
|
|
||||||
<th>Started</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-for="job in jobs" :key="job.id">
|
|
||||||
<tr>
|
|
||||||
<td><strong x-text="`#${job.id}`"></strong></td>
|
|
||||||
<td x-text="job.language.toUpperCase()"></td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="badge"
|
|
||||||
:class="{
|
|
||||||
'badge-warning': job.status === 'pending' || job.status === 'processing',
|
|
||||||
'badge-success': job.status === 'completed',
|
|
||||||
'badge-danger': job.status === 'failed'
|
|
||||||
}"
|
|
||||||
x-text="job.status"
|
|
||||||
></span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<template x-if="job.status === 'processing'">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div
|
|
||||||
class="progress-fill"
|
|
||||||
:style="`width: ${(job.processed_rows / job.total_rows * 100)}%`"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<small x-text="`${job.processed_rows} / ${job.total_rows}`"></small>
|
|
||||||
</template>
|
|
||||||
<template x-if="job.status === 'completed'">
|
|
||||||
<span x-text="`${job.total_rows} rows`"></span>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<template x-if="job.status === 'completed'">
|
|
||||||
<div class="text-sm">
|
|
||||||
<div>✓ <span x-text="job.imported_count"></span> imported</div>
|
|
||||||
<div>↻ <span x-text="job.updated_count"></span> updated</div>
|
|
||||||
<template x-if="job.error_count > 0">
|
|
||||||
<div class="text-danger">✗ <span x-text="job.error_count"></span> errors</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
<td x-text="formatDate(job.started_at)"></td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
@click="viewProducts(job.id)"
|
|
||||||
class="btn btn-sm"
|
|
||||||
:disabled="job.status !== 'completed'"
|
|
||||||
>
|
|
||||||
View Products
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- New Import Modal -->
|
|
||||||
<div x-show="showImportModal" class="modal-overlay" @click.self="showImportModal = false">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>New Import</h3>
|
|
||||||
<button @click="showImportModal = false" class="modal-close">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form @submit.prevent="triggerImport()">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Language</label>
|
|
||||||
<select x-model="newImport.language" class="form-control" required>
|
|
||||||
<option value="">Select language</option>
|
|
||||||
<option value="fr">French (FR)</option>
|
|
||||||
<option value="en">English (EN)</option>
|
|
||||||
<option value="de">German (DE)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>CSV URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
x-model="newImport.csv_url"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="https://..."
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<div class="form-help">
|
|
||||||
Or use configured URL:
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="newImport.csv_url = vendor.letzshop_csv_url_fr"
|
|
||||||
class="btn-link"
|
|
||||||
x-show="newImport.language === 'fr'"
|
|
||||||
>
|
|
||||||
Use FR URL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" @click="showImportModal = false" class="btn btn-secondary">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="importing">
|
|
||||||
<span x-show="!importing">Start Import</span>
|
|
||||||
<span x-show="importing" class="loading-spinner"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.vendorData = {{ vendor|tojson }};
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_scripts %}
|
|
||||||
<script>
|
|
||||||
function marketplaceImport() {
|
|
||||||
return {
|
|
||||||
vendor: window.vendorData,
|
|
||||||
jobs: [],
|
|
||||||
loading: false,
|
|
||||||
importing: false,
|
|
||||||
showImportModal: false,
|
|
||||||
newImport: {
|
|
||||||
language: '',
|
|
||||||
csv_url: ''
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadJobs() {
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
this.jobs = await apiClient.get('/api/v1/vendor/marketplace/jobs');
|
|
||||||
|
|
||||||
// Poll for active jobs
|
|
||||||
const activeJobs = this.jobs.filter(j =>
|
|
||||||
j.status === 'pending' || j.status === 'processing'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (activeJobs.length > 0) {
|
|
||||||
setTimeout(() => this.loadJobs(), 3000); // Poll every 3 seconds
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showNotification('Failed to load imports', 'error');
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async triggerImport() {
|
|
||||||
this.importing = true;
|
|
||||||
try {
|
|
||||||
const job = await apiClient.post('/api/v1/vendor/marketplace/import', {
|
|
||||||
csv_url: this.newImport.csv_url,
|
|
||||||
language: this.newImport.language
|
|
||||||
});
|
|
||||||
|
|
||||||
this.jobs.unshift(job);
|
|
||||||
this.showImportModal = false;
|
|
||||||
this.newImport = { language: '', csv_url: '' };
|
|
||||||
|
|
||||||
showNotification('Import started successfully', 'success');
|
|
||||||
|
|
||||||
// Start polling
|
|
||||||
setTimeout(() => this.loadJobs(), 3000);
|
|
||||||
} catch (error) {
|
|
||||||
showNotification(error.message || 'Import failed', 'error');
|
|
||||||
} finally {
|
|
||||||
this.importing = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
viewProducts(jobId) {
|
|
||||||
window.location.href = `/vendor/marketplace/products?job_id=${jobId}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
formatDate(dateString) {
|
|
||||||
if (!dateString) return '-';
|
|
||||||
return new Date(dateString).toLocaleString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ Testing Checklist
|
|
||||||
|
|
||||||
### Backend Tests
|
|
||||||
- [ ] CSV download works with valid URL
|
|
||||||
- [ ] CSV parsing handles various formats
|
|
||||||
- [ ] Products created in staging table
|
|
||||||
- [ ] Duplicate SKUs are updated, not duplicated
|
|
||||||
- [ ] Import job status updates correctly
|
|
||||||
- [ ] Progress tracking is accurate
|
|
||||||
- [ ] Error handling works for invalid CSV
|
|
||||||
- [ ] Large CSV files (10,000+ rows) process successfully
|
|
||||||
- [ ] Celery tasks execute correctly
|
|
||||||
|
|
||||||
### Frontend Tests
|
|
||||||
- [ ] Import page loads correctly
|
|
||||||
- [ ] New import modal works
|
|
||||||
- [ ] Import jobs display in table
|
|
||||||
- [ ] Real-time progress updates (polling)
|
|
||||||
- [ ] Completed imports show results
|
|
||||||
- [ ] Can view products from import
|
|
||||||
- [ ] Error states display correctly
|
|
||||||
- [ ] Loading states work correctly
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
- [ ] Complete import workflow works end-to-end
|
|
||||||
- [ ] Vendor isolation maintained
|
|
||||||
- [ ] API endpoints require authentication
|
|
||||||
- [ ] Vendor can only see their own imports
|
|
||||||
|
|
||||||
## 🚀 Deployment Checklist
|
|
||||||
|
|
||||||
- [ ] Celery worker running
|
|
||||||
- [ ] Redis/RabbitMQ configured for Celery
|
|
||||||
- [ ] Database migrations applied
|
|
||||||
- [ ] CSV download timeout configured
|
|
||||||
- [ ] Error logging configured
|
|
||||||
- [ ] Background task monitoring set up
|
|
||||||
|
|
||||||
## ➡️ Next Steps
|
|
||||||
|
|
||||||
After completing Slice 2, move to **Slice 3: Product Catalog Management** to enable vendors to publish imported products to their catalog.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Slice 2 Status**: 📋 Not Started
|
|
||||||
**Dependencies**: Slice 1 must be complete
|
|
||||||
**Estimated Duration**: 5 days
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
# Slice 3: Product Catalog Management
|
|
||||||
## Vendor Selects and Publishes Products
|
|
||||||
|
|
||||||
**Status**: 📋 NOT STARTED
|
|
||||||
**Timeline**: Week 3 (5 days)
|
|
||||||
**Prerequisites**: Slice 1 & 2 complete
|
|
||||||
|
|
||||||
## 🎯 Slice Objectives
|
|
||||||
|
|
||||||
Enable vendors to browse imported products, select which to publish, customize them, and manage their product catalog.
|
|
||||||
|
|
||||||
### User Stories
|
|
||||||
- As a Vendor Owner, I can browse imported products from staging
|
|
||||||
- As a Vendor Owner, I can select which products to publish to my catalog
|
|
||||||
- As a Vendor Owner, I can customize product information (pricing, descriptions)
|
|
||||||
- As a Vendor Owner, I can manage my published product catalog
|
|
||||||
- As a Vendor Owner, I can manually add products (not from marketplace)
|
|
||||||
- As a Vendor Owner, I can manage inventory for catalog products
|
|
||||||
|
|
||||||
### Success Criteria
|
|
||||||
- [ ] Vendor can browse all imported products in staging
|
|
||||||
- [ ] Vendor can filter/search staging products
|
|
||||||
- [ ] Vendor can select products for publishing
|
|
||||||
- [ ] Vendor can customize product details before/after publishing
|
|
||||||
- [ ] Published products appear in vendor catalog
|
|
||||||
- [ ] Vendor can manually create products
|
|
||||||
- [ ] Vendor can update product inventory
|
|
||||||
- [ ] Vendor can activate/deactivate products
|
|
||||||
- [ ] Product operations are properly isolated by vendor
|
|
||||||
|
|
||||||
## 📋 Backend Implementation
|
|
||||||
|
|
||||||
### Database Models
|
|
||||||
|
|
||||||
#### Product Model (`models/database/product.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Product(Base, TimestampMixin):
|
|
||||||
"""
|
|
||||||
Vendor's published product catalog
|
|
||||||
These are customer-facing products
|
|
||||||
"""
|
|
||||||
__tablename__ = "products"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
|
||||||
|
|
||||||
# Basic information
|
|
||||||
sku = Column(String, nullable=False, index=True)
|
|
||||||
title = Column(String, nullable=False)
|
|
||||||
description = Column(Text)
|
|
||||||
short_description = Column(String(500))
|
|
||||||
|
|
||||||
# Pricing
|
|
||||||
price = Column(Numeric(10, 2), nullable=False)
|
|
||||||
compare_at_price = Column(Numeric(10, 2)) # Original price for discounts
|
|
||||||
cost_per_item = Column(Numeric(10, 2)) # For profit tracking
|
|
||||||
currency = Column(String(3), default="EUR")
|
|
||||||
|
|
||||||
# Categorization
|
|
||||||
category = Column(String)
|
|
||||||
subcategory = Column(String)
|
|
||||||
brand = Column(String)
|
|
||||||
tags = Column(JSON, default=list)
|
|
||||||
|
|
||||||
# SEO
|
|
||||||
slug = Column(String, unique=True, index=True)
|
|
||||||
meta_title = Column(String)
|
|
||||||
meta_description = Column(String)
|
|
||||||
|
|
||||||
# Images
|
|
||||||
featured_image = Column(String) # Main product image
|
|
||||||
image_urls = Column(JSON, default=list) # Additional images
|
|
||||||
|
|
||||||
# Status
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
is_featured = Column(Boolean, default=False)
|
|
||||||
is_on_sale = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Inventory (simple - detailed in Inventory model)
|
|
||||||
track_inventory = Column(Boolean, default=True)
|
|
||||||
stock_quantity = Column(Integer, default=0)
|
|
||||||
low_stock_threshold = Column(Integer, default=10)
|
|
||||||
|
|
||||||
# Marketplace source (if imported)
|
|
||||||
marketplace_product_id = Column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("marketplace_products.id"),
|
|
||||||
nullable=True
|
|
||||||
)
|
|
||||||
external_sku = Column(String, nullable=True) # Original marketplace SKU
|
|
||||||
|
|
||||||
# Additional data
|
|
||||||
attributes = Column(JSON, default=dict) # Custom attributes
|
|
||||||
weight = Column(Numeric(10, 2)) # For shipping
|
|
||||||
dimensions = Column(JSON) # {length, width, height}
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
vendor = relationship("Vendor", back_populates="products")
|
|
||||||
marketplace_source = relationship(
|
|
||||||
"MarketplaceProduct",
|
|
||||||
back_populates="published_product"
|
|
||||||
)
|
|
||||||
inventory_records = relationship("Inventory", back_populates="product")
|
|
||||||
order_items = relationship("OrderItem", back_populates="product")
|
|
||||||
|
|
||||||
# Indexes
|
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_product_vendor_sku', 'vendor_id', 'sku'),
|
|
||||||
Index('ix_product_active', 'vendor_id', 'is_active'),
|
|
||||||
Index('ix_product_featured', 'vendor_id', 'is_featured'),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Inventory Model (`models/database/inventory.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Inventory(Base, TimestampMixin):
|
|
||||||
"""Track product inventory by location"""
|
|
||||||
__tablename__ = "inventory"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
|
||||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
|
||||||
|
|
||||||
# Location
|
|
||||||
location_name = Column(String, default="Default") # Warehouse name
|
|
||||||
|
|
||||||
# Quantities
|
|
||||||
available_quantity = Column(Integer, default=0)
|
|
||||||
reserved_quantity = Column(Integer, default=0) # Pending orders
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
vendor = relationship("Vendor")
|
|
||||||
product = relationship("Product", back_populates="inventory_records")
|
|
||||||
movements = relationship("InventoryMovement", back_populates="inventory")
|
|
||||||
|
|
||||||
class InventoryMovement(Base, TimestampMixin):
|
|
||||||
"""Track inventory changes"""
|
|
||||||
__tablename__ = "inventory_movements"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
inventory_id = Column(Integer, ForeignKey("inventory.id"), nullable=False)
|
|
||||||
|
|
||||||
# Movement details
|
|
||||||
movement_type = Column(String) # 'received', 'sold', 'adjusted', 'returned'
|
|
||||||
quantity_change = Column(Integer) # Positive or negative
|
|
||||||
|
|
||||||
# Context
|
|
||||||
reference_type = Column(String, nullable=True) # 'order', 'import', 'manual'
|
|
||||||
reference_id = Column(Integer, nullable=True)
|
|
||||||
notes = Column(Text)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
inventory = relationship("Inventory", back_populates="movements")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pydantic Schemas
|
|
||||||
|
|
||||||
#### Product Schemas (`models/schema/product.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ProductCreate(BaseModel):
|
|
||||||
"""Create product from scratch"""
|
|
||||||
sku: str
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
price: float = Field(..., gt=0)
|
|
||||||
compare_at_price: Optional[float] = None
|
|
||||||
cost_per_item: Optional[float] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
brand: Optional[str] = None
|
|
||||||
tags: List[str] = []
|
|
||||||
image_urls: List[str] = []
|
|
||||||
track_inventory: bool = True
|
|
||||||
stock_quantity: int = 0
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
class ProductPublishFromMarketplace(BaseModel):
|
|
||||||
"""Publish product from marketplace staging"""
|
|
||||||
marketplace_product_id: int
|
|
||||||
custom_title: Optional[str] = None
|
|
||||||
custom_description: Optional[str] = None
|
|
||||||
custom_price: Optional[float] = None
|
|
||||||
custom_sku: Optional[str] = None
|
|
||||||
stock_quantity: int = 0
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
class ProductUpdate(BaseModel):
|
|
||||||
"""Update existing product"""
|
|
||||||
title: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
short_description: Optional[str] = None
|
|
||||||
price: Optional[float] = None
|
|
||||||
compare_at_price: Optional[float] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
brand: Optional[str] = None
|
|
||||||
tags: Optional[List[str]] = None
|
|
||||||
image_urls: Optional[List[str]] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
is_featured: Optional[bool] = None
|
|
||||||
stock_quantity: Optional[int] = None
|
|
||||||
|
|
||||||
class ProductResponse(BaseModel):
|
|
||||||
"""Product details"""
|
|
||||||
id: int
|
|
||||||
vendor_id: int
|
|
||||||
sku: str
|
|
||||||
title: str
|
|
||||||
description: Optional[str]
|
|
||||||
price: float
|
|
||||||
compare_at_price: Optional[float]
|
|
||||||
category: Optional[str]
|
|
||||||
brand: Optional[str]
|
|
||||||
tags: List[str]
|
|
||||||
featured_image: Optional[str]
|
|
||||||
image_urls: List[str]
|
|
||||||
is_active: bool
|
|
||||||
is_featured: bool
|
|
||||||
stock_quantity: int
|
|
||||||
marketplace_product_id: Optional[int]
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service Layer
|
|
||||||
|
|
||||||
#### Product Service (`app/services/product_service.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ProductService:
|
|
||||||
"""Handle product catalog operations"""
|
|
||||||
|
|
||||||
async def publish_from_marketplace(
|
|
||||||
self,
|
|
||||||
vendor_id: int,
|
|
||||||
publish_data: ProductPublishFromMarketplace,
|
|
||||||
db: Session
|
|
||||||
) -> Product:
|
|
||||||
"""Publish marketplace product to catalog"""
|
|
||||||
|
|
||||||
# Get marketplace product
|
|
||||||
mp_product = db.query(MarketplaceProduct).filter(
|
|
||||||
MarketplaceProduct.id == publish_data.marketplace_product_id,
|
|
||||||
MarketplaceProduct.vendor_id == vendor_id,
|
|
||||||
MarketplaceProduct.is_published == False
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not mp_product:
|
|
||||||
raise ProductNotFoundError("Marketplace product not found")
|
|
||||||
|
|
||||||
# Check if SKU already exists
|
|
||||||
sku = publish_data.custom_sku or mp_product.external_sku
|
|
||||||
existing = db.query(Product).filter(
|
|
||||||
Product.vendor_id == vendor_id,
|
|
||||||
Product.sku == sku
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
raise ProductAlreadyExistsError(f"Product with SKU {sku} already exists")
|
|
||||||
|
|
||||||
# Create product
|
|
||||||
product = Product(
|
|
||||||
vendor_id=vendor_id,
|
|
||||||
sku=sku,
|
|
||||||
title=publish_data.custom_title or mp_product.title,
|
|
||||||
description=publish_data.custom_description or mp_product.description,
|
|
||||||
price=publish_data.custom_price or mp_product.price,
|
|
||||||
currency=mp_product.currency,
|
|
||||||
category=mp_product.category,
|
|
||||||
brand=mp_product.brand,
|
|
||||||
image_urls=mp_product.image_urls,
|
|
||||||
featured_image=mp_product.image_urls[0] if mp_product.image_urls else None,
|
|
||||||
marketplace_product_id=mp_product.id,
|
|
||||||
external_sku=mp_product.external_sku,
|
|
||||||
stock_quantity=publish_data.stock_quantity,
|
|
||||||
is_active=publish_data.is_active,
|
|
||||||
slug=self._generate_slug(publish_data.custom_title or mp_product.title)
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(product)
|
|
||||||
|
|
||||||
# Mark marketplace product as published
|
|
||||||
mp_product.is_published = True
|
|
||||||
mp_product.published_product_id = product.id
|
|
||||||
|
|
||||||
# Create initial inventory record
|
|
||||||
inventory = Inventory(
|
|
||||||
vendor_id=vendor_id,
|
|
||||||
product_id=product.id,
|
|
||||||
location_name="Default",
|
|
||||||
available_quantity=publish_data.stock_quantity,
|
|
||||||
reserved_quantity=0
|
|
||||||
)
|
|
||||||
db.add(inventory)
|
|
||||||
|
|
||||||
# Record inventory movement
|
|
||||||
if publish_data.stock_quantity > 0:
|
|
||||||
movement = InventoryMovement(
|
|
||||||
inventory_id=inventory.id,
|
|
||||||
movement_type="received",
|
|
||||||
quantity_change=publish_data.stock_quantity,
|
|
||||||
reference_type="import",
|
|
||||||
notes="Initial stock from marketplace import"
|
|
||||||
)
|
|
||||||
db.add(movement)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(product)
|
|
||||||
|
|
||||||
return product
|
|
||||||
|
|
||||||
async def create_product(
|
|
||||||
self,
|
|
||||||
vendor_id: int,
|
|
||||||
product_data: ProductCreate,
|
|
||||||
db: Session
|
|
||||||
) -> Product:
|
|
||||||
"""Create product manually"""
|
|
||||||
|
|
||||||
# Check SKU uniqueness
|
|
||||||
existing = db.query(Product).filter(
|
|
||||||
Product.vendor_id == vendor_id,
|
|
||||||
Product.sku == product_data.sku
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
raise ProductAlreadyExistsError(f"SKU {product_data.sku} already exists")
|
|
||||||
|
|
||||||
product = Product(
|
|
||||||
vendor_id=vendor_id,
|
|
||||||
**product_data.dict(),
|
|
||||||
slug=self._generate_slug(product_data.title),
|
|
||||||
featured_image=product_data.image_urls[0] if product_data.image_urls else None
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(product)
|
|
||||||
|
|
||||||
# Create inventory
|
|
||||||
if product_data.track_inventory:
|
|
||||||
inventory = Inventory(
|
|
||||||
vendor_id=vendor_id,
|
|
||||||
product_id=product.id,
|
|
||||||
available_quantity=product_data.stock_quantity
|
|
||||||
)
|
|
||||||
db.add(inventory)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(product)
|
|
||||||
|
|
||||||
return product
|
|
||||||
|
|
||||||
def get_products(
|
|
||||||
self,
|
|
||||||
vendor_id: int,
|
|
||||||
db: Session,
|
|
||||||
is_active: Optional[bool] = None,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
search: Optional[str] = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100
|
|
||||||
) -> List[Product]:
|
|
||||||
"""Get vendor's product catalog"""
|
|
||||||
|
|
||||||
query = db.query(Product).filter(Product.vendor_id == vendor_id)
|
|
||||||
|
|
||||||
if is_active is not None:
|
|
||||||
query = query.filter(Product.is_active == is_active)
|
|
||||||
|
|
||||||
if category:
|
|
||||||
query = query.filter(Product.category == category)
|
|
||||||
|
|
||||||
if search:
|
|
||||||
query = query.filter(
|
|
||||||
or_(
|
|
||||||
Product.title.ilike(f"%{search}%"),
|
|
||||||
Product.sku.ilike(f"%{search}%"),
|
|
||||||
Product.brand.ilike(f"%{search}%")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return query.order_by(
|
|
||||||
Product.created_at.desc()
|
|
||||||
).offset(skip).limit(limit).all()
|
|
||||||
|
|
||||||
async def update_product(
|
|
||||||
self,
|
|
||||||
vendor_id: int,
|
|
||||||
product_id: int,
|
|
||||||
update_data: ProductUpdate,
|
|
||||||
db: Session
|
|
||||||
) -> Product:
|
|
||||||
"""Update product details"""
|
|
||||||
|
|
||||||
product = db.query(Product).filter(
|
|
||||||
Product.id == product_id,
|
|
||||||
Product.vendor_id == vendor_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not product:
|
|
||||||
raise ProductNotFoundError()
|
|
||||||
|
|
||||||
# Update fields
|
|
||||||
update_dict = update_data.dict(exclude_unset=True)
|
|
||||||
for field, value in update_dict.items():
|
|
||||||
setattr(product, field, value)
|
|
||||||
|
|
||||||
# Update stock if changed
|
|
||||||
if 'stock_quantity' in update_dict:
|
|
||||||
self._update_inventory(product, update_dict['stock_quantity'], db)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(product)
|
|
||||||
|
|
||||||
return product
|
|
||||||
|
|
||||||
def _generate_slug(self, title: str) -> str:
|
|
||||||
"""Generate URL-friendly slug"""
|
|
||||||
import re
|
|
||||||
slug = title.lower()
|
|
||||||
slug = re.sub(r'[^a-z0-9]+', '-', slug)
|
|
||||||
slug = slug.strip('-')
|
|
||||||
return slug
|
|
||||||
|
|
||||||
def _update_inventory(
|
|
||||||
self,
|
|
||||||
product: Product,
|
|
||||||
new_quantity: int,
|
|
||||||
db: Session
|
|
||||||
):
|
|
||||||
"""Update product inventory"""
|
|
||||||
inventory = db.query(Inventory).filter(
|
|
||||||
Inventory.product_id == product.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if inventory:
|
|
||||||
quantity_change = new_quantity - inventory.available_quantity
|
|
||||||
inventory.available_quantity = new_quantity
|
|
||||||
|
|
||||||
# Record movement
|
|
||||||
movement = InventoryMovement(
|
|
||||||
inventory_id=inventory.id,
|
|
||||||
movement_type="adjusted",
|
|
||||||
quantity_change=quantity_change,
|
|
||||||
reference_type="manual",
|
|
||||||
notes="Manual adjustment"
|
|
||||||
)
|
|
||||||
db.add(movement)
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
|
|
||||||
#### Product Endpoints (`app/api/v1/vendor/products.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.get("", response_model=List[ProductResponse])
|
|
||||||
async def get_products(
|
|
||||||
is_active: Optional[bool] = None,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
search: Optional[str] = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get vendor's product catalog"""
|
|
||||||
service = ProductService()
|
|
||||||
products = service.get_products(
|
|
||||||
vendor.id, db, is_active, category, search, skip, limit
|
|
||||||
)
|
|
||||||
return products
|
|
||||||
|
|
||||||
@router.post("", response_model=ProductResponse)
|
|
||||||
async def create_product(
|
|
||||||
product_data: ProductCreate,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Create product manually"""
|
|
||||||
service = ProductService()
|
|
||||||
product = await service.create_product(vendor.id, product_data, db)
|
|
||||||
return product
|
|
||||||
|
|
||||||
@router.post("/from-marketplace", response_model=ProductResponse)
|
|
||||||
async def publish_from_marketplace(
|
|
||||||
publish_data: ProductPublishFromMarketplace,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Publish marketplace product to catalog"""
|
|
||||||
service = ProductService()
|
|
||||||
product = await service.publish_from_marketplace(
|
|
||||||
vendor.id, publish_data, db
|
|
||||||
)
|
|
||||||
return product
|
|
||||||
|
|
||||||
@router.get("/{product_id}", response_model=ProductResponse)
|
|
||||||
async def get_product(
|
|
||||||
product_id: int,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get product details"""
|
|
||||||
product = db.query(Product).filter(
|
|
||||||
Product.id == product_id,
|
|
||||||
Product.vendor_id == vendor.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
|
||||||
|
|
||||||
return product
|
|
||||||
|
|
||||||
@router.put("/{product_id}", response_model=ProductResponse)
|
|
||||||
async def update_product(
|
|
||||||
product_id: int,
|
|
||||||
update_data: ProductUpdate,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Update product"""
|
|
||||||
service = ProductService()
|
|
||||||
product = await service.update_product(vendor.id, product_id, update_data, db)
|
|
||||||
return product
|
|
||||||
|
|
||||||
@router.put("/{product_id}/toggle-active")
|
|
||||||
async def toggle_product_active(
|
|
||||||
product_id: int,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Activate/deactivate product"""
|
|
||||||
product = db.query(Product).filter(
|
|
||||||
Product.id == product_id,
|
|
||||||
Product.vendor_id == vendor.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
|
||||||
|
|
||||||
product.is_active = not product.is_active
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"is_active": product.is_active}
|
|
||||||
|
|
||||||
@router.delete("/{product_id}")
|
|
||||||
async def delete_product(
|
|
||||||
product_id: int,
|
|
||||||
current_user: User = Depends(get_current_vendor_user),
|
|
||||||
vendor: Vendor = Depends(get_current_vendor),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Remove product from catalog"""
|
|
||||||
product = db.query(Product).filter(
|
|
||||||
Product.id == product_id,
|
|
||||||
Product.vendor_id == vendor.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
|
||||||
|
|
||||||
# Mark as inactive instead of deleting
|
|
||||||
product.is_active = False
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 Frontend Implementation
|
|
||||||
|
|
||||||
### Templates
|
|
||||||
|
|
||||||
#### Browse Marketplace Products (`templates/vendor/marketplace/browse.html`)
|
|
||||||
|
|
||||||
Uses Alpine.js for reactive filtering, selection, and bulk publishing.
|
|
||||||
|
|
||||||
#### Product Catalog (`templates/vendor/products/list.html`)
|
|
||||||
|
|
||||||
Product management interface with search, filters, and quick actions.
|
|
||||||
|
|
||||||
#### Product Edit (`templates/vendor/products/edit.html`)
|
|
||||||
|
|
||||||
Detailed product editing with image management and inventory tracking.
|
|
||||||
|
|
||||||
## ✅ Testing Checklist
|
|
||||||
|
|
||||||
### Backend Tests
|
|
||||||
- [ ] Product publishing from marketplace works
|
|
||||||
- [ ] Manual product creation works
|
|
||||||
- [ ] Product updates work correctly
|
|
||||||
- [ ] Inventory tracking is accurate
|
|
||||||
- [ ] SKU uniqueness is enforced
|
|
||||||
- [ ] Vendor isolation maintained
|
|
||||||
- [ ] Product search/filtering works
|
|
||||||
- [ ] Slug generation works correctly
|
|
||||||
|
|
||||||
### Frontend Tests
|
|
||||||
- [ ] Browse marketplace products
|
|
||||||
- [ ] Select multiple products for publishing
|
|
||||||
- [ ] Publish single product with customization
|
|
||||||
- [ ] View product catalog
|
|
||||||
- [ ] Edit product details
|
|
||||||
- [ ] Toggle product active status
|
|
||||||
- [ ] Delete/deactivate products
|
|
||||||
- [ ] Search and filter products
|
|
||||||
|
|
||||||
## ➡️ Next Steps
|
|
||||||
|
|
||||||
After completing Slice 3, move to **Slice 4: Customer Shopping Experience** to build the public-facing shop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Slice 3 Status**: 📋 Not Started
|
|
||||||
**Dependencies**: Slices 1 & 2 must be complete
|
|
||||||
**Estimated Duration**: 5 days
|
|
||||||
@@ -1,887 +0,0 @@
|
|||||||
# Slice 4: Customer Shopping Experience
|
|
||||||
## Customers Browse and Shop on Vendor Stores
|
|
||||||
|
|
||||||
**Status**: 📋 NOT STARTED
|
|
||||||
**Timeline**: Week 4 (5 days)
|
|
||||||
**Prerequisites**: Slices 1, 2, & 3 complete
|
|
||||||
|
|
||||||
## 🎯 Slice Objectives
|
|
||||||
|
|
||||||
Build the public-facing customer shop where customers can browse products, register accounts, and add items to cart.
|
|
||||||
|
|
||||||
### User Stories
|
|
||||||
- As a Customer, I can browse products on a vendor's shop
|
|
||||||
- As a Customer, I can view detailed product information
|
|
||||||
- As a Customer, I can search for products
|
|
||||||
- As a Customer, I can register for a vendor-specific account
|
|
||||||
- As a Customer, I can log into my account
|
|
||||||
- As a Customer, I can add products to my shopping cart
|
|
||||||
- As a Customer, I can manage my cart (update quantities, remove items)
|
|
||||||
- Cart persists across sessions
|
|
||||||
|
|
||||||
### Success Criteria
|
|
||||||
- [ ] Customers can browse products without authentication
|
|
||||||
- [ ] Product catalog displays correctly with images and prices
|
|
||||||
- [ ] Product detail pages show complete information
|
|
||||||
- [ ] Search functionality works
|
|
||||||
- [ ] Customers can register vendor-specific accounts
|
|
||||||
- [ ] Customer login/logout works
|
|
||||||
- [ ] Shopping cart is functional with Alpine.js reactivity
|
|
||||||
- [ ] Cart persists (session-based before login, user-based after)
|
|
||||||
- [ ] Customer data is properly isolated by vendor
|
|
||||||
- [ ] Mobile responsive design
|
|
||||||
|
|
||||||
## 📋 Backend Implementation
|
|
||||||
|
|
||||||
### Database Models
|
|
||||||
|
|
||||||
#### Customer Model (`models/database/customer.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Customer(Base, TimestampMixin):
|
|
||||||
"""
|
|
||||||
Vendor-scoped customer accounts
|
|
||||||
Each customer belongs to ONE vendor
|
|
||||||
"""
|
|
||||||
__tablename__ = "customers"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
|
||||||
|
|
||||||
# Authentication
|
|
||||||
email = Column(String, nullable=False, index=True)
|
|
||||||
hashed_password = Column(String, nullable=False)
|
|
||||||
|
|
||||||
# Personal information
|
|
||||||
first_name = Column(String)
|
|
||||||
last_name = Column(String)
|
|
||||||
phone = Column(String)
|
|
||||||
|
|
||||||
# Customer metadata
|
|
||||||
customer_number = Column(String, unique=True, index=True) # Auto-generated
|
|
||||||
|
|
||||||
# Preferences
|
|
||||||
language = Column(String(2), default="en")
|
|
||||||
newsletter_subscribed = Column(Boolean, default=False)
|
|
||||||
marketing_emails = Column(Boolean, default=True)
|
|
||||||
preferences = Column(JSON, default=dict)
|
|
||||||
|
|
||||||
# Statistics
|
|
||||||
total_orders = Column(Integer, default=0)
|
|
||||||
total_spent = Column(Numeric(10, 2), default=0)
|
|
||||||
|
|
||||||
# Status
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
email_verified = Column(Boolean, default=False)
|
|
||||||
last_login_at = Column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
vendor = relationship("Vendor", back_populates="customers")
|
|
||||||
addresses = relationship("CustomerAddress", back_populates="customer", cascade="all, delete-orphan")
|
|
||||||
orders = relationship("Order", back_populates="customer")
|
|
||||||
cart = relationship("Cart", back_populates="customer", uselist=False)
|
|
||||||
|
|
||||||
# Indexes
|
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_customer_vendor_email', 'vendor_id', 'email', unique=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
class CustomerAddress(Base, TimestampMixin):
|
|
||||||
"""Customer shipping/billing addresses"""
|
|
||||||
__tablename__ = "customer_addresses"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
|
|
||||||
|
|
||||||
# Address type
|
|
||||||
address_type = Column(String, default="shipping") # shipping, billing, both
|
|
||||||
is_default = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Address details
|
|
||||||
first_name = Column(String)
|
|
||||||
last_name = Column(String)
|
|
||||||
company = Column(String)
|
|
||||||
address_line1 = Column(String, nullable=False)
|
|
||||||
address_line2 = Column(String)
|
|
||||||
city = Column(String, nullable=False)
|
|
||||||
state_province = Column(String)
|
|
||||||
postal_code = Column(String, nullable=False)
|
|
||||||
country = Column(String, nullable=False, default="LU")
|
|
||||||
phone = Column(String)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
customer = relationship("Customer", back_populates="addresses")
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Cart Model (`models/database/cart.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Cart(Base, TimestampMixin):
|
|
||||||
"""Shopping cart - session or customer-based"""
|
|
||||||
__tablename__ = "carts"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
|
||||||
|
|
||||||
# Owner (one of these must be set)
|
|
||||||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
|
|
||||||
session_id = Column(String, nullable=True, index=True) # For guest users
|
|
||||||
|
|
||||||
# Cart metadata
|
|
||||||
currency = Column(String(3), default="EUR")
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
vendor = relationship("Vendor")
|
|
||||||
customer = relationship("Customer", back_populates="cart")
|
|
||||||
items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
# Computed properties
|
|
||||||
@property
|
|
||||||
def total_items(self) -> int:
|
|
||||||
return sum(item.quantity for item in self.items)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def subtotal(self) -> Decimal:
|
|
||||||
return sum(item.line_total for item in self.items)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index('ix_cart_vendor_session', 'vendor_id', 'session_id'),
|
|
||||||
Index('ix_cart_vendor_customer', 'vendor_id', 'customer_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
class CartItem(Base, TimestampMixin):
|
|
||||||
"""Individual items in cart"""
|
|
||||||
__tablename__ = "cart_items"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
cart_id = Column(Integer, ForeignKey("carts.id"), nullable=False)
|
|
||||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
|
||||||
|
|
||||||
# Item details
|
|
||||||
quantity = Column(Integer, nullable=False, default=1)
|
|
||||||
unit_price = Column(Numeric(10, 2), nullable=False) # Snapshot at time of add
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
cart = relationship("Cart", back_populates="items")
|
|
||||||
product = relationship("Product")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def line_total(self) -> Decimal:
|
|
||||||
return self.unit_price * self.quantity
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pydantic Schemas
|
|
||||||
|
|
||||||
#### Customer Schemas (`models/schema/customer.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class CustomerRegister(BaseModel):
|
|
||||||
"""Customer registration"""
|
|
||||||
email: EmailStr
|
|
||||||
password: str = Field(..., min_length=8)
|
|
||||||
first_name: str
|
|
||||||
last_name: str
|
|
||||||
phone: Optional[str] = None
|
|
||||||
newsletter_subscribed: bool = False
|
|
||||||
|
|
||||||
class CustomerLogin(BaseModel):
|
|
||||||
"""Customer login"""
|
|
||||||
email: EmailStr
|
|
||||||
password: str
|
|
||||||
|
|
||||||
class CustomerResponse(BaseModel):
|
|
||||||
"""Customer details"""
|
|
||||||
id: int
|
|
||||||
vendor_id: int
|
|
||||||
email: str
|
|
||||||
first_name: str
|
|
||||||
last_name: str
|
|
||||||
phone: Optional[str]
|
|
||||||
customer_number: str
|
|
||||||
total_orders: int
|
|
||||||
total_spent: float
|
|
||||||
is_active: bool
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class CustomerAddressCreate(BaseModel):
|
|
||||||
"""Create address"""
|
|
||||||
address_type: str = "shipping"
|
|
||||||
is_default: bool = False
|
|
||||||
first_name: str
|
|
||||||
last_name: str
|
|
||||||
company: Optional[str] = None
|
|
||||||
address_line1: str
|
|
||||||
address_line2: Optional[str] = None
|
|
||||||
city: str
|
|
||||||
state_province: Optional[str] = None
|
|
||||||
postal_code: str
|
|
||||||
country: str = "LU"
|
|
||||||
phone: Optional[str] = None
|
|
||||||
|
|
||||||
class CustomerAddressResponse(BaseModel):
|
|
||||||
"""Address details"""
|
|
||||||
id: int
|
|
||||||
address_type: str
|
|
||||||
is_default: bool
|
|
||||||
first_name: str
|
|
||||||
last_name: str
|
|
||||||
company: Optional[str]
|
|
||||||
address_line1: str
|
|
||||||
address_line2: Optional[str]
|
|
||||||
city: str
|
|
||||||
state_province: Optional[str]
|
|
||||||
postal_code: str
|
|
||||||
country: str
|
|
||||||
phone: Optional[str]
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Cart Schemas (`models/schema/cart.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class CartItemAdd(BaseModel):
|
|
||||||
"""Add item to cart"""
|
|
||||||
product_id: int
|
|
||||||
quantity: int = Field(..., gt=0)
|
|
||||||
|
|
||||||
class CartItemUpdate(BaseModel):
|
|
||||||
"""Update cart item"""
|
|
||||||
quantity: int = Field(..., gt=0)
|
|
||||||
|
|
||||||
class CartItemResponse(BaseModel):
|
|
||||||
"""Cart item details"""
|
|
||||||
id: int
|
|
||||||
product_id: int
|
|
||||||
product_title: str
|
|
||||||
product_image: Optional[str]
|
|
||||||
product_sku: str
|
|
||||||
quantity: int
|
|
||||||
unit_price: float
|
|
||||||
line_total: float
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class CartResponse(BaseModel):
|
|
||||||
"""Complete cart"""
|
|
||||||
id: int
|
|
||||||
vendor_id: int
|
|
||||||
total_items: int
|
|
||||||
subtotal: float
|
|
||||||
currency: str
|
|
||||||
items: List[CartItemResponse]
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service Layer
|
|
||||||
|
|
||||||
#### Customer Service (`app/services/customer_service.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class CustomerService:
|
|
||||||
"""Handle customer operations"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.auth_manager = AuthManager()
|
|
||||||
|
|
||||||
async def register_customer(
|
|
||||||
self,
|
|
||||||
vendor_id: int,
|
|
||||||
customer_data: CustomerRegister,
|
|
||||||
db: Session
|
|
||||||
) -> Customer:
|
|
||||||
"""Register new customer for vendor"""
|
|
||||||
|
|
||||||
# Check if email already exists for this vendor
|
|
||||||
existing = db.query(Customer).filter(
|
|
||||||
Customer.vendor_id == vendor_id,
|
|
||||||
Customer.email == customer_data.email
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
raise CustomerAlreadyExistsError("Email already registered")
|
|
||||||
|
|
||||||
# Generate customer number
|
|
||||||
customer_number = self._generate_customer_number(vendor_id, db)
|
|
||||||
|
|
||||||
# Create customer
|
|
||||||
customer = Customer(
|
|
||||||
vendor_id=vendor_id,
|
|
||||||
email=customer_data.email,
|
|
||||||
hashed_password=self.auth_manager.hash_password(customer_data.password),
|
|
||||||
first_name=customer_data.first_name,
|
|
||||||
last_name=customer_data.last_name,
|
|
||||||
phone=customer_data.phone,
|
|
||||||
customer_number=customer_number,
|
|
||||||
newsletter_subscribed=customer_data.newsletter_subscribed,
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(customer)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(customer)
|
|
||||||
|
|
||||||
return customer
|
|
||||||
|
|
||||||
async def authenticate_customer(
|
|
||||||
self,
|
|
||||||
vendor_id: int,
|
|
||||||
email: str,
|
|
||||||
password: str,
|
|
||||||
db: Session
|
|
||||||
) -> Tuple[Customer, str]:
|
|
||||||
"""Authenticate customer and return token"""
|
|
||||||
|
|
||||||
customer = db.query(Customer).filter(
|
|
||||||
Customer.vendor_id == vendor_id,
|
|
||||||
Customer.email == email
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not customer:
|
|
||||||
raise InvalidCredentialsError()
|
|
||||||
|
|
||||||
if not customer.is_active:
|
|
||||||
raise CustomerInactiveError()
|
|
||||||
|
|
||||||
if not self.auth_manager.verify_password(password, customer.hashed_password):
|
|
||||||
raise InvalidCredentialsError()
|
|
||||||
|
|
||||||
# Update last login
|
|
||||||
customer.last_login_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Generate JWT token
|
|
||||||
token = self.auth_manager.create_access_token({
|
|
||||||
"sub": str(customer.id),
|
|
||||||
"email": customer.email,
|
|
||||||
"vendor_id": vendor_id,
|
|
||||||
"type": "customer"
|
|
||||||
})
|
|
||||||
|
|
||||||
return customer, token
|
|
||||||
|
|
||||||
def _generate_customer_number(self, vendor_id: int, db: Session) -> str:
|
|
||||||
"""Generate unique customer number"""
|
|
||||||
# Format: VENDOR_CODE-YYYYMMDD-XXXX
|
|
||||||
from models.database.vendor import Vendor
|
|
||||||
|
|
||||||
vendor = db.query(Vendor).get(vendor_id)
|
|
||||||
date_str = datetime.utcnow().strftime("%Y%m%d")
|
|
||||||
|
|
||||||
# Count customers today
|
|
||||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0)
|
|
||||||
count = db.query(Customer).filter(
|
|
||||||
Customer.vendor_id == vendor_id,
|
|
||||||
Customer.created_at >= today_start
|
|
||||||
).count()
|
|
||||||
|
|
||||||
return f"{vendor.vendor_code}-{date_str}-{count+1:04d}"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Cart Service (`app/services/cart_service.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class CartService:
|
|
||||||
"""Handle shopping cart operations"""
|
|
||||||
|
|
||||||
async def get_or_create_cart(
|
|
||||||
self,
|
|
||||||
vendor_id: int,
|
|
||||||
db: Session,
|
|
||||||
customer_id: Optional[int] = None,
|
|
||||||
session_id: Optional[str] = None
|
|
||||||
) -> Cart:
|
|
||||||
"""Get existing cart or create new one"""
|
|
||||||
|
|
||||||
if customer_id:
|
|
||||||
cart = db.query(Cart).filter(
|
|
||||||
Cart.vendor_id == vendor_id,
|
|
||||||
Cart.customer_id == customer_id
|
|
||||||
).first()
|
|
||||||
else:
|
|
||||||
cart = db.query(Cart).filter(
|
|
||||||
Cart.vendor_id == vendor_id,
|
|
||||||
Cart.session_id == session_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not cart:
|
|
||||||
cart = Cart(
|
|
||||||
vendor_id=vendor_id,
|
|
||||||
customer_id=customer_id,
|
|
||||||
session_id=session_id
|
|
||||||
)
|
|
||||||
db.add(cart)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(cart)
|
|
||||||
|
|
||||||
return cart
|
|
||||||
|
|
||||||
async def add_to_cart(
|
|
||||||
self,
|
|
||||||
cart: Cart,
|
|
||||||
product_id: int,
|
|
||||||
quantity: int,
|
|
||||||
db: Session
|
|
||||||
) -> CartItem:
|
|
||||||
"""Add product to cart"""
|
|
||||||
|
|
||||||
# Verify product exists and is active
|
|
||||||
product = db.query(Product).filter(
|
|
||||||
Product.id == product_id,
|
|
||||||
Product.vendor_id == cart.vendor_id,
|
|
||||||
Product.is_active == True
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not product:
|
|
||||||
raise ProductNotFoundError()
|
|
||||||
|
|
||||||
# Check if product already in cart
|
|
||||||
existing_item = db.query(CartItem).filter(
|
|
||||||
CartItem.cart_id == cart.id,
|
|
||||||
CartItem.product_id == product_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_item:
|
|
||||||
# Update quantity
|
|
||||||
existing_item.quantity += quantity
|
|
||||||
db.commit()
|
|
||||||
db.refresh(existing_item)
|
|
||||||
return existing_item
|
|
||||||
else:
|
|
||||||
# Add new item
|
|
||||||
cart_item = CartItem(
|
|
||||||
cart_id=cart.id,
|
|
||||||
product_id=product_id,
|
|
||||||
quantity=quantity,
|
|
||||||
unit_price=product.price
|
|
||||||
)
|
|
||||||
db.add(cart_item)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(cart_item)
|
|
||||||
return cart_item
|
|
||||||
|
|
||||||
async def update_cart_item(
|
|
||||||
self,
|
|
||||||
cart_item_id: int,
|
|
||||||
quantity: int,
|
|
||||||
cart: Cart,
|
|
||||||
db: Session
|
|
||||||
) -> CartItem:
|
|
||||||
"""Update cart item quantity"""
|
|
||||||
|
|
||||||
cart_item = db.query(CartItem).filter(
|
|
||||||
CartItem.id == cart_item_id,
|
|
||||||
CartItem.cart_id == cart.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not cart_item:
|
|
||||||
raise CartItemNotFoundError()
|
|
||||||
|
|
||||||
cart_item.quantity = quantity
|
|
||||||
db.commit()
|
|
||||||
db.refresh(cart_item)
|
|
||||||
|
|
||||||
return cart_item
|
|
||||||
|
|
||||||
async def remove_from_cart(
|
|
||||||
self,
|
|
||||||
cart_item_id: int,
|
|
||||||
cart: Cart,
|
|
||||||
db: Session
|
|
||||||
):
|
|
||||||
"""Remove item from cart"""
|
|
||||||
|
|
||||||
cart_item = db.query(CartItem).filter(
|
|
||||||
CartItem.id == cart_item_id,
|
|
||||||
CartItem.cart_id == cart.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not cart_item:
|
|
||||||
raise CartItemNotFoundError()
|
|
||||||
|
|
||||||
db.delete(cart_item)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
async def clear_cart(self, cart: Cart, db: Session):
|
|
||||||
"""Clear all items from cart"""
|
|
||||||
|
|
||||||
db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
async def merge_carts(
|
|
||||||
self,
|
|
||||||
session_cart_id: int,
|
|
||||||
customer_cart_id: int,
|
|
||||||
db: Session
|
|
||||||
):
|
|
||||||
"""Merge session cart into customer cart after login"""
|
|
||||||
|
|
||||||
session_cart = db.query(Cart).get(session_cart_id)
|
|
||||||
customer_cart = db.query(Cart).get(customer_cart_id)
|
|
||||||
|
|
||||||
if not session_cart or not customer_cart:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Move items from session cart to customer cart
|
|
||||||
for item in session_cart.items:
|
|
||||||
# Check if product already in customer cart
|
|
||||||
existing = db.query(CartItem).filter(
|
|
||||||
CartItem.cart_id == customer_cart.id,
|
|
||||||
CartItem.product_id == item.product_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
existing.quantity += item.quantity
|
|
||||||
else:
|
|
||||||
item.cart_id = customer_cart.id
|
|
||||||
|
|
||||||
# Delete session cart
|
|
||||||
db.delete(session_cart)
|
|
||||||
db.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
|
|
||||||
#### Public Product Endpoints (`app/api/v1/public/vendors/products.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.get("", response_model=List[ProductResponse])
|
|
||||||
async def get_public_products(
|
|
||||||
vendor_id: int,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
search: Optional[str] = None,
|
|
||||||
min_price: Optional[float] = None,
|
|
||||||
max_price: Optional[float] = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 50,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get public product catalog (no auth required)"""
|
|
||||||
|
|
||||||
query = db.query(Product).filter(
|
|
||||||
Product.vendor_id == vendor_id,
|
|
||||||
Product.is_active == True
|
|
||||||
)
|
|
||||||
|
|
||||||
if category:
|
|
||||||
query = query.filter(Product.category == category)
|
|
||||||
|
|
||||||
if search:
|
|
||||||
query = query.filter(
|
|
||||||
or_(
|
|
||||||
Product.title.ilike(f"%{search}%"),
|
|
||||||
Product.description.ilike(f"%{search}%")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if min_price:
|
|
||||||
query = query.filter(Product.price >= min_price)
|
|
||||||
|
|
||||||
if max_price:
|
|
||||||
query = query.filter(Product.price <= max_price)
|
|
||||||
|
|
||||||
products = query.order_by(
|
|
||||||
Product.is_featured.desc(),
|
|
||||||
Product.created_at.desc()
|
|
||||||
).offset(skip).limit(limit).all()
|
|
||||||
|
|
||||||
return products
|
|
||||||
|
|
||||||
@router.get("/{product_id}", response_model=ProductResponse)
|
|
||||||
async def get_public_product(
|
|
||||||
vendor_id: int,
|
|
||||||
product_id: int,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get product details (no auth required)"""
|
|
||||||
|
|
||||||
product = db.query(Product).filter(
|
|
||||||
Product.id == product_id,
|
|
||||||
Product.vendor_id == vendor_id,
|
|
||||||
Product.is_active == True
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not product:
|
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
|
||||||
|
|
||||||
return product
|
|
||||||
|
|
||||||
@router.get("/search")
|
|
||||||
async def search_products(
|
|
||||||
vendor_id: int,
|
|
||||||
q: str,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Search products"""
|
|
||||||
# Implement search logic
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Customer Auth Endpoints (`app/api/v1/public/vendors/auth.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.post("/register", response_model=CustomerResponse)
|
|
||||||
async def register_customer(
|
|
||||||
vendor_id: int,
|
|
||||||
customer_data: CustomerRegister,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Register new customer"""
|
|
||||||
service = CustomerService()
|
|
||||||
customer = await service.register_customer(vendor_id, customer_data, db)
|
|
||||||
return customer
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
async def login_customer(
|
|
||||||
vendor_id: int,
|
|
||||||
credentials: CustomerLogin,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Customer login"""
|
|
||||||
service = CustomerService()
|
|
||||||
customer, token = await service.authenticate_customer(
|
|
||||||
vendor_id, credentials.email, credentials.password, db
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"access_token": token,
|
|
||||||
"token_type": "bearer",
|
|
||||||
"customer": CustomerResponse.from_orm(customer)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Cart Endpoints (`app/api/v1/public/vendors/cart.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.get("/{session_id}", response_model=CartResponse)
|
|
||||||
async def get_cart(
|
|
||||||
vendor_id: int,
|
|
||||||
session_id: str,
|
|
||||||
current_customer: Optional[Customer] = Depends(get_current_customer_optional),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Get cart (session or customer)"""
|
|
||||||
service = CartService()
|
|
||||||
cart = await service.get_or_create_cart(
|
|
||||||
vendor_id,
|
|
||||||
db,
|
|
||||||
customer_id=current_customer.id if current_customer else None,
|
|
||||||
session_id=session_id if not current_customer else None
|
|
||||||
)
|
|
||||||
return cart
|
|
||||||
|
|
||||||
@router.post("/{session_id}/items", response_model=CartItemResponse)
|
|
||||||
async def add_to_cart(
|
|
||||||
vendor_id: int,
|
|
||||||
session_id: str,
|
|
||||||
item_data: CartItemAdd,
|
|
||||||
current_customer: Optional[Customer] = Depends(get_current_customer_optional),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Add item to cart"""
|
|
||||||
service = CartService()
|
|
||||||
cart = await service.get_or_create_cart(vendor_id, db, current_customer.id if current_customer else None, session_id)
|
|
||||||
item = await service.add_to_cart(cart, item_data.product_id, item_data.quantity, db)
|
|
||||||
return item
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 Frontend Implementation
|
|
||||||
|
|
||||||
### Templates
|
|
||||||
|
|
||||||
#### Shop Homepage (`templates/shop/home.html`)
|
|
||||||
|
|
||||||
```html
|
|
||||||
{% extends "shop/base_shop.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div x-data="shopHome()" x-init="loadFeaturedProducts()">
|
|
||||||
<!-- Hero Section -->
|
|
||||||
<div class="hero-section">
|
|
||||||
<h1>Welcome to {{ vendor.name }}</h1>
|
|
||||||
<p>{{ vendor.description }}</p>
|
|
||||||
<a href="/shop/products" class="btn btn-primary btn-lg">
|
|
||||||
Shop Now
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Featured Products -->
|
|
||||||
<div class="products-section">
|
|
||||||
<h2>Featured Products</h2>
|
|
||||||
<div class="product-grid">
|
|
||||||
<template x-for="product in featuredProducts" :key="product.id">
|
|
||||||
<div class="product-card">
|
|
||||||
<a :href="`/shop/products/${product.id}`">
|
|
||||||
<img :src="product.featured_image || '/static/images/no-image.png'"
|
|
||||||
:alt="product.title">
|
|
||||||
<h3 x-text="product.title"></h3>
|
|
||||||
<p class="price">€<span x-text="product.price.toFixed(2)"></span></p>
|
|
||||||
</a>
|
|
||||||
<button @click="addToCart(product.id)" class="btn btn-primary btn-sm">
|
|
||||||
Add to Cart
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Product Detail (`templates/shop/product.html`)
|
|
||||||
|
|
||||||
```html
|
|
||||||
{% extends "shop/base_shop.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div x-data="productDetail()" x-init="loadProduct()">
|
|
||||||
<div class="product-detail">
|
|
||||||
<!-- Product Images -->
|
|
||||||
<div class="product-images">
|
|
||||||
<img :src="product.featured_image" :alt="product.title" class="main-image">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Product Info -->
|
|
||||||
<div class="product-info">
|
|
||||||
<h1 x-text="product.title"></h1>
|
|
||||||
<div class="price-section">
|
|
||||||
<span class="price">€<span x-text="product.price"></span></span>
|
|
||||||
<template x-if="product.compare_at_price">
|
|
||||||
<span class="compare-price">€<span x-text="product.compare_at_price"></span></span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="product-description" x-html="product.description"></div>
|
|
||||||
|
|
||||||
<!-- Quantity Selector -->
|
|
||||||
<div class="quantity-selector">
|
|
||||||
<label>Quantity</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
x-model.number="quantity"
|
|
||||||
:min="1"
|
|
||||||
:max="product.stock_quantity"
|
|
||||||
>
|
|
||||||
<span class="stock-info" x-text="`${product.stock_quantity} in stock`"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add to Cart -->
|
|
||||||
<button
|
|
||||||
@click="addToCart()"
|
|
||||||
class="btn btn-primary btn-lg"
|
|
||||||
:disabled="!canAddToCart || adding"
|
|
||||||
>
|
|
||||||
<span x-show="!adding">Add to Cart</span>
|
|
||||||
<span x-show="adding" class="loading-spinner"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.productId = {{ product.id }};
|
|
||||||
window.vendorId = {{ vendor.id }};
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_scripts %}
|
|
||||||
<script>
|
|
||||||
function productDetail() {
|
|
||||||
return {
|
|
||||||
product: {},
|
|
||||||
quantity: 1,
|
|
||||||
adding: false,
|
|
||||||
loading: false,
|
|
||||||
|
|
||||||
get canAddToCart() {
|
|
||||||
return this.product.stock_quantity >= this.quantity && this.quantity > 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadProduct() {
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
this.product = await apiClient.get(
|
|
||||||
`/api/v1/public/vendors/${window.vendorId}/products/${window.productId}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
showNotification('Failed to load product', 'error');
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async addToCart() {
|
|
||||||
this.adding = true;
|
|
||||||
try {
|
|
||||||
const sessionId = getOrCreateSessionId();
|
|
||||||
await apiClient.post(
|
|
||||||
`/api/v1/public/vendors/${window.vendorId}/cart/${sessionId}/items`,
|
|
||||||
{
|
|
||||||
product_id: this.product.id,
|
|
||||||
quantity: this.quantity
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
showNotification('Added to cart!', 'success');
|
|
||||||
updateCartCount(); // Update cart icon
|
|
||||||
} catch (error) {
|
|
||||||
showNotification(error.message || 'Failed to add to cart', 'error');
|
|
||||||
} finally {
|
|
||||||
this.adding = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Shopping Cart (`templates/shop/cart.html`)
|
|
||||||
|
|
||||||
Full Alpine.js reactive cart with real-time totals and quantity updates.
|
|
||||||
|
|
||||||
## ✅ Testing Checklist
|
|
||||||
|
|
||||||
### Backend Tests
|
|
||||||
- [ ] Customer registration works
|
|
||||||
- [ ] Duplicate email prevention works
|
|
||||||
- [ ] Customer login/authentication works
|
|
||||||
- [ ] Customer number generation is unique
|
|
||||||
- [ ] Public product browsing works without auth
|
|
||||||
- [ ] Product search/filtering works
|
|
||||||
- [ ] Cart creation works (session and customer)
|
|
||||||
- [ ] Add to cart works
|
|
||||||
- [ ] Update cart quantity works
|
|
||||||
- [ ] Remove from cart works
|
|
||||||
- [ ] Cart persists across sessions
|
|
||||||
- [ ] Cart merges after login
|
|
||||||
- [ ] Vendor isolation maintained
|
|
||||||
|
|
||||||
### Frontend Tests
|
|
||||||
- [ ] Shop homepage loads
|
|
||||||
- [ ] Product listing displays
|
|
||||||
- [ ] Product search works
|
|
||||||
- [ ] Product detail page works
|
|
||||||
- [ ] Customer registration form works
|
|
||||||
- [ ] Customer login works
|
|
||||||
- [ ] Add to cart works
|
|
||||||
- [ ] Cart updates in real-time (Alpine.js)
|
|
||||||
- [ ] Cart icon shows count
|
|
||||||
- [ ] Mobile responsive
|
|
||||||
|
|
||||||
## ➡️ Next Steps
|
|
||||||
|
|
||||||
After completing Slice 4, move to **Slice 5: Order Processing** to complete the checkout flow and order management.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Slice 4 Status**: 📋 Not Started
|
|
||||||
**Dependencies**: Slices 1, 2, & 3 must be complete
|
|
||||||
**Estimated Duration**: 5 days
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,306 +0,0 @@
|
|||||||
# Multi-Tenant Ecommerce Platform - Vertical Slices Overview
|
|
||||||
|
|
||||||
## 📋 Development Approach
|
|
||||||
|
|
||||||
This project follows a **vertical slice development approach**, delivering complete, working user workflows incrementally. Each slice is fully functional and provides immediate value.
|
|
||||||
|
|
||||||
## 🎯 Technology Stack
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- **Framework**: FastAPI (Python 3.11+)
|
|
||||||
- **Database**: PostgreSQL with SQLAlchemy ORM
|
|
||||||
- **Authentication**: JWT tokens with bcrypt
|
|
||||||
- **Background Jobs**: Celery (for async tasks)
|
|
||||||
- **API Documentation**: Auto-generated OpenAPI/Swagger
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **Templating**: Jinja2 (server-side rendering)
|
|
||||||
- **JavaScript Framework**: Alpine.js v3.x (15KB, CDN-based)
|
|
||||||
- **Styling**: Custom CSS with CSS variables
|
|
||||||
- **AJAX**: Vanilla JavaScript with Fetch API
|
|
||||||
- **No Build Step**: Everything runs directly in the browser
|
|
||||||
|
|
||||||
### Why Alpine.js + Jinja2?
|
|
||||||
- ✅ **Lightweight**: Only 15KB, no build step required
|
|
||||||
- ✅ **Perfect Jinja2 Integration**: Works seamlessly with server-side templates
|
|
||||||
- ✅ **Reactive State**: Modern UX without framework complexity
|
|
||||||
- ✅ **Scoped Components**: Natural vendor isolation
|
|
||||||
- ✅ **Progressive Enhancement**: Works even if JS fails
|
|
||||||
- ✅ **Minimal Learning Curve**: Feels like inline JavaScript
|
|
||||||
|
|
||||||
## 📚 Slice Documentation Structure
|
|
||||||
|
|
||||||
Each slice has its own comprehensive markdown file:
|
|
||||||
|
|
||||||
### Slice 1: Multi-Tenant Foundation ✅ IN PROGRESS
|
|
||||||
**File**: `01_slice1_admin_vendor_foundation.md`
|
|
||||||
- Admin creates vendors through admin interface
|
|
||||||
- Vendor owner login with context detection
|
|
||||||
- Complete vendor data isolation
|
|
||||||
- **Status**: Backend mostly complete, frontend in progress
|
|
||||||
|
|
||||||
### Slice 2: Marketplace Integration
|
|
||||||
**File**: `02_slice2_marketplace_import.md`
|
|
||||||
- CSV import from Letzshop marketplace
|
|
||||||
- Background job processing
|
|
||||||
- Product staging area
|
|
||||||
- Import status tracking with Alpine.js
|
|
||||||
|
|
||||||
### Slice 3: Product Catalog Management
|
|
||||||
**File**: `03_slice3_product_catalog.md`
|
|
||||||
- Browse imported products in staging
|
|
||||||
- Select and publish to vendor catalog
|
|
||||||
- Product customization (pricing, descriptions)
|
|
||||||
- Inventory management
|
|
||||||
|
|
||||||
### Slice 4: Customer Shopping Experience
|
|
||||||
**File**: `04_slice4_customer_shopping.md`
|
|
||||||
- Public product browsing
|
|
||||||
- Customer registration/login
|
|
||||||
- Shopping cart with Alpine.js reactivity
|
|
||||||
- Product search functionality
|
|
||||||
|
|
||||||
### Slice 5: Order Processing
|
|
||||||
**File**: `05_slice5_order_processing.md`
|
|
||||||
- Checkout workflow
|
|
||||||
- Order placement
|
|
||||||
- Order management (vendor side)
|
|
||||||
- Order history (customer side)
|
|
||||||
|
|
||||||
## 🎯 Slice Completion Criteria
|
|
||||||
|
|
||||||
Each slice must pass these gates before moving to the next:
|
|
||||||
|
|
||||||
### Technical Criteria
|
|
||||||
- [ ] All backend endpoints implemented and tested
|
|
||||||
- [ ] Frontend pages created with Jinja2 templates
|
|
||||||
- [ ] Alpine.js components working (where applicable)
|
|
||||||
- [ ] Database migrations applied successfully
|
|
||||||
- [ ] Service layer business logic complete
|
|
||||||
- [ ] Exception handling implemented
|
|
||||||
- [ ] API documentation updated
|
|
||||||
|
|
||||||
### Quality Criteria
|
|
||||||
- [ ] Manual testing complete (all user flows)
|
|
||||||
- [ ] Security validation (vendor isolation)
|
|
||||||
- [ ] Performance acceptable (basic load testing)
|
|
||||||
- [ ] No console errors in browser
|
|
||||||
- [ ] Responsive design works on mobile
|
|
||||||
- [ ] Code follows project conventions
|
|
||||||
|
|
||||||
### Documentation Criteria
|
|
||||||
- [ ] Slice markdown file updated
|
|
||||||
- [ ] API endpoints documented
|
|
||||||
- [ ] Frontend components documented
|
|
||||||
- [ ] Database changes documented
|
|
||||||
- [ ] Testing checklist completed
|
|
||||||
|
|
||||||
## 🗓️ Estimated Timeline
|
|
||||||
|
|
||||||
### Week 1: Slice 1 - Foundation ⏳ Current
|
|
||||||
- Days 1-3: Backend completion (vendor context, admin APIs)
|
|
||||||
- Days 4-5: Frontend completion (admin pages, vendor login)
|
|
||||||
- **Deliverable**: Admin can create vendors, vendor owners can log in
|
|
||||||
|
|
||||||
### Week 2: Slice 2 - Import
|
|
||||||
- Days 1-3: Import backend (CSV processing, job tracking)
|
|
||||||
- Days 4-5: Import frontend (upload UI, status tracking)
|
|
||||||
- **Deliverable**: Vendors can import products from Letzshop
|
|
||||||
|
|
||||||
### Week 3: Slice 3 - Catalog
|
|
||||||
- Days 1-3: Catalog backend (product publishing, inventory)
|
|
||||||
- Days 4-5: Catalog frontend (product management UI)
|
|
||||||
- **Deliverable**: Vendors can manage product catalog
|
|
||||||
|
|
||||||
### Week 4: Slice 4 - Shopping
|
|
||||||
- Days 1-3: Customer backend (registration, cart, products)
|
|
||||||
- Days 4-5: Shop frontend (product browsing, cart)
|
|
||||||
- **Deliverable**: Customers can browse and add to cart
|
|
||||||
|
|
||||||
### Week 5: Slice 5 - Orders
|
|
||||||
- Days 1-3: Order backend (checkout, order management)
|
|
||||||
- Days 4-5: Order frontend (checkout flow, order history)
|
|
||||||
- **Deliverable**: Complete order workflow functional
|
|
||||||
|
|
||||||
## 📊 Progress Tracking
|
|
||||||
|
|
||||||
### ✅ Completed
|
|
||||||
- Database schema design
|
|
||||||
- Core models (User, Vendor, Roles)
|
|
||||||
- Authentication system
|
|
||||||
- Admin service layer
|
|
||||||
- Vendor context detection middleware
|
|
||||||
|
|
||||||
### 🔄 In Progress (Slice 1)
|
|
||||||
- Admin frontend pages (login, dashboard, vendors)
|
|
||||||
- Vendor frontend pages (login, dashboard)
|
|
||||||
- Admin API endpoints refinement
|
|
||||||
- Frontend-backend integration
|
|
||||||
|
|
||||||
### 📋 Upcoming (Slice 2)
|
|
||||||
- MarketplaceProduct model
|
|
||||||
- ImportJob model
|
|
||||||
- CSV processing service
|
|
||||||
- Import frontend with Alpine.js
|
|
||||||
|
|
||||||
## 🎨 Frontend Architecture Pattern
|
|
||||||
|
|
||||||
### Page Structure (Jinja2 + Alpine.js)
|
|
||||||
```html
|
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div x-data="componentName()">
|
|
||||||
<!-- Alpine.js reactive component -->
|
|
||||||
<h1 x-text="title"></h1>
|
|
||||||
|
|
||||||
<!-- Jinja2 for initial data -->
|
|
||||||
<script>
|
|
||||||
window.initialData = {{ data|tojson }};
|
|
||||||
</script>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="/static/js/admin/component.js"></script>
|
|
||||||
{% endblock %}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Alpine.js Component Pattern
|
|
||||||
```javascript
|
|
||||||
function componentName() {
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
data: window.initialData || [],
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
init() {
|
|
||||||
this.loadData();
|
|
||||||
},
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
async loadData() {
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get('/api/endpoint');
|
|
||||||
this.data = response;
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error.message;
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔑 Key Principles
|
|
||||||
|
|
||||||
### 1. Complete Features
|
|
||||||
Each slice delivers a complete, working feature from database to UI.
|
|
||||||
|
|
||||||
### 2. Vendor Isolation
|
|
||||||
All slices maintain strict vendor data isolation and context detection.
|
|
||||||
|
|
||||||
### 3. Progressive Enhancement
|
|
||||||
- HTML works without JavaScript
|
|
||||||
- Alpine.js enhances interactivity
|
|
||||||
- Jinja2 provides server-side rendering
|
|
||||||
|
|
||||||
### 4. API-First Design
|
|
||||||
- Backend exposes RESTful APIs
|
|
||||||
- Frontend consumes APIs via Fetch
|
|
||||||
- Clear separation of concerns
|
|
||||||
|
|
||||||
### 5. Clean Architecture
|
|
||||||
- Service layer for business logic
|
|
||||||
- Repository pattern for data access
|
|
||||||
- Exception-first error handling
|
|
||||||
- Dependency injection
|
|
||||||
|
|
||||||
## 📖 Documentation Files
|
|
||||||
|
|
||||||
### Slice Files (This Directory)
|
|
||||||
- `00_slices_overview.md` - This file
|
|
||||||
- `01_slice1_admin_vendor_foundation.md`
|
|
||||||
- `02_slice2_marketplace_import.md`
|
|
||||||
- `03_slice3_product_catalog.md`
|
|
||||||
- `04_slice4_customer_shopping.md`
|
|
||||||
- `05_slice5_order_processing.md`
|
|
||||||
|
|
||||||
### Supporting Documentation
|
|
||||||
- `../quick_start_guide.md` - Get running in 15 minutes
|
|
||||||
- `../css_structure_guide.txt` - CSS organization
|
|
||||||
- `../css_quick_reference.txt` - CSS usage guide
|
|
||||||
- `../12.project_readme_final.md` - Complete project README
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
|
||||||
|
|
||||||
### For Current Development (Slice 1)
|
|
||||||
1. Read `01_slice1_admin_vendor_foundation.md`
|
|
||||||
2. Follow setup in `../quick_start_guide.md`
|
|
||||||
3. Complete Slice 1 testing checklist
|
|
||||||
4. Move to Slice 2
|
|
||||||
|
|
||||||
### For New Features
|
|
||||||
1. Review this overview
|
|
||||||
2. Read the relevant slice documentation
|
|
||||||
3. Follow the implementation pattern
|
|
||||||
4. Test thoroughly before moving forward
|
|
||||||
|
|
||||||
## 💡 Tips for Success
|
|
||||||
|
|
||||||
### Working with Slices
|
|
||||||
- ✅ Complete one slice fully before starting the next
|
|
||||||
- ✅ Test each slice thoroughly
|
|
||||||
- ✅ Update documentation as you go
|
|
||||||
- ✅ Commit code after each slice completion
|
|
||||||
- ✅ Demo each slice to stakeholders
|
|
||||||
|
|
||||||
### Alpine.js Best Practices
|
|
||||||
- Keep components small and focused
|
|
||||||
- Use `x-data` for component state
|
|
||||||
- Use `x-init` for initialization
|
|
||||||
- Prefer `x-show` over `x-if` for toggles
|
|
||||||
- Use Alpine directives, not vanilla JS DOM manipulation
|
|
||||||
|
|
||||||
### Jinja2 Best Practices
|
|
||||||
- Extend base templates
|
|
||||||
- Use template inheritance
|
|
||||||
- Pass initial data from backend
|
|
||||||
- Keep logic in backend, not templates
|
|
||||||
- Use filters for formatting
|
|
||||||
|
|
||||||
## 🎯 Success Metrics
|
|
||||||
|
|
||||||
### By End of Slice 1
|
|
||||||
- Admin can create vendors ✅
|
|
||||||
- Vendor owners can log in ⏳
|
|
||||||
- Vendor context detection works ✅
|
|
||||||
- Complete data isolation verified
|
|
||||||
|
|
||||||
### By End of Slice 2
|
|
||||||
- Vendors can import CSV files
|
|
||||||
- Import jobs tracked in background
|
|
||||||
- Product staging area functional
|
|
||||||
|
|
||||||
### By End of Slice 3
|
|
||||||
- Products published to catalog
|
|
||||||
- Inventory management working
|
|
||||||
- Product customization enabled
|
|
||||||
|
|
||||||
### By End of Slice 4
|
|
||||||
- Customers can browse products
|
|
||||||
- Shopping cart functional
|
|
||||||
- Customer accounts working
|
|
||||||
|
|
||||||
### By End of Slice 5
|
|
||||||
- Complete checkout workflow
|
|
||||||
- Order management operational
|
|
||||||
- Platform ready for production
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Next Steps**: Start with `01_slice1_admin_vendor_foundation.md` to continue your current work on Slice 1.
|
|
||||||
Reference in New Issue
Block a user