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:
@@ -0,0 +1,189 @@
|
||||
// One typed client method per production-run endpoint (docs/30-BACKEND-PHASE2.md §D.2–D.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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user