35 KiB
11 · BACKEND — Phase 1 API Reference (Inventory & Supply Chain)
Authoritative for: the REST API contract — every endpoint with complete request/response bodies, the error catalog, and enums. Navigation: you arrived from
00-CORE.md. Business rules, architecture, and the entity model are in10-BACKEND-PHASE1.md. Frontend consumers follow20-FRONTEND.md. Record work inBackend/PROGRESS.md. Consistency: field names match the entity model in10-BACKEND-PHASE1.md Part C. This document should match the Swashbuckle-generated OpenAPI; an OpenAPI 3.1 YAML can be produced from it.
1. Conventions
1.1 Base URL & versioning
https://{host}/api/v1
Path-based versioning. Breaking changes bump the major version.
1.2 Authentication & authorization
Authorization: Bearer <access_token>
- Every endpoint requires a valid Bearer JWT, sent either as an
Authorization: Bearer <token>header or as theerp_athttpOnly cookie issued byAuthController(§2.0); unauthenticated →401. Tokens are issued by the external AuthHex IdP (not ERPCore) — RS256, issuerAuthHex, audienceAuthHexClient. ERPCore validates them against AuthHex's static RSA public key (no JWKS) and admits only holders of the configured ERPUserType/Role(door policy) → otherwise403. - Per-endpoint RBAC is NOT enforced in Phase 1 (FR-X-01): any ERP-admitted user may call any endpoint.
- The audit actor is AuthHex's custom
UserId(GUID) claim, mapped to a local shadow user (int). Clients never sendcreatedBy; the server derives it (docs/10 A.4).
1.3 Content type & encoding
application/json, UTF-8, camelCase. Timestamps ISO 8601 UTC (2026-07-07T09:30:00Z); dates YYYY-MM-DD. Base currency LKR in Phase 1.
1.4 List envelope
{ "items": [ /* ... */ ],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 137, "totalPages": 7 } }
1.5 Pagination / filtering / sorting
page (1-based, default 1) · pageSize (default 20, max 200) · sort (name / -createdAt) · q (free text) · resource filters documented per endpoint.
1.6 Concurrency & idempotency
Mutable resources expose ETag (EF RowVersion); PUT/PATCH send If-Match → 412 on mismatch. Transactional POSTs (GRN confirm, transfer dispatch/receive, adjustment) accept an optional Idempotency-Key header.
1.7 Status codes
200 read/update · 201 created (+Location) · 204 no content · 400 validation · 401 unauth · 404 not found · 409 domain conflict · 412 ETag mismatch · 422 semantically invalid.
1.8 Error format (RFC 7807)
{ "type": "https://errors.erp.local/validation",
"title": "One or more validation errors occurred.",
"status": 400, "traceId": "00-6f1c...-01",
"errors": { "sku": ["The sku field is required."], "lines": ["At least one line is required."] } }
Domain errors add a stable code (catalog §7):
{ "type": "https://errors.erp.local/insufficient-stock",
"title": "Insufficient stock to fulfil the issue.",
"status": 409, "code": "STOCK_NEGATIVE_BLOCKED",
"detail": "Available 4 < requested 10 for item ITM-1001 at WH-MAIN.", "traceId": "00-9a2f...-01" }
2. Master Data
2.0 Auth — AuthController, proxying the external AuthHex IdP
Superseding note (2026-07-16). Un-superseded: the frontend no longer calls AuthHex directly. All of the endpoints below live on ERPCore under
/api/v1/auth/*(Controllers/AuthController.cs), each forwarding to the matching AuthHexfunctionName(see the project-rootAPI_REFERENCE.mdfor AuthHex's own contract) viaIAuthHexClient. Session-issuing endpoints deliver AuthHex's access/refresh tokens as httpOnly Secure cookies (erp_at,erp_rt) plus a non-httpOnlyXSRF-TOKENcookie (docs/02-SECURITY.md §B.2) — response bodies never contain raw tokens. Mutating, cookie-authenticated requests must echo the CSRF cookie value in anX-XSRF-TOKENheader or receive403 CSRF_TOKEN_MISMATCH; Bearer-header callers (Swagger, service-to-service) are exempt. The JWT bearer handler also accepts theerp_atcookie in place of anAuthorizationheader (docs/10 A.4 Auth proxy), so every other/api/v1/*controller keeps working unchanged either way.
| Route | AuthHex function | Auth |
|---|---|---|
POST /api/v1/auth/register |
registerUser | Anonymous |
POST /api/v1/auth/login |
loginUser | Anonymous |
POST /api/v1/auth/login/otp/verify |
VerifyOtpForLogin | Anonymous |
POST /api/v1/auth/refresh-token |
refreshToken | Anonymous (reads erp_rt cookie) |
GET /api/v1/auth/users/{userId} |
getUserDetails | Anonymous* |
GET /api/v1/auth/sessions |
getUserSessions | Required |
POST /api/v1/auth/status |
ChangeUserStatus | Required + CSRF |
POST /api/v1/auth/lock |
LockUserAccount | Required + CSRF |
POST /api/v1/auth/change-password |
ChangeUserPassword | Required + CSRF |
POST /api/v1/auth/verify-password |
VerifyPassword | Required |
POST /api/v1/auth/logout |
LogoutUser | Anonymous* — userId optional |
PUT /api/v1/auth/me |
UpdateUser | Required + CSRF |
POST /api/v1/auth/2fa/initiate |
initiateTwoFASetup | Required + CSRF |
POST /api/v1/auth/2fa/complete |
completeTwoFASetup | Required + CSRF |
POST /api/v1/auth/2fa/verify |
verifyTwoFA | Required + CSRF |
POST /api/v1/auth/2fa/disable |
disableTwoFA | Required + CSRF |
GET /api/v1/auth/2fa/status |
getTwoFAStatus | Required |
POST /api/v1/auth/recovery/forgot-password |
forgotPassword | Anonymous |
POST /api/v1/auth/recovery/verify-otp |
verifyOTP | Anonymous |
POST /api/v1/auth/recovery/reset-password |
resetPassword | Anonymous |
POST /api/v1/auth/recovery/reset-password-token |
resetPasswordWithToken | Anonymous |
POST /api/v1/auth/availability |
IsAvailable | Anonymous |
POST /api/v1/auth/otp/send |
sendOtp | Anonymous |
POST /api/v1/auth/otp/verify |
VerifyOTP (Alt) | Anonymous |
* getUserDetails and LogoutUser are anonymous because AuthHex itself resolves them from the request payload rather
than the bearer token — carried over from AuthHex's own design, not introduced by this proxy. Tracked as an accepted risk
in docs/02-SECURITY.md Part A.
POST /auth/logout—userIdis optional (2026-07-17). AuthHex returnsuser.userId: nullin its own login/register response, so a browser never learns the id it would need to send. When omitted, ERPCore resolves it from the session token'sUserIdclaim. Theerp_at/erp_rt/XSRF-TOKENcookies are cleared regardless of whether the upstream revoke succeeds — a logout that leaves the caller holding a live session cookie is worse than one that leaves a stale session server-side (which lapses on its own). Previously the requireduserIdmade a browser logout impossible: the cookies survived and "logging out" was cosmetic.
Request/response field shapes match AuthHex's own payloads one-for-one (project-root API_REFERENCE.md §3–§5), except
session-issuing responses omit AccessToken/RefreshToken (cookie-delivered instead) and refreshToken is read from the
erp_rt cookie rather than the request body.
2.1 Items
itemType→stockNature(2026-07-16). The Stocked/NonStocked/Service field was renamed so the nameitemTypecould be taken by the new Item Type master (§2.7) — an unrelated concept. Items gainedsubCategoryIdandbrandId(both nullable). Items carry no item-type reference: the values chosen in the builder are encoded into the client-generated SKU (docs/10 Part C.9).
GET /items
Query: q, status (Active|Inactive), categoryId, subCategoryId, brandId, trackingMode (None|Batch|Serial), + paging.
200 OK
{ "items": [ { "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40",
"categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5,
"stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active" } ],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } }
GET /items/{itemId} → 200 OK (header ETag: "AAAAAAAAB9E=")
{ "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40",
"description": "Grade 8.8 zinc-plated hex bolt", "categoryId": 12, "subCategoryId": 30,
"brandId": 2, "baseUomId": 1,
"defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD",
"status": "Active", "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ],
"conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ],
"createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" }
conversions is inlined (added 2026-07-17) because they are otherwise unreadable: PUT /items/{id}/uom-conversions returns them but nothing reads them back, so a detail screen could never show current state before editing.
POST /items
The sku is generated by the client (it encodes the chosen item-type values, e.g. BL-100-0003); the server only enforces uniqueness. subCategoryId/brandId are optional.
{ "sku": "ITM-1002", "name": "Steel Nut M8", "description": "Grade 8 zinc-plated hex nut",
"categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5,
"stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD" }
201 Created — Location: /api/v1/items/1002
{ "itemId": 1002, "sku": "ITM-1002", "name": "Steel Nut M8", "categoryId": 12,
"subCategoryId": 30, "brandId": 2, "baseUomId": 1,
"defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD",
"status": "Active", "createdAt": "2026-07-07T09:30:00Z" }
400 → code: SKU_DUPLICATE if SKU exists.
422 → code: CONFIG_DISABLED if subCategoryId is sent while subcategories are disabled, or brandId while brands are disabled (§2.8).
422 → validation error if the subcategory does not belong to categoryId, or if a referenced subcategory/brand/vendor is missing or inactive.
PUT /items/{itemId}
Full update; requires If-Match. → 200 OK updated resource; 412 on ETag mismatch.
PATCH /items/{itemId}/status
{ "status": "Inactive" }
204 No Content. Masters are deactivated, not hard-deleted (FR-MD-08); hard DELETE of a referenced master → 409 MASTER_IN_USE.
PUT /items/{itemId}/reorder
{ "settings": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 },
{ "warehouseId": 2, "reorderPoint": 100, "reorderQty": 400 } ] }
200 OK → persisted settings array.
2.2 Units of Measure
GET /uoms · POST /uoms
{ "name": "Box-12" }
201 Created → { "uomId": 7, "name": "Box-12" }
PUT /items/{itemId}/uom-conversions
{ "conversions": [ { "fromUom": 7, "toUom": 1, "factor": 12 } ] }
200 OK
{ "itemId": 1001, "baseUomId": 1,
"conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ] }
2.3 Categories & Subcategories
Two-level hierarchy (2026-07-16). Categories no longer self-nest:
parentIdandGET /categories?tree=trueare gone, replaced by a dedicated Subcategory resource one level below. Categories also gainedstatus+ anETag(they previously had neither, so there was no update path at all).
GET /categories
Query: q, status (Active|Inactive), + paging. 200 OK → list envelope of CategoryDto.
GET /categories/{categoryId} → 200 OK (+ ETag); 404 if absent.
{ "categoryId": 12, "name": "Fasteners", "status": "Active",
"createdAt": "2026-06-01T08:00:00Z", "updatedAt": null }
POST /categories
{ "name": "Fasteners" }
201 Created → { "categoryId": 12, "name": "Fasteners", "status": "Active", "createdAt": "...", "updatedAt": null }
409 if the name already exists (names are unique, case-insensitive).
PUT /categories/{categoryId}
Requires If-Match. → 200 OK; 412 on ETag mismatch; 409 on duplicate name.
PATCH /categories/{categoryId}/status
{ "status": "Inactive" }
204 No Content. Deactivate, never delete (FR-MD-08).
GET /categories/{categoryId}/subcategories
Query: q, status, + paging. 200 OK → list envelope of SubCategoryDto; 404 if the category itself is absent.
POST /categories/{categoryId}/subcategories
The parent comes from the route.
{ "name": "Hex Bolts" }
201 Created — Location: /api/v1/subcategories/30
{ "subCategoryId": 30, "categoryId": 12, "name": "Hex Bolts", "status": "Active",
"createdAt": "2026-07-16T09:00:00Z", "updatedAt": null }
404 if the category does not exist · 422 if it is inactive · 409 if the name already exists within that category (names need only be unique per parent).
GET /subcategories/{subCategoryId} → 200 OK (+ ETag); 404 if absent.
PUT /subcategories/{subCategoryId}
Requires If-Match. Name only — a subcategory cannot be moved to another category, since that would silently invalidate the categoryId of every item referencing it. → 200 OK; 412 on mismatch.
{ "name": "Hex Bolts (metric)" }
PATCH /subcategories/{subCategoryId}/status → 204 No Content.
2.4 Vendors
POST /vendors
{ "code": "VN-005", "name": "Lanka Steel Traders (Pvt) Ltd", "terms": "NET30",
"taxReg": "134567890-7000", "currency": "LKR" }
201 Created
{ "vendorId": 5, "code": "VN-005", "name": "Lanka Steel Traders (Pvt) Ltd", "terms": "NET30",
"taxReg": "134567890-7000", "currency": "LKR", "status": "Active", "createdAt": "2026-07-07T09:31:00Z" }
GET /vendors, GET /vendors/{id}, PUT /vendors/{id}, PATCH /vendors/{id}/status follow the Item pattern.
2.5 Warehouses & Bins
POST /warehouses
{ "code": "WH-MAIN", "name": "Main Warehouse - Negombo" }
201 Created → { "warehouseId": 1, "code": "WH-MAIN", "name": "Main Warehouse - Negombo" }
POST /warehouses/{warehouseId}/bins
{ "code": "A-01-01", "binType": "Shelf" }
201 Created → { "binId": 45, "warehouseId": 1, "code": "A-01-01", "binType": "Shelf" }
GET /warehouses/{warehouseId}/bins lists bins.
2.6 Brands
Referenced optionally by Item.brandId. Rejected on item writes when brands are disabled (§2.8).
GET /brands
Query: q, status (Active|Inactive), + paging. 200 OK → list envelope of BrandDto.
GET /brands/{brandId} → 200 OK (+ ETag); 404 if absent.
POST /brands
{ "name": "Bosch" }
201 Created — Location: /api/v1/brands/2
{ "brandId": 2, "name": "Bosch", "status": "Active",
"createdAt": "2026-07-16T09:00:00Z", "updatedAt": null }
409 if the name already exists (unique, case-insensitive).
PUT /brands/{brandId}
Requires If-Match. → 200 OK; 412 on mismatch; 409 on duplicate name.
PATCH /brands/{brandId}/status → 204 No Content. Deactivate, never delete (FR-MD-08).
2.7 Item Types
Read this before assuming a relationship exists. An item type is a dimension name (Color, Size, Material) and nothing more. No item references an item type, and there is no value resource: the values chosen in the frontend builder (Red, S, M) are encoded into the client-generated SKU —
BL-0002for one type,BL-100-0003for two — and are never stored or parsed server-side.GET /item-typesexists to populate the builder's dropdown; that is the entire purpose of this master. Consequently the API cannot filter items by colour/size, and renaming an item type does not alter any existing SKU. See docs/10 Part C.9 for the recorded trade-off. Not to be confused withstockNature(§2.1), which is what the olditemTypeenum became.
GET /item-types
Query: q, status (Active|Inactive), + paging. Pass status=Active for selectable rows.
200 OK
{ "items": [ { "itemTypeId": 1, "name": "Color", "status": "Active",
"createdAt": "2026-07-16T09:00:00Z", "updatedAt": null },
{ "itemTypeId": 2, "name": "Size", "status": "Active",
"createdAt": "2026-07-16T09:00:00Z", "updatedAt": null } ],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 2, "totalPages": 1 } }
Color and Size are seeded on first start; users add their own (e.g. Material).
GET /item-types/{itemTypeId} → 200 OK (+ ETag); 404 if absent.
POST /item-types
{ "name": "Material" }
201 Created — Location: /api/v1/item-types/3 → the ItemTypeDto. 409 if the name exists.
Callable from the item builder's inline "+" as well as the admin screen.
PUT /item-types/{itemTypeId}
Requires If-Match. → 200 OK; 412 on mismatch; 409 on duplicate name.
Renaming does not touch existing items — nothing joins back to this row.
PATCH /item-types/{itemTypeId}/status → 204 No Content. Deactivate, never delete (FR-MD-08).
2.8 Product Configuration
A singleton feature gate (FR-MD-11), seeded with every flag true.
Enforcement is not uniform, by design:
| Flag | Enforced? | Effect when false |
|---|---|---|
subcategoriesEnabled |
Server-side | POST/PUT /items with a non-null subCategoryId → 422 CONFIG_DISABLED |
brandsEnabled |
Server-side | POST/PUT /items with a non-null brandId → 422 CONFIG_DISABLED |
itemTypesEnabled |
Advisory only | Nothing server-side. Items carry no item-type reference (§2.7), so there is nothing on a write to reject — the frontend honours it by hiding the builder's type section. |
Reads are never gated: switching a flag off leaves existing items readable with their subcategory/brand intact.
GET /product-config → 200 OK (+ ETag)
{ "subcategoriesEnabled": true, "brandsEnabled": true, "itemTypesEnabled": true,
"updatedAt": "2026-07-16T10:00:00Z", "updatedBy": 17 }
PUT /product-config
Requires If-Match. All three flags are required — a partial body is a 400, so a feature can never be switched off by omission. updatedBy is derived from the token, never posted.
{ "subcategoriesEnabled": false, "brandsEnabled": true, "itemTypesEnabled": true }
200 OK → the updated resource; 412 on ETag mismatch.
Authorization: writes are admitted by the ERP door policy only — any authenticated ERP user may flip these flags. A
CONFIG_MANAGEpermission is reserved for when per-endpoint RBAC lands (FR-X-01, deferred); no schema or route change will be needed to enable it. Tracked as open decision #13 in docs/10 §B.8.4.
3. Procurement
3.1 Requisitions
POST /requisitions
{ "lines": [ { "itemId": 1001, "qty": 5000, "requiredBy": "2026-07-20" },
{ "itemId": 1002, "qty": 8000, "requiredBy": "2026-07-20" } ] }
201 Created (requestedBy from token)
{ "requisitionId": 210, "docNo": "PR-2026-00210", "status": "Draft", "requestedBy": 17,
"createdAt": "2026-07-07T09:35:00Z",
"lines": [ { "reqLineId": 501, "itemId": 1001, "qty": 5000, "requiredBy": "2026-07-20" },
{ "reqLineId": 502, "itemId": 1002, "qty": 8000, "requiredBy": "2026-07-20" } ] }
POST /requisitions/{id}/submit → 200 OK status: "Submitted".
GET /requisitions?status=Submitted → list envelope of RequisitionSummaryDto ({requisitionId, docNo, status, requestedBy, createdAt, lineCount}).
3.2 RFQs & Quotations
GET /rfqs
Query: q, status (Open|Closed), + paging. → list envelope of
{ rfqId, docNo, requisitionId, status, lineCount, quotationCount }.
vendorIdsis not persisted.POST /rfqsvalidates the invited vendors but stores no RFQ↔vendor link, so neitherGET /rfqsnorGET /rfqs/{id}returns them. Quotations reference vendors directly —quotationCount(and the comparison'svendorIds) are the facts that survive. A UI cannot show "invited but not yet quoted".
POST /rfqs
{ "requisitionId": 210, "vendorIds": [5, 8, 11],
"lines": [ { "itemId": 1001, "qty": 5000 }, { "itemId": 1002, "qty": 8000 } ] }
201 Created
{ "rfqId": 88, "docNo": "RFQ-2026-00088", "requisitionId": 210, "status": "Open",
"lines": [ { "rfqLineId": 701, "itemId": 1001, "qty": 5000 },
{ "rfqLineId": 702, "itemId": 1002, "qty": 8000 } ] }
POST /rfqs/{rfqId}/quotations
{ "vendorId": 5, "lines": [ { "itemId": 1001, "unitPrice": 12.50, "leadDays": 7 },
{ "itemId": 1002, "unitPrice": 6.20, "leadDays": 7 } ] }
201 Created
{ "quotationId": 140, "rfqId": 88, "vendorId": 5,
"lines": [ { "itemId": 1001, "unitPrice": 12.50, "leadDays": 7 },
{ "itemId": 1002, "unitPrice": 6.20, "leadDays": 7 } ] }
GET /rfqs/{rfqId}/comparison → vendor-by-line price matrix.
3.3 Purchase Orders
Phase 1:
approvalRequireddefaultsfalse→ PO auto-approved on creation. Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). PO freely editable while open (Option B, FR-PROC-05).
POST /purchase-orders
{ "vendorId": 5, "requisitionId": 210,
"lines": [ { "itemId": 1001, "uomId": 1, "warehouseId": 1, "qty": 5000, "unitPrice": 12.50, "tax": 0.18 },
{ "itemId": 1002, "uomId": 1, "warehouseId": 1, "qty": 8000, "unitPrice": 6.20, "tax": 0.18 } ] }
201 Created — Location: /api/v1/purchase-orders/342
{ "poId": 342, "docNo": "PO-2026-00342", "vendorId": 5, "requisitionId": 210,
"status": "Approved", "approvalRequired": false, "createdBy": 17, "createdAt": "2026-07-07T09:40:00Z",
"totals": { "subTotal": 112100.00, "tax": 20178.00, "grandTotal": 132278.00, "currency": "LKR" },
"lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "warehouseId": 1, "qty": 5000, "unitPrice": 12.50, "tax": 0.18, "qtyReceived": 0 },
{ "poLineId": 901, "itemId": 1002, "uomId": 1, "warehouseId": 1, "qty": 8000, "unitPrice": 6.20, "tax": 0.18, "qtyReceived": 0 } ] }
GET /purchase-orders?status=Approved&vendorId=5 → list envelope of PO summaries.
PUT /purchase-orders/{poId}
Edit while open (not FullyReceived/Closed/Cancelled); requires If-Match. → 200 OK updated resource; 409 PO_NOT_EDITABLE if closed.
POST /purchase-orders/{poId}/approve → 200 OK (no-op in Phase 1; transitions PendingApproval→Approved when enabled).
POST /purchase-orders/{poId}/cancel
{ "reason": "Duplicate order" }
200 OK status: "Cancelled"; 409 if any receipt exists.
3.4 Purchase Returns
GET /purchase-returns
Query: q, vendorId, warehouseId, + paging. → list envelope of
{ returnId, docNo, vendorId, warehouseId, reasonCodeId, status, createdBy, createdAt, lineCount }.
GET /purchase-returns/{returnId} → the full return incl. lines and ledgerRefs; 404 if absent.
POST /purchase-returns
{ "vendorId": 5, "warehouseId": 1, "reasonCodeId": 22,
"lines": [ { "grnLineId": 1300, "itemId": 1001, "qty": 200 } ] }
201 Created
{ "returnId": 61, "docNo": "PRET-2026-00061", "vendorId": 5, "warehouseId": 1, "reasonCodeId": 22,
"status": "Posted", "createdBy": 17,
"lines": [ { "returnLineId": 120, "grnLineId": 1300, "itemId": 1001, "qty": 200 } ],
"ledgerRefs": [ 55021 ] }
409 STOCK_NEGATIVE_BLOCKED if return qty exceeds available.
4. Goods Receipt (GRN)
On confirm, each line creates a FIFO cost layer and posts an inbound ledger entry (FR-GRN-06). Goods may land
holdStatus: "OnHold"(not issuable) until released. NoPUTorDELETEexists for a GRN — a receipt is corrected with a reversing document, never edited or erased (FR-X-05). Gap vs FR-GRN-04:CreateGrnLineInputcarriesbatchbut has no serial-number field, so serials cannot be captured on receipt as the requirement mandates. Tracked inBackend/PROGRESS.md.
4.0 GET /grns
Query: q, status (Draft|Confirmed|Closed), poId, vendorId, warehouseId, + paging. → list envelope of
{ grnId, docNo, poId, vendorId, warehouseId, status, createdBy, createdAt, postedAt, lineCount }.
4.1 POST /grns
Against a PO (lines default from open PO lines) or direct (poId: null, by permission).
{ "poId": 342, "warehouseId": 1,
"lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, "qty": 5000,
"unitCost": 12.50, "holdStatus": "OnHold",
"batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } } ] }
201 Created — status Draft
{ "grnId": 780, "docNo": "GRN-2026-00780", "poId": 342, "vendorId": 5, "warehouseId": 1,
"status": "Draft", "createdBy": 17,
"lines": [ { "grnLineId": 1300, "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45,
"qty": 5000, "unitCost": 12.50, "receivedValue": 62500.00, "holdStatus": "OnHold", "batchId": 410 } ] }
422 OVER_RECEIPT_TOLERANCE if qty exceeds open PO qty beyond tolerance.
4.2 POST /grns/{grnId}/confirm
Header: optional Idempotency-Key. 200 OK — creates FIFO layers + ledger; updates PO line qtyReceived.
{ "grnId": 780, "status": "Confirmed", "postedAt": "2026-07-07T10:05:00Z",
"createdLayers": [ { "layerId": 9001, "itemId": 1001, "warehouseId": 1, "batchId": 410,
"qtyReceived": 5000, "qtyRemaining": 5000, "unitCost": 12.50, "receiptDate": "2026-07-07T10:05:00Z" } ],
"ledgerRefs": [ 55010 ], "poStatus": "Fully Received" }
Received OnHold → the layer is not available until released.
4.3 POST /grns/{grnId}/lines/{grnLineId}/release
{ "action": "Release" }
200 OK → { "grnLineId": 1300, "holdStatus": "Available" }
action: "Reject" moves the quantity to a return workflow instead.
5. Stock Management
5.1 GET /stock/on-hand?itemId=1001&warehouseId=1
{ "itemId": 1001, "warehouseId": 1, "onHand": 5000, "available": 0, "onHold": 5000,
"inTransit": 0, "reserved": 0, "asOf": "2026-07-07T10:06:00Z" }
available = onHand − onHold − reserved. inTransit is reported, not subtracted again: dispatch has already consumed the source layers, so removing it a second time would double-count (docs/10 C.9). reserved always 0 in Phase 1 (stub, FR-STK-11).
GET /stock/on-hand/list?warehouseId=1&itemId=1001
Both filters optional; + paging. On-hand for every (item, warehouse) pair that holds stock — backs the Stock Enquiry list. Pairs come from STOCK_LAYER, so an item that never had a receipt in a warehouse does not appear.
{ "items": [ { "itemId": 1001, "warehouseId": 1, "onHand": 5000, "available": 0, "onHold": 5000,
"inTransit": 0, "reserved": 0, "asOf": "2026-07-17T10:06:00Z" } ],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } }
5.2 GET /stock/ledger?itemId=1001&warehouseId=1&from=2026-07-01&to=2026-07-07
Also accepts sourceDocType + sourceDocId — the only way to ask "what movements did this document post?", since the ledger's document reference is polymorphic with no FK to follow (docs/10 C.9). sourceDocType is the document-type prefix as stored: GRN, ADJ, TRF, PRET, CNT (Domain/DocumentTypes.cs), not the friendly name.
{ "items": [ { "ledgerId": 55010, "itemId": 1001, "warehouseId": 1, "binId": 45, "batchId": 410,
"serialId": null, "direction": "In", "qtyBase": 5000, "unitCost": 12.50, "value": 62500.00,
"runningBalance": 5000, "sourceDocType": "GRN", "sourceDocId": 780, "userId": 17,
"createdAt": "2026-07-07T10:05:00Z" } ],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } }
5.3 GET /stock/valuation?itemId=1001&warehouseId=1
{ "itemId": 1001, "warehouseId": 1,
"layers": [ { "layerId": 9001, "qtyRemaining": 5000, "unitCost": 12.50, "value": 62500.00,
"receiptDate": "2026-07-07T10:05:00Z" } ],
"totalQty": 5000, "totalValue": 62500.00, "currency": "LKR", "costingMethod": "FIFO" }
5.4 Transfers (in-transit)
create → dispatch → receive. Dispatch consumes source FIFO layers into in-transit; receive creates the destination layer at inherited cost (cost-preserving, FR-STK-06).
GET /stock-transfers
Query: q, status (Draft|InTransit|Received|Closed), srcWarehouseId, destWarehouseId, + paging. → list envelope of
{ transferId, docNo, srcWarehouseId, destWarehouseId, status, createdBy, createdAt, lineCount }.
POST /stock-transfers
{ "srcWarehouseId": 1, "destWarehouseId": 2,
"lines": [ { "itemId": 1001, "srcBinId": 45, "destBinId": 90, "batchId": 410, "qty": 1000 } ] }
201 Created
{ "transferId": 55, "docNo": "TRF-2026-00055", "srcWarehouseId": 1, "destWarehouseId": 2,
"status": "Draft",
"lines": [ { "transferLineId": 300, "itemId": 1001, "srcBinId": 45, "destBinId": 90, "batchId": 410, "qty": 1000 } ] }
POST /stock-transfers/{id}/dispatch → 200 OK (status InTransit)
{ "transferId": 55, "status": "InTransit",
"consumedLayers": [ { "layerId": 9001, "qtyConsumed": 1000, "unitCost": 12.50 } ],
"ledgerRefs": [ 55033 ] }
409 STOCK_NEGATIVE_BLOCKED if source available < requested.
POST /stock-transfers/{id}/receive
{ "lines": [ { "transferLineId": 300, "qty": 1000 } ] }
200 OK (status Received)
{ "transferId": 55, "status": "Received",
"createdLayers": [ { "layerId": 9040, "warehouseId": 2, "qtyReceived": 1000, "unitCost": 12.50 } ],
"ledgerRefs": [ 55034 ] }
5.5 Adjustments (auto-post)
Reason code mandatory. Decrease consumes FIFO layers; increase creates a layer at supplied/last cost (FR-STK-07).
GET /stock-adjustments
Query: q, warehouseId, reasonCodeId, + paging. Newest first. → list envelope of
{ adjustmentId, docNo, warehouseId, reasonCodeId, status, createdBy, createdAt, lineCount }.
GET /stock-adjustments/{adjustmentId} → the full adjustment incl. lines and ledgerRefs; 404 if absent.
POST /stock-adjustments
{ "warehouseId": 1, "reasonCodeId": 4,
"lines": [ { "itemId": 1001, "binId": 45, "batchId": 410, "qtyDelta": -15 } ] }
201 Created
{ "adjustmentId": 77, "docNo": "ADJ-2026-00077", "warehouseId": 1, "reasonCodeId": 4,
"status": "Posted", "createdBy": 17, "createdAt": "2026-07-07T10:20:00Z",
"lines": [ { "adjLineId": 210, "itemId": 1001, "binId": 45, "batchId": 410, "qtyDelta": -15 } ],
"ledgerRefs": [ 55050 ] }
400 REASON_CODE_REQUIRED if reasonCodeId omitted.
5.6 Counts
GET /stock-counts
Query: q, status (Draft|Counted|Posted), warehouseId, + paging. → list envelope of
{ countId, docNo, warehouseId, countType, status, createdBy, createdAt, lineCount }.
POST /stock-counts
{ "warehouseId": 1, "countType": "Cycle", "itemIds": [1001, 1002] }
201 Created — status Draft, system quantities snapshotted
{ "countId": 30, "docNo": "CNT-2026-00030", "warehouseId": 1, "countType": "Cycle", "status": "Draft",
"lines": [ { "countLineId": 400, "itemId": 1001, "binId": 45, "systemQty": 4985, "countedQty": null, "variance": null } ] }
PUT /stock-counts/{id}/counts
{ "lines": [ { "countLineId": 400, "countedQty": 4980 } ] }
200 OK → the whole CountDto (with server-computed variance), not just the lines.
POST /stock-counts/{id}/post → 200 OK (posts variance adjustment, closes count)
{ "countId": 30, "status": "Posted", "adjustmentId": 78, "ledgerRefs": [ 55060 ] }
adjustmentId is null when the count had no variance to post.
5.7 Reorder alerts
GET /stock/reorder-alerts?warehouseId=1
Items at/below ROP (FR-STK-10); computed on read, no stored entity.
{ "items": [ { "itemId": 1002, "warehouseId": 1, "available": 90, "reorderPoint": 100,
"reorderQty": 400, "suggestedRequisitionQty": 400 } ],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } }
POST /stock/reorder-alerts/{itemId}/requisition?warehouseId=1 → creates a draft requisition for the suggested qty.
6. Reference Data
GET /reason-codes?context=Adjustment
{ "items": [ { "reasonCodeId": 4, "code": "DMG", "description": "Damage", "context": "Adjustment" },
{ "reasonCodeId": 22, "code": "QREJ", "description": "Quality Reject", "context": "Return" } ],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 2, "totalPages": 1 } }
POST /reason-codes (admin). Number sequences are server-managed; no write API in Phase 1.
7. Domain Error Catalog
code |
HTTP | When |
|---|---|---|
SKU_DUPLICATE |
400 | Item SKU already exists. |
MASTER_IN_USE |
409 | Hard delete of a referenced master (use deactivate). |
PO_NOT_EDITABLE |
409 | Editing a PO that is fully received / closed / cancelled. |
OVER_RECEIPT_TOLERANCE |
422 | GRN qty exceeds PO open qty beyond tolerance. |
STOCK_NEGATIVE_BLOCKED |
409 | Issue/transfer/adjustment would drive available stock negative. |
EXPIRED_BATCH_BLOCKED |
409 | Issue/pick of an expired batch. |
ONHOLD_NOT_ISSUABLE |
409 | Issue against on-hold/quarantined stock. |
REASON_CODE_REQUIRED |
400 | Adjustment/return without a reason code. |
CONFIG_DISABLED |
422 | An item write carries a field whose feature is switched off in the product configuration (subCategoryId with subcategories disabled, brandId with brands disabled). See §2.8. |
CONCURRENCY_CONFLICT |
412 | ETag / RowVersion mismatch. |
IDEMPOTENCY_REPLAY |
200 | Duplicate Idempotency-Key; original result returned. |
Example (409, application/problem+json):
{ "type": "https://errors.erp.local/onhold-not-issuable",
"title": "Stock is on inspection hold and cannot be issued.",
"status": 409, "code": "ONHOLD_NOT_ISSUABLE",
"detail": "5000 units of ITM-1001 at WH-MAIN are OnHold; release via GRN inspection before issue.",
"traceId": "00-1b7c...-01" }
8. Enumerations
| Enum | Values |
|---|---|
stockNature |
Stocked, NonStocked, Service — renamed from itemType (2026-07-16). Item types (Color/Size/Material) are now master data, not an enum: see §2.7. |
trackingMode |
None, Batch, Serial |
holdStatus |
Available, OnHold, Rejected |
direction (ledger) |
In, Out |
PO status |
Draft, PendingApproval, Approved, PartiallyReceived, FullyReceived, Closed, Cancelled |
GRN status |
Draft, Confirmed, Closed |
Transfer status |
Draft, InTransit, Received, Closed |
Count status |
Draft, Counted, Posted |
countType |
Cycle, Full |
9. Implementation notes (ASP.NET Core)
- Serve via Swashbuckle; annotate controllers with
[ProducesResponseType]per status so generated OpenAPI matches this document. - Use
ProblemDetails/ValidationProblemDetailsfor all errors (§1.8) — framework default. - Map
ETag/If-Matchto EF[Timestamp] byte[] RowVersion. - Wrap every stock-affecting operation (GRN confirm, transfer dispatch/receive, adjustment, return, count post) in one UoW transaction; FIFO layer consumption locks affected layer rows (NFR-02). See
10-BACKEND-PHASE1.md Part A. - Derive audit actor from
User.FindFirst("sub"), never from the body. - Deferred (Phase 2+): vendor invoice + 3-way match, reservation/allocation, RBAC policy attributes — all additive, no breaking change to these routes.
End of 11-BACKEND-PHASE1.md. Model & rules: 10-BACKEND-PHASE1.md. Record work: Backend/PROGRESS.md.