diff --git a/src/app/api/itemTracing/index.ts b/src/app/api/itemTracing/index.ts index fedea25a..790db390 100644 --- a/src/app/api/itemTracing/index.ts +++ b/src/app/api/itemTracing/index.ts @@ -287,6 +287,15 @@ export interface ItemLotTraceJoPickLine { requiredQty: number; pickedQty: number; 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 { diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 5f380266..2815d43b 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -1683,12 +1683,37 @@ export const fetchOperatorKpi = cache(async (date?: string) => { }); // ===== 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 { jobOrderId: number; jobOrderCode?: string | null; productionDate?: string | null; reqQty: 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 { @@ -1701,17 +1726,20 @@ export interface DrinkProductionQtyResponse { } /** 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(url, { - method: "GET", - next: { tags: ["drinkProductionQty"] }, - }); -}); + return serverFetchJson(url, { + method: "GET", + next: { tags: ["drinkProductionQty"] }, + }); + }, +); // ===== Equipment Status Dashboard ===== diff --git a/src/components/ItemTracing/buildJoPreludeGraphNodes.ts b/src/components/ItemTracing/buildJoPreludeGraphNodes.ts index 537e6672..76016595 100644 --- a/src/components/ItemTracing/buildJoPreludeGraphNodes.ts +++ b/src/components/ItemTracing/buildJoPreludeGraphNodes.ts @@ -14,6 +14,7 @@ import { import { formatQty, formatSignedQty } from "./traceQtyUtils"; import { createMaterialPickNode, + createMaterialPickNodeFromPickLine, docLinkFromOriginType, docLinkFromRefType, field, @@ -264,6 +265,9 @@ const buildMaterialStockInPreludeNodes = ( // ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整). ctx.seenInboundLots.add(lotId); const isJoOrigin = originType === "JO"; + const joSubtitleBase = [m.materialLotNo, m.materialItemCode, m.materialItemName] + .filter(Boolean) + .join(" · "); nodes.push({ id: `mat-in-${lotId}-${origin.stockInLineId}`, kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN", @@ -272,11 +276,13 @@ const buildMaterialStockInPreludeNodes = ( title: isJoOrigin ? labels.nodeJoCreated : `${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, uom: matUom, refType: origin.type, @@ -300,13 +306,23 @@ const buildMaterialStockInPreludeNodes = ( linkCode: origin.refCode, 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.detailItemName, m.materialItemName), field(labels.detailLot, m.materialLotNo), 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), - ...pickOrderDetailFields(picks, labels.pickOrder), + ...(isJoOrigin ? [] : pickOrderDetailFields(picks, labels.pickOrder)), ], }); } @@ -501,7 +517,6 @@ const buildMaterialStockInPreludeNodes = ( const buildNestedJoCreatedNode = ( m: ItemLotTraceMaterialInput, labels: JoPreludeGraphLabels, - picks: LotPickRef[], ctx: PreludeBuildContext, ): TraceGraphNode | null => { const nested = m.nestedJoPrelude; @@ -524,7 +539,8 @@ const buildNestedJoCreatedNode = ( origin?.receiptDate?.trim() || null; 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 { id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`, @@ -532,11 +548,9 @@ const buildNestedJoCreatedNode = ( timestamp: ts, sortKey: parseSortKey(ts, nextSeq(ctx)), 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, uom: matUom, refType: "JO", @@ -554,10 +568,10 @@ const buildNestedJoCreatedNode = ( linkId: jo.jobOrderId, }), field(labels.detailItemCode, m.materialItemCode), + field(labels.detailItemName, m.materialItemName), field(labels.detailLot, m.materialLotNo), - field(labels.detailQty, formatQty(qty, matUom)), + field(labels.detailRequiredQty, formatQty(qty, matUom)), field(labels.detailTime, ts), - ...pickOrderDetailFields(picks, labels.pickOrder), ], }; }; @@ -576,22 +590,60 @@ const buildMaterialInputChainNodes = ( const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); if (joProduced && m.nestedJoPrelude) { - const nestedJoCreated = buildNestedJoCreatedNode(m, labels, picks, ctx); + const nestedJoCreated = buildNestedJoCreatedNode(m, labels, ctx); 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; }; +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 => { + const set = new Set(); + materialInputs.forEach((m) => { + const code = m.materialItemCode?.trim().toUpperCase(); + if (code) set.add(code); + }); + return set; +}; + export const buildJoPreludeGraphNodes = ( joPrelude: ItemLotTraceJoPrelude, labels: JoPreludeGraphLabels & Partial, @@ -619,12 +694,61 @@ export const buildJoPreludeGraphNodes = ( seq: 0, }; 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[] = [ ...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(); + 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) => { if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; return a.id.localeCompare(b.id); diff --git a/src/components/ItemTracing/buildTraceGraphNodes.ts b/src/components/ItemTracing/buildTraceGraphNodes.ts index 62ad86ad..b79ab868 100644 --- a/src/components/ItemTracing/buildTraceGraphNodes.ts +++ b/src/components/ItemTracing/buildTraceGraphNodes.ts @@ -178,6 +178,8 @@ export interface TraceGraphDetailLabels extends TraceGraphNodeLabels { nodeExpired: string; nodeDepleted: string; detailQty: string; + /** JO_CREATED detail / chip qty — job order required qty (需求數量). */ + detailRequiredQty: string; detailTime: string; detailHandler: string; detailStockTaker: string; @@ -435,6 +437,59 @@ const resolveJoCreatedTimestamp = ( 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 = [ + 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 = ( refCode: string | null | undefined, refId: number | null | undefined, @@ -532,14 +587,16 @@ const buildInboundOriginNodes = ( const joKey = `${refCode}|${origin.refId ?? 0}`; coveredJoInbound.add(joKey); const eventTimestamp = resolveJoCreatedTimestamp(data, refCode, origin.receiptDate); + const joQty = resolveJoReqQty(data, refCode, origin.acceptedQty); + const lot = data.lot; nodes.push({ id: `${idPfx}origin-${i}-${origin.stockInLineId}`, kind: "JO_CREATED", timestamp: eventTimestamp, sortKey: parseSortKey(eventTimestamp, seq++), title: labels.nodeJoCreated, - subtitle: refCode, - qty: origin.acceptedQty, + subtitle: joCreatedLotSubtitle(lot), + qty: joQty, uom: stockUom, refType: origin.type, refCode, @@ -547,15 +604,14 @@ const buildInboundOriginNodes = ( docLinkKind: linkKind, warehouseCode: defaultWh, 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" ? resolveJoCreatedTimestamp(data, m.refCode, m.timestamp) : m.timestamp; + const joCreatedQty = + kind === "JO_CREATED" ? resolveJoReqQty(data, m.refCode, m.qty) : m.qty; const title = kind === "RECEIPT" ? labels.nodeReceipt @@ -729,7 +787,9 @@ export const buildTraceGraphNodes = ( const subtitle = 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 linkKind = isDoOut || isJoOut || isPoOut ? "pick" : docLinkFromRefType(m.refType); const receiptDetails = @@ -745,6 +805,16 @@ export const buildTraceGraphNodes = ( field(labels.detailTime, m.timestamp), fieldIf(labels.detailRemarks, m.remarks), ) + : kind === "JO_CREATED" + ? joCreatedLotDetails( + labels, + data.lot, + m.refCode?.trim() || "—", + m.refId, + joCreatedQty, + stockUom, + eventTimestamp, + ) : kind === "DO_OUT" ? detailsOf( field(labels.detailType, labels.nodeDoOut), @@ -875,7 +945,7 @@ export const buildTraceGraphNodes = ( sortKey: parseSortKey(eventTimestamp, seq++), title, subtitle, - qty: m.qty, + qty: kind === "JO_CREATED" ? joCreatedQty : m.qty, uom: stockUom, meta: meta || undefined, refType: m.refType, diff --git a/src/components/ItemTracing/traceGraphLabels.ts b/src/components/ItemTracing/traceGraphLabels.ts index 8f5d7620..4b0db9d0 100644 --- a/src/components/ItemTracing/traceGraphLabels.ts +++ b/src/components/ItemTracing/traceGraphLabels.ts @@ -63,6 +63,7 @@ export const buildTraceGraphLabels = ( formatQcSubtitle: (failQty: number, acceptedQty: number) => `${t("failQty")}: ${formatQty(failQty, lotUom)} / ${t("acceptedQty")}: ${formatQty(acceptedQty, lotUom)}`, detailQty: t("qty"), + detailRequiredQty: t("requiredQty"), detailTime: t("timestamp"), detailHandler: t("handler"), detailStockTaker: t("stockTaker"), @@ -139,6 +140,14 @@ export const buildTraceGraphLabels = ( categoryQc: t("phaseQc"), categoryOutbound: t("phaseOutbound"), 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"), matchStatus: t("matchStatus"), }; @@ -146,7 +155,7 @@ export const buildTraceGraphLabels = ( /** Minimal labels for unit tests (no i18n). */ export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { - const identity = (s: string | null | undefined) => s?.trim() || "—"; + const identity = (s: string | null | undefined) => s?.trim() || "N/A"; const tr = { refType: identity, movementType: identity, @@ -209,6 +218,7 @@ export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { directionOut: "Out", formatQcSubtitle: (f, a) => `Fail ${f} / Accepted ${a}`, detailQty: "Qty", + detailRequiredQty: "Required qty", detailTime: "Time", detailHandler: "Handler", detailStockTaker: "First counter", @@ -285,6 +295,14 @@ export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { categoryQc: "QC", categoryOutbound: "Outbound", 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", matchStatus: "Match status", }; diff --git a/src/components/ItemTracing/traceGraphSemantics.ts b/src/components/ItemTracing/traceGraphSemantics.ts index d864838f..f306c6de 100644 --- a/src/components/ItemTracing/traceGraphSemantics.ts +++ b/src/components/ItemTracing/traceGraphSemantics.ts @@ -248,16 +248,23 @@ export const shouldSkipTraceFlowEdge = ( to: TraceGraphLayoutNode, ): boolean => { 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; } // 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 ( to.kind === "PRODUCTION_STEP" && from.kind !== "MATERIAL_PICK" && from.kind !== "PICK_GROUP" && - from.kind !== "PRODUCTION_STEP" + from.kind !== "PRODUCTION_STEP" && + from.kind !== "JO_CREATED" ) { return true; } @@ -435,6 +442,9 @@ const buildMaterialLotFlowEdgePairs = ( nodes.forEach((n) => { 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); if (!key) return; const list = byLot.get(key) ?? []; @@ -464,12 +474,14 @@ const buildMaterialLotFlowEdgePairs = ( if (toPhase === "MATERIAL_PICK") { const upstream = fromList[fromList.length - 1]!; const seenPickTargets = new Set(); - 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 { add(fromList[fromList.length - 1]!, toList[0]!); } @@ -686,15 +698,11 @@ const sortProductionStepsForChain = ( 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(); prodSteps.forEach((n) => { if (n.bomProcessId == null) return; @@ -704,38 +712,43 @@ const buildMaterialPickToProductionStepEdges = ( 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 = ( source: TraceGraphLayoutNode, @@ -743,7 +756,7 @@ const buildMaterialPickToProductionStepEdges = ( seenTargets: Set, ) => { 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; seenTargets.add(target.id); 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 = ( doOut: TraceGraphLayoutNode, nodes: TraceGraphLayoutNode[], @@ -1102,6 +1186,9 @@ export const buildTraceFlowEdgePairs = ( const to = toList[0]!; 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") { continue; } @@ -1129,6 +1216,7 @@ export const buildTraceFlowEdgePairs = ( buildProductionStepChainEdges(nodes, add); buildProductionToFgQcEdges(nodes, add); buildJoCreatedToMaterialPickEdges(nodes, add); + buildJoCreatedToProductionStepEdges(nodes, add); buildReplenishmentToDoOutEdges(nodes, add); buildMaterialPickToProductionStepEdges(nodes, add); buildFgLotFlowEdgePairs(nodes, phaseOrder, add); diff --git a/src/components/ItemTracing/traceNodeFactory.ts b/src/components/ItemTracing/traceNodeFactory.ts index 2446c94d..f0daba53 100644 --- a/src/components/ItemTracing/traceNodeFactory.ts +++ b/src/components/ItemTracing/traceNodeFactory.ts @@ -1,5 +1,8 @@ import dayjs from "dayjs"; -import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; +import { + ItemLotTraceJoPickLine, + ItemLotTraceMaterialInput, +} from "@/app/api/itemTracing"; import { TraceGraphDetailField, TraceGraphDocLinkKind, @@ -21,6 +24,16 @@ export type MaterialPickLabels = { detailAssignedStep: string; detailPickTargetDate: 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; matchStatus: string; tr: { @@ -30,32 +43,37 @@ export type MaterialPickLabels = { }; export const pickStatusDetailFields = ( - labels: Pick, + labels: Pick, processingStatus?: string | null, matchStatus?: string | null, opts?: { always?: boolean }, ): TraceGraphDetailField[] => { const always = opts?.always === true; + const na = labels.na || "N/A"; const rows: TraceGraphDetailField[] = []; if (always || processingStatus?.trim()) { 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()) { 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; }; export const pickStatusNodeLabels = ( - labels: Pick, + labels: Pick, processingStatus?: string | null, matchStatus?: string | null, opts?: { always?: boolean }, @@ -64,13 +82,20 @@ export const pickStatusNodeLabels = ( "processingStatusLabel" | "matchStatusLabel" | "processingStatus" | "matchStatus" > => { const always = opts?.always === true; + const na = labels.na || "N/A"; const showProcessing = always || Boolean(processingStatus?.trim()); const showMatch = always || Boolean(matchStatus?.trim()); return { processingStatusLabel: showProcessing - ? labels.tr.processingStatus(processingStatus) + ? processingStatus?.trim() + ? labels.tr.processingStatus(processingStatus) + : na + : undefined, + matchStatusLabel: showMatch + ? matchStatus?.trim() + ? labels.tr.matchStatus(matchStatus) + : na : undefined, - matchStatusLabel: showMatch ? labels.tr.matchStatus(matchStatus) : undefined, processingStatus: showProcessing ? processingStatus?.trim() || undefined : undefined, matchStatus: showMatch ? matchStatus?.trim() || undefined : undefined, }; @@ -143,33 +168,78 @@ export type MaterialPickNodeContext = { 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 = ( m: ItemLotTraceMaterialInput, index: number, labels: MaterialPickLabels, ctx: MaterialPickNodeContext, feedsProductionScopeLotNo?: string, + pickLine?: ItemLotTraceJoPickLine | null, ): TraceGraphNode => { - const matUom = m.materialUom?.trim() || ""; + const matUom = m.materialUom?.trim() || pickLine?.stockUom?.trim() || ""; 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 pickTargetDate = normalizeTargetDateForLink( ctx.pickOrderTargetDate?.(m.pickOrderCode ?? ""), ); const pickedDate = normalizeTargetDateForLink(m.pickedAt); 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 { id: `mat-pick-${index}-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}`, kind: "MATERIAL_PICK", timestamp: m.pickedAt, sortKey: parseSortKey(m.pickedAt, ctx.nextSeq()), title: pickTitle, - subtitle: [m.materialLotNo, stepLabel, m.consoCode].filter(Boolean).join(" · "), + subtitle: [lotDisplay !== labels.na ? m.materialLotNo : "", stepLabel] + .filter(Boolean) + .join(" · "), qty: m.materialQty, uom: matUom, meta: itemLabel || undefined, @@ -179,13 +249,22 @@ export const createMaterialPickNode = ( jobOrderCode: m.jobOrderCode?.trim() || undefined, docLinkKind: m.pickOrderCode ? "jodetail" : undefined, docLinkTargetDate: linkTargetDate, - traceLotNo: m.materialLotNo, + traceLotNo: m.materialLotNo?.trim() || undefined, traceItemCode: m.materialItemCode, - bomProcessId: m.bomProcessId, - bomProcessSeqNo: m.bomProcessSeqNo, - assignedStepName: m.assignedStepName, + bomProcessId: m.bomProcessId ?? pickLine?.bomProcessId, + bomProcessSeqNo: stepSeq, + assignedStepName: stepName || undefined, feedsProductionScopeLotNo, 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: [ field(labels.pickOrder, m.pickOrderCode, { linkKind: "jodetail", @@ -194,20 +273,134 @@ export const createMaterialPickNode = ( consoCode: m.consoCode, linkTargetDate, }), - field(labels.detailPickTargetDate, pickTargetDate || pickedDate), + field(labels.detailPickTargetDate, pickTargetDate || pickedDate || labels.na), 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.detailType, labels.nodeMaterialPick), - field(labels.detailAssignedStep, stepLabel || "—"), + field(labels.detailAssignedStep, stepLabel || labels.na), field(labels.jobOrder, m.jobOrderCode, { linkKind: "jo", linkCode: m.jobOrderCode, 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), }; }; diff --git a/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx b/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx index 94626e90..2ae0d900 100644 --- a/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx +++ b/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx @@ -17,9 +17,18 @@ import { IconButton, Collapse, Link as MuiLink, + ToggleButton, + ToggleButtonGroup, + FormControl, + InputLabel, + Select, + MenuItem, + Chip, + Button, } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import FileDownloadIcon from "@mui/icons-material/FileDownload"; import NextLink from "next/link"; import { useTranslation } from "react-i18next"; import dayjs from "dayjs"; @@ -29,10 +38,25 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { fetchDrinkProductionQty, DrinkProductionQtyResponse, + DrinkProductionQtyJobOrderDetail, } 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 JO_STATUS_FILTER_VALUES = [ + "planning", + "pending", + "packaging", + "processing", + "pendingQC", + "storing", + "completed", +] as const; + +type DrinkViewMode = "actual" | "planned"; + const formatQty = (qty: number | null | undefined): string => { if (qty === null || qty === undefined || Number.isNaN(qty)) return "-"; 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; }; +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 ( + <> + {d.format("MM-DD")} + {" "} + {d.format("HH:mm")} + + ); +}; + const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => `${row.itemCode || "unknown"}-${idx}`; @@ -53,6 +106,8 @@ const DrinkProductionQtyDashboard: React.FC = () => { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [selectedDate, setSelectedDate] = useState(dayjs()); + const [joStatusFilter, setJoStatusFilter] = useState(""); + const [viewMode, setViewMode] = useState("actual"); const [expandedRowKeys, setExpandedRowKeys] = useState>( new Set(), ); @@ -61,11 +116,14 @@ const DrinkProductionQtyDashboard: React.FC = () => { null, ); + const isPlanned = viewMode === "planned"; + const loadData = useCallback(async () => { setLoading(true); try { const result = await fetchDrinkProductionQty( selectedDate.format("YYYY-MM-DD"), + viewMode, ); setData(result || []); setExpandedRowKeys(new Set()); @@ -78,7 +136,7 @@ const DrinkProductionQtyDashboard: React.FC = () => { } finally { setLoading(false); } - }, [selectedDate]); + }, [selectedDate, viewMode]); useEffect(() => { 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 ; + if (start) { + return ( + + {t("In progress")} + + ); + } + return <>—; + }; + + const renderJoStatusChip = (status: string | null | undefined) => { + if (!status) return <>—; + return ( + + ); + }; + return ( @@ -107,7 +197,11 @@ const DrinkProductionQtyDashboard: React.FC = () => { {t("Drink Production Qty Dashboard")} - + { /> + + + {t("Job Order Status")} + + + + + + { + + + {t("Drink detail mode label")} + + { + if (value != null) setViewMode(value); + }} + > + + {t("Drink detail mode: actual")} + + + {t("Drink detail mode: planned")} + + + + {loading ? ( @@ -181,14 +345,18 @@ const DrinkProductionQtyDashboard: React.FC = () => { {t("Unit")} - + - {t("Stock Req. Qty")} + {isPlanned + ? t("Planned Output Qty") + : t("Stock Req. Qty")} - {t("Production Qty")} + {isPlanned + ? t("Actual Output Qty") + : t("Production Qty")} @@ -208,15 +376,18 @@ const DrinkProductionQtyDashboard: React.FC = () => { ) : ( data.map((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 hasJobOrders = jobOrders.length > 0; + const hasExpandable = + allJobOrders.length > 0 && + (joStatusFilter === "" || jobOrders.length > 0); return ( - + - {hasJobOrders ? ( + {hasExpandable ? ( { - {hasJobOrders && ( + {hasExpandable && ( { unmountOnExit > - - - - - {t("Job Order Code")} - - - {t("Production Date")} - - - {t("Stock Req. Qty")} - - - {t("Production Qty")} - - - - - {jobOrders.map((jo) => ( - - - {jo.jobOrderId > 0 ? ( - - {jo.jobOrderCode || - `JO-${jo.jobOrderId}`} - - ) : ( - jo.jobOrderCode || "-" - )} + {jobOrders.length === 0 ? ( + + {t("No matching job orders for status")} + + ) : ( +
+ + + + {t("Job Order Code")} - - {formatProductionDate( - jo.productionDate, - )} - - - {formatQty(jo.reqQty)} + + {t("Job Order Status")} - - {formatQty(jo.productionQty)} + + {t("Production Date")} + {isPlanned ? ( + <> + + {t("Assume Time Need")} + + + {t("Latest Start By")} + + + {t("Start Time")} + + + {t("Assume End Time")} + + + {t("Actual End Time")} + + + {t("Planned Output Qty")} + + + {t("Actual Output Qty")} + + + ) : ( + <> + + {t("Start Time")} + + + {t("Actual End Time")} + + + {t("Stock Req. Qty")} + + + {t("Production Qty")} + + + )} - ))} - -
+ + + {jobOrders.map((jo) => ( + + + {jo.jobOrderId > 0 ? ( + + {jo.jobOrderCode || + `JO-${jo.jobOrderId}`} + + ) : ( + jo.jobOrderCode || "-" + )} + + + {renderJoStatusChip( + jo.jobOrderStatus, + )} + + + {formatProductionDate( + jo.productionDate, + )} + + {isPlanned ? ( + <> + + + {jo.assumeTimeNeedMins ?? 0}{" "} + {t("minutes")} + + + + + + + + + + + + + {renderActualEnd(jo)} + + + {formatQty(jo.reqQty)} + + + {formatQty(jo.productionQty)} + + + ) : ( + <> + + + + + {renderActualEnd(jo)} + + + {formatQty(jo.reqQty)} + + + {formatQty(jo.productionQty)} + + + )} + + ))} + + + )}
diff --git a/src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts b/src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts new file mode 100644 index 00000000..8584a884 --- /dev/null +++ b/src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts @@ -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[] = []; + const stepRows: Record[] = []; + + 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, + ); +} diff --git a/src/i18n/en/itemTracing.json b/src/i18n/en/itemTracing.json index fdb698b4..281b85ac 100644 --- a/src/i18n/en/itemTracing.json +++ b/src/i18n/en/itemTracing.json @@ -286,6 +286,12 @@ "code.joStatus.storing": "Storing", "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", "nodeJoOut": "Job order material issue", "nodePoOut": "Purchase pick", @@ -317,5 +323,13 @@ "direction": "Direction", "origins": "Origins", "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" } diff --git a/src/i18n/en/productionProcess.json b/src/i18n/en/productionProcess.json index 4668f623..af69d1f0 100644 --- a/src/i18n/en/productionProcess.json +++ b/src/i18n/en/productionProcess.json @@ -95,6 +95,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": "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", "Job Type": "Job Type", "Job process detail mode label": "Job process detail mode label", diff --git a/src/i18n/zh/itemTracing.json b/src/i18n/zh/itemTracing.json index 5c3d1ba8..031dee0a 100644 --- a/src/i18n/zh/itemTracing.json +++ b/src/i18n/zh/itemTracing.json @@ -283,9 +283,15 @@ "code.joStatus.packaging": "提料中", "code.joStatus.processing": "生產中", "code.joStatus.pendingQC": "待品檢", - "code.joStatus.storing": "待品檢入倉", + "code.joStatus.storing": "待QC上架", "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": "請先完成目前掃描", "nodeJoOut": "工單提料", "nodePoOut": "採購提料", @@ -317,5 +323,13 @@ "direction": "方向", "origins": "來源", "refCode": "單號", - "lastMove": "最近異動" + "lastMove": "最近異動", + "N/A": "不適用", + "pendingPick": "待提料", + "detailBomReqQty": "BOM 需求數", + "detailStockReqQty": "庫存需求數", + "detailStockAvailable": "庫存數", + "detailStockStatus": "庫存狀態", + "stockStatusSufficient": "足夠", + "stockStatusInsufficient": "不足" } diff --git a/src/i18n/zh/productionProcess.json b/src/i18n/zh/productionProcess.json index f78826f1..2c339f30 100644 --- a/src/i18n/zh/productionProcess.json +++ b/src/i18n/zh/productionProcess.json @@ -95,6 +95,27 @@ "Drink Production Qty Dashboard": "儀表板 - 飲料生產數量", "Expand 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": "貨品名稱", "Job Type": "工單類型", "Job process detail mode label": "工序格顯示",