Ver a proveniência

飲料儀表板加實際/預計模式、狀態篩選與 Excel(含人員);Item Tracing UOM issue

fix負數倉
tommy há 1 mês
ascendente
cometimento
a58a06f0c8
13 ficheiros alterados com 1231 adições e 197 eliminações
  1. +9
    -0
      src/app/api/itemTracing/index.ts
  2. +38
    -10
      src/app/api/jo/actions.ts
  3. +156
    -32
      src/components/ItemTracing/buildJoPreludeGraphNodes.ts
  4. +83
    -13
      src/components/ItemTracing/buildTraceGraphNodes.ts
  5. +19
    -1
      src/components/ItemTracing/traceGraphLabels.ts
  6. +135
    -47
      src/components/ItemTracing/traceGraphSemantics.ts
  7. +221
    -28
      src/components/ItemTracing/traceNodeFactory.ts
  8. +356
    -63
      src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx
  9. +141
    -0
      src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts
  10. +15
    -1
      src/i18n/en/itemTracing.json
  11. +21
    -0
      src/i18n/en/productionProcess.json
  12. +16
    -2
      src/i18n/zh/itemTracing.json
  13. +21
    -0
      src/i18n/zh/productionProcess.json

+ 9
- 0
src/app/api/itemTracing/index.ts Ver ficheiro

@@ -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 {


+ 38
- 10
src/app/api/jo/actions.ts Ver ficheiro

@@ -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<DrinkProductionQtyResponse[]>(url, {
method: "GET",
next: { tags: ["drinkProductionQty"] },
});
});
return serverFetchJson<DrinkProductionQtyResponse[]>(url, {
method: "GET",
next: { tags: ["drinkProductionQty"] },
});
},
);

// ===== Equipment Status Dashboard =====



+ 156
- 32
src/components/ItemTracing/buildJoPreludeGraphNodes.ts Ver ficheiro

@@ -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<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 = (
joPrelude: ItemLotTraceJoPrelude,
labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>,
@@ -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<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) => {
if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
return a.id.localeCompare(b.id);


+ 83
- 13
src/components/ItemTracing/buildTraceGraphNodes.ts Ver ficheiro

@@ -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<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 = (
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,


+ 19
- 1
src/components/ItemTracing/traceGraphLabels.ts Ver ficheiro

@@ -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",
};


+ 135
- 47
src/components/ItemTracing/traceGraphSemantics.ts Ver ficheiro

@@ -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<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 {
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<string, TraceGraphLayoutNode[]>();
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<string>,
) => {
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);


+ 221
- 28
src/components/ItemTracing/traceNodeFactory.ts Ver ficheiro

@@ -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<MaterialPickLabels, "processingStatus" | "matchStatus" | "tr">,
labels: Pick<MaterialPickLabels, "processingStatus" | "matchStatus" | "tr" | "na">,
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<MaterialPickLabels, "tr">,
labels: Pick<MaterialPickLabels, "tr" | "na">,
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),
};
};

+ 356
- 63
src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx Ver ficheiro

@@ -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 (
<>
<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 =>
`${row.itemCode || "unknown"}-${idx}`;

@@ -53,6 +106,8 @@ const DrinkProductionQtyDashboard: React.FC = () => {
const [data, setData] = useState<DrinkProductionQtyResponse[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [joStatusFilter, setJoStatusFilter] = useState<string>("");
const [viewMode, setViewMode] = useState<DrinkViewMode>("actual");
const [expandedRowKeys, setExpandedRowKeys] = useState<Set<string>>(
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 <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 (
<Card sx={{ mb: 2 }}>
<CardContent>
@@ -107,7 +197,11 @@ const DrinkProductionQtyDashboard: React.FC = () => {
{t("Drink Production Qty Dashboard")}
</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}>
<DatePicker
label={t("Date")}
@@ -122,8 +216,51 @@ const DrinkProductionQtyDashboard: React.FC = () => {
/>
</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 }} />

<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
variant="body2"
sx={{ color: "text.secondary" }}
@@ -137,6 +274,33 @@ const DrinkProductionQtyDashboard: React.FC = () => {
</Typography>
</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 ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
@@ -181,14 +345,18 @@ const DrinkProductionQtyDashboard: React.FC = () => {
{t("Unit")}
</Typography>
</TableCell>
<TableCell align="right" sx={{ width: 120 }}>
<TableCell align="right" sx={{ width: 140 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Stock Req. Qty")}
{isPlanned
? t("Planned Output Qty")
: t("Stock Req. Qty")}
</Typography>
</TableCell>
<TableCell align="right" sx={{ width: 140 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Production Qty")}
{isPlanned
? t("Actual Output Qty")
: t("Production Qty")}
</Typography>
</TableCell>
</TableRow>
@@ -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 (
<React.Fragment key={rowKey}>
<TableRow hover={hasJobOrders}>
<TableRow hover={hasExpandable}>
<TableCell padding="checkbox">
{hasJobOrders ? (
{hasExpandable ? (
<IconButton
size="small"
aria-label={
@@ -260,7 +431,7 @@ const DrinkProductionQtyDashboard: React.FC = () => {
</Typography>
</TableCell>
</TableRow>
{hasJobOrders && (
{hasExpandable && (
<TableRow>
<TableCell
colSpan={6}
@@ -272,63 +443,185 @@ const DrinkProductionQtyDashboard: React.FC = () => {
unmountOnExit
>
<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>
{formatProductionDate(
jo.productionDate,
)}
</TableCell>
<TableCell align="right">
{formatQty(jo.reqQty)}
<TableCell sx={{ fontWeight: 600 }}>
{t("Job Order Status")}
</TableCell>
<TableCell align="right">
{formatQty(jo.productionQty)}
<TableCell sx={{ fontWeight: 600 }}>
{t("Production Date")}
</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>
))}
</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>
</Collapse>
</TableCell>


+ 141
- 0
src/components/ProductionProcess/exportDrinkProductionQtyXlsx.ts Ver ficheiro

@@ -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,
);
}

+ 15
- 1
src/i18n/en/itemTracing.json Ver ficheiro

@@ -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"
}

+ 21
- 0
src/i18n/en/productionProcess.json Ver ficheiro

@@ -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",


+ 16
- 2
src/i18n/zh/itemTracing.json Ver ficheiro

@@ -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": "不足"
}

+ 21
- 0
src/i18n/zh/productionProcess.json Ver ficheiro

@@ -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": "工序格顯示",


Carregando…
Cancelar
Guardar