Files
ERP-core/docs/11-BACKEND-PHASE1.md
T
2026-07-16 14:23:23 +05:30

514 lines
23 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* |
| `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.
Request/response field shapes match AuthHex's own payloads one-for-one (project-root `API_REFERENCE.md` §3–§5), except
session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered instead) and `refreshToken` is read from the
`erp_rt` cookie rather than the request body.
### 2.1 Items
#### `GET /items`
Query: `q`, `status` (`Active|Inactive`), `categoryId`, `trackingMode` (`None|Batch|Serial`), + paging.
**200 OK**
```json
{ "items": [ { "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40",
"categoryId": 12, "baseUomId": 1, "defaultVendorId": 5,
"itemType": "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, "baseUomId": 1,
"defaultVendorId": 5, "itemType": "Stocked", "trackingMode": "Batch", "taxClass": "STD",
"status": "Active", "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ],
"createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" }
```
#### `POST /items`
```json
{ "sku": "ITM-1002", "name": "Steel Nut M8", "description": "Grade 8 zinc-plated hex nut",
"categoryId": 12, "baseUomId": 1, "defaultVendorId": 5,
"itemType": "Stocked", "trackingMode": "None", "taxClass": "STD" }
```
**201 Created**`Location: /api/v1/items/1002`
```json
{ "itemId": 1002, "sku": "ITM-1002", "name": "Steel Nut M8", "categoryId": 12, "baseUomId": 1,
"defaultVendorId": 5, "itemType": "Stocked", "trackingMode": "None", "taxClass": "STD",
"status": "Active", "createdAt": "2026-07-07T09:30:00Z" }
```
`400``code: SKU_DUPLICATE` if SKU exists.
#### `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
#### `POST /categories`
```json
{ "name": "Fasteners", "parentId": 3 }
```
**201 Created**`{ "categoryId": 12, "name": "Fasteners", "parentId": 3 }`
`GET /categories?tree=true` returns a nested tree.
### 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.
---
## 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"`.
### 3.2 RFQs & Quotations
#### `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` → PO **auto-approved on creation**. Approve endpoint exists but is a no-op unless enabled (FR-PROC-04). PO **freely editable while open** (Option B, FR-PROC-05).
#### `POST /purchase-orders`
```json
{ "vendorId": 5, "requisitionId": 210,
"lines": [ { "itemId": 1001, "uomId": 1, "warehouseId": 1, "qty": 5000, "unitPrice": 12.50, "tax": 0.18 },
{ "itemId": 1002, "uomId": 1, "warehouseId": 1, "qty": 8000, "unitPrice": 6.20, "tax": 0.18 } ] }
```
**201 Created**`Location: /api/v1/purchase-orders/342`
```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 while open (not FullyReceived/Closed/Cancelled); requires `If-Match`. → **200 OK** updated resource; `409 PO_NOT_EDITABLE` if closed.
#### `POST /purchase-orders/{poId}/approve` → **200 OK** (no-op in Phase 1; transitions PendingApproval→Approved when enabled).
#### `POST /purchase-orders/{poId}/cancel`
```json
{ "reason": "Duplicate order" }
```
**200 OK** `status: "Cancelled"`; `409` if any receipt exists.
### 3.4 Purchase Returns
#### `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.
### 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, "holdStatus": "OnHold",
"batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } } ] }
```
**201 Created** — status `Draft`
```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, "receivedValue": 62500.00, "holdStatus": "OnHold", "batchId": 410 } ] }
```
`422 OVER_RECEIPT_TOLERANCE` if qty exceeds open PO qty beyond tolerance.
### 4.2 `POST /grns/{grnId}/confirm`
Header: optional `Idempotency-Key`. **200 OK** — creates FIFO layers + ledger; updates PO line `qtyReceived`.
```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(out)`. `reserved` always `0` in Phase 1 (stub, FR-STK-11).
### 5.2 `GET /stock/ledger?itemId=1001&warehouseId=1&from=2026-07-01&to=2026-07-07`
```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).
#### `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).
#### `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
#### `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**`{ "lines": [ { "countLineId": 400, "systemQty": 4985, "countedQty": 4980, "variance": -5 } ] }`
#### `POST /stock-counts/{id}/post` → **200 OK** (posts variance adjustment, closes count)
```json
{ "countId": 30, "status": "Posted", "adjustmentId": 78, "ledgerRefs": [ 55060 ] }
```
### 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. |
| `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 |
|---|---|
| `itemType` | `Stocked`, `NonStocked`, `Service` |
| `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`.*