The old landed cost module used one allocation method for the entire document. Every cost component (freight, duty, handling) was split the same way — usually by value. This produced wrong numbers for real-world imports.
V2 gives each cost component its own allocation method, plus four new methods designed for import/distribution operations.
| Method | Basis | When to Use |
|---|---|---|
by_dim_weight |
MAX(actual weight, dimensional weight) | Air/sea freight |
by_hs_value |
Groups by HS code duty rate, allocates within groups | Customs duty |
by_duty_rate |
Line value × duty rate | Excise/simple duty |
equal |
Even split | Flat fees (documentation, brokerage) |
Plus the original four: by_value, by_weight, by_volume, by_quantity, manual.
The system auto-suggests methods per component:
| Cost Type | Suggested Method |
|---|---|
| Freight | by_dim_weight |
| Customs / Excise Duty | by_hs_value |
| Handling / Storage | by_weight |
| Insurance | by_value |
| Brokerage / Documentation | equal |
Users can preview allocation results before committing — see exactly where every dollar lands.
Shipment: Malé ← Singapore, Air, AWB SQ-882431
| Line | Item | Qty | Value | Weight | Dims (cm) | HS Code | Duty |
|---|---|---|---|---|---|---|---|
| 1 | KVM/100G Switch | 1 | $8,000 | 5kg | 50×40×10 | 8517.62 | 0% |
| 2 | 42U Server Rack | 1 | $1,200 | 80kg | 200×60×100 | 9403.20 | 20% |
| 3 | RJ45 Plugs (box) | 1 | $2,000 | 30kg | 40×30×30 | 8536.69 | 0% |
| Item | Actual Wt | Dim Weight | Chargeable | Share | Allocated |
|---|---|---|---|---|---|
| KVM Switch | 5kg | 4kg | 5kg | 1.8% | $45.45 |
| Server Rack | 80kg | 240kg | 240kg | 87.3% | $2,181.82 |
| RJ45 Plugs | 30kg | 7.2kg | 30kg | 10.9% | $272.73 |
The rack's massive volume (200×60×100cm = 240kg dimensional) dominates freight. Old
by_weightwould give it 69.6%, oldby_valueonly 10.7%. Dim weight gives the correct 87.3%.
| Item | HS Code | Rate | Duty Basis | Allocated |
|---|---|---|---|---|
| KVM Switch | 8517.62 | 0% | $0 | $0 |
| Server Rack | 9403.20 | 20% | $240 | $240 |
| RJ45 Plugs | 8536.69 | 0% | $0 | $0 |
Only the rack has duty. HS-value grouping puts 100% on the rack, zero on duty-free items. Old
by_valuewould have incorrectly spread $171 to the switch.
| Item | Weight | Share | Allocated |
|---|---|---|---|
| KVM Switch | 5kg | 4.3% | $6.52 |
| Server Rack | 80kg | 69.6% | $104.35 |
| RJ45 Plugs | 30kg | 26.1% | $39.13 |
| Item | Allocated |
|---|---|
| KVM Switch | $66.67 |
| Server Rack | $66.67 |
| RJ45 Plugs | $66.66 |
| Item | Purchase | Freight | Duty | Handling | Clearing | Total Landed | Per Unit |
|---|---|---|---|---|---|---|---|
| KVM Switch | $8,000 | $45 | $0 | $7 | $67 | $8,119 | $8,119 |
| Server Rack | $1,200 | $2,182 | $240 | $104 | $67 | $3,793 | $3,793 |
| RJ45 Plugs | $2,000 | $273 | $0 | $39 | $67 | $2,379 | $0.12 |
The rack's true cost is 3.16× its purchase price — freight alone nearly doubled it. Without V2, this would've been hidden.
Shipment: Malé ← Colombo, Sea, BL MAEU-2847291
| Line | Item | Qty | Value | Weight | Dims (cm) | HS Code | Duty |
|---|---|---|---|---|---|---|---|
| 1 | Rice 25kg bags | 400 | $6,000 | 10,000kg | 60×40×20 | 1006.30 | 10% |
| 2 | Cooking Oil 5L | 200 | $2,400 | 1,000kg | 30×20×35 | 1507.90 | 15% |
| 3 | Canned Tuna | 2,000 | $8,000 | 1,200kg | 10×10×8 | 1604.14 | 5% |
| Item | Actual | Dim Weight | Chargeable | Share | Allocated |
|---|---|---|---|---|---|
| Rice (400 bags) | 10,000kg | 4,800kg | 10,000kg | 81.6% | $2,612 |
| Oil (200 bottles) | 1,000kg | 1,050kg | 1,050kg | 8.6% | $274 |
| Tuna (2,000 cans) | 1,200kg | 400kg | 1,200kg | 9.8% | $314 |
Dense goods like rice and tuna — actual weight dominates. Cooking oil is slightly bulkier than heavy, so dim weight edges out. The system picks the right chargeable weight automatically.
| Duty Group | Items | Rate | Group Value | Expected Duty | Allocated |
|---|---|---|---|---|---|
| 10% | Rice | 10% | $6,000 | $600 | $600 |
| 15% | Cooking Oil | 15% | $2,400 | $360 | $360 |
| 5% | Canned Tuna | 5% | $8,000 | $400 | $400 |
Total expected: $1,360 — matches the component amount exactly. Each product absorbs only its own duty rate. No cross-subsidy between items.
Dense goods = expensive to handle. Rice at 10,000kg takes 81.9% = $410.
Shipment: Malé ← Dubai, Courier, AWB 1234567890
| Line | Item | Qty | Value | Weight | Dims (cm) | Duty |
|---|---|---|---|---|---|---|
| 1 | Perfume Set | 50 | $5,000 | 25kg | 20×15×10 | 15% |
| 2 | Phone Cases | 500 | $1,500 | 15kg | 30×20×15 | 0% |
| 3 | Designer Sunglasses | 30 | $9,000 | 3kg | 15×8×5 | 25% |
| Item | Actual | Dim Weight | Chargeable | Share | Allocated |
|---|---|---|---|---|---|
| Perfume (50) | 25kg | 30kg | 30kg | 3.2% | $26 |
| Cases (500) | 15kg | 900kg | 900kg | 96.5% | $772 |
| Sunglasses (30) | 3kg | 1.08kg | 3kg | 0.3% | $2 |
500 phone cases are individually small but collectively massive in volume (900kg dim weight). They eat most of the freight despite being the cheapest line. This is exactly right — courier charges are volume-driven.
| Item | Rate | Duty Basis | Allocated |
|---|---|---|---|
| Perfume | 15% | $750 | $750 |
| Phone Cases | 0% | $0 | $0 |
| Sunglasses | 25% | $2,250 | $2,250 |
Sunglasses carry the heaviest duty burden at 25%. Phone cases are duty-free — zero allocation. No cross-subsidy.
Using by_value for everything on Example 1:
| Item | Freight (wrong) | Duty (wrong) |
|---|---|---|
| KVM Switch | $1,785 (71.4%) | $171 (71.4%) |
| Server Rack | $268 (10.7%) | $26 (10.7%) |
| RJ45 Plugs | $447 (17.9%) | $43 (17.9%) |
The $8,000 switch absorbs 71% of freight when the bulky rack caused it. The switch gets $171 in phantom duty when it's duty-free. Every unit cost is wrong.
| Item | Freight (correct) | Duty (correct) |
|---|---|---|
| KVM Switch | $45 (1.8%) | $0 (0%) |
| Server Rack | $2,182 (87.3%) | $240 (100%) |
| RJ45 Plugs | $273 (10.9%) | $0 (0%) |
Accurate to how costs actually incur. Freight follows volume. Duty follows tariff rates. Each item carries only the costs it actually caused.
POST /landed-costs/:id/auto-allocate
Body: {
"defaultMethod": "by_value",
"componentOverrides": {
"1": { "method": "by_dim_weight" },
"2": { "method": "by_hs_value" }
},
"dimWeightDivisor": 5000
}
POST /landed-costs/:id/allocation-preview
→ Returns full breakdown without saving — test before you commit
GET /landed-costs/:id/suggest-methods
→ Returns recommended method per component based on cost type
Landed cost documents now capture:
| Field | Description |
|---|---|
shipmentMode |
air / sea / land / courier |
carrierName |
Carrier or freight forwarder |
awbNumber |
Air waybill or bill of lading |
originCountry |
3-letter country code |
portOfEntry |
Arrival port/airport |
dimWeightDivisor |
Per-shipment override (5000 air, 4000 sea) |
When a landed cost is posted, each StockBatch is updated with:
landedCostPerUnit— total landed cost ÷ batch quantitytotalLandedCost— sum of all allocations for this batch
This enables accurate lot-based pricing downstream: salePrice = purchasePrice + landedCostPerUnit + margin
| Task | Description | Status |
|---|---|---|
| LC-1 | Database migration (19 new columns across 5 tables) | ✅ |
| LC-2 | Model updates (Product, PO Line, LC, Component, Allocation) | ✅ |
| LC-3 | Dimensional weight calculation | ✅ |
| LC-4 | by_hs_value allocation (duty rate grouping) |
✅ |
| LC-5 | by_dim_weight allocation |
✅ |
| LC-6 | equal allocation |
✅ |
| LC-7 | Per-component method refactor of autoAllocate | ✅ |
| LC-8 | suggestAllocationMethods() |
✅ |
| LC-9 | previewAllocation() endpoint |
✅ |
| LC-10 | Validator updates | ✅ |
| LC-11 | Controller + route updates | ✅ |
| LC-12 | Batch landed cost tracking | ✅ |
| LC-13 | Unit tests (15/15 passing) | ✅ |
Audit grade: A+ — production-ready, fully typed, all issues resolved.
| Feature | SAP B1 | Dynamics 365 | Atlas V2 |
|---|---|---|---|
| Auto allocation | ❌ Manual only | ❌ Manual only | ✅ 8 methods |
| Per-component methods | ❌ | ❌ | ✅ |
| Dimensional weight | ❌ | ❌ | ✅ |
| HS code duty grouping | ❌ | ❌ | ✅ |
| Allocation preview | ❌ | ❌ | ✅ |
| Smart suggestions | ❌ | ❌ | ✅ |
| Batch cost tracking | Partial | Partial | ✅ Full |