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

23 KiB
Raw Blame History

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

{ "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-Match412 on mismatch. Transactional POSTs (GRN confirm, transfer dispatch/receive, adjustment) accept an optional Idempotency-Key header.

1.7 Status codes

200 read/update · 201 created (+Location) · 204 no content · 400 validation · 401 unauth · 404 not found · 409 domain conflict · 412 ETag mismatch · 422 semantically invalid.

1.8 Error format (RFC 7807)

{ "type": "https://errors.erp.local/validation",
  "title": "One or more validation errors occurred.",
  "status": 400, "traceId": "00-6f1c...-01",
  "errors": { "sku": ["The sku field is required."], "lines": ["At least one line is required."] } }

Domain errors add a stable code (catalog §7):

{ "type": "https://errors.erp.local/insufficient-stock",
  "title": "Insufficient stock to fulfil the issue.",
  "status": 409, "code": "STOCK_NEGATIVE_BLOCKED",
  "detail": "Available 4 < requested 10 for item ITM-1001 at WH-MAIN.", "traceId": "00-9a2f...-01" }

2. Master Data

2.0 Auth — AuthController, proxying the external AuthHex IdP

Superseding note (2026-07-16). Un-superseded: the frontend no longer calls AuthHex directly. All of the endpoints below live on ERPCore under /api/v1/auth/* (Controllers/AuthController.cs), each forwarding to the matching 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

{ "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=")

{ "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

{ "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 CreatedLocation: /api/v1/items/1002

{ "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" }

400code: 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

{ "status": "Inactive" }

204 No Content. Masters are deactivated, not hard-deleted (FR-MD-08); hard DELETE of a referenced master → 409 MASTER_IN_USE.

PUT /items/{itemId}/reorder

{ "settings": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 },
                { "warehouseId": 2, "reorderPoint": 100, "reorderQty": 400 } ] }

200 OK → persisted settings array.

2.2 Units of Measure

GET /uoms · POST /uoms

{ "name": "Box-12" }

201 Created{ "uomId": 7, "name": "Box-12" }

PUT /items/{itemId}/uom-conversions

{ "conversions": [ { "fromUom": 7, "toUom": 1, "factor": 12 } ] }

200 OK

{ "itemId": 1001, "baseUomId": 1,
  "conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ] }

2.3 Categories

POST /categories

{ "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

{ "code": "VN-005", "name": "Lanka Steel Traders (Pvt) Ltd", "terms": "NET30",
  "taxReg": "134567890-7000", "currency": "LKR" }

201 Created

{ "vendorId": 5, "code": "VN-005", "name": "Lanka Steel Traders (Pvt) Ltd", "terms": "NET30",
  "taxReg": "134567890-7000", "currency": "LKR", "status": "Active", "createdAt": "2026-07-07T09:31:00Z" }

GET /vendors, GET /vendors/{id}, PUT /vendors/{id}, PATCH /vendors/{id}/status follow the Item pattern.

2.5 Warehouses & Bins

POST /warehouses

{ "code": "WH-MAIN", "name": "Main Warehouse - Negombo" }

201 Created{ "warehouseId": 1, "code": "WH-MAIN", "name": "Main Warehouse - Negombo" }

POST /warehouses/{warehouseId}/bins

{ "code": "A-01-01", "binType": "Shelf" }

201 Created{ "binId": 45, "warehouseId": 1, "code": "A-01-01", "binType": "Shelf" } GET /warehouses/{warehouseId}/bins lists bins.


3. Procurement

3.1 Requisitions

POST /requisitions

{ "lines": [ { "itemId": 1001, "qty": 5000, "requiredBy": "2026-07-20" },
             { "itemId": 1002, "qty": 8000, "requiredBy": "2026-07-20" } ] }

201 Created (requestedBy from token)

{ "requisitionId": 210, "docNo": "PR-2026-00210", "status": "Draft", "requestedBy": 17,
  "createdAt": "2026-07-07T09:35:00Z",
  "lines": [ { "reqLineId": 501, "itemId": 1001, "qty": 5000, "requiredBy": "2026-07-20" },
             { "reqLineId": 502, "itemId": 1002, "qty": 8000, "requiredBy": "2026-07-20" } ] }

POST /requisitions/{id}/submit200 OK status: "Submitted".

3.2 RFQs & Quotations

POST /rfqs

{ "requisitionId": 210, "vendorIds": [5, 8, 11],
  "lines": [ { "itemId": 1001, "qty": 5000 }, { "itemId": 1002, "qty": 8000 } ] }

201 Created

{ "rfqId": 88, "docNo": "RFQ-2026-00088", "requisitionId": 210, "status": "Open",
  "lines": [ { "rfqLineId": 701, "itemId": 1001, "qty": 5000 },
             { "rfqLineId": 702, "itemId": 1002, "qty": 8000 } ] }

POST /rfqs/{rfqId}/quotations

{ "vendorId": 5, "lines": [ { "itemId": 1001, "unitPrice": 12.50, "leadDays": 7 },
                            { "itemId": 1002, "unitPrice": 6.20, "leadDays": 7 } ] }

201 Created

{ "quotationId": 140, "rfqId": 88, "vendorId": 5,
  "lines": [ { "itemId": 1001, "unitPrice": 12.50, "leadDays": 7 },
             { "itemId": 1002, "unitPrice": 6.20, "leadDays": 7 } ] }

GET /rfqs/{rfqId}/comparison → vendor-by-line price matrix.

3.3 Purchase Orders

Phase 1: 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

{ "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 CreatedLocation: /api/v1/purchase-orders/342

{ "poId": 342, "docNo": "PO-2026-00342", "vendorId": 5, "requisitionId": 210,
  "status": "Approved", "approvalRequired": false, "createdBy": 17, "createdAt": "2026-07-07T09:40:00Z",
  "totals": { "subTotal": 112100.00, "tax": 20178.00, "grandTotal": 132278.00, "currency": "LKR" },
  "lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "warehouseId": 1, "qty": 5000, "unitPrice": 12.50, "tax": 0.18, "qtyReceived": 0 },
             { "poLineId": 901, "itemId": 1002, "uomId": 1, "warehouseId": 1, "qty": 8000, "unitPrice": 6.20, "tax": 0.18, "qtyReceived": 0 } ] }

GET /purchase-orders?status=Approved&vendorId=5 → list envelope of PO summaries.

PUT /purchase-orders/{poId}

Edit while open (not FullyReceived/Closed/Cancelled); requires If-Match. → 200 OK updated resource; 409 PO_NOT_EDITABLE if closed.

POST /purchase-orders/{poId}/approve200 OK (no-op in Phase 1; transitions PendingApproval→Approved when enabled).

POST /purchase-orders/{poId}/cancel

{ "reason": "Duplicate order" }

200 OK status: "Cancelled"; 409 if any receipt exists.

3.4 Purchase Returns

POST /purchase-returns

{ "vendorId": 5, "warehouseId": 1, "reasonCodeId": 22,
  "lines": [ { "grnLineId": 1300, "itemId": 1001, "qty": 200 } ] }

201 Created

{ "returnId": 61, "docNo": "PRET-2026-00061", "vendorId": 5, "warehouseId": 1, "reasonCodeId": 22,
  "status": "Posted", "createdBy": 17,
  "lines": [ { "returnLineId": 120, "grnLineId": 1300, "itemId": 1001, "qty": 200 } ],
  "ledgerRefs": [ 55021 ] }

409 STOCK_NEGATIVE_BLOCKED if return qty exceeds available.


4. Goods Receipt (GRN)

On confirm, each line creates a FIFO cost layer and posts an inbound ledger entry (FR-GRN-06). Goods may land holdStatus: "OnHold" (not issuable) until released.

4.1 POST /grns

Against a PO (lines default from open PO lines) or direct (poId: null, by permission).

{ "poId": 342, "warehouseId": 1,
  "lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, "qty": 5000,
      "unitCost": 12.50, "holdStatus": "OnHold",
      "batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } } ] }

201 Created — status Draft

{ "grnId": 780, "docNo": "GRN-2026-00780", "poId": 342, "vendorId": 5, "warehouseId": 1,
  "status": "Draft", "createdBy": 17,
  "lines": [ { "grnLineId": 1300, "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45,
      "qty": 5000, "unitCost": 12.50, "receivedValue": 62500.00, "holdStatus": "OnHold", "batchId": 410 } ] }

422 OVER_RECEIPT_TOLERANCE if qty exceeds open PO qty beyond tolerance.

4.2 POST /grns/{grnId}/confirm

Header: optional Idempotency-Key. 200 OK — creates FIFO layers + ledger; updates PO line qtyReceived.

{ "grnId": 780, "status": "Confirmed", "postedAt": "2026-07-07T10:05:00Z",
  "createdLayers": [ { "layerId": 9001, "itemId": 1001, "warehouseId": 1, "batchId": 410,
      "qtyReceived": 5000, "qtyRemaining": 5000, "unitCost": 12.50, "receiptDate": "2026-07-07T10:05:00Z" } ],
  "ledgerRefs": [ 55010 ], "poStatus": "Fully Received" }

Received OnHold → the layer is not available until released.

4.3 POST /grns/{grnId}/lines/{grnLineId}/release

{ "action": "Release" }

200 OK{ "grnLineId": 1300, "holdStatus": "Available" } action: "Reject" moves the quantity to a return workflow instead.


5. Stock Management

5.1 GET /stock/on-hand?itemId=1001&warehouseId=1

{ "itemId": 1001, "warehouseId": 1, "onHand": 5000, "available": 0, "onHold": 5000,
  "inTransit": 0, "reserved": 0, "asOf": "2026-07-07T10:06:00Z" }

available = onHand onHold reserved inTransit(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

{ "items": [ { "ledgerId": 55010, "itemId": 1001, "warehouseId": 1, "binId": 45, "batchId": 410,
      "serialId": null, "direction": "In", "qtyBase": 5000, "unitCost": 12.50, "value": 62500.00,
      "runningBalance": 5000, "sourceDocType": "GRN", "sourceDocId": 780, "userId": 17,
      "createdAt": "2026-07-07T10:05:00Z" } ],
  "pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } }

5.3 GET /stock/valuation?itemId=1001&warehouseId=1

{ "itemId": 1001, "warehouseId": 1,
  "layers": [ { "layerId": 9001, "qtyRemaining": 5000, "unitCost": 12.50, "value": 62500.00,
      "receiptDate": "2026-07-07T10:05:00Z" } ],
  "totalQty": 5000, "totalValue": 62500.00, "currency": "LKR", "costingMethod": "FIFO" }

5.4 Transfers (in-transit)

create → dispatch → receive. Dispatch consumes source FIFO layers into in-transit; receive creates the destination layer at inherited cost (cost-preserving, FR-STK-06).

POST /stock-transfers

{ "srcWarehouseId": 1, "destWarehouseId": 2,
  "lines": [ { "itemId": 1001, "srcBinId": 45, "destBinId": 90, "batchId": 410, "qty": 1000 } ] }

201 Created

{ "transferId": 55, "docNo": "TRF-2026-00055", "srcWarehouseId": 1, "destWarehouseId": 2,
  "status": "Draft",
  "lines": [ { "transferLineId": 300, "itemId": 1001, "srcBinId": 45, "destBinId": 90, "batchId": 410, "qty": 1000 } ] }

POST /stock-transfers/{id}/dispatch200 OK (status InTransit)

{ "transferId": 55, "status": "InTransit",
  "consumedLayers": [ { "layerId": 9001, "qtyConsumed": 1000, "unitCost": 12.50 } ],
  "ledgerRefs": [ 55033 ] }

409 STOCK_NEGATIVE_BLOCKED if source available < requested.

POST /stock-transfers/{id}/receive

{ "lines": [ { "transferLineId": 300, "qty": 1000 } ] }

200 OK (status Received)

{ "transferId": 55, "status": "Received",
  "createdLayers": [ { "layerId": 9040, "warehouseId": 2, "qtyReceived": 1000, "unitCost": 12.50 } ],
  "ledgerRefs": [ 55034 ] }

5.5 Adjustments (auto-post)

Reason code mandatory. Decrease consumes FIFO layers; increase creates a layer at supplied/last cost (FR-STK-07).

POST /stock-adjustments

{ "warehouseId": 1, "reasonCodeId": 4,
  "lines": [ { "itemId": 1001, "binId": 45, "batchId": 410, "qtyDelta": -15 } ] }

201 Created

{ "adjustmentId": 77, "docNo": "ADJ-2026-00077", "warehouseId": 1, "reasonCodeId": 4,
  "status": "Posted", "createdBy": 17, "createdAt": "2026-07-07T10:20:00Z",
  "lines": [ { "adjLineId": 210, "itemId": 1001, "binId": 45, "batchId": 410, "qtyDelta": -15 } ],
  "ledgerRefs": [ 55050 ] }

400 REASON_CODE_REQUIRED if reasonCodeId omitted.

5.6 Counts

POST /stock-counts

{ "warehouseId": 1, "countType": "Cycle", "itemIds": [1001, 1002] }

201 Created — status Draft, system quantities snapshotted

{ "countId": 30, "docNo": "CNT-2026-00030", "warehouseId": 1, "countType": "Cycle", "status": "Draft",
  "lines": [ { "countLineId": 400, "itemId": 1001, "binId": 45, "systemQty": 4985, "countedQty": null, "variance": null } ] }

PUT /stock-counts/{id}/counts

{ "lines": [ { "countLineId": 400, "countedQty": 4980 } ] }

200 OK{ "lines": [ { "countLineId": 400, "systemQty": 4985, "countedQty": 4980, "variance": -5 } ] }

POST /stock-counts/{id}/post200 OK (posts variance adjustment, closes count)

{ "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.

{ "items": [ { "itemId": 1002, "warehouseId": 1, "available": 90, "reorderPoint": 100,
      "reorderQty": 400, "suggestedRequisitionQty": 400 } ],
  "pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } }

POST /stock/reorder-alerts/{itemId}/requisition?warehouseId=1 → creates a draft requisition for the suggested qty.


6. Reference Data

GET /reason-codes?context=Adjustment

{ "items": [ { "reasonCodeId": 4, "code": "DMG", "description": "Damage", "context": "Adjustment" },
             { "reasonCodeId": 22, "code": "QREJ", "description": "Quality Reject", "context": "Return" } ],
  "pagination": { "page": 1, "pageSize": 20, "totalItems": 2, "totalPages": 1 } }

POST /reason-codes (admin). Number sequences are server-managed; no write API in Phase 1.


7. Domain Error Catalog

code HTTP When
SKU_DUPLICATE 400 Item SKU already exists.
MASTER_IN_USE 409 Hard delete of a referenced master (use deactivate).
PO_NOT_EDITABLE 409 Editing a PO that is fully received / closed / cancelled.
OVER_RECEIPT_TOLERANCE 422 GRN qty exceeds PO open qty beyond tolerance.
STOCK_NEGATIVE_BLOCKED 409 Issue/transfer/adjustment would drive available stock negative.
EXPIRED_BATCH_BLOCKED 409 Issue/pick of an expired batch.
ONHOLD_NOT_ISSUABLE 409 Issue against on-hold/quarantined stock.
REASON_CODE_REQUIRED 400 Adjustment/return without a reason code.
CONCURRENCY_CONFLICT 412 ETag / RowVersion mismatch.
IDEMPOTENCY_REPLAY 200 Duplicate Idempotency-Key; original result returned.

Example (409, application/problem+json):

{ "type": "https://errors.erp.local/onhold-not-issuable",
  "title": "Stock is on inspection hold and cannot be issued.",
  "status": 409, "code": "ONHOLD_NOT_ISSUABLE",
  "detail": "5000 units of ITM-1001 at WH-MAIN are OnHold; release via GRN inspection before issue.",
  "traceId": "00-1b7c...-01" }

8. Enumerations

Enum Values
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.