Files
ERP-core/docs/11-BACKEND-PHASE1.md
T
ImanThiyanga f02c89b3cb feat(procurement): enhance purchase order and GRN functionalities
- Updated purchase order descriptions for clarity on draft and submission processes.
- Implemented submit and delete functionalities for draft purchase orders, allowing users to manage their orders more effectively.
- Added discount and VAT fields to GRN lines, enabling better cost tracking and reporting.
- Enhanced validation for GRN lines to ensure discount and VAT percentages are within acceptable ranges.
- Updated API to support new functionalities, including submitting and deleting purchase orders.
- Improved UI components for better user experience in managing purchase orders and GRNs.
- Documented changes in security and backend phase documentation to reflect new processes and requirements.
2026-07-21 10:08:32 +05:30

770 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 in `10-BACKEND-PHASE1.md`. Frontend consumers follow `20-FRONTEND.md`. Record work in `Backend/PROGRESS.md`.
> **Consistency:** field names match the entity model in `10-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 the `erp_at` httpOnly cookie issued by `AuthController` (§2.0); unauthenticated → `401`. Tokens are issued by the **external AuthHex IdP** (not ERPCore) — **RS256**, issuer `AuthHex`, audience `AuthHexClient`. ERPCore validates them against AuthHex's static RSA public key (no JWKS) and admits only holders of the configured ERP `UserType`/`Role` (door policy) → otherwise `403`.
- **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 send `createdBy`; 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
```json
{ "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)
```json
{ "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):
```json
{ "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 AuthHex
> `functionName` (see the project-root `API_REFERENCE.md` for AuthHex's own contract) via `IAuthHexClient`. Session-issuing
> endpoints deliver AuthHex's access/refresh tokens as **httpOnly Secure cookies** (`erp_at`, `erp_rt`) plus a non-httpOnly
> `XSRF-TOKEN` cookie (docs/02-SECURITY.md §B.2) — response bodies never contain raw tokens. Mutating, cookie-authenticated
> requests must echo the CSRF cookie value in an `X-XSRF-TOKEN` header or receive `403 CSRF_TOKEN_MISMATCH`; Bearer-header
> callers (Swagger, service-to-service) are exempt. The JWT bearer handler also accepts the `erp_at` cookie in place of an
> `Authorization` header (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` — `userId` is optional (2026-07-17).** AuthHex returns `user.userId: null` in 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's `UserId` claim. The `erp_at`/`erp_rt`/`XSRF-TOKEN` cookies 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 required `userId` made 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**
```json
{ "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. |
> **`code` is server-accepted but frontend-derived, never hand-typed (2026-07-18).** The Roles UI computes `code` from
> `name` (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, so `POST /roles` and `PUT /roles/{roleId}/permissions` fire as one user action.
>
> **Bug fixed (2026-07-18): `POST /roles`/`PUT /roles/{roleId}` 500ing via `AUTH_UPSTREAM_ERROR`.** `IsSystemRole` being
> omitted serialized as JSON `null`, and AuthHex's `createRole`/`updateRole` called `JsonElement.GetBoolean()` on it
> unconditionally when the key was present — which throws on `null` (unlike `GetString()`, which tolerates it). Fixed on
> both sides: AuthHex now checks `ValueKind != JsonValueKind.Null` before reading `isSystemRole`, and ERPCore's
> `AuthHexClient` now serializes with `JsonIgnoreCondition.WhenWritingNull` so 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 `loginUser` resolves `identifier` against `Email`/`MobileNumber`/`Nic`
> only — **not** `Username` (`UserManageRepository.GetUserByIdentifierAndType`). A user created via `POST /users` can 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 name `itemType` could be taken by the new Item Type master (§2.7) — an unrelated concept. Items gained `subCategoryId` and `brandId` (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**
```json
{ "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="`)
```json
{ "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.
```json
{ "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`
```json
{ "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`
```json
{ "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`
```json
{ "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`
```json
{ "name": "Box-12" }
```
**201 Created**`{ "uomId": 7, "name": "Box-12" }`
#### `PUT /items/{itemId}/uom-conversions`
```json
{ "conversions": [ { "fromUom": 7, "toUom": 1, "factor": 12 } ] }
```
**200 OK**
```json
{ "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: `parentId` and `GET /categories?tree=true` are **gone**, replaced by a dedicated Subcategory resource one level below. Categories also gained `status` + an `ETag` (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.
```json
{ "categoryId": 12, "name": "Fasteners", "status": "Active",
"createdAt": "2026-06-01T08:00:00Z", "updatedAt": null }
```
#### `POST /categories`
```json
{ "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`
```json
{ "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.
```json
{ "name": "Hex Bolts" }
```
**201 Created**`Location: /api/v1/subcategories/30`
```json
{ "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.
```json
{ "name": "Hex Bolts (metric)" }
```
#### `PATCH /subcategories/{subCategoryId}/status` → **204 No Content**.
### 2.4 Vendors
#### `POST /vendors`
```json
{ "code": "VN-005", "name": "Lanka Steel Traders (Pvt) Ltd", "terms": "NET30",
"taxReg": "134567890-7000", "currency": "LKR" }
```
**201 Created**
```json
{ "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`
```json
{ "code": "WH-MAIN", "name": "Main Warehouse - Negombo" }
```
**201 Created**`{ "warehouseId": 1, "code": "WH-MAIN", "name": "Main Warehouse - Negombo" }`
#### `POST /warehouses/{warehouseId}/bins`
```json
{ "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`
```json
{ "name": "Bosch" }
```
**201 Created**`Location: /api/v1/brands/2`
```json
{ "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-0002` for one type, `BL-100-0003` for two — and are never stored or parsed server-side. `GET /item-types` exists 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 with `stockNature` (§2.1), which is what the old `itemType` enum became.
#### `GET /item-types`
Query: `q`, `status` (`Active|Inactive`), + paging. Pass `status=Active` for selectable rows.
**200 OK**
```json
{ "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`
```json
{ "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`)
```json
{ "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.
```json
{ "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_MANAGE` permission 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`
```json
{ "lines": [ { "itemId": 1001, "qty": 5000, "requiredBy": "2026-07-20" },
{ "itemId": 1002, "qty": 8000, "requiredBy": "2026-07-20" } ] }
```
**201 Created** (`requestedBy` from token)
```json
{ "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 }`.
> **`vendorIds` is not persisted.** `POST /rfqs` validates the invited vendors but stores no
> RFQ↔vendor link, so neither `GET /rfqs` nor `GET /rfqs/{id}` returns them. Quotations
> reference vendors directly — `quotationCount` (and the comparison's `vendorIds`) are the
> facts that survive. A UI cannot show "invited but not yet quoted".
#### `POST /rfqs`
```json
{ "requisitionId": 210, "vendorIds": [5, 8, 11],
"lines": [ { "itemId": 1001, "qty": 5000 }, { "itemId": 1002, "qty": 8000 } ] }
```
**201 Created**
```json
{ "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`
```json
{ "vendorId": 5, "lines": [ { "itemId": 1001, "unitPrice": 12.50, "leadDays": 7 },
{ "itemId": 1002, "unitPrice": 6.20, "leadDays": 7 } ] }
```
**201 Created**
```json
{ "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:** `approvalRequired` defaults `false`. Create takes **`saveAsDraft`** (default `false` → **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 while `Draft`; submitting locks it** (FR-PROC-05, revised 2026-07-20 — Option B "freely edit while open" superseded).
#### `POST /purchase-orders`
```json
{ "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`
```json
{ "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`
```json
{ "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`
```json
{ "vendorId": 5, "warehouseId": 1, "reasonCodeId": 22,
"lines": [ { "grnLineId": 1300, "itemId": 1001, "qty": 200 } ] }
```
**201 Created**
```json
{ "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.
> **No `PUT` or `DELETE` exists for a GRN** — a receipt is corrected with a reversing document, never edited or erased (FR-X-05).
> **Gap vs FR-GRN-04:** `CreateGrnLineInput` carries `batch` but has **no serial-number field**, so serials cannot be captured on receipt as the requirement mandates. Tracked in `Backend/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).
```json
{ "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" } } ] }
```
`discountPct`/`vatPct` optional (default 0, range 0100). `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.
**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`.
```json
{ "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`.
```json
{ "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`
```json
{ "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`
```json
{ "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.
```json
{ "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.
```json
{ "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`
```json
{ "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`
```json
{ "srcWarehouseId": 1, "destWarehouseId": 2,
"lines": [ { "itemId": 1001, "srcBinId": 45, "destBinId": 90, "batchId": 410, "qty": 1000 } ] }
```
**201 Created**
```json
{ "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`)
```json
{ "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`
```json
{ "lines": [ { "transferLineId": 300, "qty": 1000 } ] }
```
**200 OK** (status `Received`)
```json
{ "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`
```json
{ "warehouseId": 1, "reasonCodeId": 4,
"lines": [ { "itemId": 1001, "binId": 45, "batchId": 410, "qtyDelta": -15 } ] }
```
**201 Created**
```json
{ "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`
```json
{ "warehouseId": 1, "countType": "Cycle", "itemIds": [1001, 1002] }
```
**201 Created** — status `Draft`, system quantities snapshotted
```json
{ "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`
```json
{ "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)
```json
{ "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.
```json
{ "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`
```json
{ "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`):
```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` / `ValidationProblemDetails`** for all errors (§1.8) — framework default.
- Map `ETag`/`If-Match` to 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`.*