| @@ -287,6 +287,15 @@ export interface ItemLotTraceJoPickLine { | |||||
| requiredQty: number; | requiredQty: number; | ||||
| pickedQty: number; | pickedQty: number; | ||||
| status: string; | status: string; | ||||
| itemType?: string; | |||||
| stockUom?: string; | |||||
| bomReqQty?: number | null; | |||||
| bomUom?: string; | |||||
| bomProcessId?: number | null; | |||||
| bomProcessSeqNo?: number | null; | |||||
| assignedStepName?: string; | |||||
| stockAvailable?: number | null; | |||||
| stockStatusApplicable?: boolean; | |||||
| } | } | ||||
| export interface ItemLotTraceJoPickOrder { | export interface ItemLotTraceJoPickOrder { | ||||
| @@ -905,7 +905,7 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n | |||||
| ); | ); | ||||
| }); | }); | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.7 | 2026-08-06 */ | |||||
| export const fetchJoborderProductProcessesPage = cache(async (params: { | export const fetchJoborderProductProcessesPage = cache(async (params: { | ||||
| /** Job order / process date(YYYY-MM-DD) */ | /** Job order / process date(YYYY-MM-DD) */ | ||||
| date?: string | null; | date?: string | null; | ||||
| @@ -1683,12 +1683,37 @@ export const fetchOperatorKpi = cache(async (date?: string) => { | |||||
| }); | }); | ||||
| // ===== Drink Production Qty Dashboard ===== | // ===== Drink Production Qty Dashboard ===== | ||||
| export interface DrinkProductionQtyProcessStep { | |||||
| jobOrderId: number; | |||||
| jobOrderCode?: string | null; | |||||
| itemCode?: string | null; | |||||
| itemName?: string | null; | |||||
| seqNo?: number | null; | |||||
| processName?: string | null; | |||||
| operatorName?: string | null; | |||||
| handlerName?: string | null; | |||||
| startTime?: string | null; | |||||
| endTime?: string | null; | |||||
| status?: string | null; | |||||
| } | |||||
| export interface DrinkProductionQtyJobOrderDetail { | export interface DrinkProductionQtyJobOrderDetail { | ||||
| jobOrderId: number; | jobOrderId: number; | ||||
| jobOrderCode?: string | null; | jobOrderCode?: string | null; | ||||
| productionDate?: string | null; | productionDate?: string | null; | ||||
| reqQty: number; | reqQty: number; | ||||
| productionQty: number; | productionQty: number; | ||||
| jobOrderStatus?: string | null; | |||||
| startTime?: string | null; | |||||
| assumeTimeNeedMins?: number; | |||||
| assumeEndTime?: string | null; | |||||
| actualEndTime?: string | null; | |||||
| latestStartBy?: string | null; | |||||
| processOperators?: string | null; | |||||
| processHandlers?: string | null; | |||||
| qcUsers?: string | null; | |||||
| putAwayUsers?: string | null; | |||||
| processSteps?: DrinkProductionQtyProcessStep[]; | |||||
| } | } | ||||
| export interface DrinkProductionQtyResponse { | export interface DrinkProductionQtyResponse { | ||||
| @@ -1701,17 +1726,20 @@ export interface DrinkProductionQtyResponse { | |||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ | ||||
| export const fetchDrinkProductionQty = cache(async (date?: string) => { | |||||
| const params = new URLSearchParams(); | |||||
| if (date) params.set("date", date); | |||||
| const qs = params.toString(); | |||||
| const url = `${BASE_API_URL}/product-process/Demo/DrinkProductionQty${qs ? `?${qs}` : ""}`; | |||||
| export const fetchDrinkProductionQty = cache( | |||||
| async (date?: string, view: "actual" | "planned" = "actual") => { | |||||
| const params = new URLSearchParams(); | |||||
| if (date) params.set("date", date); | |||||
| params.set("view", view); | |||||
| const qs = params.toString(); | |||||
| const url = `${BASE_API_URL}/product-process/Demo/DrinkProductionQty${qs ? `?${qs}` : ""}`; | |||||
| return serverFetchJson<DrinkProductionQtyResponse[]>(url, { | |||||
| method: "GET", | |||||
| next: { tags: ["drinkProductionQty"] }, | |||||
| }); | |||||
| }); | |||||
| return serverFetchJson<DrinkProductionQtyResponse[]>(url, { | |||||
| method: "GET", | |||||
| next: { tags: ["drinkProductionQty"] }, | |||||
| }); | |||||
| }, | |||||
| ); | |||||
| // ===== Equipment Status Dashboard ===== | // ===== Equipment Status Dashboard ===== | ||||
| @@ -2,6 +2,8 @@ | |||||
| @tailwind components; | @tailwind components; | ||||
| @tailwind utilities; | @tailwind utilities; | ||||
| /* FP-MTMS Version Checklist | Functions Ref. No. 57 | v1.0.0 | 2026-08-06 */ | |||||
| /* UI standard: light default, primary #3b82f6, accent #10b981 */ | /* UI standard: light default, primary #3b82f6, accent #10b981 */ | ||||
| @layer base { | @layer base { | ||||
| :root { | :root { | ||||
| @@ -48,6 +50,7 @@ html { | |||||
| /* Base font size: slightly larger for readability */ | /* Base font size: slightly larger for readability */ | ||||
| font-size: 16px; | font-size: 16px; | ||||
| } | } | ||||
| @media (min-width: 640px) { | @media (min-width: 640px) { | ||||
| html { | html { | ||||
| font-size: 17px; | font-size: 17px; | ||||
| @@ -20,7 +20,7 @@ export const AUTH = { | |||||
| JOB_MAT: "JOB_MAT", | JOB_MAT: "JOB_MAT", | ||||
| JOB_PROD: "JOB_PROD", | JOB_PROD: "JOB_PROD", | ||||
| /** | /** | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05 | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 | |||||
| * 工單 生產流程 完成工單 | * 工單 生產流程 完成工單 | ||||
| */ | */ | ||||
| PRODUCT_PROCESS: "PRODUCT_PROCESS", | PRODUCT_PROCESS: "PRODUCT_PROCESS", | ||||
| @@ -28,7 +28,7 @@ export const AUTH = { | |||||
| } as const; | } as const; | ||||
| /** | /** | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05 | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 | |||||
| * Match session ability codes (exact, trimmed). | * Match session ability codes (exact, trimmed). | ||||
| */ | */ | ||||
| export function hasAbility( | export function hasAbility( | ||||
| @@ -14,6 +14,7 @@ import { | |||||
| import { formatQty, formatSignedQty } from "./traceQtyUtils"; | import { formatQty, formatSignedQty } from "./traceQtyUtils"; | ||||
| import { | import { | ||||
| createMaterialPickNode, | createMaterialPickNode, | ||||
| createMaterialPickNodeFromPickLine, | |||||
| docLinkFromOriginType, | docLinkFromOriginType, | ||||
| docLinkFromRefType, | docLinkFromRefType, | ||||
| field, | field, | ||||
| @@ -264,6 +265,9 @@ const buildMaterialStockInPreludeNodes = ( | |||||
| // ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整). | // ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整). | ||||
| ctx.seenInboundLots.add(lotId); | ctx.seenInboundLots.add(lotId); | ||||
| const isJoOrigin = originType === "JO"; | const isJoOrigin = originType === "JO"; | ||||
| const joSubtitleBase = [m.materialLotNo, m.materialItemCode, m.materialItemName] | |||||
| .filter(Boolean) | |||||
| .join(" · "); | |||||
| nodes.push({ | nodes.push({ | ||||
| id: `mat-in-${lotId}-${origin.stockInLineId}`, | id: `mat-in-${lotId}-${origin.stockInLineId}`, | ||||
| kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN", | kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN", | ||||
| @@ -272,11 +276,13 @@ const buildMaterialStockInPreludeNodes = ( | |||||
| title: isJoOrigin | title: isJoOrigin | ||||
| ? labels.nodeJoCreated | ? labels.nodeJoCreated | ||||
| : `${labels.tr.refType(origin.type)} · ${labels.nodeMaterialIn}`, | : `${labels.tr.refType(origin.type)} · ${labels.nodeMaterialIn}`, | ||||
| subtitle: appendPickToSubtitle( | |||||
| subtitle || m.materialItemCode, | |||||
| picks, | |||||
| labels.pickOrder, | |||||
| ), | |||||
| subtitle: isJoOrigin | |||||
| ? joSubtitleBase || origin.refCode || m.materialItemCode | |||||
| : appendPickToSubtitle( | |||||
| subtitle || m.materialItemCode, | |||||
| picks, | |||||
| labels.pickOrder, | |||||
| ), | |||||
| qty: origin.acceptedQty, | qty: origin.acceptedQty, | ||||
| uom: matUom, | uom: matUom, | ||||
| refType: origin.type, | refType: origin.type, | ||||
| @@ -300,13 +306,23 @@ const buildMaterialStockInPreludeNodes = ( | |||||
| linkCode: origin.refCode, | linkCode: origin.refCode, | ||||
| linkId: origin.refId, | linkId: origin.refId, | ||||
| }), | }), | ||||
| field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")), | |||||
| ...(isJoOrigin | |||||
| ? [] | |||||
| : [ | |||||
| field( | |||||
| labels.detailSupplier, | |||||
| [origin.supplierCode, origin.supplierName].filter(Boolean).join(" "), | |||||
| ), | |||||
| ]), | |||||
| field(labels.detailItemCode, m.materialItemCode), | field(labels.detailItemCode, m.materialItemCode), | ||||
| field(labels.detailItemName, m.materialItemName), | |||||
| field(labels.detailLot, m.materialLotNo), | field(labels.detailLot, m.materialLotNo), | ||||
| field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), | field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), | ||||
| field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), | |||||
| ...(isJoOrigin | |||||
| ? [] | |||||
| : [field(labels.detailStatus, labels.tr.stockInStatus(origin.status))]), | |||||
| field(labels.detailTime, origin.receiptDate), | field(labels.detailTime, origin.receiptDate), | ||||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||||
| ...(isJoOrigin ? [] : pickOrderDetailFields(picks, labels.pickOrder)), | |||||
| ], | ], | ||||
| }); | }); | ||||
| } | } | ||||
| @@ -501,7 +517,6 @@ const buildMaterialStockInPreludeNodes = ( | |||||
| const buildNestedJoCreatedNode = ( | const buildNestedJoCreatedNode = ( | ||||
| m: ItemLotTraceMaterialInput, | m: ItemLotTraceMaterialInput, | ||||
| labels: JoPreludeGraphLabels, | labels: JoPreludeGraphLabels, | ||||
| picks: LotPickRef[], | |||||
| ctx: PreludeBuildContext, | ctx: PreludeBuildContext, | ||||
| ): TraceGraphNode | null => { | ): TraceGraphNode | null => { | ||||
| const nested = m.nestedJoPrelude; | const nested = m.nestedJoPrelude; | ||||
| @@ -524,7 +539,8 @@ const buildNestedJoCreatedNode = ( | |||||
| origin?.receiptDate?.trim() || | origin?.receiptDate?.trim() || | ||||
| null; | null; | ||||
| const matUom = m.materialUom?.trim() || ""; | const matUom = m.materialUom?.trim() || ""; | ||||
| const qty = origin?.acceptedQty ?? jo.reqQty; | |||||
| const req = Number(jo.reqQty); | |||||
| const qty = Number.isFinite(req) ? req : (origin?.acceptedQty ?? 0); | |||||
| return { | return { | ||||
| id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`, | id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`, | ||||
| @@ -532,11 +548,9 @@ const buildNestedJoCreatedNode = ( | |||||
| timestamp: ts, | timestamp: ts, | ||||
| sortKey: parseSortKey(ts, nextSeq(ctx)), | sortKey: parseSortKey(ts, nextSeq(ctx)), | ||||
| title: labels.nodeJoCreated, | title: labels.nodeJoCreated, | ||||
| subtitle: appendPickToSubtitle( | |||||
| [m.materialLotNo, m.materialItemCode].filter(Boolean).join(" · ") || jo.jobOrderCode, | |||||
| picks, | |||||
| labels.pickOrder, | |||||
| ), | |||||
| subtitle: | |||||
| [m.materialLotNo, m.materialItemCode, m.materialItemName].filter(Boolean).join(" · ") || | |||||
| jo.jobOrderCode, | |||||
| qty, | qty, | ||||
| uom: matUom, | uom: matUom, | ||||
| refType: "JO", | refType: "JO", | ||||
| @@ -554,10 +568,10 @@ const buildNestedJoCreatedNode = ( | |||||
| linkId: jo.jobOrderId, | linkId: jo.jobOrderId, | ||||
| }), | }), | ||||
| field(labels.detailItemCode, m.materialItemCode), | field(labels.detailItemCode, m.materialItemCode), | ||||
| field(labels.detailItemName, m.materialItemName), | |||||
| field(labels.detailLot, m.materialLotNo), | field(labels.detailLot, m.materialLotNo), | ||||
| field(labels.detailQty, formatQty(qty, matUom)), | |||||
| field(labels.detailRequiredQty, formatQty(qty, matUom)), | |||||
| field(labels.detailTime, ts), | field(labels.detailTime, ts), | ||||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||||
| ], | ], | ||||
| }; | }; | ||||
| }; | }; | ||||
| @@ -576,22 +590,60 @@ const buildMaterialInputChainNodes = ( | |||||
| const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); | const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); | ||||
| if (joProduced && m.nestedJoPrelude) { | if (joProduced && m.nestedJoPrelude) { | ||||
| const nestedJoCreated = buildNestedJoCreatedNode(m, labels, picks, ctx); | |||||
| const nestedJoCreated = buildNestedJoCreatedNode(m, labels, ctx); | |||||
| if (nestedJoCreated) nodes.push(nestedJoCreated); | if (nestedJoCreated) nodes.push(nestedJoCreated); | ||||
| } | |||||
| if (joProduced && nestedInputs.length > 0) { | |||||
| const nestedLotPicks = buildLotPickOrderMap(nestedInputs); | |||||
| nodes.push( | |||||
| ...buildMaterialInputChainNodes( | |||||
| nestedInputs, | |||||
| labels, | |||||
| nestedLotPicks, | |||||
| ctx, | |||||
| ), | |||||
| ); | |||||
| nestedInputs.forEach((nm, ni) => { | |||||
| nodes.push(createMaterialPickNode(nm, ni, labels, pickCtx(ctx), m.materialLotNo)); | |||||
| const nestedPrelude = m.nestedJoPrelude; | |||||
| if (nestedInputs.length > 0) { | |||||
| const nestedLotPicks = buildLotPickOrderMap(nestedInputs); | |||||
| nodes.push( | |||||
| ...buildMaterialInputChainNodes( | |||||
| nestedInputs, | |||||
| labels, | |||||
| nestedLotPicks, | |||||
| ctx, | |||||
| ), | |||||
| ); | |||||
| nestedInputs.forEach((nm, ni) => { | |||||
| nodes.push( | |||||
| createMaterialPickNode( | |||||
| nm, | |||||
| ni, | |||||
| labels, | |||||
| pickCtx(ctx), | |||||
| m.materialLotNo, | |||||
| pickLineByItemCode(nestedPrelude, nm.materialItemCode), | |||||
| ), | |||||
| ); | |||||
| }); | |||||
| } | |||||
| // Always show nested JO pick-order lines (even when no stock-outs / nestedInputs yet). | |||||
| const nestedCovered = coveredPickItemCodes(nestedInputs); | |||||
| (nestedPrelude.pickOrders ?? []).forEach((po) => { | |||||
| (po.lines ?? []).forEach((line, li) => { | |||||
| const code = line.itemCode?.trim().toUpperCase(); | |||||
| if (!code || nestedCovered.has(code)) return; | |||||
| nestedCovered.add(code); | |||||
| nodes.push( | |||||
| createMaterialPickNodeFromPickLine( | |||||
| line, | |||||
| li, | |||||
| labels, | |||||
| { | |||||
| ...pickCtx(ctx), | |||||
| pickOrderCode: po.pickOrderCode, | |||||
| pickOrderId: po.pickOrderId, | |||||
| consoCode: po.consoCode, | |||||
| jobOrderCode: nestedPrelude.jobOrder.jobOrderCode, | |||||
| jobOrderId: nestedPrelude.jobOrder.jobOrderId, | |||||
| timestamp: | |||||
| po.releasedDate || po.targetDate || nestedPrelude.jobOrder.createdAt, | |||||
| }, | |||||
| m.materialLotNo, | |||||
| ), | |||||
| ); | |||||
| }); | |||||
| }); | }); | ||||
| } | } | ||||
| @@ -605,6 +657,29 @@ const buildMaterialInputChainNodes = ( | |||||
| return nodes; | return nodes; | ||||
| }; | }; | ||||
| const pickLineByItemCode = ( | |||||
| joPrelude: ItemLotTraceJoPrelude, | |||||
| itemCode: string, | |||||
| ): ItemLotTraceJoPickLine | null => { | |||||
| const key = itemCode.trim().toUpperCase(); | |||||
| if (!key) return null; | |||||
| for (const po of joPrelude.pickOrders ?? []) { | |||||
| for (const line of po.lines ?? []) { | |||||
| if (line.itemCode?.trim().toUpperCase() === key) return line; | |||||
| } | |||||
| } | |||||
| return null; | |||||
| }; | |||||
| const coveredPickItemCodes = (materialInputs: ItemLotTraceMaterialInput[]): Set<string> => { | |||||
| const set = new Set<string>(); | |||||
| materialInputs.forEach((m) => { | |||||
| const code = m.materialItemCode?.trim().toUpperCase(); | |||||
| if (code) set.add(code); | |||||
| }); | |||||
| return set; | |||||
| }; | |||||
| export const buildJoPreludeGraphNodes = ( | export const buildJoPreludeGraphNodes = ( | ||||
| joPrelude: ItemLotTraceJoPrelude, | joPrelude: ItemLotTraceJoPrelude, | ||||
| labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>, | labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>, | ||||
| @@ -619,12 +694,61 @@ export const buildJoPreludeGraphNodes = ( | |||||
| seq: 0, | seq: 0, | ||||
| }; | }; | ||||
| const lotPicks = buildLotPickOrderMap(joPrelude.materialInputs); | const lotPicks = buildLotPickOrderMap(joPrelude.materialInputs); | ||||
| const coveredItems = coveredPickItemCodes(joPrelude.materialInputs); | |||||
| const lotPickNodes = joPrelude.materialInputs.map((m, i) => | |||||
| createMaterialPickNode( | |||||
| m, | |||||
| i, | |||||
| labels, | |||||
| pickCtx(ctx), | |||||
| undefined, | |||||
| pickLineByItemCode(joPrelude, m.materialItemCode), | |||||
| ), | |||||
| ); | |||||
| const linePickNodes: TraceGraphNode[] = []; | |||||
| (joPrelude.pickOrders ?? []).forEach((po) => { | |||||
| (po.lines ?? []).forEach((line, li) => { | |||||
| const code = line.itemCode?.trim().toUpperCase(); | |||||
| if (!code || coveredItems.has(code)) return; | |||||
| coveredItems.add(code); | |||||
| linePickNodes.push( | |||||
| createMaterialPickNodeFromPickLine(line, li, labels, { | |||||
| ...pickCtx(ctx), | |||||
| pickOrderCode: po.pickOrderCode, | |||||
| pickOrderId: po.pickOrderId, | |||||
| consoCode: po.consoCode, | |||||
| jobOrderCode: joPrelude.jobOrder.jobOrderCode, | |||||
| jobOrderId: joPrelude.jobOrder.jobOrderId, | |||||
| timestamp: po.releasedDate || po.targetDate || joPrelude.jobOrder.createdAt, | |||||
| }), | |||||
| ); | |||||
| }); | |||||
| }); | |||||
| const nodes: TraceGraphNode[] = [ | const nodes: TraceGraphNode[] = [ | ||||
| ...buildMaterialInputChainNodes(joPrelude.materialInputs, labels, lotPicks, ctx), | ...buildMaterialInputChainNodes(joPrelude.materialInputs, labels, lotPicks, ctx), | ||||
| ...joPrelude.materialInputs.map((m, i) => createMaterialPickNode(m, i, labels, pickCtx(ctx))), | |||||
| ...lotPickNodes, | |||||
| ...linePickNodes, | |||||
| ]; | ]; | ||||
| // Keep 工單提料 after 建立工單 for the same JO. | |||||
| const joCreatedSort = new Map<string, number>(); | |||||
| nodes.forEach((n) => { | |||||
| if (n.kind !== "JO_CREATED") return; | |||||
| const code = (n.refCode || n.jobOrderCode || "").trim(); | |||||
| if (!code) return; | |||||
| const prev = joCreatedSort.get(code); | |||||
| if (prev == null || n.sortKey < prev) joCreatedSort.set(code, n.sortKey); | |||||
| }); | |||||
| nodes.forEach((n) => { | |||||
| if (n.kind !== "MATERIAL_PICK" && n.kind !== "PICK_GROUP") return; | |||||
| const code = (n.jobOrderCode || "").trim(); | |||||
| const joSort = code ? joCreatedSort.get(code) : undefined; | |||||
| if (joSort != null && n.sortKey <= joSort) n.sortKey = joSort + 1; | |||||
| }); | |||||
| return nodes.sort((a, b) => { | return nodes.sort((a, b) => { | ||||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | ||||
| return a.id.localeCompare(b.id); | return a.id.localeCompare(b.id); | ||||
| @@ -178,6 +178,8 @@ export interface TraceGraphDetailLabels extends TraceGraphNodeLabels { | |||||
| nodeExpired: string; | nodeExpired: string; | ||||
| nodeDepleted: string; | nodeDepleted: string; | ||||
| detailQty: string; | detailQty: string; | ||||
| /** JO_CREATED detail / chip qty — job order required qty (需求數量). */ | |||||
| detailRequiredQty: string; | |||||
| detailTime: string; | detailTime: string; | ||||
| detailHandler: string; | detailHandler: string; | ||||
| detailStockTaker: string; | detailStockTaker: string; | ||||
| @@ -435,6 +437,59 @@ const resolveJoCreatedTimestamp = ( | |||||
| return fallback ?? null; | return fallback ?? null; | ||||
| }; | }; | ||||
| /** Prefer job-order reqQty for 工單建立; fall back to stock-in / movement qty when JO context is missing. */ | |||||
| const resolveJoReqQty = ( | |||||
| data: ItemLotTraceResponse, | |||||
| refCode: string | null | undefined, | |||||
| fallback: number, | |||||
| ): number => { | |||||
| const code = refCode?.trim(); | |||||
| if (!code) return fallback; | |||||
| const candidates: Array<ItemLotTraceJoContext | undefined> = [ | |||||
| data.joPrelude?.jobOrder, | |||||
| ...(data.joPrelude?.materialInputs ?? []).map((m) => m.nestedJoPrelude?.jobOrder), | |||||
| ]; | |||||
| for (const jo of candidates) { | |||||
| if (jo?.jobOrderCode?.trim() !== code) continue; | |||||
| const req = Number(jo.reqQty); | |||||
| if (Number.isFinite(req)) return req; | |||||
| } | |||||
| return fallback; | |||||
| }; | |||||
| /** Subtitle for FG / current-lot JO_CREATED: lot · itemCode · itemName */ | |||||
| const joCreatedLotSubtitle = (lot: { | |||||
| lotNo?: string | null; | |||||
| itemCode?: string | null; | |||||
| itemName?: string | null; | |||||
| }): string => | |||||
| [lot.lotNo, lot.itemCode, lot.itemName].filter(Boolean).join(" · ") || "—"; | |||||
| const joCreatedLotDetails = ( | |||||
| labels: TraceGraphDetailLabels, | |||||
| lot: { lotNo?: string | null; itemCode?: string | null; itemName?: string | null }, | |||||
| refCode: string, | |||||
| refId: number | null | undefined, | |||||
| qty: number, | |||||
| uom: string, | |||||
| timestamp: string | null | undefined, | |||||
| ) => | |||||
| detailsOf( | |||||
| field(labels.detailType, labels.nodeJoCreated), | |||||
| field(labels.jobOrder, refCode, { | |||||
| linkKind: "jo", | |||||
| linkCode: refCode, | |||||
| linkId: refId, | |||||
| }), | |||||
| field(labels.detailItemCode, lot.itemCode), | |||||
| field(labels.detailItemName, lot.itemName), | |||||
| field(labels.detailLot, lot.lotNo), | |||||
| field(labels.detailRequiredQty, formatQty(qty, uom)), | |||||
| field(labels.detailTime, timestamp), | |||||
| ); | |||||
| const poInboundKey = ( | const poInboundKey = ( | ||||
| refCode: string | null | undefined, | refCode: string | null | undefined, | ||||
| refId: number | null | undefined, | refId: number | null | undefined, | ||||
| @@ -532,14 +587,16 @@ const buildInboundOriginNodes = ( | |||||
| const joKey = `${refCode}|${origin.refId ?? 0}`; | const joKey = `${refCode}|${origin.refId ?? 0}`; | ||||
| coveredJoInbound.add(joKey); | coveredJoInbound.add(joKey); | ||||
| const eventTimestamp = resolveJoCreatedTimestamp(data, refCode, origin.receiptDate); | const eventTimestamp = resolveJoCreatedTimestamp(data, refCode, origin.receiptDate); | ||||
| const joQty = resolveJoReqQty(data, refCode, origin.acceptedQty); | |||||
| const lot = data.lot; | |||||
| nodes.push({ | nodes.push({ | ||||
| id: `${idPfx}origin-${i}-${origin.stockInLineId}`, | id: `${idPfx}origin-${i}-${origin.stockInLineId}`, | ||||
| kind: "JO_CREATED", | kind: "JO_CREATED", | ||||
| timestamp: eventTimestamp, | timestamp: eventTimestamp, | ||||
| sortKey: parseSortKey(eventTimestamp, seq++), | sortKey: parseSortKey(eventTimestamp, seq++), | ||||
| title: labels.nodeJoCreated, | title: labels.nodeJoCreated, | ||||
| subtitle: refCode, | |||||
| qty: origin.acceptedQty, | |||||
| subtitle: joCreatedLotSubtitle(lot), | |||||
| qty: joQty, | |||||
| uom: stockUom, | uom: stockUom, | ||||
| refType: origin.type, | refType: origin.type, | ||||
| refCode, | refCode, | ||||
| @@ -547,15 +604,14 @@ const buildInboundOriginNodes = ( | |||||
| docLinkKind: linkKind, | docLinkKind: linkKind, | ||||
| warehouseCode: defaultWh, | warehouseCode: defaultWh, | ||||
| categoryLabel: categoryForKind("JO_CREATED", labels), | categoryLabel: categoryForKind("JO_CREATED", labels), | ||||
| details: detailsOf( | |||||
| field(labels.detailType, labels.nodeJoCreated), | |||||
| field(labels.jobOrder, refCode, { | |||||
| linkKind, | |||||
| linkCode: refCode, | |||||
| linkId: origin.refId, | |||||
| }), | |||||
| field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)), | |||||
| field(labels.detailTime, eventTimestamp), | |||||
| details: joCreatedLotDetails( | |||||
| labels, | |||||
| lot, | |||||
| refCode, | |||||
| origin.refId, | |||||
| joQty, | |||||
| stockUom, | |||||
| eventTimestamp, | |||||
| ), | ), | ||||
| }); | }); | ||||
| } | } | ||||
| @@ -714,6 +770,8 @@ export const buildTraceGraphNodes = ( | |||||
| kind === "JO_CREATED" | kind === "JO_CREATED" | ||||
| ? resolveJoCreatedTimestamp(data, m.refCode, m.timestamp) | ? resolveJoCreatedTimestamp(data, m.refCode, m.timestamp) | ||||
| : m.timestamp; | : m.timestamp; | ||||
| const joCreatedQty = | |||||
| kind === "JO_CREATED" ? resolveJoReqQty(data, m.refCode, m.qty) : m.qty; | |||||
| const title = | const title = | ||||
| kind === "RECEIPT" | kind === "RECEIPT" | ||||
| ? labels.nodeReceipt | ? labels.nodeReceipt | ||||
| @@ -729,7 +787,9 @@ export const buildTraceGraphNodes = ( | |||||
| const subtitle = | const subtitle = | ||||
| kind === "DO_OUT" || kind === "JO_OUT" || kind === "PO_OUT" | kind === "DO_OUT" || kind === "JO_OUT" || kind === "PO_OUT" | ||||
| ? "" | ? "" | ||||
| : [m.refCode, m.warehouseCode].filter(Boolean).join(" · ") || "—"; | |||||
| : kind === "JO_CREATED" | |||||
| ? joCreatedLotSubtitle(data.lot) | |||||
| : [m.refCode, m.warehouseCode].filter(Boolean).join(" · ") || "—"; | |||||
| const meta = [m.handledBy, m.remarks].filter(Boolean).join(" · "); | const meta = [m.handledBy, m.remarks].filter(Boolean).join(" · "); | ||||
| const linkKind = isDoOut || isJoOut || isPoOut ? "pick" : docLinkFromRefType(m.refType); | const linkKind = isDoOut || isJoOut || isPoOut ? "pick" : docLinkFromRefType(m.refType); | ||||
| const receiptDetails = | const receiptDetails = | ||||
| @@ -745,6 +805,16 @@ export const buildTraceGraphNodes = ( | |||||
| field(labels.detailTime, m.timestamp), | field(labels.detailTime, m.timestamp), | ||||
| fieldIf(labels.detailRemarks, m.remarks), | fieldIf(labels.detailRemarks, m.remarks), | ||||
| ) | ) | ||||
| : kind === "JO_CREATED" | |||||
| ? joCreatedLotDetails( | |||||
| labels, | |||||
| data.lot, | |||||
| m.refCode?.trim() || "—", | |||||
| m.refId, | |||||
| joCreatedQty, | |||||
| stockUom, | |||||
| eventTimestamp, | |||||
| ) | |||||
| : kind === "DO_OUT" | : kind === "DO_OUT" | ||||
| ? detailsOf( | ? detailsOf( | ||||
| field(labels.detailType, labels.nodeDoOut), | field(labels.detailType, labels.nodeDoOut), | ||||
| @@ -875,7 +945,7 @@ export const buildTraceGraphNodes = ( | |||||
| sortKey: parseSortKey(eventTimestamp, seq++), | sortKey: parseSortKey(eventTimestamp, seq++), | ||||
| title, | title, | ||||
| subtitle, | subtitle, | ||||
| qty: m.qty, | |||||
| qty: kind === "JO_CREATED" ? joCreatedQty : m.qty, | |||||
| uom: stockUom, | uom: stockUom, | ||||
| meta: meta || undefined, | meta: meta || undefined, | ||||
| refType: m.refType, | refType: m.refType, | ||||
| @@ -63,6 +63,7 @@ export const buildTraceGraphLabels = ( | |||||
| formatQcSubtitle: (failQty: number, acceptedQty: number) => | formatQcSubtitle: (failQty: number, acceptedQty: number) => | ||||
| `${t("failQty")}: ${formatQty(failQty, lotUom)} / ${t("acceptedQty")}: ${formatQty(acceptedQty, lotUom)}`, | `${t("failQty")}: ${formatQty(failQty, lotUom)} / ${t("acceptedQty")}: ${formatQty(acceptedQty, lotUom)}`, | ||||
| detailQty: t("qty"), | detailQty: t("qty"), | ||||
| detailRequiredQty: t("requiredQty"), | |||||
| detailTime: t("timestamp"), | detailTime: t("timestamp"), | ||||
| detailHandler: t("handler"), | detailHandler: t("handler"), | ||||
| detailStockTaker: t("stockTaker"), | detailStockTaker: t("stockTaker"), | ||||
| @@ -139,6 +140,14 @@ export const buildTraceGraphLabels = ( | |||||
| categoryQc: t("phaseQc"), | categoryQc: t("phaseQc"), | ||||
| categoryOutbound: t("phaseOutbound"), | categoryOutbound: t("phaseOutbound"), | ||||
| categoryTerminal: t("categoryTerminal"), | categoryTerminal: t("categoryTerminal"), | ||||
| detailBomReqQty: t("detailBomReqQty"), | |||||
| detailStockReqQty: t("detailStockReqQty"), | |||||
| detailStockAvailable: t("detailStockAvailable"), | |||||
| detailStockStatus: t("detailStockStatus"), | |||||
| stockStatusSufficient: t("stockStatusSufficient"), | |||||
| stockStatusInsufficient: t("stockStatusInsufficient"), | |||||
| na: t("N/A"), | |||||
| pendingPick: t("pendingPick"), | |||||
| processingStatus: t("processingStatus"), | processingStatus: t("processingStatus"), | ||||
| matchStatus: t("matchStatus"), | matchStatus: t("matchStatus"), | ||||
| }; | }; | ||||
| @@ -146,7 +155,7 @@ export const buildTraceGraphLabels = ( | |||||
| /** Minimal labels for unit tests (no i18n). */ | /** Minimal labels for unit tests (no i18n). */ | ||||
| export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { | export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { | ||||
| const identity = (s: string | null | undefined) => s?.trim() || "—"; | |||||
| const identity = (s: string | null | undefined) => s?.trim() || "N/A"; | |||||
| const tr = { | const tr = { | ||||
| refType: identity, | refType: identity, | ||||
| movementType: identity, | movementType: identity, | ||||
| @@ -209,6 +218,7 @@ export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { | |||||
| directionOut: "Out", | directionOut: "Out", | ||||
| formatQcSubtitle: (f, a) => `Fail ${f} / Accepted ${a}`, | formatQcSubtitle: (f, a) => `Fail ${f} / Accepted ${a}`, | ||||
| detailQty: "Qty", | detailQty: "Qty", | ||||
| detailRequiredQty: "Required qty", | |||||
| detailTime: "Time", | detailTime: "Time", | ||||
| detailHandler: "Handler", | detailHandler: "Handler", | ||||
| detailStockTaker: "First counter", | detailStockTaker: "First counter", | ||||
| @@ -285,6 +295,14 @@ export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { | |||||
| categoryQc: "QC", | categoryQc: "QC", | ||||
| categoryOutbound: "Outbound", | categoryOutbound: "Outbound", | ||||
| categoryTerminal: "Terminal", | categoryTerminal: "Terminal", | ||||
| detailBomReqQty: "BOM req. qty", | |||||
| detailStockReqQty: "Stock req. qty", | |||||
| detailStockAvailable: "Stock available", | |||||
| detailStockStatus: "Stock status", | |||||
| stockStatusSufficient: "Sufficient", | |||||
| stockStatusInsufficient: "Insufficient", | |||||
| na: "N/A", | |||||
| pendingPick: "Pending pick", | |||||
| processingStatus: "Processing status", | processingStatus: "Processing status", | ||||
| matchStatus: "Match status", | matchStatus: "Match status", | ||||
| }; | }; | ||||
| @@ -248,16 +248,23 @@ export const shouldSkipTraceFlowEdge = ( | |||||
| to: TraceGraphLayoutNode, | to: TraceGraphLayoutNode, | ||||
| ): boolean => { | ): boolean => { | ||||
| if (from.kind === "JO_CREATED" && !isMaterialPreludeNode(from)) { | if (from.kind === "JO_CREATED" && !isMaterialPreludeNode(from)) { | ||||
| if (to.kind === "MATERIAL_PICK" || to.kind === "PICK_GROUP") return false; | |||||
| if ( | |||||
| to.kind === "MATERIAL_PICK" || | |||||
| to.kind === "PICK_GROUP" || | |||||
| to.kind === "PRODUCTION_STEP" | |||||
| ) { | |||||
| return false; | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| // Allow PRODUCTION_STEP → PRODUCTION_STEP (process chain). Block other sources into steps | // Allow PRODUCTION_STEP → PRODUCTION_STEP (process chain). Block other sources into steps | ||||
| // except material picks (handled by dedicated pick→step edges). | |||||
| // except material picks / JO_CREATED fallback (dedicated builders). | |||||
| if ( | if ( | ||||
| to.kind === "PRODUCTION_STEP" && | to.kind === "PRODUCTION_STEP" && | ||||
| from.kind !== "MATERIAL_PICK" && | from.kind !== "MATERIAL_PICK" && | ||||
| from.kind !== "PICK_GROUP" && | from.kind !== "PICK_GROUP" && | ||||
| from.kind !== "PRODUCTION_STEP" | |||||
| from.kind !== "PRODUCTION_STEP" && | |||||
| from.kind !== "JO_CREATED" | |||||
| ) { | ) { | ||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -435,6 +442,9 @@ const buildMaterialLotFlowEdgePairs = ( | |||||
| nodes.forEach((n) => { | nodes.forEach((n) => { | ||||
| if (!isMaterialPreludeNode(n)) return; | if (!isMaterialPreludeNode(n)) return; | ||||
| // JO_CREATED lives in MATERIAL_PICK for layout, but its early planStart would pull that | |||||
| // phase before PUTAWAY and break 已上架 → 工單提料. Dedicated builder handles JO→提料. | |||||
| if (n.kind === "JO_CREATED") return; | |||||
| const key = materialLotKey(n); | const key = materialLotKey(n); | ||||
| if (!key) return; | if (!key) return; | ||||
| const list = byLot.get(key) ?? []; | const list = byLot.get(key) ?? []; | ||||
| @@ -464,12 +474,14 @@ const buildMaterialLotFlowEdgePairs = ( | |||||
| if (toPhase === "MATERIAL_PICK") { | if (toPhase === "MATERIAL_PICK") { | ||||
| const upstream = fromList[fromList.length - 1]!; | const upstream = fromList[fromList.length - 1]!; | ||||
| const seenPickTargets = new Set<string>(); | const seenPickTargets = new Set<string>(); | ||||
| toList.forEach((pick) => { | |||||
| const target = resolvePickFlowTarget(pick, nodes); | |||||
| if (seenPickTargets.has(target.id)) return; | |||||
| seenPickTargets.add(target.id); | |||||
| add(upstream, target); | |||||
| }); | |||||
| toList | |||||
| .filter((n) => n.kind === "MATERIAL_PICK" || n.kind === "PICK_GROUP") | |||||
| .forEach((pick) => { | |||||
| const target = resolvePickFlowTarget(pick, nodes); | |||||
| if (seenPickTargets.has(target.id)) return; | |||||
| seenPickTargets.add(target.id); | |||||
| add(upstream, target); | |||||
| }); | |||||
| } else { | } else { | ||||
| add(fromList[fromList.length - 1]!, toList[0]!); | add(fromList[fromList.length - 1]!, toList[0]!); | ||||
| } | } | ||||
| @@ -686,15 +698,11 @@ const sortProductionStepsForChain = ( | |||||
| return sortNodesInPhase(a, b); | return sortNodesInPhase(a, b); | ||||
| }; | }; | ||||
| const buildMaterialPickToProductionStepEdges = ( | |||||
| nodes: TraceGraphLayoutNode[], | |||||
| add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, | |||||
| ): void => { | |||||
| const prodSteps = nodes.filter( | |||||
| (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n), | |||||
| ); | |||||
| if (!prodSteps.length) return; | |||||
| const resolveProductionTargetForPick = ( | |||||
| scope: string, | |||||
| pick: TraceGraphLayoutNode, | |||||
| prodSteps: TraceGraphLayoutNode[], | |||||
| ): TraceGraphLayoutNode | null => { | |||||
| const productionByScopeAndProcess = new Map<string, TraceGraphLayoutNode[]>(); | const productionByScopeAndProcess = new Map<string, TraceGraphLayoutNode[]>(); | ||||
| prodSteps.forEach((n) => { | prodSteps.forEach((n) => { | ||||
| if (n.bomProcessId == null) return; | if (n.bomProcessId == null) return; | ||||
| @@ -704,38 +712,43 @@ const buildMaterialPickToProductionStepEdges = ( | |||||
| productionByScopeAndProcess.set(key, list); | productionByScopeAndProcess.set(key, list); | ||||
| }); | }); | ||||
| const resolveProductionTarget = ( | |||||
| scope: string, | |||||
| pick: TraceGraphLayoutNode, | |||||
| ): TraceGraphLayoutNode | null => { | |||||
| const inScope = (n: TraceGraphLayoutNode) => productionScopeKey(n) === scope; | |||||
| const inScope = (n: TraceGraphLayoutNode) => productionScopeKey(n) === scope; | |||||
| if (pick.bomProcessId != null) { | |||||
| const byId = productionByScopeAndProcess.get(`${scope}::${pick.bomProcessId}`); | |||||
| if (byId?.length) return [...byId].sort(sortProductionStepsForChain)[0]!; | |||||
| } | |||||
| if (pick.bomProcessId != null) { | |||||
| const byId = productionByScopeAndProcess.get(`${scope}::${pick.bomProcessId}`); | |||||
| if (byId?.length) return [...byId].sort(sortProductionStepsForChain)[0]!; | |||||
| } | |||||
| if (pick.bomProcessSeqNo != null) { | |||||
| const bySeq = prodSteps.filter( | |||||
| (n) => inScope(n) && n.bomProcessSeqNo === pick.bomProcessSeqNo, | |||||
| ); | |||||
| if (bySeq.length) return [...bySeq].sort(sortProductionStepsForChain)[0]!; | |||||
| } | |||||
| if (pick.bomProcessSeqNo != null) { | |||||
| const bySeq = prodSteps.filter( | |||||
| (n) => inScope(n) && n.bomProcessSeqNo === pick.bomProcessSeqNo, | |||||
| ); | |||||
| if (bySeq.length) return [...bySeq].sort(sortProductionStepsForChain)[0]!; | |||||
| } | |||||
| const itemCode = pick.traceItemCode?.trim().toUpperCase(); | |||||
| if (itemCode) { | |||||
| const byItem = prodSteps.filter( | |||||
| (n) => | |||||
| inScope(n) && | |||||
| (n.stepMaterialItemCodes ?? []).some( | |||||
| (code) => code.trim().toUpperCase() === itemCode, | |||||
| ), | |||||
| ); | |||||
| if (byItem.length) return [...byItem].sort(sortProductionStepsForChain)[0]!; | |||||
| } | |||||
| const itemCode = pick.traceItemCode?.trim().toUpperCase(); | |||||
| if (itemCode) { | |||||
| const byItem = prodSteps.filter( | |||||
| (n) => | |||||
| inScope(n) && | |||||
| (n.stepMaterialItemCodes ?? []).some( | |||||
| (code) => code.trim().toUpperCase() === itemCode, | |||||
| ), | |||||
| ); | |||||
| if (byItem.length) return [...byItem].sort(sortProductionStepsForChain)[0]!; | |||||
| } | |||||
| return null; | |||||
| }; | |||||
| return null; | |||||
| }; | |||||
| const buildMaterialPickToProductionStepEdges = ( | |||||
| nodes: TraceGraphLayoutNode[], | |||||
| add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, | |||||
| ): void => { | |||||
| const prodSteps = nodes.filter( | |||||
| (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n), | |||||
| ); | |||||
| if (!prodSteps.length) return; | |||||
| const connectPickOrGroup = ( | const connectPickOrGroup = ( | ||||
| source: TraceGraphLayoutNode, | source: TraceGraphLayoutNode, | ||||
| @@ -743,7 +756,7 @@ const buildMaterialPickToProductionStepEdges = ( | |||||
| seenTargets: Set<string>, | seenTargets: Set<string>, | ||||
| ) => { | ) => { | ||||
| const scope = pick.feedsProductionScopeLotNo?.trim() || source.feedsProductionScopeLotNo?.trim() || "__fg__"; | const scope = pick.feedsProductionScopeLotNo?.trim() || source.feedsProductionScopeLotNo?.trim() || "__fg__"; | ||||
| const target = resolveProductionTarget(scope, pick); | |||||
| const target = resolveProductionTargetForPick(scope, pick, prodSteps); | |||||
| if (!target || seenTargets.has(target.id)) return; | if (!target || seenTargets.has(target.id)) return; | ||||
| seenTargets.add(target.id); | seenTargets.add(target.id); | ||||
| add( | add( | ||||
| @@ -884,6 +897,77 @@ const buildJoCreatedToMaterialPickEdges = ( | |||||
| }); | }); | ||||
| }; | }; | ||||
| /** When a JO has no 提料 that reaches 生產步驟, link 工單建立 → first 生產步驟 in scope. */ | |||||
| const buildJoCreatedToProductionStepEdges = ( | |||||
| nodes: TraceGraphLayoutNode[], | |||||
| add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, | |||||
| ): void => { | |||||
| const joCreatedNodes = nodes.filter((n) => n.kind === "JO_CREATED"); | |||||
| const prodSteps = nodes.filter( | |||||
| (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n), | |||||
| ); | |||||
| if (!joCreatedNodes.length || !prodSteps.length) return; | |||||
| const pickTargets = nodes.filter( | |||||
| (n) => | |||||
| !isDoGroupChild(n) && | |||||
| (n.kind === "PICK_GROUP" || | |||||
| (n.kind === "MATERIAL_PICK" && !n.doGroupId?.trim())), | |||||
| ); | |||||
| const feedLotForTarget = (target: TraceGraphLayoutNode): string => { | |||||
| if (target.kind === "PICK_GROUP") { | |||||
| return ( | |||||
| nodes | |||||
| .find( | |||||
| (n) => | |||||
| n.doGroupId === target.id && Boolean(n.feedsProductionScopeLotNo?.trim()), | |||||
| ) | |||||
| ?.feedsProductionScopeLotNo?.trim() ?? "" | |||||
| ); | |||||
| } | |||||
| return target.feedsProductionScopeLotNo?.trim() ?? ""; | |||||
| }; | |||||
| const materialPicksForTarget = (target: TraceGraphLayoutNode): TraceGraphLayoutNode[] => { | |||||
| if (target.kind === "PICK_GROUP") { | |||||
| return nodes.filter((n) => n.doGroupId === target.id && n.kind === "MATERIAL_PICK"); | |||||
| } | |||||
| return [target]; | |||||
| }; | |||||
| /** Only suppress fallback when a pick for this JO can actually reach a production step. */ | |||||
| const joHasPickReachingProduction = (jo: TraceGraphLayoutNode): boolean => { | |||||
| const joCode = jo.refCode?.trim(); | |||||
| if (!joCode) return false; | |||||
| const joLot = jo.traceLotNo?.trim() ?? ""; | |||||
| const scope = productionScopeKey(jo); | |||||
| return pickTargets.some((target) => { | |||||
| if (joCodeForPickTarget(target, nodes) !== joCode) return false; | |||||
| const feedLot = feedLotForTarget(target); | |||||
| if (joLot) { | |||||
| // Nested JO: only picks that feed this SF lot. | |||||
| if (feedLot !== joLot) return false; | |||||
| } else if (feedLot) { | |||||
| // FG JO: ignore nested-scoped picks. | |||||
| return false; | |||||
| } | |||||
| return materialPicksForTarget(target).some( | |||||
| (pick) => resolveProductionTargetForPick(scope, pick, prodSteps) != null, | |||||
| ); | |||||
| }); | |||||
| }; | |||||
| joCreatedNodes.forEach((jo) => { | |||||
| if (joHasPickReachingProduction(jo)) return; | |||||
| const scope = productionScopeKey(jo); | |||||
| const firstStep = prodSteps | |||||
| .filter((n) => productionScopeKey(n) === scope) | |||||
| .sort(sortProductionStepsForChain)[0]; | |||||
| if (firstStep) add(jo, firstStep); | |||||
| }); | |||||
| }; | |||||
| const resolveDoOutFlowTarget = ( | const resolveDoOutFlowTarget = ( | ||||
| doOut: TraceGraphLayoutNode, | doOut: TraceGraphLayoutNode, | ||||
| nodes: TraceGraphLayoutNode[], | nodes: TraceGraphLayoutNode[], | ||||
| @@ -1102,6 +1186,9 @@ export const buildTraceFlowEdgePairs = ( | |||||
| const to = toList[0]!; | const to = toList[0]!; | ||||
| if (isMaterialPreludeNode(from) || isMaterialPreludeNode(to)) continue; | if (isMaterialPreludeNode(from) || isMaterialPreludeNode(to)) continue; | ||||
| // Dedicated builder owns JO_CREATED → PRODUCTION_STEP (avoid day-phase noise). | |||||
| if (from.kind === "JO_CREATED" && to.kind === "PRODUCTION_STEP") continue; | |||||
| if (phasesPresent[i] === "OUTBOUND" && phasesPresent[i + 1] === "STOCK_TAKE") { | if (phasesPresent[i] === "OUTBOUND" && phasesPresent[i + 1] === "STOCK_TAKE") { | ||||
| continue; | continue; | ||||
| } | } | ||||
| @@ -1129,6 +1216,7 @@ export const buildTraceFlowEdgePairs = ( | |||||
| buildProductionStepChainEdges(nodes, add); | buildProductionStepChainEdges(nodes, add); | ||||
| buildProductionToFgQcEdges(nodes, add); | buildProductionToFgQcEdges(nodes, add); | ||||
| buildJoCreatedToMaterialPickEdges(nodes, add); | buildJoCreatedToMaterialPickEdges(nodes, add); | ||||
| buildJoCreatedToProductionStepEdges(nodes, add); | |||||
| buildReplenishmentToDoOutEdges(nodes, add); | buildReplenishmentToDoOutEdges(nodes, add); | ||||
| buildMaterialPickToProductionStepEdges(nodes, add); | buildMaterialPickToProductionStepEdges(nodes, add); | ||||
| buildFgLotFlowEdgePairs(nodes, phaseOrder, add); | buildFgLotFlowEdgePairs(nodes, phaseOrder, add); | ||||
| @@ -1,5 +1,8 @@ | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; | |||||
| import { | |||||
| ItemLotTraceJoPickLine, | |||||
| ItemLotTraceMaterialInput, | |||||
| } from "@/app/api/itemTracing"; | |||||
| import { | import { | ||||
| TraceGraphDetailField, | TraceGraphDetailField, | ||||
| TraceGraphDocLinkKind, | TraceGraphDocLinkKind, | ||||
| @@ -21,6 +24,16 @@ export type MaterialPickLabels = { | |||||
| detailAssignedStep: string; | detailAssignedStep: string; | ||||
| detailPickTargetDate: string; | detailPickTargetDate: string; | ||||
| detailTime: string; | detailTime: string; | ||||
| detailBomReqQty: string; | |||||
| detailStockReqQty: string; | |||||
| detailStockAvailable: string; | |||||
| detailStockStatus: string; | |||||
| stockStatusSufficient: string; | |||||
| stockStatusInsufficient: string; | |||||
| /** Displayed when a JO pick-table field does not apply. */ | |||||
| na: string; | |||||
| /** Unpicked pick-order line (no stock-out yet). */ | |||||
| pendingPick: string; | |||||
| processingStatus: string; | processingStatus: string; | ||||
| matchStatus: string; | matchStatus: string; | ||||
| tr: { | tr: { | ||||
| @@ -30,32 +43,37 @@ export type MaterialPickLabels = { | |||||
| }; | }; | ||||
| export const pickStatusDetailFields = ( | export const pickStatusDetailFields = ( | ||||
| labels: Pick<MaterialPickLabels, "processingStatus" | "matchStatus" | "tr">, | |||||
| labels: Pick<MaterialPickLabels, "processingStatus" | "matchStatus" | "tr" | "na">, | |||||
| processingStatus?: string | null, | processingStatus?: string | null, | ||||
| matchStatus?: string | null, | matchStatus?: string | null, | ||||
| opts?: { always?: boolean }, | opts?: { always?: boolean }, | ||||
| ): TraceGraphDetailField[] => { | ): TraceGraphDetailField[] => { | ||||
| const always = opts?.always === true; | const always = opts?.always === true; | ||||
| const na = labels.na || "N/A"; | |||||
| const rows: TraceGraphDetailField[] = []; | const rows: TraceGraphDetailField[] = []; | ||||
| if (always || processingStatus?.trim()) { | if (always || processingStatus?.trim()) { | ||||
| rows.push( | rows.push( | ||||
| field(labels.processingStatus, labels.tr.processingStatus(processingStatus), { | |||||
| valueColor: pickStatusValueColor(processingStatus), | |||||
| }), | |||||
| field( | |||||
| labels.processingStatus, | |||||
| processingStatus?.trim() ? labels.tr.processingStatus(processingStatus) : na, | |||||
| { valueColor: pickStatusValueColor(processingStatus) }, | |||||
| ), | |||||
| ); | ); | ||||
| } | } | ||||
| if (always || matchStatus?.trim()) { | if (always || matchStatus?.trim()) { | ||||
| rows.push( | rows.push( | ||||
| field(labels.matchStatus, labels.tr.matchStatus(matchStatus), { | |||||
| valueColor: pickStatusValueColor(matchStatus), | |||||
| }), | |||||
| field( | |||||
| labels.matchStatus, | |||||
| matchStatus?.trim() ? labels.tr.matchStatus(matchStatus) : na, | |||||
| { valueColor: pickStatusValueColor(matchStatus) }, | |||||
| ), | |||||
| ); | ); | ||||
| } | } | ||||
| return rows; | return rows; | ||||
| }; | }; | ||||
| export const pickStatusNodeLabels = ( | export const pickStatusNodeLabels = ( | ||||
| labels: Pick<MaterialPickLabels, "tr">, | |||||
| labels: Pick<MaterialPickLabels, "tr" | "na">, | |||||
| processingStatus?: string | null, | processingStatus?: string | null, | ||||
| matchStatus?: string | null, | matchStatus?: string | null, | ||||
| opts?: { always?: boolean }, | opts?: { always?: boolean }, | ||||
| @@ -64,13 +82,20 @@ export const pickStatusNodeLabels = ( | |||||
| "processingStatusLabel" | "matchStatusLabel" | "processingStatus" | "matchStatus" | "processingStatusLabel" | "matchStatusLabel" | "processingStatus" | "matchStatus" | ||||
| > => { | > => { | ||||
| const always = opts?.always === true; | const always = opts?.always === true; | ||||
| const na = labels.na || "N/A"; | |||||
| const showProcessing = always || Boolean(processingStatus?.trim()); | const showProcessing = always || Boolean(processingStatus?.trim()); | ||||
| const showMatch = always || Boolean(matchStatus?.trim()); | const showMatch = always || Boolean(matchStatus?.trim()); | ||||
| return { | return { | ||||
| processingStatusLabel: showProcessing | processingStatusLabel: showProcessing | ||||
| ? labels.tr.processingStatus(processingStatus) | |||||
| ? processingStatus?.trim() | |||||
| ? labels.tr.processingStatus(processingStatus) | |||||
| : na | |||||
| : undefined, | |||||
| matchStatusLabel: showMatch | |||||
| ? matchStatus?.trim() | |||||
| ? labels.tr.matchStatus(matchStatus) | |||||
| : na | |||||
| : undefined, | : undefined, | ||||
| matchStatusLabel: showMatch ? labels.tr.matchStatus(matchStatus) : undefined, | |||||
| processingStatus: showProcessing ? processingStatus?.trim() || undefined : undefined, | processingStatus: showProcessing ? processingStatus?.trim() || undefined : undefined, | ||||
| matchStatus: showMatch ? matchStatus?.trim() || undefined : undefined, | matchStatus: showMatch ? matchStatus?.trim() || undefined : undefined, | ||||
| }; | }; | ||||
| @@ -143,33 +168,78 @@ export type MaterialPickNodeContext = { | |||||
| pickOrderTargetDate?: (pickOrderCode: string) => string | undefined; | pickOrderTargetDate?: (pickOrderCode: string) => string | undefined; | ||||
| }; | }; | ||||
| export const isPickStockApplicable = (itemType?: string | null): boolean => { | |||||
| const normalized = (itemType ?? "").trim().toLowerCase(); | |||||
| return !["consumables", "consumable", "cmb", "nm"].includes(normalized); | |||||
| }; | |||||
| const formatStockStatus = ( | |||||
| labels: MaterialPickLabels, | |||||
| stockStatusApplicable: boolean, | |||||
| stockAvailable: number | null | undefined, | |||||
| requiredQty: number, | |||||
| ): string => { | |||||
| if (!stockStatusApplicable) return labels.na; | |||||
| if (stockAvailable == null) return labels.na; | |||||
| return stockAvailable >= requiredQty | |||||
| ? labels.stockStatusSufficient | |||||
| : labels.stockStatusInsufficient; | |||||
| }; | |||||
| const formatMaybeNaQty = ( | |||||
| labels: MaterialPickLabels, | |||||
| applicable: boolean, | |||||
| qty: number | null | undefined, | |||||
| uom: string, | |||||
| ): string => { | |||||
| if (!applicable) return labels.na; | |||||
| if (qty == null) return labels.na; | |||||
| return formatQty(qty, uom); | |||||
| }; | |||||
| const stepLabelOf = (stepName: string, stepSeq: number | null | undefined): string => { | |||||
| if (!stepName) return ""; | |||||
| if (stepSeq != null) return `${stepSeq}. ${stepName}`; | |||||
| return stepName; | |||||
| }; | |||||
| export const createMaterialPickNode = ( | export const createMaterialPickNode = ( | ||||
| m: ItemLotTraceMaterialInput, | m: ItemLotTraceMaterialInput, | ||||
| index: number, | index: number, | ||||
| labels: MaterialPickLabels, | labels: MaterialPickLabels, | ||||
| ctx: MaterialPickNodeContext, | ctx: MaterialPickNodeContext, | ||||
| feedsProductionScopeLotNo?: string, | feedsProductionScopeLotNo?: string, | ||||
| pickLine?: ItemLotTraceJoPickLine | null, | |||||
| ): TraceGraphNode => { | ): TraceGraphNode => { | ||||
| const matUom = m.materialUom?.trim() || ""; | |||||
| const matUom = m.materialUom?.trim() || pickLine?.stockUom?.trim() || ""; | |||||
| const pickTitle = m.pickOrderCode || labels.nodeMaterialPick; | const pickTitle = m.pickOrderCode || labels.nodeMaterialPick; | ||||
| const stepLabel = m.assignedStepName?.trim() | |||||
| ? m.bomProcessSeqNo != null | |||||
| ? `${m.bomProcessSeqNo}. ${m.assignedStepName}` | |||||
| : m.assignedStepName | |||||
| : ""; | |||||
| const stepName = m.assignedStepName?.trim() || pickLine?.assignedStepName?.trim() || ""; | |||||
| const stepSeq = m.bomProcessSeqNo ?? pickLine?.bomProcessSeqNo ?? null; | |||||
| const stepLabel = stepLabelOf(stepName, stepSeq); | |||||
| const itemLabel = [m.materialItemCode, m.materialItemName].filter(Boolean).join(" · "); | const itemLabel = [m.materialItemCode, m.materialItemName].filter(Boolean).join(" · "); | ||||
| const pickTargetDate = normalizeTargetDateForLink( | const pickTargetDate = normalizeTargetDateForLink( | ||||
| ctx.pickOrderTargetDate?.(m.pickOrderCode ?? ""), | ctx.pickOrderTargetDate?.(m.pickOrderCode ?? ""), | ||||
| ); | ); | ||||
| const pickedDate = normalizeTargetDateForLink(m.pickedAt); | const pickedDate = normalizeTargetDateForLink(m.pickedAt); | ||||
| const linkTargetDate = pickTargetDate || pickedDate; | const linkTargetDate = pickTargetDate || pickedDate; | ||||
| const stockApplicable = pickLine | |||||
| ? pickLine.stockStatusApplicable !== false && isPickStockApplicable(pickLine.itemType) | |||||
| : true; | |||||
| const bomQty = pickLine?.bomReqQty ?? m.bomQtyPerUnit; | |||||
| const bomUom = pickLine?.bomUom?.trim() || matUom; | |||||
| const stockReqQty = pickLine?.requiredQty ?? m.materialQty; | |||||
| const stockAvail = pickLine?.stockAvailable; | |||||
| const lotDisplay = m.materialLotNo?.trim() || labels.na; | |||||
| return { | return { | ||||
| id: `mat-pick-${index}-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}`, | id: `mat-pick-${index}-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}`, | ||||
| kind: "MATERIAL_PICK", | kind: "MATERIAL_PICK", | ||||
| timestamp: m.pickedAt, | timestamp: m.pickedAt, | ||||
| sortKey: parseSortKey(m.pickedAt, ctx.nextSeq()), | sortKey: parseSortKey(m.pickedAt, ctx.nextSeq()), | ||||
| title: pickTitle, | title: pickTitle, | ||||
| subtitle: [m.materialLotNo, stepLabel, m.consoCode].filter(Boolean).join(" · "), | |||||
| subtitle: [lotDisplay !== labels.na ? m.materialLotNo : "", stepLabel] | |||||
| .filter(Boolean) | |||||
| .join(" · "), | |||||
| qty: m.materialQty, | qty: m.materialQty, | ||||
| uom: matUom, | uom: matUom, | ||||
| meta: itemLabel || undefined, | meta: itemLabel || undefined, | ||||
| @@ -179,13 +249,22 @@ export const createMaterialPickNode = ( | |||||
| jobOrderCode: m.jobOrderCode?.trim() || undefined, | jobOrderCode: m.jobOrderCode?.trim() || undefined, | ||||
| docLinkKind: m.pickOrderCode ? "jodetail" : undefined, | docLinkKind: m.pickOrderCode ? "jodetail" : undefined, | ||||
| docLinkTargetDate: linkTargetDate, | docLinkTargetDate: linkTargetDate, | ||||
| traceLotNo: m.materialLotNo, | |||||
| traceLotNo: m.materialLotNo?.trim() || undefined, | |||||
| traceItemCode: m.materialItemCode, | traceItemCode: m.materialItemCode, | ||||
| bomProcessId: m.bomProcessId, | |||||
| bomProcessSeqNo: m.bomProcessSeqNo, | |||||
| assignedStepName: m.assignedStepName, | |||||
| bomProcessId: m.bomProcessId ?? pickLine?.bomProcessId, | |||||
| bomProcessSeqNo: stepSeq, | |||||
| assignedStepName: stepName || undefined, | |||||
| feedsProductionScopeLotNo, | feedsProductionScopeLotNo, | ||||
| categoryLabel: labels.categoryPick, | categoryLabel: labels.categoryPick, | ||||
| stockAvailableLabel: formatMaybeNaQty(labels, stockApplicable, stockAvail, matUom), | |||||
| stockStatusLabel: formatStockStatus( | |||||
| labels, | |||||
| stockApplicable, | |||||
| stockAvail, | |||||
| Number(stockReqQty) || 0, | |||||
| ), | |||||
| bomReqQtyLabel: bomQty != null ? formatQty(bomQty, bomUom) : labels.na, | |||||
| stockReqQtyLabel: formatQty(stockReqQty, matUom), | |||||
| details: [ | details: [ | ||||
| field(labels.pickOrder, m.pickOrderCode, { | field(labels.pickOrder, m.pickOrderCode, { | ||||
| linkKind: "jodetail", | linkKind: "jodetail", | ||||
| @@ -194,20 +273,134 @@ export const createMaterialPickNode = ( | |||||
| consoCode: m.consoCode, | consoCode: m.consoCode, | ||||
| linkTargetDate, | linkTargetDate, | ||||
| }), | }), | ||||
| field(labels.detailPickTargetDate, pickTargetDate || pickedDate), | |||||
| field(labels.detailPickTargetDate, pickTargetDate || pickedDate || labels.na), | |||||
| field(labels.detailMaterial, `${m.materialItemCode} · ${m.materialItemName}`), | field(labels.detailMaterial, `${m.materialItemCode} · ${m.materialItemName}`), | ||||
| field(labels.detailLot, m.materialLotNo), | |||||
| field(labels.detailLot, lotDisplay), | |||||
| field(labels.detailBomReqQty, bomQty != null ? formatQty(bomQty, bomUom) : labels.na), | |||||
| field(labels.detailStockReqQty, formatQty(stockReqQty, matUom)), | |||||
| field( | |||||
| labels.detailStockAvailable, | |||||
| formatMaybeNaQty(labels, stockApplicable, stockAvail, matUom), | |||||
| ), | |||||
| field( | |||||
| labels.detailStockStatus, | |||||
| formatStockStatus(labels, stockApplicable, stockAvail, Number(stockReqQty) || 0), | |||||
| ), | |||||
| field(labels.detailQty, formatQty(m.materialQty, matUom)), | field(labels.detailQty, formatQty(m.materialQty, matUom)), | ||||
| field(labels.detailType, labels.nodeMaterialPick), | field(labels.detailType, labels.nodeMaterialPick), | ||||
| field(labels.detailAssignedStep, stepLabel || "—"), | |||||
| field(labels.detailAssignedStep, stepLabel || labels.na), | |||||
| field(labels.jobOrder, m.jobOrderCode, { | field(labels.jobOrder, m.jobOrderCode, { | ||||
| linkKind: "jo", | linkKind: "jo", | ||||
| linkCode: m.jobOrderCode, | linkCode: m.jobOrderCode, | ||||
| linkId: m.jobOrderId, | linkId: m.jobOrderId, | ||||
| }), | }), | ||||
| ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus), | |||||
| field(labels.detailTime, m.pickedAt), | |||||
| ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus, { always: true }), | |||||
| field(labels.detailTime, m.pickedAt?.trim() || labels.na), | |||||
| ], | |||||
| ...pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus, { always: true }), | |||||
| }; | |||||
| }; | |||||
| /** Pick-order line with no lot stock-out yet (or N/A stock types like oil/water). */ | |||||
| export const createMaterialPickNodeFromPickLine = ( | |||||
| line: ItemLotTraceJoPickLine, | |||||
| index: number, | |||||
| labels: MaterialPickLabels, | |||||
| ctx: MaterialPickNodeContext & { | |||||
| pickOrderCode: string; | |||||
| pickOrderId: number | null; | |||||
| consoCode: string; | |||||
| jobOrderCode: string; | |||||
| jobOrderId?: number | null; | |||||
| timestamp?: string | null; | |||||
| }, | |||||
| feedsProductionScopeLotNo?: string, | |||||
| ): TraceGraphNode => { | |||||
| const stockUom = line.stockUom?.trim() || ""; | |||||
| const stockApplicable = | |||||
| line.stockStatusApplicable !== false && isPickStockApplicable(line.itemType); | |||||
| const stepName = line.assignedStepName?.trim() || ""; | |||||
| const stepLabel = stepLabelOf(stepName, line.bomProcessSeqNo); | |||||
| const itemLabel = [line.itemCode, line.itemName].filter(Boolean).join(" · "); | |||||
| const pickTargetDate = normalizeTargetDateForLink( | |||||
| ctx.pickOrderTargetDate?.(ctx.pickOrderCode ?? ""), | |||||
| ); | |||||
| const linkTargetDate = pickTargetDate || normalizeTargetDateForLink(ctx.timestamp); | |||||
| const requiredQty = Number(line.requiredQty) || 0; | |||||
| return { | |||||
| id: `mat-pick-line-${index}-${ctx.pickOrderId}-${line.pickOrderLineId}`, | |||||
| kind: "MATERIAL_PICK", | |||||
| timestamp: ctx.timestamp ?? null, | |||||
| sortKey: parseSortKey(ctx.timestamp, ctx.nextSeq()), | |||||
| title: ctx.pickOrderCode || labels.nodeMaterialPick, | |||||
| subtitle: [labels.pendingPick, stepLabel].filter(Boolean).join(" · "), | |||||
| qty: Number(line.pickedQty) || 0, | |||||
| uom: stockUom, | |||||
| meta: itemLabel || undefined, | |||||
| refCode: ctx.pickOrderCode, | |||||
| refId: ctx.pickOrderId, | |||||
| consoCode: ctx.consoCode, | |||||
| jobOrderCode: ctx.jobOrderCode?.trim() || undefined, | |||||
| docLinkKind: ctx.pickOrderCode ? "jodetail" : undefined, | |||||
| docLinkTargetDate: linkTargetDate, | |||||
| traceItemCode: line.itemCode, | |||||
| bomProcessId: line.bomProcessId, | |||||
| bomProcessSeqNo: line.bomProcessSeqNo, | |||||
| assignedStepName: stepName || undefined, | |||||
| feedsProductionScopeLotNo, | |||||
| categoryLabel: labels.categoryPick, | |||||
| stockAvailableLabel: formatMaybeNaQty( | |||||
| labels, | |||||
| stockApplicable, | |||||
| line.stockAvailable, | |||||
| stockUom, | |||||
| ), | |||||
| stockStatusLabel: formatStockStatus( | |||||
| labels, | |||||
| stockApplicable, | |||||
| line.stockAvailable, | |||||
| requiredQty, | |||||
| ), | |||||
| bomReqQtyLabel: | |||||
| line.bomReqQty != null ? formatQty(line.bomReqQty, line.bomUom || stockUom) : labels.na, | |||||
| stockReqQtyLabel: formatQty(requiredQty, stockUom), | |||||
| processingStatus: "pending", | |||||
| processingStatusLabel: labels.pendingPick, | |||||
| details: [ | |||||
| field(labels.pickOrder, ctx.pickOrderCode, { | |||||
| linkKind: "jodetail", | |||||
| linkCode: ctx.pickOrderCode, | |||||
| linkId: ctx.pickOrderId, | |||||
| consoCode: ctx.consoCode, | |||||
| linkTargetDate, | |||||
| }), | |||||
| field(labels.detailPickTargetDate, pickTargetDate || labels.na), | |||||
| field(labels.detailMaterial, itemLabel), | |||||
| field(labels.detailLot, labels.na), | |||||
| field( | |||||
| labels.detailBomReqQty, | |||||
| line.bomReqQty != null ? formatQty(line.bomReqQty, line.bomUom || stockUom) : labels.na, | |||||
| ), | |||||
| field(labels.detailStockReqQty, formatQty(requiredQty, stockUom)), | |||||
| field( | |||||
| labels.detailStockAvailable, | |||||
| formatMaybeNaQty(labels, stockApplicable, line.stockAvailable, stockUom), | |||||
| ), | |||||
| field( | |||||
| labels.detailStockStatus, | |||||
| formatStockStatus(labels, stockApplicable, line.stockAvailable, requiredQty), | |||||
| ), | |||||
| field(labels.detailQty, formatQty(Number(line.pickedQty) || 0, stockUom)), | |||||
| field(labels.detailType, labels.nodeMaterialPick), | |||||
| field(labels.detailAssignedStep, stepLabel || labels.na), | |||||
| field(labels.jobOrder, ctx.jobOrderCode, { | |||||
| linkKind: "jo", | |||||
| linkCode: ctx.jobOrderCode, | |||||
| linkId: ctx.jobOrderId, | |||||
| }), | |||||
| field(labels.processingStatus, labels.pendingPick, { valueColor: "warning" }), | |||||
| field(labels.detailTime, labels.na), | |||||
| ], | ], | ||||
| ...pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus), | |||||
| }; | }; | ||||
| }; | }; | ||||
| @@ -17,9 +17,18 @@ import { | |||||
| IconButton, | IconButton, | ||||
| Collapse, | Collapse, | ||||
| Link as MuiLink, | Link as MuiLink, | ||||
| ToggleButton, | |||||
| ToggleButtonGroup, | |||||
| FormControl, | |||||
| InputLabel, | |||||
| Select, | |||||
| MenuItem, | |||||
| Chip, | |||||
| Button, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | ||||
| import ExpandLessIcon from "@mui/icons-material/ExpandLess"; | import ExpandLessIcon from "@mui/icons-material/ExpandLess"; | ||||
| import FileDownloadIcon from "@mui/icons-material/FileDownload"; | |||||
| import NextLink from "next/link"; | import NextLink from "next/link"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| @@ -29,10 +38,25 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||||
| import { | import { | ||||
| fetchDrinkProductionQty, | fetchDrinkProductionQty, | ||||
| DrinkProductionQtyResponse, | DrinkProductionQtyResponse, | ||||
| DrinkProductionQtyJobOrderDetail, | |||||
| } from "@/app/api/jo/actions"; | } from "@/app/api/jo/actions"; | ||||
| import { arrayToDayjs } from "@/app/utils/formatUtil"; | |||||
| import { exportDrinkProductionQtyXlsx } from "@/components/ProductionProcess/exportDrinkProductionQtyXlsx"; | |||||
| const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘 | const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘 | ||||
| const JO_STATUS_FILTER_VALUES = [ | |||||
| "planning", | |||||
| "pending", | |||||
| "packaging", | |||||
| "processing", | |||||
| "pendingQC", | |||||
| "storing", | |||||
| "completed", | |||||
| ] as const; | |||||
| type DrinkViewMode = "actual" | "planned"; | |||||
| const formatQty = (qty: number | null | undefined): string => { | const formatQty = (qty: number | null | undefined): string => { | ||||
| if (qty === null || qty === undefined || Number.isNaN(qty)) return "-"; | if (qty === null || qty === undefined || Number.isNaN(qty)) return "-"; | ||||
| return qty.toLocaleString(undefined, { maximumFractionDigits: 2 }); | return qty.toLocaleString(undefined, { maximumFractionDigits: 2 }); | ||||
| @@ -44,6 +68,35 @@ const formatProductionDate = (value: string | null | undefined): string => { | |||||
| return parsed.isValid() ? parsed.format("YYYY-MM-DD") : value; | return parsed.isValid() ? parsed.format("YYYY-MM-DD") : value; | ||||
| }; | }; | ||||
| const parseProcessTime = (timeData: unknown): Dayjs | null => { | |||||
| if (timeData == null) return null; | |||||
| if (Array.isArray(timeData)) { | |||||
| try { | |||||
| const parsed = arrayToDayjs(timeData, true); | |||||
| return parsed.isValid() ? parsed : null; | |||||
| } catch { | |||||
| return null; | |||||
| } | |||||
| } | |||||
| if (typeof timeData === "string") { | |||||
| const parsed = dayjs(timeData); | |||||
| return parsed.isValid() ? parsed : null; | |||||
| } | |||||
| return null; | |||||
| }; | |||||
| const ProcessSummaryTimeText: React.FC<{ value: unknown }> = ({ value }) => { | |||||
| const d = parseProcessTime(value); | |||||
| if (!d) return <>—</>; | |||||
| return ( | |||||
| <> | |||||
| <span style={{ color: "green" }}>{d.format("MM-DD")}</span> | |||||
| {" "} | |||||
| <span style={{ color: "blue" }}>{d.format("HH:mm")}</span> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => | const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => | ||||
| `${row.itemCode || "unknown"}-${idx}`; | `${row.itemCode || "unknown"}-${idx}`; | ||||
| @@ -53,6 +106,8 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| const [data, setData] = useState<DrinkProductionQtyResponse[]>([]); | const [data, setData] = useState<DrinkProductionQtyResponse[]>([]); | ||||
| const [loading, setLoading] = useState<boolean>(true); | const [loading, setLoading] = useState<boolean>(true); | ||||
| const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); | const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); | ||||
| const [joStatusFilter, setJoStatusFilter] = useState<string>(""); | |||||
| const [viewMode, setViewMode] = useState<DrinkViewMode>("actual"); | |||||
| const [expandedRowKeys, setExpandedRowKeys] = useState<Set<string>>( | const [expandedRowKeys, setExpandedRowKeys] = useState<Set<string>>( | ||||
| new Set(), | new Set(), | ||||
| ); | ); | ||||
| @@ -61,11 +116,14 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| null, | null, | ||||
| ); | ); | ||||
| const isPlanned = viewMode === "planned"; | |||||
| const loadData = useCallback(async () => { | const loadData = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const result = await fetchDrinkProductionQty( | const result = await fetchDrinkProductionQty( | ||||
| selectedDate.format("YYYY-MM-DD"), | selectedDate.format("YYYY-MM-DD"), | ||||
| viewMode, | |||||
| ); | ); | ||||
| setData(result || []); | setData(result || []); | ||||
| setExpandedRowKeys(new Set()); | setExpandedRowKeys(new Set()); | ||||
| @@ -78,7 +136,7 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }, [selectedDate]); | |||||
| }, [selectedDate, viewMode]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| loadData(); | loadData(); | ||||
| @@ -100,6 +158,38 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| }); | }); | ||||
| }; | }; | ||||
| const filterJobOrders = ( | |||||
| jobOrders: DrinkProductionQtyJobOrderDetail[], | |||||
| ): DrinkProductionQtyJobOrderDetail[] => { | |||||
| if (!joStatusFilter) return jobOrders; | |||||
| return jobOrders.filter((jo) => jo.jobOrderStatus === joStatusFilter); | |||||
| }; | |||||
| const renderActualEnd = (jo: DrinkProductionQtyJobOrderDetail) => { | |||||
| const start = parseProcessTime(jo.startTime); | |||||
| const end = parseProcessTime(jo.actualEndTime); | |||||
| if (end) return <ProcessSummaryTimeText value={jo.actualEndTime} />; | |||||
| if (start) { | |||||
| return ( | |||||
| <Typography variant="body2" color="info.main" component="span"> | |||||
| {t("In progress")} | |||||
| </Typography> | |||||
| ); | |||||
| } | |||||
| return <>—</>; | |||||
| }; | |||||
| const renderJoStatusChip = (status: string | null | undefined) => { | |||||
| if (!status) return <>—</>; | |||||
| return ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(status, { ns: "jo", defaultValue: status })} | |||||
| sx={{ height: 22 }} | |||||
| /> | |||||
| ); | |||||
| }; | |||||
| return ( | return ( | ||||
| <Card sx={{ mb: 2 }}> | <Card sx={{ mb: 2 }}> | ||||
| <CardContent> | <CardContent> | ||||
| @@ -107,7 +197,11 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| {t("Drink Production Qty Dashboard")} | {t("Drink Production Qty Dashboard")} | ||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: "center" }}> | |||||
| <Stack | |||||
| direction="row" | |||||
| spacing={2} | |||||
| sx={{ mb: 2, flexWrap: "wrap", alignItems: "center", gap: 1 }} | |||||
| > | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | <LocalizationProvider dateAdapter={AdapterDayjs}> | ||||
| <DatePicker | <DatePicker | ||||
| label={t("Date")} | label={t("Date")} | ||||
| @@ -122,8 +216,51 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| /> | /> | ||||
| </LocalizationProvider> | </LocalizationProvider> | ||||
| <FormControl size="small" sx={{ minWidth: 180 }}> | |||||
| <InputLabel id="drink-jo-status-filter-label"> | |||||
| {t("Job Order Status")} | |||||
| </InputLabel> | |||||
| <Select | |||||
| labelId="drink-jo-status-filter-label" | |||||
| id="drink-jo-status-filter" | |||||
| label={t("Job Order Status")} | |||||
| value={joStatusFilter} | |||||
| onChange={(e) => { | |||||
| setJoStatusFilter(String(e.target.value)); | |||||
| }} | |||||
| > | |||||
| <MenuItem value=""> | |||||
| <em>{t("All")}</em> | |||||
| </MenuItem> | |||||
| {JO_STATUS_FILTER_VALUES.map((v) => ( | |||||
| <MenuItem key={v} value={v}> | |||||
| {t(v, { ns: "jo" })} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| <Box sx={{ flexGrow: 1 }} /> | <Box sx={{ flexGrow: 1 }} /> | ||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| startIcon={<FileDownloadIcon />} | |||||
| disabled={loading || data.length === 0} | |||||
| sx={{ display: "none" }} | |||||
| onClick={() => { | |||||
| exportDrinkProductionQtyXlsx({ | |||||
| data, | |||||
| viewMode, | |||||
| selectedDate: selectedDate.format("YYYY-MM-DD"), | |||||
| statusFilter: joStatusFilter, | |||||
| t, | |||||
| }); | |||||
| }} | |||||
| > | |||||
| {t("Export Excel")} | |||||
| </Button> | |||||
| <Typography | <Typography | ||||
| variant="body2" | variant="body2" | ||||
| sx={{ color: "text.secondary" }} | sx={{ color: "text.secondary" }} | ||||
| @@ -137,6 +274,33 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| <Stack | |||||
| direction="row" | |||||
| alignItems="center" | |||||
| spacing={1} | |||||
| sx={{ mb: 2, flexWrap: "wrap", gap: 1 }} | |||||
| > | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Drink detail mode label")} | |||||
| </Typography> | |||||
| <ToggleButtonGroup | |||||
| value={viewMode} | |||||
| exclusive | |||||
| size="small" | |||||
| color="primary" | |||||
| onChange={(_, value: DrinkViewMode | null) => { | |||||
| if (value != null) setViewMode(value); | |||||
| }} | |||||
| > | |||||
| <ToggleButton value="actual"> | |||||
| {t("Drink detail mode: actual")} | |||||
| </ToggleButton> | |||||
| <ToggleButton value="planned"> | |||||
| {t("Drink detail mode: planned")} | |||||
| </ToggleButton> | |||||
| </ToggleButtonGroup> | |||||
| </Stack> | |||||
| {loading ? ( | {loading ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | ||||
| <CircularProgress /> | <CircularProgress /> | ||||
| @@ -181,14 +345,18 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| {t("Unit")} | {t("Unit")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right" sx={{ width: 120 }}> | |||||
| <TableCell align="right" sx={{ width: 140 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Stock Req. Qty")} | |||||
| {isPlanned | |||||
| ? t("Planned Output Qty") | |||||
| : t("Stock Req. Qty")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right" sx={{ width: 140 }}> | <TableCell align="right" sx={{ width: 140 }}> | ||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Production Qty")} | |||||
| {isPlanned | |||||
| ? t("Actual Output Qty") | |||||
| : t("Production Qty")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -208,15 +376,18 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| ) : ( | ) : ( | ||||
| data.map((row, idx) => { | data.map((row, idx) => { | ||||
| const rowKey = getRowKey(row, idx); | const rowKey = getRowKey(row, idx); | ||||
| const jobOrders = row.jobOrders ?? []; | |||||
| const allJobOrders = row.jobOrders ?? []; | |||||
| const jobOrders = filterJobOrders(allJobOrders); | |||||
| const isExpanded = expandedRowKeys.has(rowKey); | const isExpanded = expandedRowKeys.has(rowKey); | ||||
| const hasJobOrders = jobOrders.length > 0; | |||||
| const hasExpandable = | |||||
| allJobOrders.length > 0 && | |||||
| (joStatusFilter === "" || jobOrders.length > 0); | |||||
| return ( | return ( | ||||
| <React.Fragment key={rowKey}> | <React.Fragment key={rowKey}> | ||||
| <TableRow hover={hasJobOrders}> | |||||
| <TableRow hover={hasExpandable}> | |||||
| <TableCell padding="checkbox"> | <TableCell padding="checkbox"> | ||||
| {hasJobOrders ? ( | |||||
| {hasExpandable ? ( | |||||
| <IconButton | <IconButton | ||||
| size="small" | size="small" | ||||
| aria-label={ | aria-label={ | ||||
| @@ -260,7 +431,7 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| {hasJobOrders && ( | |||||
| {hasExpandable && ( | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell | <TableCell | ||||
| colSpan={6} | colSpan={6} | ||||
| @@ -272,63 +443,185 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||||
| unmountOnExit | unmountOnExit | ||||
| > | > | ||||
| <Box sx={{ py: 1.5, pl: 6, pr: 2 }}> | <Box sx={{ py: 1.5, pl: 6, pr: 2 }}> | ||||
| <Table size="small"> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell sx={{ fontWeight: 600 }}> | |||||
| {t("Job Order Code")} | |||||
| </TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }}> | |||||
| {t("Production Date")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| align="right" | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Stock Req. Qty")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| align="right" | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Production Qty")} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {jobOrders.map((jo) => ( | |||||
| <TableRow | |||||
| key={`${rowKey}-jo-${jo.jobOrderId}`} | |||||
| > | |||||
| <TableCell> | |||||
| {jo.jobOrderId > 0 ? ( | |||||
| <MuiLink | |||||
| component={NextLink} | |||||
| href={`/jo/edit?id=${jo.jobOrderId}`} | |||||
| underline="hover" | |||||
| > | |||||
| {jo.jobOrderCode || | |||||
| `JO-${jo.jobOrderId}`} | |||||
| </MuiLink> | |||||
| ) : ( | |||||
| jo.jobOrderCode || "-" | |||||
| )} | |||||
| {jobOrders.length === 0 ? ( | |||||
| <Typography | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| sx={{ py: 1 }} | |||||
| > | |||||
| {t("No matching job orders for status")} | |||||
| </Typography> | |||||
| ) : ( | |||||
| <Table size="small"> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell sx={{ fontWeight: 600 }}> | |||||
| {t("Job Order Code")} | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | |||||
| {formatProductionDate( | |||||
| jo.productionDate, | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {formatQty(jo.reqQty)} | |||||
| <TableCell sx={{ fontWeight: 600 }}> | |||||
| {t("Job Order Status")} | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right"> | |||||
| {formatQty(jo.productionQty)} | |||||
| <TableCell sx={{ fontWeight: 600 }}> | |||||
| {t("Production Date")} | |||||
| </TableCell> | </TableCell> | ||||
| {isPlanned ? ( | |||||
| <> | |||||
| <TableCell | |||||
| align="right" | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Assume Time Need")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Latest Start By")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Start Time")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Assume End Time")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Actual End Time")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| align="right" | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Planned Output Qty")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| align="right" | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Actual Output Qty")} | |||||
| </TableCell> | |||||
| </> | |||||
| ) : ( | |||||
| <> | |||||
| <TableCell | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Start Time")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Actual End Time")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| align="right" | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Stock Req. Qty")} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| align="right" | |||||
| sx={{ fontWeight: 600 }} | |||||
| > | |||||
| {t("Production Qty")} | |||||
| </TableCell> | |||||
| </> | |||||
| )} | |||||
| </TableRow> | </TableRow> | ||||
| ))} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {jobOrders.map((jo) => ( | |||||
| <TableRow | |||||
| key={`${rowKey}-jo-${jo.jobOrderId}`} | |||||
| > | |||||
| <TableCell> | |||||
| {jo.jobOrderId > 0 ? ( | |||||
| <MuiLink | |||||
| component={NextLink} | |||||
| href={`/jo/edit?id=${jo.jobOrderId}`} | |||||
| underline="hover" | |||||
| > | |||||
| {jo.jobOrderCode || | |||||
| `JO-${jo.jobOrderId}`} | |||||
| </MuiLink> | |||||
| ) : ( | |||||
| jo.jobOrderCode || "-" | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {renderJoStatusChip( | |||||
| jo.jobOrderStatus, | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {formatProductionDate( | |||||
| jo.productionDate, | |||||
| )} | |||||
| </TableCell> | |||||
| {isPlanned ? ( | |||||
| <> | |||||
| <TableCell align="right"> | |||||
| <Typography | |||||
| variant="body2" | |||||
| component="span" | |||||
| sx={{ color: "blue" }} | |||||
| > | |||||
| {jo.assumeTimeNeedMins ?? 0}{" "} | |||||
| {t("minutes")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <ProcessSummaryTimeText | |||||
| value={jo.latestStartBy} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <ProcessSummaryTimeText | |||||
| value={jo.startTime} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <ProcessSummaryTimeText | |||||
| value={jo.assumeEndTime} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {renderActualEnd(jo)} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {formatQty(jo.reqQty)} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {formatQty(jo.productionQty)} | |||||
| </TableCell> | |||||
| </> | |||||
| ) : ( | |||||
| <> | |||||
| <TableCell> | |||||
| <ProcessSummaryTimeText | |||||
| value={jo.startTime} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {renderActualEnd(jo)} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {formatQty(jo.reqQty)} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {formatQty(jo.productionQty)} | |||||
| </TableCell> | |||||
| </> | |||||
| )} | |||||
| </TableRow> | |||||
| ))} | |||||
| </TableBody> | |||||
| </Table> | |||||
| )} | |||||
| </Box> | </Box> | ||||
| </Collapse> | </Collapse> | ||||
| </TableCell> | </TableCell> | ||||
| @@ -223,7 +223,7 @@ interface JobOrderOpsTableProps { | |||||
| printerCombo?: PrinterCombo[]; | printerCombo?: PrinterCombo[]; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.4 | 2026-08-06 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.5 | 2026-08-06 */ | |||||
| const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | ||||
| onSelectProcess, | onSelectProcess, | ||||
| printerCombo = [], | printerCombo = [], | ||||
| @@ -232,10 +232,9 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const sessionToken = session as SessionWithTokens | null; | const sessionToken = session as SessionWithTokens | null; | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | const abilities = session?.abilities ?? session?.user?.abilities ?? []; | ||||
| /** 取消工單:僅 ADMIN */ | |||||
| /** 取消工單 / 完成工單:僅 ADMIN */ | |||||
| const canCancel = hasAbility(abilities, AUTH.ADMIN); | const canCancel = hasAbility(abilities, AUTH.ADMIN); | ||||
| /** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單) */ | |||||
| const canComplete = hasAbility(abilities, AUTH.PRODUCT_PROCESS); | |||||
| const canComplete = hasAbility(abilities, AUTH.ADMIN); | |||||
| const labelPrinterCombo = useMemo( | const labelPrinterCombo = useMemo( | ||||
| () => (printerCombo || []).filter((p) => p.type === "Label"), | () => (printerCombo || []).filter((p) => p.type === "Label"), | ||||
| @@ -53,7 +53,7 @@ interface ProductProcessJobOrderDetailProps { | |||||
| initialTabIndex?: number; | initialTabIndex?: number; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.7 | 2026-08-06 */ | |||||
| const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | ||||
| jobOrderId, | jobOrderId, | ||||
| onBack, | onBack, | ||||
| @@ -178,7 +178,7 @@ function isWaitingQcPutAway( | |||||
| return s !== "completed" && s !== "rejected"; | return s !== "completed" && s !== "rejected"; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.8 | 2026-08-09 */ | |||||
| const ProductProcessList: React.FC<ProductProcessListProps> = ({ | const ProductProcessList: React.FC<ProductProcessListProps> = ({ | ||||
| onSelectProcess, | onSelectProcess, | ||||
| printerCombo, | printerCombo, | ||||
| @@ -209,8 +209,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | const abilities = session?.abilities ?? session?.user?.abilities ?? []; | ||||
| /** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單);不含 ADMIN 以免群組 ADMIN 繞過勾選 */ | |||||
| const canManageUpdateJo = hasAbility(abilities, AUTH.PRODUCT_PROCESS); | |||||
| /** 完成工單:僅 ADMIN */ | |||||
| const canManageUpdateJo = hasAbility(abilities, AUTH.ADMIN); | |||||
| type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other"; | type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other"; | ||||
| const listTab = normalizeListTab(listPersistedState.pickBucket); | const listTab = normalizeListTab(listPersistedState.pickBucket); | ||||
| @@ -447,7 +447,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| return productionCache; | return productionCache; | ||||
| }, [listTab, productionCache, pendingQcCache, putawayedCache, offPlanRows]); | }, [listTab, productionCache, pendingQcCache, putawayedCache, offPlanRows]); | ||||
| // QC 的业务判定:同一个 jobOrder 下,所有 productProcess 的所有 lines 都必须是 Completed/Pass | |||||
| // QC ready: same JO — all lines Completed/Pass (sibling 包裝 auto-Pass on backend) + has SIL | |||||
| const jobOrderQcReadyById = useMemo(() => { | const jobOrderQcReadyById = useMemo(() => { | ||||
| const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>(); | const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>(); | ||||
| for (const p of tabProcesses) { | for (const p of tabProcesses) { | ||||
| @@ -465,31 +465,11 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { | byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { | ||||
| const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null); | const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null); | ||||
| const allLines = jobOrderProcesses.flatMap((p) => p.lines ?? []); | |||||
| const allLinesDone = | |||||
| allLines.length > 0 && allLines.every((l) => isDone(l.status)); | |||||
| const packingProcesses = jobOrderProcesses.filter( | |||||
| (p) => String((p as any).code ?? "").trim() === "包裝", | |||||
| ); | |||||
| const nonPackingProcesses = jobOrderProcesses.filter( | |||||
| (p) => String((p as any).code ?? "").trim() !== "包裝", | |||||
| ); | |||||
| const allNonPackingDone = | |||||
| nonPackingProcesses.length === 0 || | |||||
| nonPackingProcesses.every((p) => { | |||||
| const lines = p.lines ?? []; | |||||
| return lines.length > 0 && lines.every((l) => isDone(l.status)); | |||||
| }); | |||||
| const hasOnePackingDone = | |||||
| packingProcesses.length > 0 && | |||||
| packingProcesses.some((p) => { | |||||
| const lines = p.lines ?? []; | |||||
| return lines.some((l) => isDone(l.status)); | |||||
| }); | |||||
| const packingOk = packingProcesses.length === 0 ? true : hasOnePackingDone; | |||||
| result.set(jobOrderId, hasStockInLine && allNonPackingDone && packingOk); | |||||
| result.set(jobOrderId, hasStockInLine && allLinesDone); | |||||
| }); | }); | ||||
| return result; | return result; | ||||
| @@ -1230,20 +1210,17 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| {t("Matching Stock")} | {t("Matching Stock")} | ||||
| </Button> | </Button> | ||||
| {statusLower !== "completed" && ( | |||||
| {canManageUpdateJo && statusLower !== "completed" && ( | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| size="small" | size="small" | ||||
| disabled={!canManageUpdateJo} | |||||
| onClick={() => | onClick={() => | ||||
| canManageUpdateJo | |||||
| ? openConfirm( | |||||
| t("Confirm to update this Job Order?"), | |||||
| async () => { | |||||
| await handleUpdateJo(process); | |||||
| }, | |||||
| ) | |||||
| : undefined | |||||
| openConfirm( | |||||
| t("Confirm to update this Job Order?"), | |||||
| async () => { | |||||
| await handleUpdateJo(process); | |||||
| }, | |||||
| ) | |||||
| } | } | ||||
| > | > | ||||
| {t("Update Job Order")} | {t("Update Job Order")} | ||||
| @@ -17,6 +17,7 @@ import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkPro | |||||
| import JobOrderOpsTable from "@/components/ProductionProcess/JobOrderOpsTable"; | import JobOrderOpsTable from "@/components/ProductionProcess/JobOrderOpsTable"; | ||||
| import type { PrinterCombo } from "@/app/api/settings/printer"; | import type { PrinterCombo } from "@/app/api/settings/printer"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { AUTH, hasAbility } from "@/authorities"; | |||||
| interface ProductionProcessPageProps { | interface ProductionProcessPageProps { | ||||
| printerCombo: PrinterCombo[]; | printerCombo: PrinterCombo[]; | ||||
| @@ -24,9 +25,17 @@ interface ProductionProcessPageProps { | |||||
| const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; | const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; | ||||
| type PageTab = | |||||
| | "list" | |||||
| | "ops" | |||||
| | "jobProcessStatus" | |||||
| | "operatorKpi" | |||||
| | "equipmentStatus" | |||||
| | "drinkQty"; | |||||
| /** | /** | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 | * FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.4 | 2026-08-06 | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.5 | 2026-08-06 | |||||
| */ | */ | ||||
| const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | ||||
| const { t } = useTranslation(["common", "productionProcess"]); | const { t } = useTranslation(["common", "productionProcess"]); | ||||
| @@ -36,19 +45,27 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| productProcessId: number; | productProcessId: number; | ||||
| pickOrderId: number; | pickOrderId: number; | ||||
| } | null>(null); | } | null>(null); | ||||
| /** 0 = Production Process list; 1 = JO ops table; 2..5 = dashboards */ | |||||
| const [tabIndex, setTabIndex] = useState(0); | |||||
| const [pageTab, setPageTab] = useState<PageTab>("list"); | |||||
| const [productionListState, setProductionListState] = useState(() => ({ | const [productionListState, setProductionListState] = useState(() => ({ | ||||
| ...createDefaultProductionProcessListPersistedState(), | ...createDefaultProductionProcessListPersistedState(), | ||||
| })); | })); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const sessionToken = session as SessionWithTokens | null; | const sessionToken = session as SessionWithTokens | null; | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||||
| /** 「查看工單流程情況」僅 ADMIN */ | |||||
| const canSeeOpsTable = hasAbility(abilities, AUTH.ADMIN); | |||||
| const searchParams = useSearchParams(); | const searchParams = useSearchParams(); | ||||
| const pathname = usePathname(); | const pathname = usePathname(); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const [linkQcOpen, setLinkQcOpen] = useState(false); | const [linkQcOpen, setLinkQcOpen] = useState(false); | ||||
| const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null); | const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null); | ||||
| useEffect(() => { | |||||
| if (!canSeeOpsTable && pageTab === "ops") { | |||||
| setPageTab("list"); | |||||
| } | |||||
| }, [canSeeOpsTable, pageTab]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (typeof window !== 'undefined') { | if (typeof window !== 'undefined') { | ||||
| try { | try { | ||||
| @@ -83,8 +100,8 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| const handleTabChange = useCallback((event: React.SyntheticEvent, newValue: number) => { | |||||
| setTabIndex(newValue); | |||||
| const handleTabChange = useCallback((_: React.SyntheticEvent, newValue: PageTab) => { | |||||
| setPageTab(newValue); | |||||
| }, []); | }, []); | ||||
| const openStockInLineIdQ = searchParams.get("openStockInLineId"); | const openStockInLineIdQ = searchParams.get("openStockInLineId"); | ||||
| @@ -100,7 +117,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| if (!Number.isFinite(id) || id <= 0) return; | if (!Number.isFinite(id) || id <= 0) return; | ||||
| setSelectedProcessId(null); | setSelectedProcessId(null); | ||||
| setSelectedMatchingStock(null); | setSelectedMatchingStock(null); | ||||
| setTabIndex(0); | |||||
| setPageTab("list"); | |||||
| setProductionListState((prev) => ({ | setProductionListState((prev) => ({ | ||||
| ...prev, | ...prev, | ||||
| pickBucket: "pending_qc", | pickBucket: "pending_qc", | ||||
| @@ -144,16 +161,18 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Box> | <Box> | ||||
| <Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}> | |||||
| <Tab label={t("Production Process")} /> | |||||
| <Tab label={t("Job Order Ops Table")} /> | |||||
| <Tab label={t("Job Process Status Dashboard")} /> | |||||
| <Tab label={t("Operator KPI Dashboard")} /> | |||||
| <Tab label={t("Production Equipment Status Dashboard")} /> | |||||
| <Tab label={t("Drink Production Qty Dashboard")} /> | |||||
| <Tabs value={pageTab} onChange={handleTabChange} sx={{ mb: 2 }}> | |||||
| <Tab value="list" label={t("Production Process")} /> | |||||
| {canSeeOpsTable && ( | |||||
| <Tab value="ops" label={t("Job Order Ops Table")} /> | |||||
| )} | |||||
| <Tab value="jobProcessStatus" label={t("Job Process Status Dashboard")} /> | |||||
| <Tab value="operatorKpi" label={t("Operator KPI Dashboard")} /> | |||||
| <Tab value="equipmentStatus" label={t("Production Equipment Status Dashboard")} /> | |||||
| <Tab value="drinkQty" label={t("Drink Production Qty Dashboard")} /> | |||||
| </Tabs> | </Tabs> | ||||
| {tabIndex === 0 && ( | |||||
| {pageTab === "list" && ( | |||||
| <ProductionProcessList | <ProductionProcessList | ||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| listPersistedState={productionListState} | listPersistedState={productionListState} | ||||
| @@ -174,7 +193,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| /> | /> | ||||
| )} | )} | ||||
| {tabIndex === 1 && ( | |||||
| {pageTab === "ops" && canSeeOpsTable && ( | |||||
| <JobOrderOpsTable | <JobOrderOpsTable | ||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| onSelectProcess={(jobOrderId) => { | onSelectProcess={(jobOrderId) => { | ||||
| @@ -183,16 +202,16 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| /> | /> | ||||
| )} | )} | ||||
| {tabIndex === 2 && ( | |||||
| {pageTab === "jobProcessStatus" && ( | |||||
| <JobProcessStatus /> | <JobProcessStatus /> | ||||
| )} | )} | ||||
| {tabIndex === 3 && ( | |||||
| {pageTab === "operatorKpi" && ( | |||||
| <OperatorKpiDashboard /> | <OperatorKpiDashboard /> | ||||
| )} | )} | ||||
| {tabIndex === 4 && ( | |||||
| {pageTab === "equipmentStatus" && ( | |||||
| <EquipmentStatusDashboard /> | <EquipmentStatusDashboard /> | ||||
| )} | )} | ||||
| {tabIndex === 5 && ( | |||||
| {pageTab === "drinkQty" && ( | |||||
| <DrinkProductionQtyDashboard /> | <DrinkProductionQtyDashboard /> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -0,0 +1,141 @@ | |||||
| import dayjs from "dayjs"; | |||||
| import type { TFunction } from "i18next"; | |||||
| import { exportMultiSheetToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||||
| import type { | |||||
| DrinkProductionQtyJobOrderDetail, | |||||
| DrinkProductionQtyResponse, | |||||
| } from "@/app/api/jo/actions"; | |||||
| type DrinkViewMode = "actual" | "planned"; | |||||
| const formatDateTime = (value: unknown): string => { | |||||
| if (value == null || value === "") return ""; | |||||
| if (Array.isArray(value)) { | |||||
| const [y, m, d, h = 0, min = 0] = value as number[]; | |||||
| if (y == null || m == null || d == null) return ""; | |||||
| return dayjs(new Date(y, m - 1, d, h, min)).format("YYYY-MM-DD HH:mm"); | |||||
| } | |||||
| const parsed = dayjs(value as string); | |||||
| return parsed.isValid() ? parsed.format("YYYY-MM-DD HH:mm") : String(value); | |||||
| }; | |||||
| const formatDate = (value: string | null | undefined): string => { | |||||
| if (!value) return ""; | |||||
| const parsed = dayjs(value); | |||||
| return parsed.isValid() ? parsed.format("YYYY-MM-DD") : value; | |||||
| }; | |||||
| export type ExportDrinkProductionQtyParams = { | |||||
| data: DrinkProductionQtyResponse[]; | |||||
| viewMode: DrinkViewMode; | |||||
| selectedDate: string; | |||||
| statusFilter: string; | |||||
| t: TFunction; | |||||
| }; | |||||
| export function exportDrinkProductionQtyXlsx({ | |||||
| data, | |||||
| viewMode, | |||||
| selectedDate, | |||||
| statusFilter, | |||||
| t, | |||||
| }: ExportDrinkProductionQtyParams): void { | |||||
| const viewLabel = | |||||
| viewMode === "planned" | |||||
| ? t("Drink detail mode: planned") | |||||
| : t("Drink detail mode: actual"); | |||||
| const statusFilterLabel = statusFilter | |||||
| ? t(statusFilter, { ns: "jo", defaultValue: statusFilter }) | |||||
| : t("All"); | |||||
| const exportedAt = dayjs().format("YYYY-MM-DD HH:mm:ss"); | |||||
| const filterJos = ( | |||||
| jobOrders: DrinkProductionQtyJobOrderDetail[], | |||||
| ): DrinkProductionQtyJobOrderDetail[] => { | |||||
| if (!statusFilter) return jobOrders; | |||||
| return jobOrders.filter((jo) => jo.jobOrderStatus === statusFilter); | |||||
| }; | |||||
| const joRows: Record<string, unknown>[] = []; | |||||
| const stepRows: Record<string, unknown>[] = []; | |||||
| for (const item of data) { | |||||
| for (const jo of filterJos(item.jobOrders ?? [])) { | |||||
| joRows.push({ | |||||
| [t("Export meta: exported at")]: exportedAt, | |||||
| [t("Drink detail mode label")]: viewLabel, | |||||
| [t("Date")]: selectedDate, | |||||
| [t("Job Order Status") + ` (${t("Filter")})`]: statusFilterLabel, | |||||
| [t("Item Code")]: item.itemCode ?? "", | |||||
| [t("Goods Name")]: item.itemName ?? "", | |||||
| [t("Unit")]: item.uom ?? "", | |||||
| [t("Job Order Code")]: jo.jobOrderCode ?? "", | |||||
| [t("Job Order Status")]: jo.jobOrderStatus | |||||
| ? t(jo.jobOrderStatus, { ns: "jo", defaultValue: jo.jobOrderStatus }) | |||||
| : "", | |||||
| [t("Production Date")]: formatDate(jo.productionDate), | |||||
| [viewMode === "planned" | |||||
| ? t("Planned Output Qty") | |||||
| : t("Stock Req. Qty")]: jo.reqQty ?? 0, | |||||
| [viewMode === "planned" | |||||
| ? t("Actual Output Qty") | |||||
| : t("Production Qty")]: jo.productionQty ?? 0, | |||||
| [t("Assume Time Need")]: jo.assumeTimeNeedMins ?? 0, | |||||
| [t("Latest Start By")]: formatDateTime(jo.latestStartBy), | |||||
| [t("Start Time")]: formatDateTime(jo.startTime), | |||||
| [t("Assume End Time")]: formatDateTime(jo.assumeEndTime), | |||||
| [t("Actual End Time")]: formatDateTime(jo.actualEndTime), | |||||
| [t("Process operators")]: jo.processOperators ?? "", | |||||
| [t("Process handlers")]: jo.processHandlers ?? "", | |||||
| [t("QC users")]: jo.qcUsers ?? "", | |||||
| [t("Put away users")]: jo.putAwayUsers ?? "", | |||||
| }); | |||||
| for (const step of jo.processSteps ?? []) { | |||||
| stepRows.push({ | |||||
| [t("Export meta: exported at")]: exportedAt, | |||||
| [t("Drink detail mode label")]: viewLabel, | |||||
| [t("Date")]: selectedDate, | |||||
| [t("Item Code")]: step.itemCode ?? item.itemCode ?? "", | |||||
| [t("Goods Name")]: step.itemName ?? item.itemName ?? "", | |||||
| [t("Job Order Code")]: step.jobOrderCode ?? jo.jobOrderCode ?? "", | |||||
| [t("Process seq")]: step.seqNo ?? "", | |||||
| [t("Process Name")]: step.processName ?? "", | |||||
| [t("Operator Name & No.")]: step.operatorName ?? "", | |||||
| [t("Handler")]: step.handlerName ?? "", | |||||
| [t("Start Time")]: formatDateTime(step.startTime), | |||||
| [t("End Time")]: formatDateTime(step.endTime), | |||||
| [t("Status")]: step.status ?? "", | |||||
| }); | |||||
| } | |||||
| } | |||||
| } | |||||
| const itemRows = data.map((item) => ({ | |||||
| [t("Export meta: exported at")]: exportedAt, | |||||
| [t("Drink detail mode label")]: viewLabel, | |||||
| [t("Date")]: selectedDate, | |||||
| [t("Item Code")]: item.itemCode ?? "", | |||||
| [t("Goods Name")]: item.itemName ?? "", | |||||
| [t("Unit")]: item.uom ?? "", | |||||
| [viewMode === "planned" | |||||
| ? t("Planned Output Qty") | |||||
| : t("Stock Req. Qty")]: item.totalReqQty ?? 0, | |||||
| [viewMode === "planned" | |||||
| ? t("Actual Output Qty") | |||||
| : t("Production Qty")]: item.totalQty ?? 0, | |||||
| [t("JO count")]: filterJos(item.jobOrders ?? []).length, | |||||
| })); | |||||
| const modeSlug = viewMode === "planned" ? "planned" : "actual"; | |||||
| const filename = `DrinkProductionQty_${modeSlug}_${selectedDate}_${dayjs().format("HHmm")}`; | |||||
| exportMultiSheetToXlsx( | |||||
| [ | |||||
| { name: t("Excel sheet: JO detail"), rows: joRows }, | |||||
| { name: t("Excel sheet: process people"), rows: stepRows }, | |||||
| { name: t("Excel sheet: item summary"), rows: itemRows }, | |||||
| ], | |||||
| filename, | |||||
| ); | |||||
| } | |||||
| @@ -286,6 +286,12 @@ | |||||
| "code.joStatus.storing": "Storing", | "code.joStatus.storing": "Storing", | ||||
| "code.joStatus.PARTIAL": "Partial", | "code.joStatus.PARTIAL": "Partial", | ||||
| "code.joStatus.partial": "Partial", | "code.joStatus.partial": "Partial", | ||||
| "code.productionStatus.Pass": "Pass", | |||||
| "code.productionStatus.Completed": "Completed", | |||||
| "code.productionStatus.Pending": "Pending", | |||||
| "code.productionStatus.Paused": "Paused", | |||||
| "code.productionStatus.InProgress": "In progress", | |||||
| "code.productionStatus.Skip": "Skip", | |||||
| "continuousScanBlocked": "Finish current scan first", | "continuousScanBlocked": "Finish current scan first", | ||||
| "nodeJoOut": "Job order material issue", | "nodeJoOut": "Job order material issue", | ||||
| "nodePoOut": "Purchase pick", | "nodePoOut": "Purchase pick", | ||||
| @@ -317,5 +323,13 @@ | |||||
| "direction": "Direction", | "direction": "Direction", | ||||
| "origins": "Origins", | "origins": "Origins", | ||||
| "refCode": "Ref.", | "refCode": "Ref.", | ||||
| "lastMove": "Last move" | |||||
| "lastMove": "Last move", | |||||
| "N/A": "N/A", | |||||
| "pendingPick": "Pending pick", | |||||
| "detailBomReqQty": "BOM req. qty", | |||||
| "detailStockReqQty": "Stock req. qty", | |||||
| "detailStockAvailable": "Stock available", | |||||
| "detailStockStatus": "Stock status", | |||||
| "stockStatusSufficient": "Sufficient", | |||||
| "stockStatusInsufficient": "Insufficient" | |||||
| } | } | ||||
| @@ -80,11 +80,11 @@ | |||||
| "Issue": "Issue", | "Issue": "Issue", | ||||
| "Job Order Ops Table": "Job Order Ops Table", | "Job Order Ops Table": "Job Order Ops Table", | ||||
| "Pending (picked)": "Picked", | "Pending (picked)": "Picked", | ||||
| "Pending (not picked)": "Not picked", | |||||
| "Pending (not picked)": "Pick incomplete", | |||||
| "Processing (picked)": "Picked", | "Processing (picked)": "Picked", | ||||
| "Processing (not picked)": "Not picked", | |||||
| "Processing (not picked)": "Pick incomplete", | |||||
| "Picked": "Picked", | "Picked": "Picked", | ||||
| "Not picked": "Not picked", | |||||
| "Not picked": "Pick incomplete", | |||||
| "Stop (paused)": "Stop (paused)", | "Stop (paused)": "Stop (paused)", | ||||
| "Cancelled": "Cancelled", | "Cancelled": "Cancelled", | ||||
| "Reload data": "Reload data", | "Reload data": "Reload data", | ||||
| @@ -96,6 +96,27 @@ | |||||
| "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | ||||
| "Expand job order details": "Expand job order details", | "Expand job order details": "Expand job order details", | ||||
| "Collapse job order details": "Collapse job order details", | "Collapse job order details": "Collapse job order details", | ||||
| "Drink detail mode label": "Detail display", | |||||
| "Drink detail mode: actual": "Actual production", | |||||
| "Drink detail mode: planned": "Planned production", | |||||
| "Planned Output Qty": "Planned output", | |||||
| "Actual Output Qty": "Actual output", | |||||
| "Latest Start By": "Latest start by", | |||||
| "Actual End Time": "Actual End Time", | |||||
| "Job Order Status": "Job Order Status", | |||||
| "No matching job orders for status": "No job orders match the selected status", | |||||
| "Export Excel": "Export Excel", | |||||
| "Export meta: exported at": "Exported at", | |||||
| "Process operators": "Process operators", | |||||
| "Process handlers": "Process handlers", | |||||
| "QC users": "QC users", | |||||
| "Put away users": "Put-away users", | |||||
| "Excel sheet: JO detail": "JO detail", | |||||
| "Excel sheet: process people": "Process people", | |||||
| "Excel sheet: item summary": "Item summary", | |||||
| "JO count": "JO count", | |||||
| "Process seq": "Process seq", | |||||
| "Handler": "Handler", | |||||
| "Goods Name": "Goods Name", | "Goods Name": "Goods Name", | ||||
| "Job Type": "Job Type", | "Job Type": "Job Type", | ||||
| "Job process detail mode label": "Job process detail mode label", | "Job process detail mode label": "Job process detail mode label", | ||||
| @@ -225,10 +246,10 @@ | |||||
| "Off-plan unfinished": "Off-plan unfinished", | "Off-plan unfinished": "Off-plan unfinished", | ||||
| "All unfinished": "Needs action", | "All unfinished": "Needs action", | ||||
| "Needs action": "Needs action", | "Needs action": "Needs action", | ||||
| "Not picked · Not started": "Not picked · Not started", | |||||
| "Not picked · Not started": "Pick incomplete · Not started", | |||||
| "Picked · Not started": "Picked · Not started", | "Picked · Not started": "Picked · Not started", | ||||
| "Picked · In production": "Picked · In production", | "Picked · In production": "Picked · In production", | ||||
| "Not picked · In production": "Not picked · In production", | |||||
| "Not picked · In production": "Pick incomplete · In production", | |||||
| "Powder_Mixture": "Powder Mixture", | "Powder_Mixture": "Powder Mixture", | ||||
| "Processing": "Processing", | "Processing": "Processing", | ||||
| "Total lines: ": "Total lines: ", | "Total lines: ": "Total lines: ", | ||||
| @@ -283,9 +283,15 @@ | |||||
| "code.joStatus.packaging": "提料中", | "code.joStatus.packaging": "提料中", | ||||
| "code.joStatus.processing": "生產中", | "code.joStatus.processing": "生產中", | ||||
| "code.joStatus.pendingQC": "待品檢", | "code.joStatus.pendingQC": "待品檢", | ||||
| "code.joStatus.storing": "待品檢入倉", | |||||
| "code.joStatus.storing": "待QC上架", | |||||
| "code.joStatus.PARTIAL": "部分完成", | "code.joStatus.PARTIAL": "部分完成", | ||||
| "code.joStatus.partial": "部分完成", | "code.joStatus.partial": "部分完成", | ||||
| "code.productionStatus.Pass": "通過", | |||||
| "code.productionStatus.Completed": "完成", | |||||
| "code.productionStatus.Pending": "待處理", | |||||
| "code.productionStatus.Paused": "已暫停", | |||||
| "code.productionStatus.InProgress": "進行中", | |||||
| "code.productionStatus.Skip": "跳過", | |||||
| "continuousScanBlocked": "請先完成目前掃描", | "continuousScanBlocked": "請先完成目前掃描", | ||||
| "nodeJoOut": "工單提料", | "nodeJoOut": "工單提料", | ||||
| "nodePoOut": "採購提料", | "nodePoOut": "採購提料", | ||||
| @@ -317,5 +323,13 @@ | |||||
| "direction": "方向", | "direction": "方向", | ||||
| "origins": "來源", | "origins": "來源", | ||||
| "refCode": "單號", | "refCode": "單號", | ||||
| "lastMove": "最近異動" | |||||
| "lastMove": "最近異動", | |||||
| "N/A": "不適用", | |||||
| "pendingPick": "待提料", | |||||
| "detailBomReqQty": "BOM 需求數", | |||||
| "detailStockReqQty": "庫存需求數", | |||||
| "detailStockAvailable": "庫存數", | |||||
| "detailStockStatus": "庫存狀態", | |||||
| "stockStatusSufficient": "足夠", | |||||
| "stockStatusInsufficient": "不足" | |||||
| } | } | ||||
| @@ -85,11 +85,11 @@ | |||||
| "Issue": "異常", | "Issue": "異常", | ||||
| "Job Order Ops Table": "工單生產流程", | "Job Order Ops Table": "工單生產流程", | ||||
| "Pending (picked)": "已提料", | "Pending (picked)": "已提料", | ||||
| "Pending (not picked)": "未提料", | |||||
| "Pending (not picked)": "未完成提料", | |||||
| "Processing (picked)": "已提料", | "Processing (picked)": "已提料", | ||||
| "Processing (not picked)": "未提料", | |||||
| "Processing (not picked)": "未完成提料", | |||||
| "Picked": "已提料", | "Picked": "已提料", | ||||
| "Not picked": "未提料", | |||||
| "Not picked": "未完成提料", | |||||
| "Stop (paused)": "暫停中", | "Stop (paused)": "暫停中", | ||||
| "Cancelled": "已取消", | "Cancelled": "已取消", | ||||
| "Reload data": "重新載入", | "Reload data": "重新載入", | ||||
| @@ -101,6 +101,27 @@ | |||||
| "Drink Production Qty Dashboard": "儀表板 - 飲料生產數量", | "Drink Production Qty Dashboard": "儀表板 - 飲料生產數量", | ||||
| "Expand job order details": "展開工單明細", | "Expand job order details": "展開工單明細", | ||||
| "Collapse job order details": "收合工單明細", | "Collapse job order details": "收合工單明細", | ||||
| "Drink detail mode label": "明細顯示", | |||||
| "Drink detail mode: actual": "實際生產", | |||||
| "Drink detail mode: planned": "預計生產", | |||||
| "Planned Output Qty": "預計生產數量", | |||||
| "Actual Output Qty": "實際生產數量", | |||||
| "Latest Start By": "最晚開工時間", | |||||
| "Actual End Time": "實際完成時間", | |||||
| "Job Order Status": "工單狀態", | |||||
| "No matching job orders for status": "無符合狀態的工單", | |||||
| "Export Excel": "匯出 Excel", | |||||
| "Export meta: exported at": "匯出時間", | |||||
| "Process operators": "工序操作員", | |||||
| "Process handlers": "工序處理人", | |||||
| "QC users": "QC人員", | |||||
| "Put away users": "上架人員", | |||||
| "Excel sheet: JO detail": "JO明細", | |||||
| "Excel sheet: process people": "工序人員", | |||||
| "Excel sheet: item summary": "貨品彙總", | |||||
| "JO count": "工單筆數", | |||||
| "Process seq": "工序序號", | |||||
| "Handler": "處理人", | |||||
| "Goods Name": "貨品名稱", | "Goods Name": "貨品名稱", | ||||
| "Job Type": "工單類型", | "Job Type": "工單類型", | ||||
| "Job process detail mode label": "工序格顯示", | "Job process detail mode label": "工序格顯示", | ||||
| @@ -230,10 +251,10 @@ | |||||
| "Off-plan unfinished": "未按規劃完成工單", | "Off-plan unfinished": "未按規劃完成工單", | ||||
| "All unfinished": "需處理", | "All unfinished": "需處理", | ||||
| "Needs action": "需處理", | "Needs action": "需處理", | ||||
| "Not picked · Not started": "未提料 · 未開工", | |||||
| "Not picked · Not started": "未完成提料 · 未開工", | |||||
| "Picked · Not started": "已提料 · 未開工", | "Picked · Not started": "已提料 · 未開工", | ||||
| "Picked · In production": "已提料 · 未完成生產", | "Picked · In production": "已提料 · 未完成生產", | ||||
| "Not picked · In production": "未提料 · 未完成生產", | |||||
| "Not picked · In production": "未完成提料 · 未完成生產", | |||||
| "Powder_Mixture": "箱料粉", | "Powder_Mixture": "箱料粉", | ||||
| "Processing": "生產中", | "Processing": "生產中", | ||||
| "Total lines: ": "總數量:", | "Total lines: ": "總數量:", | ||||