feat: integer cents money handling, order page fixes, and vendor filter persistence
Money Handling Architecture: - Store all monetary values as integer cents (€105.91 = 10591) - Add app/utils/money.py with Money class and conversion helpers - Add static/shared/js/money.js for frontend formatting - Update all database models to use _cents columns (Product, Order, etc.) - Update CSV processor to convert prices to cents on import - Add Alembic migration for Float to Integer conversion - Create .architecture-rules/money.yaml with 7 validation rules - Add docs/architecture/money-handling.md documentation Order Details Page Fixes: - Fix customer name showing 'undefined undefined' - use flat field names - Fix vendor info empty - add vendor_name/vendor_code to OrderDetailResponse - Fix shipping address using wrong nested object structure - Enrich order detail API response with vendor info Vendor Filter Persistence Fixes: - Fix orders.js: restoreSavedVendor now sets selectedVendor and filters - Fix orders.js: init() only loads orders if no saved vendor to restore - Fix marketplace-letzshop.js: restoreSavedVendor calls selectVendor() - Fix marketplace-letzshop.js: clearVendorSelection clears TomSelect dropdown - Align vendor selector placeholder text between pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
321
docs/architecture/money-handling.md
Normal file
321
docs/architecture/money-handling.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Money Handling Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the architecture for handling monetary values in the application. Following industry best practices (Stripe, PayPal, Shopify), all monetary values are stored as **integers representing cents**.
|
||||
|
||||
## Why Integer Cents?
|
||||
|
||||
### The Problem with Floating-Point
|
||||
|
||||
```python
|
||||
# Floating-point arithmetic is imprecise
|
||||
>>> 0.1 + 0.2
|
||||
0.30000000000000004
|
||||
|
||||
>>> 105.91 * 1
|
||||
105.91000000000001
|
||||
```
|
||||
|
||||
This causes issues in financial applications where precision is critical.
|
||||
|
||||
### The Solution: Integer Cents
|
||||
|
||||
```python
|
||||
# Integer arithmetic is always exact
|
||||
>>> 10 + 20 # 0.10 + 0.20 in cents
|
||||
30
|
||||
|
||||
>>> 10591 * 1 # 105.91 EUR in cents
|
||||
10591
|
||||
```
|
||||
|
||||
**€105.91** is stored as **10591** (integer)
|
||||
|
||||
## Conventions
|
||||
|
||||
### Database Schema
|
||||
|
||||
All price/amount columns use `Integer` type with a `_cents` suffix:
|
||||
|
||||
```python
|
||||
# Good - Integer cents
|
||||
price_cents = Column(Integer, nullable=False, default=0)
|
||||
total_amount_cents = Column(Integer, nullable=False)
|
||||
|
||||
# Bad - Never use Float for money
|
||||
price = Column(Float) # DON'T DO THIS
|
||||
```
|
||||
|
||||
**Column naming convention:**
|
||||
- `price_cents` - Product price in cents
|
||||
- `sale_price_cents` - Sale price in cents
|
||||
- `unit_price_cents` - Per-unit price in cents
|
||||
- `total_price_cents` - Line total in cents
|
||||
- `subtotal_cents` - Order subtotal in cents
|
||||
- `tax_amount_cents` - Tax in cents
|
||||
- `shipping_amount_cents` - Shipping cost in cents
|
||||
- `discount_amount_cents` - Discount in cents
|
||||
- `total_amount_cents` - Order total in cents
|
||||
|
||||
### Python Code
|
||||
|
||||
#### Using the Money Utility
|
||||
|
||||
```python
|
||||
from app.utils.money import Money, cents_to_euros, euros_to_cents
|
||||
|
||||
# Creating money values
|
||||
price = Money.from_euros(105.91) # Returns 10591
|
||||
price = Money.from_cents(10591) # Returns 10591
|
||||
|
||||
# Converting for display
|
||||
euros = cents_to_euros(10591) # Returns 105.91
|
||||
formatted = Money.format(10591) # Returns "105.91"
|
||||
formatted = Money.format(10591, currency="EUR") # Returns "105.91 EUR"
|
||||
|
||||
# Parsing user input
|
||||
cents = euros_to_cents("105.91") # Returns 10591
|
||||
cents = euros_to_cents(105.91) # Returns 10591
|
||||
|
||||
# Arithmetic (always use integers)
|
||||
line_total = unit_price_cents * quantity
|
||||
order_total = subtotal_cents + shipping_cents - discount_cents
|
||||
```
|
||||
|
||||
#### In Services
|
||||
|
||||
```python
|
||||
from app.utils.money import euros_to_cents, cents_to_euros
|
||||
|
||||
class OrderService:
|
||||
def create_order(self, items: list):
|
||||
subtotal_cents = 0
|
||||
for item in items:
|
||||
line_total_cents = item.unit_price_cents * item.quantity
|
||||
subtotal_cents += line_total_cents
|
||||
|
||||
# All arithmetic in cents - no precision loss
|
||||
total_cents = subtotal_cents + shipping_cents - discount_cents
|
||||
|
||||
order = Order(
|
||||
subtotal_cents=subtotal_cents,
|
||||
total_amount_cents=total_cents,
|
||||
)
|
||||
```
|
||||
|
||||
#### In Models (Properties)
|
||||
|
||||
```python
|
||||
class Order(Base):
|
||||
total_amount_cents = Column(Integer, nullable=False)
|
||||
|
||||
@property
|
||||
def total_amount(self) -> float:
|
||||
"""Get total as euros (for display/API)."""
|
||||
return cents_to_euros(self.total_amount_cents)
|
||||
|
||||
@total_amount.setter
|
||||
def total_amount(self, value: float):
|
||||
"""Set total from euros."""
|
||||
self.total_amount_cents = euros_to_cents(value)
|
||||
```
|
||||
|
||||
### Pydantic Schemas
|
||||
|
||||
#### Input Schemas (Accept Euros)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, field_validator
|
||||
from app.utils.money import euros_to_cents
|
||||
|
||||
class ProductCreate(BaseModel):
|
||||
price: float # Accept euros from API
|
||||
|
||||
@field_validator('price')
|
||||
@classmethod
|
||||
def convert_to_cents(cls, v):
|
||||
# Validation only - actual conversion in service
|
||||
if v < 0:
|
||||
raise ValueError('Price must be non-negative')
|
||||
return v
|
||||
```
|
||||
|
||||
#### Output Schemas (Return Euros)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, computed_field
|
||||
from app.utils.money import cents_to_euros
|
||||
|
||||
class ProductResponse(BaseModel):
|
||||
price_cents: int # Internal storage
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def price(self) -> float:
|
||||
"""Return price in euros for API consumers."""
|
||||
return cents_to_euros(self.price_cents)
|
||||
```
|
||||
|
||||
### API Layer
|
||||
|
||||
APIs accept and return values in **euros** (human-readable), while internally everything is stored in **cents**.
|
||||
|
||||
```json
|
||||
// API Request (euros)
|
||||
{
|
||||
"price": 105.91,
|
||||
"quantity": 2
|
||||
}
|
||||
|
||||
// API Response (euros)
|
||||
{
|
||||
"price": 105.91,
|
||||
"total": 211.82
|
||||
}
|
||||
|
||||
// Database (cents)
|
||||
price_cents: 10591
|
||||
total_cents: 21182
|
||||
```
|
||||
|
||||
### Frontend/Templates
|
||||
|
||||
JavaScript helper for formatting:
|
||||
|
||||
```javascript
|
||||
// In static/shared/js/money.js
|
||||
const Money = {
|
||||
/**
|
||||
* Format cents as currency string
|
||||
* @param {number} cents - Amount in cents
|
||||
* @param {string} currency - Currency code (default: EUR)
|
||||
* @returns {string} Formatted price
|
||||
*/
|
||||
format(cents, currency = 'EUR') {
|
||||
const euros = cents / 100;
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(euros);
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert euros to cents
|
||||
* @param {number|string} euros - Amount in euros
|
||||
* @returns {number} Amount in cents
|
||||
*/
|
||||
toCents(euros) {
|
||||
return Math.round(parseFloat(euros) * 100);
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert cents to euros
|
||||
* @param {number} cents - Amount in cents
|
||||
* @returns {number} Amount in euros
|
||||
*/
|
||||
toEuros(cents) {
|
||||
return cents / 100;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
In templates:
|
||||
|
||||
```html
|
||||
<!-- Display price from cents -->
|
||||
<span x-text="Money.format(product.price_cents)"></span>
|
||||
|
||||
<!-- Or using computed euro value from API -->
|
||||
<span x-text="formatPrice(product.price, product.currency)"></span>
|
||||
```
|
||||
|
||||
## Data Import/Export
|
||||
|
||||
### CSV Import
|
||||
|
||||
When importing prices from CSV files:
|
||||
|
||||
```python
|
||||
from app.utils.money import parse_price_to_cents
|
||||
|
||||
# Parse "19.99 EUR" or "19,99" to cents
|
||||
price_cents = parse_price_to_cents("19.99 EUR") # Returns 1999
|
||||
price_cents = parse_price_to_cents("19,99") # Returns 1999
|
||||
```
|
||||
|
||||
### Marketplace Import (Letzshop)
|
||||
|
||||
When importing from Letzshop GraphQL API:
|
||||
|
||||
```python
|
||||
from app.utils.money import euros_to_cents
|
||||
|
||||
# Letzshop returns prices as floats
|
||||
letzshop_price = 105.91
|
||||
price_cents = euros_to_cents(letzshop_price) # Returns 10591
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Database Migration
|
||||
|
||||
1. Add new `_cents` columns
|
||||
2. Migrate data: `UPDATE table SET price_cents = ROUND(price * 100)`
|
||||
3. Drop old float columns
|
||||
4. Rename columns if needed
|
||||
|
||||
```python
|
||||
# Migration example
|
||||
def upgrade():
|
||||
# Add cents columns
|
||||
op.add_column('products', sa.Column('price_cents', sa.Integer()))
|
||||
|
||||
# Migrate data
|
||||
op.execute('UPDATE products SET price_cents = ROUND(price * 100)')
|
||||
|
||||
# Drop old columns
|
||||
op.drop_column('products', 'price')
|
||||
```
|
||||
|
||||
## Currency Support
|
||||
|
||||
The system is designed for EUR but supports multiple currencies:
|
||||
|
||||
```python
|
||||
class Money:
|
||||
# Currency decimal places (for future multi-currency support)
|
||||
CURRENCY_DECIMALS = {
|
||||
'EUR': 2,
|
||||
'USD': 2,
|
||||
'GBP': 2,
|
||||
'JPY': 0, # Yen has no decimals
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Always test with values that expose floating-point issues:
|
||||
|
||||
```python
|
||||
def test_price_precision():
|
||||
# These values cause issues with float
|
||||
test_prices = [0.1, 0.2, 0.3, 19.99, 105.91]
|
||||
|
||||
for price in test_prices:
|
||||
cents = euros_to_cents(price)
|
||||
back_to_euros = cents_to_euros(cents)
|
||||
assert back_to_euros == price
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
| Layer | Format | Example |
|
||||
|-------|--------|---------|
|
||||
| Database | Integer cents | `10591` |
|
||||
| Python services | Integer cents | `10591` |
|
||||
| Pydantic schemas | Float euros (I/O) | `105.91` |
|
||||
| API request/response | Float euros | `105.91` |
|
||||
| Frontend display | Formatted string | `"105,91 €"` |
|
||||
|
||||
**Golden Rule:** All arithmetic happens with integers. Conversion to/from euros only at system boundaries (API, display).
|
||||
@@ -8,7 +8,9 @@ Complete guide for bidirectional order management with Letzshop marketplace via
|
||||
- [Architecture](#architecture)
|
||||
- [Setup and Configuration](#setup-and-configuration)
|
||||
- [Order Import](#order-import)
|
||||
- [Product Exceptions](#product-exceptions)
|
||||
- [Fulfillment Operations](#fulfillment-operations)
|
||||
- [Shipping and Tracking](#shipping-and-tracking)
|
||||
- [API Reference](#api-reference)
|
||||
- [Database Models](#database-models)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
@@ -196,6 +198,129 @@ Local orders track their sync status:
|
||||
|
||||
---
|
||||
|
||||
## Product Exceptions
|
||||
|
||||
When importing orders from Letzshop, products are matched by GTIN. If a product is not found in the vendor's catalog, the system **gracefully imports the order** with a placeholder product and creates an exception record for resolution.
|
||||
|
||||
### Exception Workflow
|
||||
|
||||
```
|
||||
Import Order → Product not found by GTIN
|
||||
│
|
||||
▼
|
||||
Create order with placeholder
|
||||
+ Flag item: needs_product_match=True
|
||||
+ Create OrderItemException record
|
||||
│
|
||||
▼
|
||||
Exception appears in QC dashboard
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
Resolve Ignore
|
||||
(assign product) (with reason)
|
||||
│ │
|
||||
▼ ▼
|
||||
Order can be confirmed Still blocks confirmation
|
||||
```
|
||||
|
||||
### Exception Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `product_not_found` | GTIN not in vendor's product catalog |
|
||||
| `gtin_mismatch` | GTIN format issue |
|
||||
| `duplicate_gtin` | Multiple products with same GTIN |
|
||||
|
||||
### Exception Statuses
|
||||
|
||||
| Status | Description | Blocks Confirmation |
|
||||
|--------|-------------|---------------------|
|
||||
| `pending` | Awaiting resolution | **Yes** |
|
||||
| `resolved` | Product assigned | No |
|
||||
| `ignored` | Marked as ignored | **Yes** |
|
||||
|
||||
**Important:** Both `pending` and `ignored` exceptions block order confirmation to Letzshop.
|
||||
|
||||
### Viewing Exceptions
|
||||
|
||||
Navigate to **Marketplace > Letzshop > Exceptions** tab to see all unmatched products.
|
||||
|
||||
The dashboard shows:
|
||||
- **Pending**: Exceptions awaiting resolution
|
||||
- **Resolved**: Exceptions that have been matched
|
||||
- **Ignored**: Exceptions marked as ignored
|
||||
- **Orders Affected**: Orders with at least one exception
|
||||
|
||||
### Resolving Exceptions
|
||||
|
||||
#### Via Admin UI
|
||||
|
||||
1. Navigate to **Marketplace > Letzshop > Exceptions**
|
||||
2. Click **Resolve** on the pending exception
|
||||
3. Search for the correct product by name, SKU, or GTIN
|
||||
4. Select the product and click **Confirm**
|
||||
5. Optionally check "Apply to all exceptions with this GTIN" for bulk resolution
|
||||
|
||||
#### Via API
|
||||
|
||||
```bash
|
||||
# Resolve a single exception
|
||||
curl -X POST /api/v1/admin/order-exceptions/{exception_id}/resolve \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"product_id": 123,
|
||||
"notes": "Matched to correct product manually"
|
||||
}'
|
||||
|
||||
# Bulk resolve all exceptions with same GTIN
|
||||
curl -X POST /api/v1/admin/order-exceptions/bulk-resolve?vendor_id=1 \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"gtin": "4006381333931",
|
||||
"product_id": 123,
|
||||
"notes": "Product imported to catalog"
|
||||
}'
|
||||
```
|
||||
|
||||
### Auto-Matching
|
||||
|
||||
When products are imported to the vendor catalog (via product sync or manual import), the system automatically:
|
||||
|
||||
1. Collects GTINs of newly imported products
|
||||
2. Finds pending exceptions with matching GTINs
|
||||
3. Resolves them by assigning the new product
|
||||
|
||||
This happens during:
|
||||
- Single product import (`copy_to_vendor_catalog`)
|
||||
- Bulk marketplace sync
|
||||
|
||||
### Exception Statistics
|
||||
|
||||
Get counts via API:
|
||||
|
||||
```bash
|
||||
curl -X GET /api/v1/admin/order-exceptions/stats?vendor_id=1 \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"pending": 15,
|
||||
"resolved": 42,
|
||||
"ignored": 3,
|
||||
"total": 60,
|
||||
"orders_with_exceptions": 8
|
||||
}
|
||||
```
|
||||
|
||||
For more details, see [Order Item Exception System](../implementation/order-item-exceptions.md).
|
||||
|
||||
---
|
||||
|
||||
## Fulfillment Operations
|
||||
|
||||
### Confirm Order
|
||||
@@ -243,6 +368,106 @@ Supported carriers: `dhl`, `ups`, `fedex`, `post_lu`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Shipping and Tracking
|
||||
|
||||
The system captures shipping information from Letzshop and provides local shipping management features.
|
||||
|
||||
### Letzshop Nomenclature
|
||||
|
||||
Letzshop uses specific terminology for order references:
|
||||
|
||||
| Term | Example | Description |
|
||||
|------|---------|-------------|
|
||||
| **Order Number** | `R532332163` | Customer-facing order reference |
|
||||
| **Shipment Number** | `H74683403433` | Carrier shipment ID for tracking |
|
||||
| **Hash ID** | `nvDv5RQEmCwbjo` | Internal Letzshop reference |
|
||||
|
||||
### Order Fields
|
||||
|
||||
Orders imported from Letzshop include:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `external_order_number` | Letzshop order number (e.g., R532332163) |
|
||||
| `shipment_number` | Carrier shipment number (e.g., H74683403433) |
|
||||
| `shipping_carrier` | Carrier code (greco, colissimo, xpresslogistics) |
|
||||
| `tracking_number` | Tracking number (if available) |
|
||||
| `tracking_url` | Full tracking URL |
|
||||
|
||||
### Carrier Detection
|
||||
|
||||
The system automatically detects the carrier from Letzshop shipment data:
|
||||
|
||||
| Carrier | Code | Label URL Prefix |
|
||||
|---------|------|------------------|
|
||||
| Greco | `greco` | `https://dispatchweb.fr/Tracky/Home/` |
|
||||
| Colissimo | `colissimo` | Configurable in settings |
|
||||
| XpressLogistics | `xpresslogistics` | Configurable in settings |
|
||||
|
||||
### Mark as Shipped
|
||||
|
||||
Mark orders as shipped locally (does **not** sync to Letzshop):
|
||||
|
||||
```bash
|
||||
curl -X POST /api/v1/admin/orders/{order_id}/ship \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tracking_number": "1Z999AA10123456784",
|
||||
"tracking_url": "https://tracking.example.com/1Z999AA10123456784",
|
||||
"shipping_carrier": "ups"
|
||||
}'
|
||||
```
|
||||
|
||||
**Note:** This updates the local order status to `shipped` and sets the `shipped_at` timestamp. It does not send anything to Letzshop API.
|
||||
|
||||
### Download Shipping Label
|
||||
|
||||
Get the shipping label URL for an order:
|
||||
|
||||
```bash
|
||||
curl -X GET /api/v1/admin/orders/{order_id}/shipping-label \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"shipment_number": "H74683403433",
|
||||
"shipping_carrier": "greco",
|
||||
"label_url": "https://dispatchweb.fr/Tracky/Home/H74683403433",
|
||||
"tracking_number": null,
|
||||
"tracking_url": null
|
||||
}
|
||||
```
|
||||
|
||||
The label URL is constructed from:
|
||||
- **Carrier label URL prefix** (configured in Admin Settings)
|
||||
- **Shipment number** from the order
|
||||
|
||||
### Carrier Label Settings
|
||||
|
||||
Configure carrier label URL prefixes in **Admin > Settings > Shipping**:
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Greco Label URL | `https://dispatchweb.fr/Tracky/Home/` | Greco tracking/label prefix |
|
||||
| Colissimo Label URL | *(empty)* | Colissimo tracking prefix |
|
||||
| XpressLogistics Label URL | *(empty)* | XpressLogistics prefix |
|
||||
|
||||
The full label URL is: `{prefix}{shipment_number}`
|
||||
|
||||
### Tracking Information
|
||||
|
||||
Letzshop does not expose Greco tracking information via API. The tracking URL visible in the Letzshop web UI is auto-generated by Letzshop using the dispatchweb.fr prefix.
|
||||
|
||||
For orders using Greco carrier:
|
||||
1. The shipment number (e.g., `H74683403433`) is captured during import
|
||||
2. The tracking URL can be constructed: `https://dispatchweb.fr/Tracky/Home/{shipment_number}`
|
||||
3. Use the Download Label feature to get this URL
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Vendor Endpoints
|
||||
@@ -283,6 +508,33 @@ Base path: `/api/v1/admin/letzshop`
|
||||
| GET | `/vendors/{id}/orders` | List vendor's Letzshop orders |
|
||||
| POST | `/vendors/{id}/sync` | Trigger sync for vendor |
|
||||
|
||||
### Order Endpoints
|
||||
|
||||
Base path: `/api/v1/admin/orders`
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `` | List orders (cross-vendor) |
|
||||
| GET | `/stats` | Get order statistics |
|
||||
| GET | `/vendors` | Get vendors with orders |
|
||||
| GET | `/{id}` | Get order details |
|
||||
| PATCH | `/{id}/status` | Update order status |
|
||||
| POST | `/{id}/ship` | Mark as shipped |
|
||||
| GET | `/{id}/shipping-label` | Get shipping label URL |
|
||||
|
||||
### Exception Endpoints
|
||||
|
||||
Base path: `/api/v1/admin/order-exceptions`
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `` | List exceptions |
|
||||
| GET | `/stats` | Get exception statistics |
|
||||
| GET | `/{id}` | Get exception details |
|
||||
| POST | `/{id}/resolve` | Resolve with product |
|
||||
| POST | `/{id}/ignore` | Mark as ignored |
|
||||
| POST | `/bulk-resolve` | Bulk resolve by GTIN |
|
||||
|
||||
### Response Schemas
|
||||
|
||||
#### Credentials Response
|
||||
@@ -502,6 +754,34 @@ curl -X GET /api/v1/vendor/letzshop/logs \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### Order Has Unresolved Exceptions
|
||||
|
||||
**Symptoms**: "Order has X unresolved exception(s)" error when confirming
|
||||
|
||||
**Cause**: Order contains items that couldn't be matched to products during import
|
||||
|
||||
**Solutions**:
|
||||
1. Navigate to **Marketplace > Letzshop > Exceptions** tab
|
||||
2. Find the pending exceptions for this order
|
||||
3. Either:
|
||||
- **Resolve**: Assign the correct product from your catalog
|
||||
- **Ignore**: Mark as ignored if product will never be matched (still blocks confirmation)
|
||||
4. Retry the confirmation after resolving all exceptions
|
||||
|
||||
### Cannot Find Shipping Label
|
||||
|
||||
**Symptoms**: "Download Label" returns empty or no URL
|
||||
|
||||
**Possible Causes**:
|
||||
- Shipment number not captured during import
|
||||
- Carrier label URL prefix not configured
|
||||
- Unknown carrier type
|
||||
|
||||
**Solutions**:
|
||||
1. Re-sync the order to capture shipment data
|
||||
2. Check **Admin > Settings > Shipping** for carrier URL prefixes
|
||||
3. Verify the order has a valid `shipping_carrier` and `shipment_number`
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
@@ -525,6 +805,7 @@ curl -X GET /api/v1/vendor/letzshop/logs \
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Order Item Exception System](../implementation/order-item-exceptions.md)
|
||||
- [Marketplace Integration (CSV Import)](marketplace-integration.md)
|
||||
- [Vendor RBAC](../backend/vendor-rbac.md)
|
||||
- [Admin Integration Guide](../backend/admin-integration-guide.md)
|
||||
@@ -534,6 +815,22 @@ curl -X GET /api/v1/vendor/letzshop/logs \
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.2** (2025-12-20): Shipping & Tracking enhancements
|
||||
- Added `shipment_number`, `shipping_carrier`, `tracking_url` fields to orders
|
||||
- Carrier detection from Letzshop shipment data (Greco, Colissimo, XpressLogistics)
|
||||
- Mark as Shipped feature (local only, does not sync to Letzshop)
|
||||
- Shipping label URL generation using configurable carrier prefixes
|
||||
- Admin settings for carrier label URL prefixes
|
||||
|
||||
- **v1.1** (2025-12-20): Product Exception System
|
||||
- Graceful order import when products not found by GTIN
|
||||
- Placeholder product per vendor for unmatched items
|
||||
- Exception tracking with pending/resolved/ignored statuses
|
||||
- Confirmation blocking until exceptions resolved
|
||||
- Auto-matching when products are imported
|
||||
- Exceptions tab in admin Letzshop management page
|
||||
- Bulk resolution by GTIN
|
||||
|
||||
- **v1.0** (2025-12-13): Initial Letzshop order integration
|
||||
- GraphQL client for order import
|
||||
- Encrypted credential storage
|
||||
|
||||
@@ -254,6 +254,30 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
## Admin UI
|
||||
|
||||
The exceptions tab is available in the Letzshop management page:
|
||||
|
||||
**Location:** `/admin/marketplace/letzshop` → Exceptions tab
|
||||
|
||||
### Features
|
||||
|
||||
- **Stats Cards**: Shows pending, resolved, ignored, and affected orders counts
|
||||
- **Filters**: Search by GTIN/product name/order number, filter by status
|
||||
- **Exception Table**: Paginated list with product info, GTIN, order link, status
|
||||
- **Actions**:
|
||||
- **Resolve**: Opens modal with product search (autocomplete)
|
||||
- **Ignore**: Marks exception as ignored (still blocks confirmation)
|
||||
- **Bulk Resolve**: Checkbox to apply resolution to all exceptions with same GTIN
|
||||
|
||||
### Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `app/templates/admin/partials/letzshop-exceptions-tab.html` | Tab HTML template |
|
||||
| `app/templates/admin/marketplace-letzshop.html` | Main page (includes tab) |
|
||||
| `static/admin/js/marketplace-letzshop.js` | JavaScript handlers |
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Exception | HTTP Status | When |
|
||||
|
||||
Reference in New Issue
Block a user