feat: add production templates API and documentation for manufacturing phase 2

- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates.
- Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs.
- Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
This commit is contained in:
2026-07-31 10:22:40 +05:30
parent 415ac94ab2
commit 7d6e597389
80 changed files with 11037 additions and 886 deletions
@@ -0,0 +1,189 @@
// One typed client method per production-run endpoint (docs/30-BACKEND-PHASE2.md §D.2D.3,
// FR-MFG-08..19).
//
// Every stage action takes an `idempotencyKey`. The server accepts the header but does not
// store it (matching GRN confirm): replay safety comes from the status guards, so a
// double-fire returns a 409 rather than acting twice. Pass a per-action
// `useRef(crypto.randomUUID())` anyway — it is part of the contract and costs nothing.
//
// Treat a 409 carrying a stage-status code as "someone else moved first": refetch the run
// and re-render silently instead of showing an error (docs/21-FRONTEND-PHASE2.md §6). Use
// `isStaleStageError` for that check.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
ApproveStageRequest,
ApproveStageResult,
CancelRunRequest,
CancelRunResult,
CompleteStageRequest,
CreateRunRequest,
ProductionRunGraph,
ProductionRunStatus,
ProductionRunSummary,
RejectIntakeResult,
RejectRequest,
ReturnLeftoverRequest,
ReturnLeftoverResult,
RunStage,
StartStageResult,
TerminalRejectResult,
TransferRemainderRequest,
TransferResult,
UpdateStageQuantitiesRequest,
} from "@/types/production"
export interface ListRunsParams {
page?: number
pageSize?: number
q?: string
status?: ProductionRunStatus
templateId?: number
warehouseId?: number
sort?: string
}
export const productionRunsApi = {
list(params: ListRunsParams = {}): Promise<PagedResponse<ProductionRunSummary>> {
return apiRequest<PagedResponse<ProductionRunSummary>>(`/production-runs${buildQuery(params)}`)
},
get(runId: number): Promise<ApiResult<ProductionRunGraph>> {
return apiRequestWithETag<ProductionRunGraph>(`/production-runs/${runId}`)
},
create(request: CreateRunRequest): Promise<ApiResult<ProductionRunGraph>> {
return apiRequestWithETag<ProductionRunGraph>("/production-runs", { method: "POST", body: request })
},
/** `409 STAGE_NOT_EDITABLE` once the stage has started (FR-MFG-08). */
updateStageQuantities(
runId: number,
runStageId: number,
request: UpdateStageQuantitiesRequest,
): Promise<RunStage> {
return apiRequest<RunStage>(`/production-runs/${runId}/stages/${runStageId}/quantities`, {
method: "PUT",
body: request,
})
},
// --- stage actions ---------------------------------------------------------
start(runId: number, runStageId: number, idempotencyKey?: string): Promise<StartStageResult> {
return apiRequest<StartStageResult>(`/production-runs/${runId}/stages/${runStageId}/start`, {
method: "POST",
idempotencyKey,
})
},
complete(
runId: number,
runStageId: number,
request: CompleteStageRequest,
idempotencyKey?: string,
): Promise<RunStage> {
return apiRequest<RunStage>(`/production-runs/${runId}/stages/${runStageId}/complete`, {
method: "POST",
body: request,
idempotencyKey,
})
},
/** Non-terminal: transfers WIP to children. Terminal: posts the receipt and completes the run. */
approve(
runId: number,
runStageId: number,
request: ApproveStageRequest = {},
idempotencyKey?: string,
): Promise<ApproveStageResult> {
return apiRequest<ApproveStageResult>(`/production-runs/${runId}/stages/${runStageId}/approve`, {
method: "POST",
body: request,
idempotencyKey,
})
},
transfer(
runId: number,
runStageId: number,
request: TransferRemainderRequest,
idempotencyKey?: string,
): Promise<TransferResult> {
return apiRequest<TransferResult>(`/production-runs/${runId}/stages/${runStageId}/transfer`, {
method: "POST",
body: request,
idempotencyKey,
})
},
rejectIntake(
runId: number,
runStageId: number,
request: RejectRequest = {},
idempotencyKey?: string,
): Promise<RejectIntakeResult> {
return apiRequest<RejectIntakeResult>(`/production-runs/${runId}/stages/${runStageId}/reject-intake`, {
method: "POST",
body: request,
idempotencyKey,
})
},
/** Terminal stage only — resets the entire run for a rework pass (FR-MFG-16). */
rejectTerminal(
runId: number,
runStageId: number,
request: RejectRequest = {},
idempotencyKey?: string,
): Promise<TerminalRejectResult> {
return apiRequest<TerminalRejectResult>(`/production-runs/${runId}/stages/${runStageId}/reject`, {
method: "POST",
body: request,
idempotencyKey,
})
},
// --- run-level actions -----------------------------------------------------
returnLeftover(
runId: number,
runInputId: number,
request: ReturnLeftoverRequest,
idempotencyKey?: string,
): Promise<ReturnLeftoverResult> {
return apiRequest<ReturnLeftoverResult>(`/production-runs/${runId}/inputs/${runInputId}/return-leftover`, {
method: "POST",
body: request,
idempotencyKey,
})
},
cancel(runId: number, request: CancelRunRequest, idempotencyKey?: string): Promise<CancelRunResult> {
return apiRequest<CancelRunResult>(`/production-runs/${runId}/cancel`, {
method: "POST",
body: request,
idempotencyKey,
})
},
}
/**
* Stage-status conflicts, i.e. "the stage already moved on — probably another user".
*
* docs/21-FRONTEND-PHASE2.md §6 says to refetch the run silently and re-render for these
* rather than surfacing an error, which is also what makes the server's accept-and-ignore
* idempotency posture feel right: a double-click just refreshes.
*/
const STALE_STAGE_CODES = new Set([
"STAGE_NOT_READY",
"STAGE_NOT_IN_PROGRESS",
"STAGE_NOT_DONE",
"STAGE_NOT_EDITABLE",
"STAGE_REJECT_INVALID",
])
export function isStaleStageError(error: unknown): boolean {
const code = (error as { code?: string } | null)?.code
return code !== undefined && STALE_STAGE_CODES.has(code)
}
@@ -0,0 +1,61 @@
// One typed client method per production-template endpoint (docs/30-BACKEND-PHASE2.md §D.1,
// FR-MFG-01..07). ETag/If-Match on update, PATCH status for deactivate (templates are
// deactivated, never hard-deleted, FR-MFG-01).
//
// Note that `update` replaces the WHOLE graph, and that the server's stage/output keys are
// load-bearing: a key it recognises is diffed in place (so historical runs stay linked to the
// stage), while anything else — a `tmp-<uuid>` from the canvas — is inserted. The builder holds
// those keys as React Flow node ids and echoes them back; see the [id]/types.ts header.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
ProductionTemplateGraph,
ProductionTemplateSummary,
SaveTemplateRequest,
TemplateStatus,
} from "@/types/production"
export interface ListTemplatesParams {
page?: number
pageSize?: number
q?: string
status?: TemplateStatus
sort?: string
}
export const productionTemplatesApi = {
list(params: ListTemplatesParams = {}): Promise<PagedResponse<ProductionTemplateSummary>> {
return apiRequest<PagedResponse<ProductionTemplateSummary>>(`/production-templates${buildQuery(params)}`)
},
get(templateId: number): Promise<ApiResult<ProductionTemplateGraph>> {
return apiRequestWithETag<ProductionTemplateGraph>(`/production-templates/${templateId}`)
},
create(request: SaveTemplateRequest): Promise<ApiResult<ProductionTemplateGraph>> {
return apiRequestWithETag<ProductionTemplateGraph>("/production-templates", {
method: "POST",
body: request,
})
},
/** Full-graph replace. `409 TEMPLATE_IN_USE` while any run of this template is InProgress. */
update(
templateId: number,
request: SaveTemplateRequest,
ifMatch: string,
): Promise<ApiResult<ProductionTemplateGraph>> {
return apiRequestWithETag<ProductionTemplateGraph>(`/production-templates/${templateId}`, {
method: "PUT",
body: request,
ifMatch,
})
},
updateStatus(templateId: number, status: TemplateStatus): Promise<void> {
return apiRequest<void>(`/production-templates/${templateId}/status`, {
method: "PATCH",
body: { status },
})
},
}
+50 -2
View File
@@ -16,6 +16,27 @@ interface ApiErrorLike {
*/
const GENERIC_CODES = new Set(["validation_error", "not_found", "conflict"])
/**
* Domain codes whose server `detail` is *more* specific than any fixed copy, so the detail
* wins and the entry in the map below is only a fallback.
*
* The graph validator names the offending stages ("...contains a cycle involving: Cut frame,
* Assemble"), and the transfer/leftover guards quote the actual figures ("only 20 is
* available (produced 50 - scrapped 0 - transferred 30)"). Replacing that with generic prose
* would throw away exactly what the user needs to fix the problem — and, for the graph codes,
* what the canvas uses to focus the offending node (docs/21-FRONTEND-PHASE2.md §2).
*/
const DETAIL_PREFERRED_CODES = new Set([
"GRAPH_CYCLE",
"GRAPH_TERMINAL_COUNT",
"GRAPH_DISCONNECTED",
"GRAPH_INPUT_SOURCE_INVALID",
"TERMINAL_OUTPUT_ITEM_REQUIRED",
"TRANSFER_EXCEEDS_AVAILABLE",
"LEFTOVER_EXCEEDS_CONSUMED",
"REQUIRED_FIELD_MISSING",
])
const CODE_MESSAGES: Record<string, string> = {
OVER_RECEIPT_TOLERANCE: "This quantity exceeds the purchase order's open quantity beyond the allowed tolerance.",
STOCK_NEGATIVE_BLOCKED: "Not enough available stock for this action.",
@@ -41,6 +62,25 @@ const CODE_MESSAGES: Record<string, string> = {
SALARY_STRUCTURE_OVERLAP: "The new effective date must be after the current salary structure's effective date.",
TAX_SLAB_GAP_INVALID: "This tax slab overlaps another slab for the same effective date.",
PAYROLL_PERIOD_LOCKED: "This payroll run is locked.",
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4). Several of these are
// listed in DETAIL_PREFERRED_CODES above, so their copy here is only a fallback.
TEMPLATE_IN_USE: "This template has runs in progress and can't be edited until they finish.",
TEMPLATE_INACTIVE: "This template is inactive, so new runs can't be started from it.",
GRAPH_CYCLE: "The stages form a loop. Remove the connection that feeds back on itself.",
GRAPH_TERMINAL_COUNT: "Connect stages so the line converges to a single final stage.",
GRAPH_DISCONNECTED: "Every stage must sit on a path from a starting stage to the final stage.",
GRAPH_INPUT_SOURCE_INVALID: "An upstream input must draw from a stage directly feeding into it.",
TERMINAL_OUTPUT_ITEM_REQUIRED: "The final stage needs exactly one output, and it must name the finished item.",
STAGE_NOT_READY: "This stage isn't ready to start yet.",
STAGE_NOT_IN_PROGRESS: "This stage isn't in progress, so it can't be completed.",
STAGE_NOT_DONE: "This stage has to be completed before it can be approved.",
STAGE_NOT_EDITABLE: "Quantities can't be changed once the stage has started.",
STAGE_REJECT_INVALID: "This stage has no received work to reject.",
REQUIRED_FIELD_MISSING: "Fill in every required field before completing this stage.",
TRANSFER_EXCEEDS_AVAILABLE: "That's more than this stage has available to transfer.",
LEFTOVER_EXCEEDS_CONSUMED: "You can't return more than was consumed and not already returned.",
RUN_COST_CLOSED: "This run is complete — its costs are closed, so leftovers can't be returned.",
RUN_NOT_CANCELLABLE: "This run can no longer be cancelled.",
validation_error: "Please check the highlighted fields.",
not_found: "The requested record was not found.",
conflict: "This action conflicts with the record's current state.",
@@ -49,8 +89,16 @@ const CODE_MESSAGES: Record<string, string> = {
export function errorMessage(error: unknown): string {
if (error && typeof error === "object") {
const e = error as ApiErrorLike
// A specific domain code beats the server's prose; a generic one loses to it.
if (e.code && !GENERIC_CODES.has(e.code) && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
// A specific domain code beats the server's prose; a generic one — or one whose detail
// carries the specifics — loses to it.
if (
e.code &&
!GENERIC_CODES.has(e.code) &&
!(DETAIL_PREFERRED_CODES.has(e.code) && e.detail) &&
CODE_MESSAGES[e.code]
) {
return CODE_MESSAGES[e.code]
}
if (e.detail) return e.detail
if (e.code && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
}
@@ -1,105 +0,0 @@
// Frontend-only mock run registry — no Dtos/Production backend exists yet
// (docs/21-FRONTEND-PHASE2.md). Shared by the Runs board and the run detail page so both
// read the same seed data (each page still keeps its own local edits — there's no backend
// to persist an advance/start-run action back to the other screen).
import { ProductionRun } from "@/types/production"
import { MOCK_TEMPLATE_INFO } from "@/lib/production-mock-templates"
import { STAGE_STATUS_ORDER, type StageStatus } from "@/lib/production-status-colors"
export const INITIAL_RUNS: ProductionRun[] = [
{
runId: 1, docNo: "PRD-2026-00001", templateName: "Steel Bracket Assembly", targetQty: 500,
finishedItemName: "Steel Bracket A", uom: "PCS", warehouseName: "Main Warehouse", status: "InProgress",
reworkCount: 0, createdAt: "2026-07-26", completedAt: null,
stageSummary: { waiting: 1, ready: 0, inProgress: 1, done: 1, approved: 0 },
},
{
runId: 2, docNo: "PRD-2026-00002", templateName: "PCB Soldering Line", targetQty: 200,
finishedItemName: "PCB Board X", uom: "PCS", warehouseName: "Colombo Warehouse", status: "InProgress",
reworkCount: 1, createdAt: "2026-07-25", completedAt: null,
stageSummary: { waiting: 0, ready: 1, inProgress: 2, done: 1, approved: 1 },
},
{
runId: 3, docNo: "PRD-2026-00003", templateName: "Wooden Pallet Build", targetQty: 1000,
finishedItemName: "Pallet Standard", uom: "PCS", warehouseName: "Main Warehouse", status: "Completed",
reworkCount: 0, createdAt: "2026-07-20", completedAt: "2026-07-24",
stageSummary: { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 2 },
},
{
runId: 4, docNo: "PRD-2026-00004", templateName: "Cable Harness Kit", targetQty: 300,
finishedItemName: "Harness Kit B", uom: "PCS", warehouseName: "Main Warehouse", status: "InProgress",
reworkCount: 0, createdAt: "2026-07-27", completedAt: null,
stageSummary: { waiting: 2, ready: 1, inProgress: 0, done: 0, approved: 0 },
},
{
runId: 5, docNo: "PRD-2026-00005", templateName: "Steel Bracket Assembly", targetQty: 150,
finishedItemName: "Steel Bracket A", uom: "PCS", warehouseName: "Colombo Warehouse", status: "Cancelled",
reworkCount: 0, createdAt: "2026-07-15", completedAt: null,
stageSummary: { waiting: 0, ready: 0, inProgress: 1, done: 0, approved: 0 },
},
{
runId: 6, docNo: "PRD-2026-00006", templateName: "PCB Soldering Line", targetQty: 400,
finishedItemName: "PCB Board X", uom: "PCS", warehouseName: "Main Warehouse", status: "InProgress",
reworkCount: 0, createdAt: "2026-07-23", completedAt: null,
stageSummary: { waiting: 1, ready: 2, inProgress: 1, done: 1, approved: 0 },
},
]
// Active templates only (docs/21-FRONTEND-PHASE2.md §4 "Template picker (Active only)") —
// mirrors the 4 Active rows on the Template list page ("Plastic Injection Mold" is Inactive
// there, so it's excluded here too. `nominalBatchQty` backs the scaled-preview calculation;
// there's no real per-template formula graph shared across routes to scale properly (each
// builder page's stage data is local, unsaved state — see templates/[id]/page.tsx), so this
// is a simplified stand-in for the doc's full per-stage scaled preview.
export interface StartableTemplate {
templateId: number
name: string
finishedItemName: string
uom: string
nominalBatchQty: number
stageCount: number
}
export const STARTABLE_TEMPLATES: StartableTemplate[] = [
{ templateId: 1, name: "Steel Bracket Assembly", finishedItemName: "Steel Bracket A", uom: "PCS", nominalBatchQty: 100, stageCount: 3 },
{ templateId: 2, name: "PCB Soldering Line", finishedItemName: "PCB Board X", uom: "PCS", nominalBatchQty: 50, stageCount: 5 },
{ templateId: 3, name: "Wooden Pallet Build", finishedItemName: "Pallet Standard", uom: "PCS", nominalBatchQty: 200, stageCount: 2 },
{ templateId: 5, name: "Cable Harness Kit", finishedItemName: "Harness Kit B", uom: "SET", nominalBatchQty: 75, stageCount: 3 },
]
export interface RunStagePlanItem {
name: string
state: StageStatus
}
const SUMMARY_KEY_BY_STATUS: Record<StageStatus, keyof ProductionRun["stageSummary"]> = {
Waiting: "waiting",
Ready: "ready",
InProgress: "inProgress",
Done: "done",
Approved: "approved",
}
/**
* `stageSummary` only carries counts per status, not which named stage each count belongs
* to. Reconstruct a per-stage breakdown by looking up the template's real stage names (via
* MOCK_TEMPLATE_INFO) and allocating the counts across them most-complete-first — stages
* run left to right, so the furthest-along stages are assumed to be the earliest ones in
* the list. Pad with "Waiting" (and truncate) when the counts don't add up to the template's
* actual stage count — e.g. the Cancelled mock run stops partway through its stage list.
*/
export function buildStagePlan(templateName: string, summary: ProductionRun["stageSummary"]): RunStagePlanItem[] {
const info = Object.values(MOCK_TEMPLATE_INFO).find((t) => t.name === templateName)
const totalCount = Object.values(summary).reduce((sum, n) => sum + n, 0)
const stageNames = info?.stages ?? Array.from({ length: Math.max(totalCount, 1) }, (_, i) => `Stage ${i + 1}`)
const statuses: StageStatus[] = []
for (const s of [...STAGE_STATUS_ORDER].reverse()) {
const count = summary[SUMMARY_KEY_BY_STATUS[s]]
for (let i = 0; i < count; i++) statuses.push(s)
}
while (statuses.length < stageNames.length) statuses.push("Waiting")
statuses.length = stageNames.length
return stageNames.map((name, i) => ({ name, state: statuses[i] }))
}
@@ -1,16 +0,0 @@
// Frontend-only mock template registry — no Dtos/Production backend exists yet
// (docs/21-FRONTEND-PHASE2.md). Shared by the Template list page (production-line preview
// per card) and the canvas builder page (initial graph + edit-lock), so the two never drift.
export interface MockTemplateInfo {
name: string
activeRunCount: number
stages: string[]
}
export const MOCK_TEMPLATE_INFO: Record<string, MockTemplateInfo> = {
"1": { name: "Steel Bracket Assembly", activeRunCount: 2, stages: ["Cutting", "Welding", "QA Inspection"] },
"2": { name: "PCB Soldering Line", activeRunCount: 0, stages: ["Component Placement", "Soldering", "Inspection", "Cleaning", "Final Test"] },
"3": { name: "Wooden Pallet Build", activeRunCount: 1, stages: ["Assembly", "Quality Check"] },
"4": { name: "Plastic Injection Mold", activeRunCount: 0, stages: ["Mold Prep", "Injection", "Cooling", "Trimming"] },
"5": { name: "Cable Harness Kit", activeRunCount: 0, stages: ["Wire Cutting", "Crimping", "Bundling"] },
}