03bc85b788
- 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.
770 lines
41 KiB
Markdown
770 lines
41 KiB
Markdown
# 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 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.
|
||
**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`.*
|