Vendor & VendorItem
1. Overview
| Property | Value |
|---|---|
| ID | FEAT-INV-VEN |
| Status | Stable |
| Owner | inventory-team |
| Depends on | Material / ProductVariant, UnitOfMeasure, PurchaseOrder (caller of recordPurchase) |
Vendor is a supplier. VendorItem is the M:N junction linking a vendor to a principal item (Material or ProductVariant) for a specific merchant — with quoted price, UoM, multiplier, preferred flag, and a snapshot of the last invoiced terms. Principals never carry vendorId (ADR-0002).
2. Entity Model
Vendor fields
| Field | Type | Required | Description |
|---|---|---|---|
merchantId | text | ✓ | Owner |
identifier | text | ✓ | Auto, VEN prefix |
slug | text | ✓ | URL-safe |
name | i18n jsonb | ✓ | Display |
description | i18n jsonb | — | |
status | text | ✓ | ACTIVATED / DEACTIVATED / ARCHIVED (default ACTIVATED) |
location | jsonb | { main, sub, long, lat, postCode } | |
taxNumber | text | Tax registration | |
currency | text | ✓ | Default VND |
contacts | jsonb (array) | Array<IVendorContact> (default []) | |
note | text | Free-form |
VendorItem fields
| Field | Type | Required | Description |
|---|---|---|---|
vendorId | text | ✓ | FK to Vendor |
merchantId | text | ✓ | Denormalized from Vendor.merchantId |
itemType | text | ✓ | MATERIAL / PRODUCT_VARIANT |
itemId | text | ✓ | FK target id (polymorphic) |
uomId | text | Vendor's catalog UoM (soft ref) | |
multiplier | decimal(15,4) | UoM-to-base conversion | |
unitPrice | decimal(15,4) | Quoted price per UoM | |
isPreferred | boolean | ✓ | Partial unique per (merchantId, itemType, itemId) — at most one preferred per item |
status | text | ✓ | ACTIVATED / DEACTIVATED / ARCHIVED |
lastInvoiced | jsonb | Snapshot from latest PO receive: { unitPrice, uomId, multiplier, orderedAt, receivedAt } |
3. Lifecycle
| From | Event | To |
|---|---|---|
ACTIVATED | deactivate | DEACTIVATED |
DEACTIVATED | activate | ACTIVATED |
* (non-archived) | archive | ARCHIVED (terminal) |
Both
VendorandVendorItemshare this lifecycle.
4. Operations
VendorService (vendor.service.ts — 313 lines)
| Method | Signature | Purpose |
|---|---|---|
createAggregate | { context, data: TCreateVendorAggregateRequest } | Create Vendor + (optional) VendorItem batch in one TX. Aggregate field name: items[] (per-parent semantic — see memory rule feedback_aggregate_field_naming) |
updateAggregate | { context, id, data: TUpdateVendorAggregateRequest } | Patch Vendor + grant/omit VendorItem batch |
VendorItemService (vendor-item.service.ts — 772 lines)
| Method | Signature | Purpose |
|---|---|---|
create (override) | { context, data, transaction? } | Create VendorItem; validates vendor.merchantId === context.merchantId; atomic isPreferred flip if isPreferred=true |
updateById (override) | { context, id, data, transaction? } | Update; same isPreferred atomicity |
setPreferred | { context, id } | Promote to vendor's preferred source for this item — atomic via setPreferredAtomic repo method |
activate | { context, id } | DEACTIVATED → ACTIVATED |
deactivate | { context, id } | ACTIVATED → DEACTIVATED |
recordPurchase | { vendorItemId, unitPrice, uomId, multiplier, orderedAt?, receivedAt? } | Update lastInvoiced snapshot — called by PurchaseOrderService on receive; bypasses scope check (service-internal) |
upsertBatch | { context, merchantId, entries: TVendorItemAssignment[], transaction } | Bulk create/update — used by Vendor aggregate |
omitBatch | { context, vendorId, itemIds, transaction } | Soft-delete (archive) batch |
5. REST Endpoints
/vendors
| Verb | Path | Auth | Permission | Handler |
|---|---|---|---|---|
| 6× CRUD | /vendors | JWT/BASIC | Vendor.<crud> | CRUD |
POST | /vendors/aggregate | JWT/BASIC | Vendor.createAggregate | VendorService.createAggregate |
PATCH | /vendors/:id/aggregate | JWT/BASIC | Vendor.updateAggregate | VendorService.updateAggregate |
/vendor-items
| Verb | Path | Auth | Permission | Handler |
|---|---|---|---|---|
| 6× CRUD | /vendor-items | JWT/BASIC | VendorItem.<crud> | CRUD (with vendor↔merchant validation) |
POST | /vendor-items/:id/set-preferred | JWT/BASIC | VendorItem.setPreferred | setPreferred |
POST | /vendor-items/:id/activate | JWT/BASIC | VendorItem.activate | activate |
POST | /vendor-items/:id/deactivate | JWT/BASIC | VendorItem.deactivate | deactivate |
recordPurchase,upsertBatch,omitBatchare not exposed as REST — internal-only.
6. Events
Inbound: none directly.
Outbound: none directly. Vendor / VendorItem mutations are persisted only.
7. Key Patterns
Atomic isPreferred flip
VendorItemRepository.setPreferredAtomic performs in a single statement:
- Demote all
isPreferred=truerows where(merchantId, itemType, itemId) = target's groupANDid ≠ target.id. - Promote target to
isPreferred=true.
Combined with the partial unique index on (merchantId, itemType, itemId) WHERE isPreferred=true, this guarantees at most one preferred per item.
lastInvoiced snapshot from PO receive
Skips scope check because called inside an authenticated PO receive flow.
Backfill from existing PO history
inventory-0005-backfill-vendor-item.ts (one-shot migration) materializes VendorItem rows for (merchantId, vendorId, itemType, itemId) groups by snapshotting the most recent non-cancelled PO line.
8. Aggregate field naming
Per memory rule feedback_aggregate_field_naming:
| Aggregate | Child collection field |
|---|---|
| Vendor → VendorItems | items[] (vendor-centric: "the items this vendor supplies") |
| Material → VendorItems | vendors[] (material-centric: "the vendors that supply this material") |
Same junction table, different semantic naming per parent context.
9. Related Pages
- Purchase Order —
recordPurchasecaller - Material & BOM — Material is the most common VendorItem principal
- Domain Model — full schemas
- ADR-0002 Vendor via VendorItem only
- Decisions