145 lines
5.4 KiB
Markdown
145 lines
5.4 KiB
Markdown
|
|
# Phase 3 Backend Contract — Product/Offer Split, Inventory, Executability
|
|||
|
|
|
|||
|
|
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 3 (Sprints 3.1–3.3). The largest structural change in the programme — nothing about multi-seller commerce works without it.
|
|||
|
|
|
|||
|
|
**Status: ready to build.** No open decisions block this phase.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. Why this exists
|
|||
|
|
|
|||
|
|
Today price, stock and currency hang directly off a single admin `Product` mock domain, unrelated to the live storefront `Item` domain. A product cannot have two sellers, two prices, or two stock levels. `Offer/Listing` does not exist in any form.
|
|||
|
|
|
|||
|
|
## 2. The two-layer split
|
|||
|
|
|
|||
|
|
`Product` describes the item itself (content). `Offer` describes one seller's commercial proposition against that product (price, stock, currency, status). One product, many offers.
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
interface Product {
|
|||
|
|
id: string;
|
|||
|
|
marketplaceId: string;
|
|||
|
|
categoryId: string;
|
|||
|
|
brand?: string;
|
|||
|
|
title: string;
|
|||
|
|
description: string;
|
|||
|
|
attributes: Record<string, unknown>;
|
|||
|
|
media: string[];
|
|||
|
|
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Variant {
|
|||
|
|
id: string;
|
|||
|
|
productId: string;
|
|||
|
|
sku: string;
|
|||
|
|
barcode?: string;
|
|||
|
|
optionValues: Record<string, string>; // e.g. { color: 'red', size: 'M' }
|
|||
|
|
dimensions?: { weight?: number; length?: number; width?: number; height?: number };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Category {
|
|||
|
|
id: string;
|
|||
|
|
marketplaceId: string;
|
|||
|
|
parentId: string | null;
|
|||
|
|
slug: string;
|
|||
|
|
attributesSchema: Record<string, unknown>;
|
|||
|
|
order: number;
|
|||
|
|
seo: { title?: string; description?: string };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Offer {
|
|||
|
|
id: string;
|
|||
|
|
marketplaceId: string;
|
|||
|
|
sellerId: string;
|
|||
|
|
variantId: string;
|
|||
|
|
sellerSku: string;
|
|||
|
|
price: Money; // Money type from Phase 1 contract
|
|||
|
|
stockPolicy: 'track' | 'no_track' | 'preorder';
|
|||
|
|
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
|
|||
|
|
publishedAt?: string;
|
|||
|
|
executabilityChecked: boolean; // see §5
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface PriceHistory {
|
|||
|
|
offerId: string;
|
|||
|
|
price: Money;
|
|||
|
|
changedBy: string; // user id or 'sync:{connectorId}'
|
|||
|
|
changedAt: string;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 3. Inventory
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
interface InventoryRecord {
|
|||
|
|
offerId: string;
|
|||
|
|
available: number;
|
|||
|
|
reserved: number;
|
|||
|
|
sold: number;
|
|||
|
|
warehouse?: string;
|
|||
|
|
source: 'manual' | 'feed_sync' | 'connector';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface StockReservation {
|
|||
|
|
id: string;
|
|||
|
|
offerId: string;
|
|||
|
|
qty: number;
|
|||
|
|
reason: 'checkout' | 'pre_payment';
|
|||
|
|
expiresAt: string; // TTL
|
|||
|
|
released: boolean;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Invariants:
|
|||
|
|
- `available`, `reserved`, `sold` are counted separately, never derived from one another implicitly.
|
|||
|
|
- Reservations are created at checkout or pre-payment (tenant-configurable strategy) and expire by TTL, releasing `reserved` back to `available`.
|
|||
|
|
- Seller feed stock updates are an **idempotent upsert** — a repeated webhook must not double-decrement.
|
|||
|
|
- Oversell (a sale exceeding `available`) routes to a dedicated incident queue, never silently hidden or auto-corrected.
|
|||
|
|
|
|||
|
|
## 4. Lifecycle
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
draft -> moderation -> published -> paused/archived
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Applies independently to both `Product` and `Offer`. Wires to the already-existing (mock) Admin Moderation module — no new frontend module needed, just a real gateway behind `ADMIN_MODERATION_GATEWAY` (token already added this session).
|
|||
|
|
|
|||
|
|
## 5. Publish-time executability
|
|||
|
|
|
|||
|
|
**An offer that cannot actually be fulfilled must not be publishable.** Before allowing `status: 'published'`, the backend validates:
|
|||
|
|
- The offer has a valid `Fulfillment` type it can realistically satisfy (see [Phase 2 contract](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) `Fulfillment.type`).
|
|||
|
|
- Stock policy is `track` with `available > 0`, or `no_track`/`preorder` explicitly.
|
|||
|
|
- Required attributes for the offer's category (`Category.attributesSchema`) are present.
|
|||
|
|
|
|||
|
|
This is the mechanism behind the plan's §3.6/§10.2 requirement: **no branch anywhere may distinguish a normal buyer from an inspector.** The only way to guarantee that is to make every published offer genuinely executable at publish time, not to special-case checkout behavior later.
|
|||
|
|
|
|||
|
|
## 6. Bulk import
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/admin/v2/products/bulk-import
|
|||
|
|
Content-Type: multipart/form-data (CSV) or application/json (array)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Response returns a **preview** of validation errors before anything is applied — required-field validation, category-attribute validation, duplicate-SKU detection — with a separate `POST .../bulk-import/{importId}/apply` to commit after review.
|
|||
|
|
|
|||
|
|
## 7. Endpoints
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
GET /api/admin/v2/products?marketplaceId=&status=&search=&page=&pageSize=
|
|||
|
|
GET /api/admin/v2/products/{id}
|
|||
|
|
POST /api/admin/v2/products
|
|||
|
|
PATCH /api/admin/v2/products/{id}
|
|||
|
|
GET /api/admin/v2/offers?productId=&sellerId=&status=
|
|||
|
|
POST /api/admin/v2/offers
|
|||
|
|
PATCH /api/admin/v2/offers/{id}
|
|||
|
|
POST /api/admin/v2/offers/{id}/publish -> runs §5 executability check, 422 with details[] on failure
|
|||
|
|
GET /api/admin/v2/offers/lookup?sku=&sellerSku=&externalId= -- "find any offer by internal SKU, seller SKU, product ID, or external mapping" per plan §2.1
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Replaces `AdminProductsLocalGateway` behind `ADMIN_PRODUCTS_GATEWAY` (token already wired this session).
|
|||
|
|
|
|||
|
|
## 8. What the frontend will start doing once this ships
|
|||
|
|
|
|||
|
|
- Unify the admin mock product domain with the live storefront `Item` domain — currently two unrelated shapes.
|
|||
|
|
- Multi-seller product page: same product card, multiple offers/sellers/prices — undefined behaviour today.
|
|||
|
|
- Wire the Moderation module to real lifecycle transitions instead of mock data.
|