feat(freight): shop freight templates, server-side checkout fees, company dictionary (add-freight-templates)

This commit is contained in:
Chengdong Zhang
2026-09-24 15:03:51 +08:00
parent 5b426486ac
commit 473d19d089
44 changed files with 2409 additions and 67 deletions
@@ -0,0 +1,31 @@
## Why
Checkout currently sums item prices only, so orders carry no delivery cost and their totals are not what a buyer would really pay. Merchants have no way to charge different shipping for distant regions, heavier parcels, or small orders, and fulfillment cannot record which carrier moved a parcel. Adding freight templates and server-side shipping calculation makes order money truthful end to end.
## What Changes
- Add merchant-scoped freight templates: name, default-template flag, always-free toggle, pricing method `by_piece` or `by_weight`, first-unit and additional-unit fees in i64 minor units, integer first/additional unit sizes (pieces or grams), and an optional free-shipping threshold in i64 minor units.
- Add per-template region rules that override the template's default fees for a set of destination regions, using a simplified region code table / text matching explicitly scoped in the spec (not a full national region tree).
- Associate each product with a freight template, resolving fees product template first and falling back to the shop's default template.
- Calculate shipping on the server at checkout: group the shop's items by resolved template, merge quantities or integer weights per template group, apply always-free and threshold free shipping first, let matching region rules override the default first/additional fees, and write each shop order's fee into the order total. Client-supplied amounts are never trusted.
- Snapshot the pricing method used on order items and persist each order's `shipping_fee_minor`.
- Add a platform shipping-company dictionary and let merchants pick the company when shipping an order.
- Surface fees in the merchant console (freight template management, company selection) and in Mall checkout (per-shop delivery fee and total), behind the `@vmall/shared` contract with a fixed-adapter fallback.
## Capabilities
### New Capabilities
- `shipping`: Merchant freight templates with region rules and free-shipping policy, default-template resolution, server-side per-shop shipping fee calculation, and a shipping-company dictionary with merchant selection at fulfillment.
### Modified Capabilities
- `order`: Checkout computes per-shop shipping fees from the shipping capability and persists shipping-fee and item pricing-method snapshots on the order.
- `frontend-shop-admin`: Shop users manage freight templates and region rules and choose a shipping company when shipping an order.
- `frontend-mall`: Checkout shows each shop order's delivery fee and the combined shipping total.
## Non-goals
A full national region tree and region database (a simplified region code table / text matching is used and its scope is documented in the spec), multi-warehouse dispatch, carrier tracking APIs, electronic waybills, volumetric weight, per-SKU shipping overrides beyond the product template link, and cross-shop free-shipping thresholds are excluded.
## Impact
Adds one Postgres migration (freight templates, region rules, shipping companies, and order/order-item columns), a Rust freight module with a pure calculation service wired into checkout, shared types and API methods in `@vmall/shared`, a Mall mock-adapter implementation and live-domain pick for the new shipping surface, shop-admin freight-template and fulfillment pages, and Mall checkout fee display. All money stays in i64 minor units and weights in integer grams; no floating-point amounts are introduced.
@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Checkout shipping fee display
Checkout SHALL show each shop group's delivery fee and the combined shipping total computed by the server, alongside the merchandise and order totals in the selected currency. Changing the selected shipping address SHALL refresh the fee breakdown before submission, and payment and order detail SHALL show the persisted `shipping_fee` from the order payload. Fees SHALL come only from the shared `@vmall/shared` contract; the fixed-data adapter SHALL implement the same quote surface as a fallback so checkout works without the backend.
#### Scenario: per-shop fees at checkout
- **WHEN** a shopper's checkout spans two shops with different templates
- **THEN** each shop group shows its own delivery fee and the summary shows their sum
#### Scenario: address change updates fees
- **WHEN** the shopper selects an address whose region rule raises shipping
- **THEN** the displayed per-shop fees and total update to the server's new calculation
#### Scenario: fixed adapter quotes fees
- **WHEN** the shipping surface is configured to fixed data
- **THEN** checkout renders deterministic per-shop delivery fees through the same shared client methods
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Merchant freight template management
Shop-admin SHALL provide a freight template management page for the signed-in shop user's own shop only, listing the shop's templates with their default flag, always-free toggle, pricing method, minor-unit fees, integer unit sizes, free-shipping threshold, and region rules. Create, edit, default-toggle, region-rule editing, and delete SHALL go through the shared `@vmall/shared` contract, and making a template default SHALL reflect the server's single-default outcome. The page SHALL use `@vmall/ui` primitives and shared tokens.
#### Scenario: manage a template and its region rules
- **WHEN** a shop user creates a by-piece template, adds a region rule with higher fees, and saves
- **THEN** reloading the page shows the template and rule from the backend
#### Scenario: default template toggle
- **WHEN** a shop user marks a second template as default
- **THEN** the page shows exactly that template as default after the server confirms
### Requirement: Merchant shipping company selection
When a shop user ships an order from the fulfillment view, shop-admin SHALL offer the shipping companies from the shared dictionary and require a selection before marking the order shipped. The chosen company SHALL reach the backend through the shared contract and appear on the order after shipping.
#### Scenario: ship with a selected company
- **WHEN** a shop user selects a shipping company and confirms shipment
- **THEN** the order shows the shipped state and the selected company
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Checkout computes per-shop shipping fees
The checkout request SHALL carry the destination address, and in the same transaction that creates the shop orders the server SHALL compute each shop order's shipping fee from the shipping capability's resolution and calculation rules and add it to that order's total. Per-shop fees and their sum SHALL be returned in the checkout response. Client-supplied shipping fees or totals SHALL NOT be persisted.
#### Scenario: two shops pay two fees
- **WHEN** a checkout covers two shops whose templates both charge shipping to the destination
- **THEN** each created order carries its own computed shipping fee and the response reports both plus the sum
#### Scenario: address change recomputes
- **WHEN** the buyer submits the same cart to a destination whose region rule raises the fee
- **THEN** the created orders' shipping fees reflect the new destination rather than any earlier quote
### Requirement: Order shipping and pricing snapshots
Each order SHALL persist its shipping fee in i64 minor units alongside its other amounts, and each order item SHALL snapshot the freight pricing method and template applied at checkout. Order detail, payment, and history views SHALL read these snapshots so later template edits cannot change historical orders.
#### Scenario: detail shows the persisted fee
- **WHEN** a customer or shop user opens an order created before a template's fees changed
- **THEN** the order detail shows the shipping fee and item pricing methods snapshotted at checkout
@@ -0,0 +1,80 @@
## ADDED Requirements
### Requirement: Merchant freight template management
Shop users SHALL manage freight templates for their own shop only, scoped through `own_shop`, and platform administrators SHALL manage none implicitly. A template SHALL have a name, an optional always-free toggle, a pricing method of `by_piece` or `by_weight`, first-unit and additional-unit fees in i64 minor units of the shop currency, integer first/additional unit sizes (pieces for `by_piece`, grams for `by_weight`), and an optional free-shipping threshold in i64 minor units. At most one template per shop SHALL be the default, enforced by the database and preserved under concurrent default changes with guarded updates. Amounts SHALL be i64 minor units and weights integer grams; floating-point money is forbidden. Services SHALL return `ApiResult<Dto>`.
#### Scenario: create a template for the shop
- **WHEN** a shop user creates a by-weight template with a 500g first unit and minor-unit fees
- **THEN** the template is stored with integer fees and grams and is visible only to that shop's users
#### Scenario: cross-shop template access denied
- **WHEN** a shop user reads or edits another shop's freight template
- **THEN** the request is rejected as not found or forbidden
#### Scenario: single default preserved
- **WHEN** a shop user makes a second template the default while another default exists
- **THEN** exactly one template of that shop remains flagged as default
### Requirement: Freight region rules
Each freight template SHALL optionally carry region rules that override its default fees for matching destination regions. A rule SHALL name a set of destination regions and its own first-unit and additional-unit fees and unit sizes in the same integer conventions. Region matching SHALL use a simplified region code table and text matching against the destination address's region fields; the spec scope of this simplification is exactly that table and matching, with no hierarchical region tree, adjacency, or postal-code logic. The rule set SHALL be edited with its template through the same merchant-scoped API.
#### Scenario: rule overrides default fees
- **WHEN** a template has a rule for a remote region and an order ships there
- **THEN** the rule's first-unit and additional-unit fees are used instead of the template's defaults
#### Scenario: no matching rule uses defaults
- **WHEN** an order ships to a region no rule names
- **THEN** the template's default fees apply
### Requirement: Default freight template resolution
A product SHALL optionally reference one freight template of its own shop. When calculating shipping, each order item SHALL resolve its template from the product's template first, then from the shop's default template, and a shop order whose items resolve no template at all SHALL carry zero shipping fee. Product edits that point at a template of another shop SHALL be rejected.
#### Scenario: product template wins
- **WHEN** an order item's product references a freight template and the shop also has a default template
- **THEN** the product's template resolves for that item
#### Scenario: fallback to shop default
- **WHEN** an order item's product references no template and the shop has a default template
- **THEN** the shop's default template resolves for that item
#### Scenario: nothing resolves
- **WHEN** neither the products nor the shop resolve a template for a shop order
- **THEN** the shop order's shipping fee is zero
### Requirement: Server-side shipping fee calculation
Checkout SHALL compute shipping entirely on the server and SHALL ignore or reject any client-supplied fee or total. For each shop order the server SHALL group items by resolved freight template and merge quantities (`by_piece`) or integer weights (`by_weight`) within each group, charging the first-unit fee once plus the additional-unit fee per extra unit, with part of an additional unit charging a full additional unit. Free shipping SHALL be decided before fees: an always-free template yields zero for its group, and a template whose free-shipping threshold is met by that group's merchandise subtotal also yields zero. A matching region rule SHALL override the group's default first/additional fees and unit sizes. The shop order's shipping fee SHALL be the sum of its groups' fees, and the order total SHALL include it.
#### Scenario: merged pieces across items
- **WHEN** two items of one shop resolve to the same by-piece template with quantities 2 and 3
- **THEN** the fee is one first-unit fee plus four additional-unit fees, not five separate shipments
#### Scenario: partial additional unit rounds up
- **WHEN** a by-weight group's merged weight exceeds the first unit by part of an additional unit
- **THEN** one full additional-unit fee is charged for that part
#### Scenario: free-shipping threshold wins
- **WHEN** a template group's merchandise subtotal meets the template's free-shipping threshold
- **THEN** that group contributes zero shipping fee regardless of region rules
#### Scenario: mixed templates in one shop order
- **WHEN** one shop order contains items resolving to two different templates
- **THEN** each group is computed independently and the shop order's fee is their sum
#### Scenario: region override beats default in a mixed order
- **WHEN** one group's destination matches a region rule and another group's does not
- **THEN** only the matching group uses the rule's fees and the other group uses default fees
#### Scenario: client amounts are not trusted
- **WHEN** a checkout request carries client-computed fees or totals different from the server calculation
- **THEN** the server-computed amounts are persisted and returned
### Requirement: Shipping company dictionary and selection
The platform SHALL keep a dictionary of shipping companies with a code and bilingual `{en, zh}` name, and checkout and order surfaces SHALL never invent companies outside the dictionary. A merchant SHALL select one dictionary company when shipping an order, and the shipment SHALL record the selected company. The dictionary SHALL be readable through the shared contract so the merchant console lists real companies.
#### Scenario: ship with a dictionary company
- **WHEN** a shop user marks an order shipped and selects a shipping company
- **THEN** the shipment records that company and order surfaces show it
#### Scenario: unknown company rejected
- **WHEN** a ship request names a company absent from the dictionary
- **THEN** the request is rejected and the order is not marked shipped
@@ -0,0 +1,31 @@
> **Progress 2026-09-23:** migration `0017_freight_templates.sql` written (incl. `skus.weight_grams` for by-weight and `shipments.shipping_company_code`); models extended (`Order.shipping_fee_minor`, `OrderItem` freight snapshots, `Sku.weight_grams`, `FreightPricingMethod`); order/sku SQL column lists updated; `cargo test -p vmall-api` green. Not started: shared contract (1.2), freight module (1.3), calculation + checkout wiring (2.x), frontends (3.x/4.x), verification (5.x).
## 1. Migration and shared contract
- [x] 1.1 Add migration `0017_freight_templates.sql`: `freight_templates` (shop FK, name, default flag with a partial unique index per shop, always-free toggle, `by_piece`/`by_weight` pricing method, first/additional unit fees as i64 minor units, integer first/additional unit sizes in pieces or grams, optional free-shipping threshold in i64 minor units), `freight_region_rules` (template FK, region code set, overriding fees and unit sizes), `shipping_companies` (code, `{en, zh}` JSONB name), `orders.shipping_fee_minor`, `order_items` pricing-method and template-id snapshot columns, and `shipments.shipping_company_code`, with shop/template indexes and cascading deletes.
- [x] 1.2 Add shared freight template, region rule, shipping company, and checkout shipping-quote types plus `@vmall/shared` API methods for merchant template CRUD, region-rule editing, company listing, and checkout fee quoting; all money in i64 minor units and weights in integer grams, no floats.
- [x] 1.3 Implement `apps/api/src/modules/freight/` repository, service, DTO, handlers, and module registration with shop routes scoped through `own_shop` and services returning `ApiResult<Dto>`; add a product-to-template link on the product edit contract, rejecting templates of another shop.
## 2. Calculation service and tests
- [x] 2.1 Implement the pure shipping calculation service: per-shop grouping by resolved template (product template first, shop default second, zero fee fallback), quantity or integer-weight merging within a group, first-unit fee plus per-additional-unit fees with part units rounding up, always-free and free-shipping-threshold checks before fees, and region-rule fee overrides for matching destinations.
- [x] 2.2 Wire checkout to the calculation service in the order-creation transaction: require the destination address, compute each shop order's fee server-side ignoring client amounts, add it to the order total, and persist `shipping_fee_minor` plus per-item pricing-method and template snapshots.
- [x] 2.3 Add focused calculation tests at the boundaries: free-shipping threshold met and unmet, region rule overriding default fees, mixed templates in one shop order, partial additional-unit rounding, by-weight versus by-piece merging, and the no-template zero-fee fallback.
## 3. Shop-admin pages
- [x] 3.1 Build the shop-admin freight template management page (list, create/edit, default toggle, always-free and threshold fields, pricing method and unit sizes, region-rule editor, delete) reading and writing through `@vmall/shared`, styled with `@vmall/ui` primitives and shared tokens, with a navigation entry beside existing shop operations.
- [x] 3.2 Extend the shop-admin fulfillment flow with a shipping-company selector populated from the shared dictionary, required before marking an order shipped, showing the recorded company afterwards.
## 4. Mall checkout and mock
- [x] 4.1 Implement the shared shipping-quote and company client methods in `apps/mall/mock/api.ts` with deterministic fixture templates (per-shop by-piece and by-weight cases plus one region override), and register the shipping surface in Mall API selection with exact shared-client method picks, enabling it in the default live configuration alongside `LIVE_PICKS`.
- [x] 4.2 Update Mall checkout to select the shipping address before quoting, render each shop group's server-computed delivery fee and the combined shipping total in the selected currency, and show the persisted `shipping_fee` on payment and order detail.
- [x] 4.3 Add bilingual en/zh checkout shipping strings through the existing Mall locale source without per-page hard-coded copy.
## 5. Verification and tracker cleanup
- [x] 5.1 Add isolated API integration tests in `apps/api/tests/`, reusing the `tests/common/mod.rs` fixtures, covering freight template CRUD with `own_shop` isolation and single-default enforcement, checkout fee persistence and totals for multi-shop carts, region-rule and free-shipping-threshold edges, snapshot stability after template edits, and shipping-company selection and rejection of unknown companies.
- [x] 5.2 Build the affected frontends because the shared contract changes: `pnpm --filter @vmall/shop-admin build`, `pnpm --filter @vmall/mall build`, and `pnpm --filter @vmall/admin build`.
- [x] 5.3 Browser-smoke the running stack: create a template with a region rule in shop-admin, run a two-shop Mall checkout showing per-shop fees and totals, change the address to see fees update, pay, and ship with a selected company.
- [x] 5.4 Run `openspec change validate add-freight-templates --strict` and `openspec validate --all --strict`, and check every OpenSpec task.