name: CI/CD - ERP Core Monorepo

on:
  pull_request:
    branches:
      - production
    types:
      - closed

jobs:
  backend-build-and-deploy:
    name: Build & Deploy ERP Core Backend
    runs-on: linux
    if: github.event.pull_request.merged == true
    steps:
      - name: Checkout code
        run: |
          git clone \
            --depth 1 \
            --branch production \
            https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
            .

      - name: Restore backend
        run: dotnet restore Backend/ERPCore.sln

      - name: Publish backend
        run: |
          rm -rf /tmp/erpcore-deploy
          mkdir -p /tmp/erpcore-deploy
          dotnet publish Backend/ERPCore/ERPCore.csproj \
            -c Release \
            -r linux-x64 \
            --self-contained true \
            -o /tmp/erpcore-deploy

      - name: Stop backend service
        run: systemctl stop erpcore.service

      - name: Copy backend to deploy folder
        run: |
          mkdir -p /home/erp/core/publish
          rm -rf /home/erp/core/publish/*
          cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/

      - name: Fix backend ownership and permissions
        run: |
          chown -R erpuser:erpuser /home/erp/core/publish
          find /home/erp/core/publish -type d -exec chmod 755 {} +
          find /home/erp/core/publish -type f -exec chmod 644 {} +
          chmod +x /home/erp/core/publish/ERPCore

      - name: Restart backend service
        run: systemctl restart erpcore.service

      - name: Verify backend
        run: |
          systemctl is-active erpcore.service && echo "Backend service running" || echo "Backend service failed"

  frontend-build-and-deploy:
    name: Build & Deploy ERP Core Frontend
    runs-on: linux
    needs: backend-build-and-deploy
    if: github.event.pull_request.merged == true
    steps:
      - name: Checkout code
        run: |
          git clone \
            --depth 1 \
            --branch Production \
            https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
            .

      - name: Build frontend Docker image
        run: docker build -t erpcore-frontend:latest Frontend/erp-system

      - name: Stop & remove existing frontend container
        run: |
          docker stop erpcore-frontend 2>/dev/null || true
          docker rm erpcore-frontend 2>/dev/null || true

      - name: Start frontend container
        run: |
          docker run -d \
            --name erpcore-frontend \
            -p 6702:6702 \
            --add-host=host.docker.internal:host-gateway \
            -e BACKEND_ORIGIN=http://host.docker.internal:6701 \
            --restart unless-stopped \
            --memory="512m" \
            --cpus="1.0" \
            --cap-drop ALL \
            --security-opt no-new-privileges \
            erpcore-frontend:latest

      - name: Remove old frontend images
        run: docker image prune -f

      - name: Verify frontend
        run: |
          docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
            | grep erpcore-frontend && echo "Frontend container running" || echo "Frontend container failed"
This commit is contained in:
Kalana B. Thilakarathna
2026-09-17 13:59:05 +05:30
parent 93f2f1bd3b
commit 03d01d4c4e
61 changed files with 2195 additions and 1245 deletions
@@ -684,15 +684,33 @@ appsettings.Development.json
---
## Open Items (must resolve before coding the relevant phase)
## Open Items
### Resolved during implementation
| # | Item | Resolution |
|---|---|---|
| 2 | Cascade when Confirmed ShopPO is cancelled and Invoice already exists | Cancel is rejected (400) if a linked Invoice exists. Invoice must be voided separately before the ShopPO can be cancelled. No automatic cascade. |
| 3 | Cascade when Confirmed ShopPO is cancelled and it's in a LoadingSheet | Cancel is rejected (400) if the ShopPO appears in any Planned LoadingSheet. Operator must remove it from the sheet first. |
| 4 | `FinalizeInvoiceRequest` payload — free issues field shape | Deferred to a later pass. v1 `POST /api/invoices/{id}/finalize` takes no body and locks the invoice as-is. |
| 5 | Who can confirm a ShopPO? | No role restriction in v1 — any user can confirm. Gating deferred until the auth layer is implemented. |
### Still open
| # | Item | Blocks |
|---|---|---|
| 1 | Who holds `loading.confirm` permission? | Phase 9 — ConfirmAsync auth guard |
| 2 | Cascade when Confirmed ShopPO is cancelled and Invoice already exists | Phase 7 — CancelAsync |
| 3 | Cascade when Confirmed ShopPO is cancelled and it's in a LoadingSheet | Phase 7 — CancelAsync |
| 4 | `FinalizeInvoiceRequest` payload — free issues field shape | Phase 6 — FinalizeAsync |
| 5 | Who can confirm a ShopPO? (agent, office, either?) | Phase 7 — ConfirmAsync permission |
| 1 | Who holds `loading.confirm` permission? | Permission seeding (`loading.populate`, `loading.confirm`) — explicitly noted as **Pending** in the API reference. Must decide role before seeding. |
### New items — identified during frontend refactor (2026-09-17)
| # | Item | Area |
|---|---|---|
| 6 | `order-return-edit-dialog.tsx` — file not verified; client imports it but it was not reviewed during the refactor pass. Needs to be checked for old `orderId`/`OrderResponse` references. | Returns |
| 7 | `sales-visits/` components — any components in this section that interact with sales/orders need auditing against the new ShopPO/Invoice types. | Sales Visits |
| 8 | SysConfig management page not built — the backend has a `SysConfig` model and the `InvoiceCreationMode` config key is read by `ShopPOController`. A UI to view and toggle system config is missing. | Config |
| 9 | InvoiceCreationMode (ModeA vs ModeB) UI branching — the ShopPO confirm button calls `confirm` or `confirmAndCreateInvoice` depending on mode, but the mode is not currently read from the backend. The UI should fetch `GET /api/sys-config` and gate the action accordingly. | Sales / Invoice |
| 10 | Loading Sheet item population UI — `PUT /api/loading-sheets/{id}/items` exists in the API layer but no UI for populating/editing items on a Planned sheet has been built. | Dispatch |
| 11 | `PaymentMethod` enum is missing `Cheque` — per `invoice_collection.md`, the final design includes `Cash`, `Cheque`, `Credit`. The frontend only has `Cash` and `Credit`. Cheque-specific fields (`ChequeNumber`, `ChequeDate`) and `EntryMode` (`RealTime`, `DayEndManual`) are also unimplemented. | Payments |
---
@@ -0,0 +1,978 @@
# Frontend API Reference — ERP Distribution (Post-Refactor)
> Last updated: 2026-09-17
> Base URL: `https://<host>/api`
> All responses are JSON. Enum fields are serialized as **strings** (e.g. `"Pending"`, `"ModeB"`).
> All `Guid` fields are UUID v4 strings. All `DateTime` fields are ISO 8601.
---
## Table of Contents
1. [Pagination](#pagination)
2. [Enums Reference](#enums-reference)
3. [ShopPO](#shoppo)
4. [Invoice](#invoice)
5. [Payment](#payment)
6. [Ride (DeliveryRun)](#ride-deliveryrun)
7. [Loading Sheet](#loading-sheet)
8. [Delivery Tasks](#delivery-tasks)
9. [Returns](#returns)
10. [Schemes](#schemes)
11. [System Config](#system-config)
12. [Key Relationships & Flows](#key-relationships--flows)
---
## Pagination
All list endpoints return a `PagedResponse<T>`:
```json
{
"page": 1,
"pageSize": 20,
"total": 84,
"items": [ /* T[] */ ]
}
```
Default page size is `20`. Pass `?page=2&pageSize=50` to paginate.
---
## Enums Reference
| Enum | Values |
|------|--------|
| `ShopPOStatus` | `Pending`, `PartiallyFulfilled`, `Fulfilled`, `Cancelled` |
| `InvoiceStatus` | `Draft`, `Printed`, `Finalized` |
| `PaymentStatus` | `Pending`, `PartiallyPaid`, `Paid` |
| `PaymentMethod` | `Cash`, `Credit` |
| `RideStatus` | `Open`, `Cancelled`, `Closed` |
| `LoadingSheetStatus` | `Planned`, `Confirmed` |
| `DeliveryTaskStatus` | `Pending`, `Delivered`, `PartiallyDelivered`, `Failed` |
| `ReturnMode` | `CreditDeduction`, `ItemExchange` |
| `ReturnReasonCode` | `Expired`, `Damaged`, `OrderError`, `Other` |
| `CancelRequestType` | `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, `Other` |
| `InvoiceCreationMode` | `ModeA`, `ModeB` |
| `SchemeRuleType` | `SlabDiscount`, `FlatPercent`, `BuyXGetY`, `FreeBundle` |
---
## ShopPO
A ShopPO is a shop-facing purchase order raised by a sales agent on behalf of a store.
### Status Flow
```
Pending → PartiallyFulfilled → Fulfilled
↘ Cancelled
```
### `GET /api/shop-pos`
List all ShopPOs with optional filters.
**Query params:**
| Param | Type | Description |
|-------|------|-------------|
| `agentId` | `Guid?` | Filter by the agent who raised the PO |
| `routeId` | `Guid?` | Filter by the store's route |
| `shopId` | `Guid?` | Filter by store |
| `confirmed` | `bool?` | `true` = confirmed only, `false` = unconfirmed only |
| `page` | `int` | Default `1` |
| `pageSize` | `int` | Default `20` |
**Response:** `PagedResponse<ShopPOResponse>`
```json
{
"page": 1,
"pageSize": 20,
"total": 12,
"items": [
{
"id": "uuid",
"storeId": "uuid",
"storeName": "Colombo Mart",
"raisedById": "uuid",
"invoiceId": "uuid | null",
"totalAmount": 15000.00,
"status": "Pending",
"confirmedAt": "2026-09-17T10:00:00 | null",
"confirmedBy": "uuid | null",
"fulfilledAt": "2026-09-17T14:00:00 | null",
"items": [
{
"id": "uuid",
"itemId": "uuid",
"itemName": "Milo 400g",
"quantity": 10,
"quantityFulfilled": 0,
"unitPrice": 850.00,
"isActive": true
}
],
"isActive": true,
"createdAt": "2026-09-17T08:00:00"
}
]
}
```
### `GET /api/shop-pos/{id}`
**Response:** `ShopPOResponse` (same shape as above, single object) or `404`.
---
## Invoice
An Invoice is the billing document generated from a confirmed ShopPO. There is at most one Invoice per ShopPO.
**InvoiceCreationMode** (system config) controls whether the invoice is:
- **ModeA** — auto-created when the ShopPO is confirmed
- **ModeB** — created as a separate explicit step (default)
### Status Flow
```
Draft → Printed → Finalized
```
Once Finalized, the invoice is locked. Payments can be collected at any status.
### `GET /api/invoices`
**Query params:**
| Param | Type | Description |
|-------|------|-------------|
| `agentId` | `Guid?` | Agent who raised the ShopPO |
| `routeId` | `Guid?` | Store's route |
| `shopId` | `Guid?` | Store |
| `status` | `InvoiceStatus?` | `Draft`, `Printed`, `Finalized` |
| `page` | `int` | Default `1` |
| `pageSize` | `int` | Default `20` |
**Response:** `PagedResponse<InvoiceResponse>`
```json
{
"page": 1,
"pageSize": 20,
"total": 5,
"items": [
{
"id": "uuid",
"shopPOId": "uuid",
"storeName": "Colombo Mart",
"status": "Draft",
"paymentStatus": "Pending",
"createdBy": "uuid",
"printedAt": null,
"printedBy": null,
"finalizedAt": null,
"finalizedBy": null,
"totalAmount": 15000.00,
"isActive": true,
"createdAt": "2026-09-17T08:30:00"
}
]
}
```
### `GET /api/invoices/{id}`
**Response:** `InvoiceResponse` (single) or `404`.
> **Note:** The detail response from the repository also loads `Payments`. The current `InvoiceResponse` DTO does not yet expose payments — use `GET /api/invoices/{id}/payments` for that.
---
## Payment
Payments are collected against an Invoice. Multiple payments can be made on one invoice (e.g. partial cash collection).
### `GET /api/invoices/{invoiceId}/payments`
Returns all payments for the invoice.
**Response:** `PaymentResponse[]`
```json
[
{
"id": "uuid",
"invoiceId": "uuid",
"amount": 5000.00,
"method": "Cash",
"collectedAt": "2026-09-17T15:00:00",
"collectedBy": "uuid",
"createdAt": "2026-09-17T15:00:00"
}
]
```
Returns `404` if the invoice does not exist.
### `POST /api/invoices/{invoiceId}/payments`
Record a payment against an invoice.
**Request body:**
```json
{
"amount": 5000.00,
"method": "Cash",
"collectedByUserId": "uuid | null"
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `amount` | `decimal` | Yes | Must be > 0 |
| `method` | `PaymentMethod` | Yes | `Cash` or `Credit` |
| `collectedByUserId` | `Guid?` | No | Defaults to authenticated user |
**Response:** `PaymentResponse` or `404` if invoice not found, `400` on business rule violation.
---
## Ride (DeliveryRun)
A Ride represents a vehicle dispatch for a day. The driver moves along a route making deliveries.
### Status Flow
```
Open → Closed
↘ Cancelled
```
### `GET /api/rides`
**Query params:**
| Param | Type | Description |
|-------|------|-------------|
| `status` | `RideStatus?` | `Open`, `Cancelled`, `Closed` |
| `driverProfileId` | `Guid?` | Filter by driver |
| `vehicleId` | `Guid?` | Filter by vehicle |
| `page` | `int` | Default `1` |
| `pageSize` | `int` | Default `20` |
**Response:** `PagedResponse<RideResponse>`
```json
{
"items": [
{
"id": "uuid",
"status": "Open",
"vehicleId": "uuid",
"vehicleNumberPlate": "WP CAB 1234",
"driverProfileId": "uuid",
"driverFullName": "Kamal Perera",
"meterstart": 12450,
"meterend": 0,
"startedAt": null,
"closedAt": null,
"createdAt": "2026-09-17T06:00:00"
}
]
}
```
### `GET /api/rides/{id}`
**Response:** `RideResponse` or `404`.
### `POST /api/rides`
Create a new ride.
**Request body:**
```json
{
"vehicleId": "uuid",
"driverProfileId": "uuid",
"meterstart": 12450
}
```
**Response:** `201 Created` with `RideResponse`. `400` on validation error.
### `PUT /api/rides/{id}/driver`
Re-assign the driver on an Open ride.
**Request body:**
```json
{
"driverProfileId": "uuid"
}
```
**Response:** `RideResponse`. `404` if ride not found. `400` if ride is not Open.
### `POST /api/rides/{id}/close`
Close a ride and record the ending odometer.
**Request body:**
```json
{
"meterend": 12890
}
```
**Response:** `RideResponse`. `400` if ride is not Open.
### `POST /api/rides/{id}/cancel`
Cancel a ride.
**Request body:**
```json
{
"typeCode": "VehicleBreakdown",
"reason": "Engine overheating"
}
```
| Field | Type | Notes |
|-------|------|-------|
| `typeCode` | `CancelRequestType` | `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, `Other` |
| `reason` | `string` | Free text reason |
**Response:** `RideResponse`. `400` if ride is not Open.
---
## Loading Sheet
A Loading Sheet records the items loaded onto a vehicle before a ride. It belongs to a Ride.
### Status Flow
```
Planned → Confirmed
```
Items can only be edited while the sheet is `Planned`. Confirming the sheet commits stock movements (Warehouse → Vehicle).
### `GET /api/loading-sheets`
**Query params:**
| Param | Type | Description |
|-------|------|-------------|
| `rideId` | `Guid?` | Filter by ride |
| `status` | `LoadingSheetStatus?` | `Planned`, `Confirmed` |
| `page` | `int` | Default `1` |
| `pageSize` | `int` | Default `20` |
**Response:** `PagedResponse<LoadingSheetResponse>`
```json
{
"items": [
{
"id": "uuid",
"fK_RideId": "uuid",
"status": "Planned",
"populatedBy": "uuid",
"populatedAt": "2026-09-17T07:00:00",
"confirmedAt": null,
"confirmedBy": null,
"createdAt": "2026-09-17T07:00:00",
"items": [
{
"id": "uuid",
"fK_ItemId": "uuid",
"itemName": "Milo 400g",
"fK_WarehouseId": "uuid",
"warehouseName": "Colombo Warehouse",
"quantityDispatched": 24.0,
"fK_ShopPOItemId": "uuid | null"
}
],
"orders": [
{ "shopPOId": "uuid" }
]
}
]
}
```
### `GET /api/loading-sheets/{id}`
**Response:** `LoadingSheetResponse` or `404`.
### `POST /api/loading-sheets`
Create a new loading sheet for a ride. The ride must exist. Only one `Planned` sheet per ride is allowed.
**Request body:**
```json
{
"fK_RideId": "uuid"
}
```
**Response:** `201 Created` with `LoadingSheetResponse`. `400` if ride already has a planned sheet.
### `PUT /api/loading-sheets/{id}/items`
Replace all items on a `Planned` loading sheet. This is a full replace — any items not included are removed.
**Request body:**
```json
{
"items": [
{
"fK_ItemId": "uuid",
"fK_WarehouseId": "uuid",
"quantity": 24.0,
"fK_ShopPOItemId": "uuid | null"
}
]
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `fK_ItemId` | `Guid` | Yes | The item being loaded |
| `fK_WarehouseId` | `Guid` | Yes | Source warehouse for this item |
| `quantity` | `decimal` | Yes | Units to load |
| `fK_ShopPOItemId` | `Guid?` | No | Links the load line to a specific ShopPO line item |
**Response:** `LoadingSheetResponse`. `400` if sheet is not `Planned`.
### `POST /api/loading-sheets/{id}/confirm`
Confirm the loading sheet. Commits stock: moves each item from its specified warehouse to the vehicle. Sheet becomes `Confirmed` and can no longer be edited.
**Request body:** _(none)_
**Response:** `LoadingSheetResponse`. `400` if already confirmed.
---
## Delivery Tasks
Delivery Tasks are the per-stop delivery jobs attached to a Ride. Each task maps to one ShopPO.
> **Note:** Auto-generation of delivery tasks from route/ShopPO assignments is deferred and not yet implemented. Tasks must be created manually or via a future dispatch endpoint.
### Status Flow
```
Pending → Delivered
↘ PartiallyDelivered
↘ Failed
```
### `GET /api/rides/{rideId}/tasks`
List all delivery tasks for a ride, ordered by `Sequence`.
**Response:** `DeliveryTaskResponse[]`
```json
[
{
"id": "uuid",
"fK_RideId": "uuid",
"fK_ShopPOId": "uuid",
"storeId": "uuid",
"storeName": "Galle Mart",
"sequence": 1,
"status": "Pending",
"customerConfirmed": false,
"confirmedAt": null,
"note": null,
"items": [
{
"itemId": "uuid",
"itemName": "Milo 400g",
"quantity": 10,
"quantityFulfilled": 0,
"unitPrice": 850.00
}
],
"createdAt": "2026-09-17T08:00:00"
}
]
```
### `GET /api/delivery-tasks/{id}`
**Response:** `DeliveryTaskResponse` or `404`.
### `PUT /api/delivery-tasks/{id}/quantities`
Set the fulfilled quantity for each item in the task. Call this before confirming.
**Request body:**
```json
{
"items": [
{ "itemId": "uuid", "quantityFulfilled": 8 }
]
}
```
**Response:** `DeliveryTaskResponse`. `400` if task is already closed.
### `POST /api/delivery-tasks/{id}/confirm`
Mark the task as Delivered or PartiallyDelivered based on quantities set. Updates ShopPO fulfillment, creates stock movement (Vehicle → External), and records payment if applicable.
**Request body:** _(none)_
**Response:** `DeliveryTaskResponse`. `400` if task is not `Pending`.
### `POST /api/delivery-tasks/{id}/fail`
Mark the task as Failed with a reason note.
**Request body:**
```json
{
"note": "Store was closed"
}
```
**Response:** `DeliveryTaskResponse`. `400` if task is not `Pending`.
---
## Returns
Returns are recorded against a ShopPO during the same store visit. Effects (credit deduction or item exchange) are applied immediately — no approval step.
> **Note:** The Create endpoint (`POST /api/shop-pos/{id}/returns`) is currently stubbed (`501 Not Implemented`) while the return flow is being redesigned. `GET` and `PATCH` endpoints are fully operational.
### `GET /api/returns`
List all returns.
**Response:** `SaleReturnResponse[]`
```json
[
{
"id": "uuid",
"shopPOId": "uuid",
"mode": "CreditDeduction",
"totalCreditAmount": 1700.00,
"orderTotalAmountAfter": 13300.00,
"isActive": true,
"createdAt": "2026-09-17T11:00:00",
"items": [
{
"id": "uuid",
"itemId": "uuid",
"sku": "MILO400",
"itemName": "Milo 400g",
"quantity": 2.0,
"reasonCode": "Damaged",
"unitPrice": 850.00
}
]
}
]
```
### `GET /api/returns/{id}`
**Response:** `SaleReturnResponse` or `404`.
### `GET /api/shop-pos/{id}/returns`
List all returns for a specific ShopPO.
**Response:** `SaleReturnResponse[]`
### `POST /api/shop-pos/{id}/returns`
> **501 Not Implemented** — return flow under redesign.
**Request body (for future reference):**
```json
{
"mode": "CreditDeduction",
"items": [
{ "itemId": "uuid", "quantity": 2.0, "reasonCode": "Damaged" }
],
"createdByUserId": "uuid"
}
```
| `mode` | Effect |
|--------|--------|
| `CreditDeduction` | `totalCreditAmount` is netted off the ShopPO's `TotalAmount` |
| `ItemExchange` | Items are swapped; no monetary credit applied |
### `PATCH /api/returns/{id}/reasons`
Update the reason code on individual return line items. Only reason codes can be edited post-creation.
**Request body:**
```json
{
"items": [
{ "itemId": "uuid", "reasonCode": "Expired" }
]
}
```
**Response:** `SaleReturnResponse`. `409` if return is no longer active.
---
## Schemes
Schemes are promotional rules applied to ShopPOs (formerly Orders).
### Scheme Rule Types
| Type | Description |
|------|-------------|
| `SlabDiscount` | Tiered % discount based on order amount. Config: `SlabDiscountConfig` |
| `FlatPercent` | Flat % discount on order total. Config: `FlatPercentConfig` |
| `BuyXGetY` | Buy N of trigger item → get free payout item |
| `FreeBundle` | Buy a bundle of items → get a free payout item |
### `GET /api/schemes`
List all schemes (paginated).
**Response:** `PagedResponse<SchemeResponse>`
```json
{
"items": [
{
"id": "uuid",
"name": "Q3 Volume Boost",
"ruleType": "SlabDiscount",
"ruleConfig": "{\"Tiers\":[{\"MinOrderAmount\":10000,\"DiscountPercent\":5}]}",
"triggerItemId": null,
"triggerItemName": null,
"triggerQty": null,
"freeItemId": null,
"freeItemName": null,
"freeQty": null,
"bundleComponents": [],
"regionId": "uuid | null",
"regionName": "Western Province | null",
"eligibleSegment": null,
"dateRangeFrom": "2026-07-01T00:00:00",
"dateRangeTo": "2026-09-30T00:00:00",
"agentLimitExempt": false,
"baselineExpectedSales": 500000.00,
"isActive": true,
"createdAt": "2026-06-15T09:00:00"
}
]
}
```
### `GET /api/schemes/{id}`
**Response:** `SchemeResponse` or `404`.
### `POST /api/schemes`
Create a scheme. For discount-only schemes (`SlabDiscount`, `FlatPercent`), free-goods fields can be omitted. For `BuyXGetY` / `FreeBundle`, you can supply the free-goods config inline here or set it later via `PUT .../free-goods-rule`.
**Request body:**
```json
{
"name": "Q3 Volume Boost",
"ruleType": "SlabDiscount",
"ruleConfig": "{\"Tiers\":[{\"MinOrderAmount\":10000,\"DiscountPercent\":5}]}",
"regionId": null,
"eligibleSegment": null,
"dateRangeFrom": "2026-07-01T00:00:00",
"dateRangeTo": "2026-09-30T00:00:00",
"agentLimitExempt": false,
"baselineExpectedSales": 500000.00
}
```
#### `ruleConfig` shapes
**SlabDiscount:**
```json
{ "Tiers": [ { "MinOrderAmount": 10000, "DiscountPercent": 5 }, { "MinOrderAmount": 20000, "DiscountPercent": 8 } ] }
```
**FlatPercent:**
```json
{ "DiscountPercent": 3.5 }
```
**BuyXGetY / FreeBundle:** `ruleConfig` is `null` — the payout is set via `PUT .../free-goods-rule`.
### `PUT /api/schemes/{id}`
Update scheme metadata. Cannot change `ruleType`.
### `DELETE /api/schemes/{id}`
Soft-delete (sets `IsActive = false`).
### `PUT /api/schemes/{id}/free-goods-rule`
Set the free-goods payout for `BuyXGetY` or `FreeBundle` schemes.
**Request body:**
```json
{
"triggerItemId": "uuid | null",
"triggerQty": 6,
"freeItemId": "uuid",
"freeQty": 1.0
}
```
- `BuyXGetY`: `triggerItemId` + `triggerQty` required (what store must buy).
- `FreeBundle`: `triggerItemId` / `triggerQty` ignored — trigger is the bundle components.
### `PUT /api/schemes/{id}/bundle-components/{itemId}`
Add or update a component in a `FreeBundle` scheme.
**Request body:**
```json
{ "qty": 4 }
```
### `DELETE /api/schemes/{id}/bundle-components/{itemId}`
Remove a bundle component.
### `GET /api/shop-pos/{shopPOId}/scheme-eligibility`
Check which active schemes the ShopPO qualifies for.
**Response:** `SchemeEligibilityResponse[]`
```json
[
{
"schemeId": "uuid",
"schemeName": "Q3 Volume Boost",
"ruleType": "SlabDiscount",
"eligible": true,
"reason": null,
"discountAmount": 750.00,
"freeItemId": null,
"freeQty": null
}
]
```
### `POST /api/shop-pos/{shopPOId}/apply-scheme`
Apply a scheme to the ShopPO (create a redemption record and apply discount/free goods).
**Request body:**
```json
{ "schemeId": "uuid" }
```
**Response:** `SchemeRedemptionResponse`
```json
{
"id": "uuid",
"schemeId": "uuid",
"shopPOId": "uuid",
"discountAmount": 750.00,
"freeItemId": null,
"freeItemName": null,
"freeQty": null,
"freeItemCostValue": null,
"redeemedAt": "2026-09-17T09:30:00"
}
```
### `GET /api/schemes/{id}/spend`
**Response:** `SchemeSpendResponse`
```json
{
"schemeId": "uuid",
"totalDiscountSpend": 45000.00,
"totalFreeGoodsCostSpend": 12000.00,
"totalSpend": 57000.00,
"redemptionCount": 38
}
```
### `GET /api/schemes/{id}/roi`
**Response:** `SchemeROIResponse`
```json
{
"schemeId": "uuid",
"baselineExpectedSales": 500000.00,
"actualSales": 620000.00,
"incrementalSales": 120000.00,
"totalSpend": 57000.00,
"roi": 1.105
}
```
---
## System Config
Key-value store for system-wide configuration. All keys and values are strings. Only known keys have typed endpoints with validation.
### `GET /api/sys-config`
Return all config entries.
**Response:** `SysConfigResponse[]`
```json
[
{
"key": "InvoiceCreationMode",
"value": "ModeB",
"updatedAt": "2026-09-17T00:00:00",
"updatedBy": null
}
]
```
### `GET /api/sys-config/invoice-creation-mode`
**Response:**
```json
{ "mode": "ModeB" }
```
### `PUT /api/sys-config/invoice-creation-mode`
**Request body:**
```json
{ "mode": "ModeA" }
```
| Value | Behaviour |
|-------|-----------|
| `ModeA` | Invoice is auto-created when a ShopPO is confirmed |
| `ModeB` | Invoice must be created as a separate explicit step (default) |
**Response:** `SysConfigResponse`. `400` if value is not a valid `InvoiceCreationMode`.
### `GET /api/sys-config/{key}`
Generic getter by key string. `404` if key not set.
### `PUT /api/sys-config/{key}`
Generic raw string setter. Requires admin-level auth.
**Request body:**
```json
{ "value": "SomeValue" }
```
**Response:** `SysConfigResponse`. `400` if value fails validation for a known key.
---
## Key Relationships & Flows
### Core Entity Chain
```
Store ──< ShopPO ──< ShopPOItem
│
└──► Invoice ──< Payment
│
└──► SaleReturn ──< SaleReturnItem
│
└──► SchemeRedemption
Ride (DeliveryRun) ──► LoadingSheet ──< LoadingSheetItem
└──< DeliveryTask ──► ShopPO
```
### ShopPO → Invoice Dependency
- `ShopPO.invoiceId` is `null` until an invoice is created.
- In **ModeA**, the invoice is created automatically when the ShopPO is confirmed.
- In **ModeB**, the invoice must be created via a dedicated endpoint (not yet exposed — planned).
- An invoice can only exist for a confirmed ShopPO.
### Payment → Invoice Dependency
- Payments are always attached to an **Invoice**, never directly to a ShopPO.
- `Invoice.paymentStatus` is computed:
- `Pending` — no payments
- `PartiallyPaid` — payments collected but total < invoice amount
- `Paid` — fully settled
### Loading Sheet → Stock
- Confirming a loading sheet deducts each item from its **source warehouse** (`fK_WarehouseId` per line) and credits the **vehicle** stock location.
- Each loading sheet line can optionally link to a `fK_ShopPOItemId` for traceability.
### Delivery Task → ShopPO Fulfillment
- Confirming a delivery task updates `ShopPOItem.quantityFulfilled` and transitions the ShopPO status:
- All items fully fulfilled → `Fulfilled`
- Some items partially fulfilled → `PartiallyFulfilled`
### Returns → ShopPO
- Returns always reference a `shopPOId`.
- `CreditDeduction` mode: `SaleReturn.totalCreditAmount` is netted off `ShopPO.totalAmount` immediately.
- `ItemExchange` mode: no monetary impact; items are swapped in stock.
- Only the `reasonCode` on each return line can be edited after creation.
---
## What Is Not Yet Implemented
| Feature | Status | Notes |
|---------|--------|-------|
| ShopPO confirm endpoint | Pending | `PUT /api/shop-pos/{id}/confirm` — triggers InvoiceCreationMode check |
| Invoice create (ModeB) | Pending | `POST /api/invoices` with `{ shopPOId }` |
| Invoice print | Pending | `POST /api/invoices/{id}/print` |
| Invoice finalize | Pending | `POST /api/invoices/{id}/finalize` |
| Delivery task auto-generation | Deferred | Route-based dispatch not yet implemented |
| Returns create | Redesign | `POST /api/shop-pos/{id}/returns` returns `501` |
| Permission seeding | Pending | `loading.populate`, `loading.confirm` scopes |