45 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.0.1 RBAC — Roles, sidebar nav, Users (added 2026-07-18; sidebar-visibility only, see docs/10 C.8)
GET /api/v1/auth/me — the frontend's authoritative source for the current session's role and permitted sidebar sections
(replaces the previous client-only roleId cached in localStorage). No payload.
200 OK
{ "roleCode": "ADMIN", "roleName": "Administrator", "navCodes": ["dashboard", "products", "products.item", "settings.roles", "..."] }
GET /api/v1/nav — read-only sidebar tree (NavItem + nested SubNavItem), seeded to mirror the frontend's hardcoded
sidebar (components/Layouts/AppSidebar.tsx); used to render the Role permission-assignment checkbox UI. Not admin-editable
in this phase.
Roles (RolesController) — Role is a local shadow of AuthHex's Role (same pattern as USER/auth_user_id,
docs/10 C.9): every write below forwards to AuthHex's new /api/role functions first, then mirrors the result locally.
| Route | Notes |
|---|---|
GET /roles |
Paged list; q, status filters. |
GET /roles/{roleId} |
ETag header for If-Match on update. |
POST /roles |
{ code, name } → 201, forwards to AuthHex createRole. |
PUT /roles/{roleId} |
Requires If-Match; forwards to AuthHex updateRole. |
PATCH /roles/{roleId}/status |
{ status } → 204. |
DELETE /roles/{roleId} |
Forwards to AuthHex deleteRole; 409 ROLE_IN_USE if any user still holds it. |
GET /roles/{roleId}/permissions |
{ roleId, navItemIds, subNavItemIds }. |
PUT /roles/{roleId}/permissions |
Replaces the role's full permission set from { navItemIds, subNavItemIds } — purely local, no AuthHex call. |
codeis server-accepted but frontend-derived, never hand-typed (2026-07-18). The Roles UI computescodefromname(uppercased, non-alphanumeric →_) and submits it read-only; the field stays free-form here for API callers, but no UI lets an operator type or edit it directly, on create or later. The Create Role dialog also now includes the permission checkbox tree, soPOST /rolesandPUT /roles/{roleId}/permissionsfire as one user action.Bug fixed (2026-07-18):
POST /roles/PUT /roles/{roleId}500ing viaAUTH_UPSTREAM_ERROR.IsSystemRolebeing omitted serialized as JSONnull, and AuthHex'screateRole/updateRolecalledJsonElement.GetBoolean()on it unconditionally when the key was present — which throws onnull(unlikeGetString(), which tolerates it). Fixed on both sides: AuthHex now checksValueKind != JsonValueKind.Nullbefore readingisSystemRole, and ERPCore'sAuthHexClientnow serializes withJsonIgnoreCondition.WhenWritingNullso unset nullable fields are omitted from the payload entirely rather than sent as explicit nulls — closing this class of bug for any other nullable field sent to AuthHex.
Users (UsersController) — manages the local shadow User table and orchestrates account creation in AuthHex.
| Route | Notes |
|---|---|
GET /users |
Paged list, joined with Role. |
GET /users/{userId} |
Single record. |
POST /users |
Creates the account in both backends: calls AuthHex's registerUser (which persists the password and emails it to the given email), then immediately mirrors the local shadow User row (rather than waiting for next-login JIT provisioning). Body: { username, fullName, roleId, userTypeId, email, nic?, mobileNumber?, password? } (password empty ⇒ AuthHex auto-generates one). |
PUT /users/{userId}/role |
{ roleId } — local role reassignment only; status/lock changes reuse the existing /auth/status and /auth/lock proxy endpoints. |
GET /users/user-types |
Added 2026-07-18. Proxies AuthHex's new listUserTypes — [{ userTypeId, code, description }]. Populates the Create User form's UserType select so operators pick from a real list instead of typing an AuthHex GUID by hand; defaults to the sole existing type when only one exists. |
Known gap, not fixed (flagged 2026-07-18): AuthHex's
loginUserresolvesidentifieragainstMobileNumber/Niconly — notUsername(UserManageRepository.GetUserByIdentifierAndType). A user created viaPOST /userscan log in with their email but not their username. Out of scope for this change; revisit if/when asked.
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).
salePriceadded (nullable, 2026-07-22). Every item body below carriessalePrice(number|null). It is the Sales-only fixed selling price:null⇒ the item is sold at its stock/FIFO value; a value ⇒ fixed price. It never affects GRN/FIFO/ledger. On write it is optional; when supplied it must be>= 0(else400validation). The item builder's "fixed price / use stock value" toggle is UI-only — the contract is just the nullable field.
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",
"salePrice": 12.5000, "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",
"salePrice": 12.5000, "status": "Active",
"contentQty": 500, "contentUnit": "Ml",
"contentBaseQty": 500, "contentBaseUnit": "Ml",
"reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ],
"createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" }
conversionswas removed 2026-08-11, together with thePUT /items/{id}/uom-conversionsendpoint and theuom_conversionstable. Stock is counted inbaseUomIdand nothing converts.Content size. All four fields are null together when the item has nothing measurable to hold (a screw, a label).
contentQty/contentUnitare what the user entered and are the only two accepted on write — send both or neither, or the server returns 422.contentBaseQty/contentBaseUnitare derived (L→Ml, Kg→G, both ×1000) and read-only, socontentBaseUnitis only everMlorG.
baseUomIdis frozen once the item has stock history — a layer or a ledger row — because it is the sole meaning of every quantity already recorded. Changing it then returns409 MASTER_IN_USE.
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", "salePrice": 3.2500 }
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",
"salePrice": 3.2500, "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-conversionswas removed 2026-08-11. Per-item conversion factors no longer exist: an item is stocked in exactly one unit, and a differently sized pack is a different item. Document lines no longer carry auomIdat all — see §2.1.
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", "isMeasurable": false, "status": "Active",
"createdAt": "2026-07-16T09:00:00Z", "updatedAt": null },
{ "itemTypeId": 2, "name": "Size", "isMeasurable": false, "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, both with isMeasurable: false; users add their own
(e.g. Material, or a Pack Size with isMeasurable: true).
isMeasurable (added 2026-08-11) marks a dimension whose values are content measurements
(500 ml, 1 L) rather than plain labels. The item builder then captures a number + unit per value
and writes that pair to each generated item's contentQty/contentUnit, instead of copying one
form-level pair into every variant — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml"
three correctly sized items. It is why an apparel Size (S/M/L) can stay plain text while a
Volume dimension carries units. The server never reads it when writing an item: each item's
pair is still validated and normalised on its own.
GET /item-types/{itemTypeId} → 200 OK (+ ETag); 404 if absent.
POST /item-types
{ "name": "Pack Size", "isMeasurable": true }
201 Created — Location: /api/v1/item-types/3 → the ItemTypeDto. 409 if the name exists.
isMeasurable is optional and defaults to false. 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.
isMeasurable is nullable in the request body and preserved when omitted: a plain bool
would bind an absent property as false, so a name-only PUT — which is what the admin screen
used to send — would clear the flag on every rename.
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. Create takessaveAsDraft(defaultfalse→ auto-approved on creation;true→Draft). Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). A PO is editable/deletable only whileDraft; submitting locks it (FR-PROC-05, revised 2026-07-20 — Option B "freely edit while open" superseded).
POST /purchase-orders
{ "vendorId": 5, "requisitionId": 210, "saveAsDraft": false,
"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 } ] }
saveAsDraft optional (default false). When true the response status is Draft.
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 a Draft only; requires If-Match. → 200 OK updated resource; 409 PO_NOT_EDITABLE once submitted (any non-Draft status).
POST /purchase-orders/{poId}/submit → 200 OK — Draft → Approved. 409 PO_NOT_EDITABLE if not Draft.
DELETE /purchase-orders/{poId} → 204 No Content — permitted only while Draft; 409 PO_NOT_EDITABLE once submitted.
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, "discountPct": 10, "vatPct": 18, "holdStatus": "OnHold",
"batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } },
{ "poLineId": null, "itemId": 1050, "uomId": 1, "qty": 20,
"unitCost": 8.00, "holdStatus": "Available" } ] }
discountPct/vatPct optional (default 0, range 0–100). unitCost on a PO line is an optional
override: 0/omitted uses the PO price; a value wins and a variance is recorded (02-SECURITY C.3, revised).
On a direct receipt unitCost is required.
Off-PO lines (poLineId: null) are allowed even on a PO-based GRN (2026-07-22, FR-GRN-01) — the second
line above receives an item that is not on the PO. Such a line behaves exactly like a direct-receipt line:
unitCost is required, OVER_RECEIPT_TOLERANCE does not apply (there is no PO qty to check), and no PO
line balance is touched. The inline "create new item" UI simply calls POST /items (§2.1) first, then adds
the returned item as an off-PO line.
201 Created — status Draft. All derived figures are server-computed:
netUnitCost = unitCost × (1 − discountPct/100), receivedValue = qty × netUnitCost (after discount,
before VAT — this is the stock value), vatAmount = receivedValue × vatPct/100,
lineTotal = receivedValue + vatAmount, priceVariance = (unitCost − poUnitPrice) × qty.
{ "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, "poUnitPrice": 12.50, "discountPct": 10.0,
"netUnitCost": 11.25, "vatPct": 18.0, "vatAmount": 10125.00,
"receivedValue": 56250.00, "lineTotal": 66375.00, "priceVariance": 0.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.
5.8 Dashboard overview (added 2026-07-28)
GET /dashboard/stats
Cross-domain aggregate counts for the dashboard UI — a single computed-on-read object, not a stored entity or a PagedResponse<T> list (same posture as reorder alerts, §5.7). lowStockAlerts reuses IReorderService.GetAlertsAsync rather than re-deriving the FIFO-available-vs-reorder-point comparison; onHandTotal/onHandWarehouses/stockValuationTotal/stockValuationByWarehouse are SQL-side SUM/GROUP BY over StockLayer (cheap — unlike reorder alerts, they need no per-item live lookup).
{
"lowStockAlerts": 6,
"onHandTotal": 15420,
"onHandWarehouses": 3,
"stockValuationTotal": 4820500.00,
"stockValuationByWarehouse": [
{ "warehouseId": 1, "total": 3120000.00 },
{ "warehouseId": 2, "total": 1700500.00 }
],
"pendingApprovalPurchaseOrders": 2,
"pendingGrns": 4,
"openRequisitions": 5,
"pendingCounts": 1,
"openRfqs": 3
}
pendingApprovalPurchaseOrders = PO status PendingApproval; pendingGrns = GRN status Draft; openRequisitions = Requisition status Submitted; pendingCounts = StockCount status Counted (counted but not yet posted); openRfqs = RFQ status Open. Not covered here: GRN inspection-hold counts (HoldStatus lives on GRN lines, no list/count endpoint exposes it yet) and recent stock movements (just call GET /stock/ledger directly with a small pageSize — no aggregation needed).
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.