|
- "use client";
-
- import React, { useCallback, useEffect, useRef, useState } from "react";
- import {
- Alert,
- Box,
- Button,
- Checkbox,
- Chip,
- CircularProgress,
- FormControlLabel,
- Paper,
- Stack,
- Tab,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableRow,
- Tabs,
- TextField,
- Typography,
- } from "@mui/material";
- import { DateCalendar } from "@mui/x-date-pickers/DateCalendar";
- import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
- import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
- import dayjs, { type Dayjs } from "dayjs";
- import "dayjs/locale/zh-hk";
- import {
- runStockLedgerFixAdj,
- fetchStockLedgerFixDay,
- fetchStockLedgerFixInventory,
- fetchStockLedgerFixInventoryScope,
- fetchStockLedgerFixLotScope,
- runStockLedgerFixDay,
- runStockLedgerFixRange,
- runStockLedgerFixInventory,
- runStockLedgerFixInventoryScope,
- runStockLedgerFixLotScope,
- searchStockLedgerFixInventory,
- searchStockLedgerFixLot,
- downloadStockLedgerFixSql,
- type StockLedgerFixCheckPart,
- type StockLedgerFixDayDetail,
- type StockLedgerFixInventoryPreview,
- type StockLedgerFixRunResponse,
- type StockLedgerFixScopeDetail,
- type StockLedgerFixSearchInventoryHit,
- type StockLedgerFixSearchLotHit,
- } from "@/app/api/stockLedgerFix/client";
-
- const FIRST_LEDGER_DAY = dayjs("2026-03-01");
-
- const DAY_FIX_STEPS = ["2.1", "2.2", "2.3", "2.4", "2.5", "2.6"] as const;
- type DayFixStep = (typeof DAY_FIX_STEPS)[number];
- const ALL_DAY_STEPS: Record<DayFixStep, boolean> = {
- "2.1": true,
- "2.2": true,
- "2.3": true,
- "2.4": true,
- "2.5": true,
- "2.6": true,
- };
- const DAY_STEP_LABEL: Record<DayFixStep, string> = {
- "2.1": "2.1 lot",
- "2.2": "2.2 uom",
- "2.3": "2.3 inventoryId",
- "2.4": "2.4 lotQty",
- "2.5": "2.5 balance",
- "2.6": "2.6 日結",
- };
-
- /** Export SQL parts (aligned with fix steps + 1.0 / 2.7). */
- const EXPORT_PARTS = ["1.0", "2.1", "2.3", "ledger", "2.6", "2.7"] as const;
- type ExportPart = (typeof EXPORT_PARTS)[number];
- const DEFAULT_EXPORT_PARTS: Record<ExportPart, boolean> = {
- "1.0": false,
- "2.1": false,
- "2.3": false,
- ledger: true,
- "2.6": true,
- "2.7": false,
- };
- const FULL_EXPORT_PARTS: Record<ExportPart, boolean> = {
- "1.0": true,
- "2.1": true,
- "2.3": true,
- ledger: true,
- "2.6": true,
- "2.7": true,
- };
- const EXPORT_PART_LABEL: Record<ExportPart, string> = {
- "1.0": "1.0 inventory",
- "2.1": "2.1 軟刪流水帳+出倉列批號",
- "2.3": "2.3 inventoryId",
- ledger: "ledger lot/uom/lotQty/balance",
- "2.6": "2.6 日結",
- "2.7": "2.7 ADJ+入出倉單+調整記錄",
- };
-
- function selectedExportParts(flags: Record<ExportPart, boolean>): ExportPart[] {
- return EXPORT_PARTS.filter((p) => flags[p]);
- }
-
- function exportPartsPayload(flags: Record<ExportPart, boolean>): string[] | undefined {
- const selected = selectedExportParts(flags);
- if (selected.length === 0) return undefined;
- // Always send explicit list so backend does not fall back to legacy default alone
- return selected;
- }
-
- function selectedDaySteps(flags: Record<DayFixStep, boolean>): DayFixStep[] {
- return DAY_FIX_STEPS.filter((s) => flags[s]);
- }
-
- function stepsPayload(flags: Record<DayFixStep, boolean>): string[] | undefined {
- const selected = selectedDaySteps(flags);
- if (selected.length === 0 || selected.length === DAY_FIX_STEPS.length) return undefined;
- return selected;
- }
-
- function lotLineFixNote(res: StockLedgerFixRunResponse): string {
- const deleted = res.deletedUnlinkedLot ?? 0;
- const matched = res.existingLotMatch ?? 0;
- const mismatched = res.existingLotMismatch ?? 0;
- const blankOut = res.deletedBlankOutLine ?? 0;
- const alignedOut = res.alignedOutLine ?? 0;
- if (deleted === 0 && matched === 0 && mismatched === 0 && blankOut === 0 && alignedOut === 0) return "";
- return `、軟刪未連批號 ${deleted}、出倉列空而軟刪 ${blankOut}、出倉列已改跟帳本 ${alignedOut}、已有批號相符 ${matched}、不相符 ${mismatched}`;
- }
-
- function apiErrorMessage(e: unknown, fallback: string): string {
- if (e && typeof e === "object" && "response" in e) {
- const data = (e as { response?: { data?: unknown } }).response?.data;
- if (typeof data === "string" && data.trim()) {
- return data.trim().slice(0, 400);
- }
- if (data && typeof data === "object") {
- const msg = (data as { message?: unknown }).message;
- if (typeof msg === "string" && msg.trim()) {
- return msg.trim().slice(0, 400);
- }
- }
- }
- if (e instanceof Error && e.message) return e.message;
- return fallback;
- }
-
- function partVerdict(
- part: StockLedgerFixCheckPart,
- ): "correct" | "miss" | "incorrect" | "over-issue" | "can-fix" | "cannot-fix" {
- if (part.group === "canFix") {
- return part.miss > 0 || part.incorrect > 0 ? "can-fix" : "correct";
- }
- if (part.group === "cannotFix") {
- return part.miss > 0 || part.incorrect > 0 ? "cannot-fix" : "correct";
- }
- if (part.key === "overIssue") {
- if (part.incorrect > 0 || part.miss > 0) return "over-issue";
- return "correct";
- }
- if (part.incorrect > 0) return "incorrect";
- if (part.miss > 0) return "miss";
- if (part.key === "dayTable" && part.ok === 0) return "miss";
- return "correct";
- }
-
- function CheckPartsTable({ parts }: { parts: StockLedgerFixCheckPart[] }) {
- const field = parts.filter((p) => !p.group || p.group === "field");
- const canFix = parts.filter(
- (p) => p.group === "canFix" && p.miss + p.incorrect > 0,
- );
- const cannotFix = parts.filter(
- (p) => p.group === "cannotFix" && p.miss + p.incorrect > 0,
- );
- const renderRows = (rows: StockLedgerFixCheckPart[]) =>
- rows.map((p) => {
- const v = partVerdict(p);
- return (
- <TableRow key={p.key}>
- <TableCell>{p.label}</TableCell>
- <TableCell>
- <Chip
- size="small"
- label={
- v === "correct"
- ? "correct"
- : v === "miss"
- ? "miss"
- : v === "over-issue"
- ? "over-issue"
- : v === "can-fix"
- ? "可修"
- : v === "cannot-fix"
- ? "不能修"
- : "incorrect"
- }
- color={
- v === "correct"
- ? "success"
- : v === "miss" || v === "over-issue" || v === "can-fix"
- ? "warning"
- : "error"
- }
- />
- </TableCell>
- <TableCell align="right">{p.ok}</TableCell>
- <TableCell align="right">{p.miss}</TableCell>
- <TableCell align="right">{p.incorrect}</TableCell>
- </TableRow>
- );
- });
- return (
- <Stack spacing={2}>
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>項目</TableCell>
- <TableCell>狀態</TableCell>
- <TableCell align="right">正確</TableCell>
- <TableCell align="right">缺</TableCell>
- <TableCell align="right">不正確</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>{renderRows(field)}</TableBody>
- </Table>
- {canFix.length > 0 && (
- <>
- <Typography variant="subtitle2">可自動修(按 Fix 會處理)</Typography>
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>原因</TableCell>
- <TableCell>狀態</TableCell>
- <TableCell align="right">正確</TableCell>
- <TableCell align="right">列數</TableCell>
- <TableCell align="right">—</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>{renderRows(canFix)}</TableBody>
- </Table>
- </>
- )}
- {cannotFix.length > 0 && (
- <>
- <Typography variant="subtitle2">
- 不能自動修(Fix 不會消;舊 TKE 開新批/真超發/沒有 lot 來源)
- </Typography>
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>原因</TableCell>
- <TableCell>狀態</TableCell>
- <TableCell align="right">正確</TableCell>
- <TableCell align="right">—</TableCell>
- <TableCell align="right">列數</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>{renderRows(cannotFix)}</TableBody>
- </Table>
- </>
- )}
- </Stack>
- );
- }
-
- const StockLedgerFixPageClient: React.FC = () => {
- const [tab, setTab] = useState<"day" | "inventory" | "lot">("day");
- const [selected, setSelected] = useState<Dayjs | null>(() =>
- dayjs().subtract(1, "day"),
- );
- const [detail, setDetail] = useState<StockLedgerFixDayDetail | null>(null);
- const [detailLoading, setDetailLoading] = useState(false);
- const [detailError, setDetailError] = useState<string | null>(null);
- const [fixing, setFixing] = useState(false);
- const [fixError, setFixError] = useState<string | null>(null);
- const [fixMessage, setFixMessage] = useState<string | null>(null);
- const detailInFlight = useRef(false);
- const fixInFlight = useRef(false);
- const inventoryLoadInFlight = useRef(false);
- const inventoryRunInFlight = useRef(false);
- const searchInFlight = useRef(false);
- const scopeInFlight = useRef(false);
- const exportInFlight = useRef(false);
- const [chainFrom, setChainFrom] = useState("2026-03-15");
- const [chainTo, setChainTo] = useState(() =>
- dayjs().subtract(1, "day").format("YYYY-MM-DD"),
- );
- const [chainRunning, setChainRunning] = useState(false);
- const [chainProgress, setChainProgress] = useState<string | null>(null);
- const [daySteps, setDaySteps] = useState<Record<DayFixStep, boolean>>(ALL_DAY_STEPS);
- const [exportFrom, setExportFrom] = useState("2026-03-15");
- const [exportTo, setExportTo] = useState(() =>
- dayjs().subtract(1, "day").format("YYYY-MM-DD"),
- );
- const [exportParts, setExportParts] =
- useState<Record<ExportPart, boolean>>(DEFAULT_EXPORT_PARTS);
- const [exporting, setExporting] = useState(false);
- const [exportError, setExportError] = useState<string | null>(null);
- const [inventoryPreview, setInventoryPreview] =
- useState<StockLedgerFixInventoryPreview | null>(null);
- const [inventoryLoading, setInventoryLoading] = useState(false);
- const [inventoryRunning, setInventoryRunning] = useState(false);
- const [inventoryError, setInventoryError] = useState<string | null>(null);
- const [inventoryMessage, setInventoryMessage] = useState<string | null>(null);
- const adjRunInFlight = useRef(false);
- const [adjRunning, setAdjRunning] = useState(false);
- const [adjError, setAdjError] = useState<string | null>(null);
- const [adjMessage, setAdjMessage] = useState<string | null>(null);
- /** Default yesterday; freeze-night dump set to today so ADJ lands on dump day. */
- const [adjDate, setAdjDate] = useState(() =>
- dayjs().subtract(1, "day").format("YYYY-MM-DD"),
- );
- const [invQuery, setInvQuery] = useState("");
- const [lotQuery, setLotQuery] = useState("");
- const [invHits, setInvHits] = useState<StockLedgerFixSearchInventoryHit[]>([]);
- const [lotHits, setLotHits] = useState<StockLedgerFixSearchLotHit[]>([]);
- const [searchError, setSearchError] = useState<string | null>(null);
- const [searching, setSearching] = useState(false);
- const [scope, setScope] = useState<StockLedgerFixScopeDetail | null>(null);
- const [scopeLoading, setScopeLoading] = useState(false);
-
- const loadInventory = useCallback(async () => {
- if (inventoryLoadInFlight.current) return;
- inventoryLoadInFlight.current = true;
- setInventoryLoading(true);
- setInventoryError(null);
- try {
- const data = await fetchStockLedgerFixInventory();
- setInventoryPreview(data);
- } catch (e) {
- console.error(e);
- setInventoryError("無法載入 inventory 1.0 預覽(需要 ADMIN / TESTING)");
- setInventoryPreview(null);
- } finally {
- setInventoryLoading(false);
- inventoryLoadInFlight.current = false;
- }
- }, []);
-
- const loadDay = useCallback(async (date: string) => {
- if (detailInFlight.current) return;
- detailInFlight.current = true;
- setDetailLoading(true);
- setDetailError(null);
- setFixMessage(null);
- try {
- const data = await fetchStockLedgerFixDay(date);
- setDetail(data);
- } catch (e) {
- console.error(e);
- setDetailError("無法載入當日檢查");
- setDetail(null);
- } finally {
- setDetailLoading(false);
- detailInFlight.current = false;
- }
- }, []);
-
- useEffect(() => {
- void loadInventory();
- }, [loadInventory]);
-
- useEffect(() => {
- if (tab === "day" && selected) {
- void loadDay(selected.format("YYYY-MM-DD"));
- }
- }, [selected, loadDay, tab]);
-
- const onFixDay = async () => {
- if (!selected || fixInFlight.current) return;
- const date = selected.format("YYYY-MM-DD");
- if (selected.isAfter(dayjs(), "day")) {
- setFixError("不能修未來日期");
- return;
- }
- const picked = selectedDaySteps(daySteps);
- if (picked.length === 0) {
- setFixError("請至少勾一個步驟(預設全跑 2.1–2.6)");
- return;
- }
- const steps = stepsPayload(daySteps);
- const stepLabel = steps?.join("、") ?? "全部 2.1–2.6";
- if (steps) {
- const ok = window.confirm(
- `只跑 ${stepLabel}(${date})。未勾的步驟不會重算。確定?`,
- );
- if (!ok) return;
- }
- fixInFlight.current = true;
- setFixing(true);
- setFixError(null);
- setFixMessage(null);
- try {
- const res = await runStockLedgerFixDay(date, steps);
- setFixMessage(
- `已修 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
- );
- await loadDay(date);
- } catch (e) {
- console.error(e);
- setFixError(apiErrorMessage(e, "修復失敗"));
- } finally {
- setFixing(false);
- fixInFlight.current = false;
- }
- };
-
- const onInventory = async () => {
- if (inventoryRunInFlight.current) return;
- const ok = window.confirm(
- "方案 A:保留現有 inventory.id;從 lot 回填 stockUomId(舊 uomId 是 stock 則改回 base);補缺 (itemId, stockUomId);用 lot 的 in−out 重算 onHand/unavailable。不寫 onHoldQty、不改 inventory_lot_line。確定執行?",
- );
- if (!ok) return;
- inventoryRunInFlight.current = true;
- setInventoryRunning(true);
- setInventoryError(null);
- setInventoryMessage(null);
- try {
- const res = await runStockLedgerFixInventory();
- setInventoryMessage(
- `inventory 1.0 完成:回填 stockUomId ${res.patchedStockUomId} 列、新增 ${res.inserted} 列、殘列 deleted ${res.orphansDeleted ?? 0}、重算 ${res.updated} 列、之後仍缺 UOM ${res.missingUomPairsAfter}、仍 NULL stockUomId ${res.nullStockUomIdAfter}`,
- );
- await loadInventory();
- } catch (e) {
- console.error(e);
- setInventoryError(apiErrorMessage(e, "inventory 1.0 失敗"));
- } finally {
- setInventoryRunning(false);
- inventoryRunInFlight.current = false;
- }
- };
-
- const onAdjApply = async () => {
- if (adjRunInFlight.current) return;
- const d = adjDate.trim();
- if (!d) {
- setAdjError("請填 ADJ 日期");
- return;
- }
- if (d > dayjs().format("YYYY-MM-DD")) {
- setAdjError("ADJ 日期不能是未來");
- return;
- }
- const ok = window.confirm(
- `會在 ${d} 對最後餘額不等於 onHandQty 的庫存桶各寫一筆 ADJ(入或出),掛在既有 lot 上,並寫入庫存調整記錄。不改 lot line、不改 inventory。已對齊的桶不會再寫;缺調整記錄的舊 ADJ 會補上。確定?`,
- );
- if (!ok) return;
- adjRunInFlight.current = true;
- setAdjRunning(true);
- setAdjError(null);
- setAdjMessage(null);
- try {
- const res = await runStockLedgerFixAdj(d);
- setAdjMessage(
- `ADJ ${res.adjDate}:入 ${res.insertedIn}、出 ${res.insertedOut}、調整記錄 ${res.adjustmentRecords ?? 0}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`,
- );
- } catch (e) {
- console.error(e);
- setAdjError(apiErrorMessage(e, "ADJ 對齊失敗"));
- } finally {
- setAdjRunning(false);
- adjRunInFlight.current = false;
- }
- };
-
- const onSearchInventory = async () => {
- if (searchInFlight.current || !invQuery.trim()) return;
- searchInFlight.current = true;
- setSearching(true);
- setSearchError(null);
- try {
- const hits = await searchStockLedgerFixInventory(invQuery.trim());
- setInvHits(hits);
- setScope(null);
- } catch (e) {
- console.error(e);
- setSearchError(apiErrorMessage(e, "搜尋失敗"));
- } finally {
- setSearching(false);
- searchInFlight.current = false;
- }
- };
-
- const onSearchLot = async () => {
- if (searchInFlight.current || !lotQuery.trim()) return;
- searchInFlight.current = true;
- setSearching(true);
- setSearchError(null);
- try {
- const hits = await searchStockLedgerFixLot(lotQuery.trim());
- setLotHits(hits);
- setScope(null);
- } catch (e) {
- console.error(e);
- setSearchError(apiErrorMessage(e, "搜尋失敗"));
- } finally {
- setSearching(false);
- searchInFlight.current = false;
- }
- };
-
- const loadInventoryScope = async (id: number) => {
- if (scopeInFlight.current) return;
- scopeInFlight.current = true;
- setScopeLoading(true);
- setFixError(null);
- setFixMessage(null);
- try {
- setScope(await fetchStockLedgerFixInventoryScope(id));
- } catch (e) {
- console.error(e);
- setFixError(apiErrorMessage(e, "無法載入 inventory"));
- setScope(null);
- } finally {
- setScopeLoading(false);
- scopeInFlight.current = false;
- }
- };
-
- const loadLotScope = async (id: number) => {
- if (scopeInFlight.current) return;
- scopeInFlight.current = true;
- setScopeLoading(true);
- setFixError(null);
- setFixMessage(null);
- try {
- setScope(await fetchStockLedgerFixLotScope(id));
- } catch (e) {
- console.error(e);
- setFixError(apiErrorMessage(e, "無法載入 lot"));
- setScope(null);
- } finally {
- setScopeLoading(false);
- scopeInFlight.current = false;
- }
- };
-
- const onFixInventoryScope = async () => {
- if (!scope || scope.kind !== "inventory" || fixInFlight.current) return;
- const ok = window.confirm(
- "會重算這顆 inventory 到今天為止全部 ledger 的 lotQty、日結,以及這顆料的 balance。確定?",
- );
- if (!ok) return;
- fixInFlight.current = true;
- setFixing(true);
- setFixError(null);
- setFixMessage(null);
- try {
- const res = await runStockLedgerFixInventoryScope(scope.id);
- setFixMessage(
- `已修 inventory ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
- );
- await loadInventoryScope(scope.id);
- } catch (e) {
- console.error(e);
- setFixError(apiErrorMessage(e, "修復失敗"));
- } finally {
- setFixing(false);
- fixInFlight.current = false;
- }
- };
-
- const onFixLotScope = async () => {
- if (!scope || scope.kind !== "lot" || fixInFlight.current) return;
- const ok = window.confirm(
- "只修這張 lot 的 lotQty 與日結,不會改整顆料的 balance。確定?",
- );
- if (!ok) return;
- fixInFlight.current = true;
- setFixing(true);
- setFixError(null);
- setFixMessage(null);
- try {
- const res = await runStockLedgerFixLotScope(scope.id);
- setFixMessage(
- `已修 lot ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
- );
- await loadLotScope(scope.id);
- } catch (e) {
- console.error(e);
- setFixError(apiErrorMessage(e, "修復失敗"));
- } finally {
- setFixing(false);
- fixInFlight.current = false;
- }
- };
-
- const canFixDay = Boolean(selected && !selected.isAfter(dayjs(), "day"));
-
- const onFixDayRange = async () => {
- if (fixInFlight.current) return;
- const from = chainFrom.trim();
- const to = chainTo.trim();
- const today = dayjs().format("YYYY-MM-DD");
- if (!from || !to) {
- setFixError("請填區間修 from / to");
- return;
- }
- if (to < from) {
- setFixError("to 必須 ≥ from");
- return;
- }
- if (from < FIRST_LEDGER_DAY.format("YYYY-MM-DD")) {
- setFixError(`from 不能早於 ${FIRST_LEDGER_DAY.format("YYYY-MM-DD")}`);
- return;
- }
- if (to > today) {
- setFixError("不能修未來日期(今天可以,給 freeze dump 夜修)");
- return;
- }
- const picked = selectedDaySteps(daySteps);
- if (picked.length === 0) {
- setFixError("請至少勾一個步驟(預設全跑 2.1–2.6)");
- return;
- }
- const steps = stepsPayload(daySteps);
- const stepLabel = steps?.join("、") ?? "全部 2.1–2.6";
- const ok = window.confirm(
- `一次修 ${from} → ${to}(步驟:${stepLabel})。改為逐日跑(每天 commit)。中途失敗時已跑完的天會留下。確定?`,
- );
- if (!ok) return;
-
- fixInFlight.current = true;
- setChainRunning(true);
- setFixing(true);
- setFixError(null);
- setFixMessage(null);
- setChainProgress(`區間修 ${from} → ${to}(${stepLabel})進行中…`);
- try {
- const res = await runStockLedgerFixRange(from, to, steps);
- setFixMessage(
- `區間修完成 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
- );
- setSelected(dayjs(to));
- setChainProgress(null);
- } catch (e) {
- console.error(e);
- setFixError(apiErrorMessage(e, "區間修失敗"));
- } finally {
- setFixing(false);
- setChainRunning(false);
- fixInFlight.current = false;
- }
- };
-
- const onExportSql = async () => {
- if (exportInFlight.current) return;
- const from = exportFrom.trim();
- const to = exportTo.trim();
- if (!from || !to) {
- setExportError("請填 from / to");
- return;
- }
- if (to < from) {
- setExportError("to 必須 ≥ from");
- return;
- }
- const picked = selectedExportParts(exportParts);
- if (picked.length === 0) {
- setExportError("請至少勾一個匯出項目");
- return;
- }
- if (exportParts["2.3"] && !exportParts["1.0"]) {
- const ok = window.confirm(
- "已勾 2.3 inventoryId 但未勾 1.0 inventory:正式庫若缺新 id,UPDATE 會指到空列。建議一併勾 1.0(完整檔)。仍要匯出?",
- );
- if (!ok) return;
- }
- const parts = exportPartsPayload(exportParts);
- exportInFlight.current = true;
- setExporting(true);
- setExportError(null);
- try {
- await downloadStockLedgerFixSql(from, to, parts);
- } catch (e) {
- console.error(e);
- const data = (e as { response?: { data?: unknown } })?.response?.data;
- if (data instanceof Blob) {
- try {
- const text = (await data.text()).trim().slice(0, 400);
- setExportError(text || "匯出失敗");
- } catch {
- setExportError(apiErrorMessage(e, "匯出失敗"));
- }
- } else {
- setExportError(apiErrorMessage(e, "匯出失敗"));
- }
- } finally {
- setExporting(false);
- exportInFlight.current = false;
- }
- };
-
- return (
- <Stack spacing={3}>
- <Paper sx={{ p: 2 }}>
- <Stack spacing={1.5}>
- <Typography variant="h6">1.0 inventory(方案 A)</Typography>
- <Typography variant="body2" color="text.secondary">
- 保留現有 id;lot 有、inventory 沒有的 UOM 會新增;onHand = Σ(in−out) 按
- item+UOM。沒有 lot 的列會變成 0。同一料多顆舊列時只留一顆 stock UOM
- 桶,其餘 deleted=1。請在進出倉暫停時執行,再逐日 Fix。
- </Typography>
- {inventoryError && <Alert severity="error">{inventoryError}</Alert>}
- {inventoryMessage && (
- <Alert severity="success">{inventoryMessage}</Alert>
- )}
- {inventoryLoading && !inventoryPreview && <CircularProgress size={24} />}
- {inventoryPreview && (
- <Typography variant="body2">
- 現有 inventory {inventoryPreview.inventoryRows} 列;lot 的
- item+UOM {inventoryPreview.lotUomPairs} 組;缺{" "}
- {inventoryPreview.missingUomPairs} 組;stockUomId 仍 NULL{" "}
- {inventoryPreview.nullStockUomId} 列。
- </Typography>
- )}
- <Box>
- <Button
- variant="contained"
- color="warning"
- disabled={inventoryRunning || inventoryLoading}
- onClick={() => void onInventory()}
- >
- {inventoryRunning ? "重算中…" : "Run inventory 1.0 (A)"}
- </Button>
- </Box>
- </Stack>
- </Paper>
-
- <Paper sx={{ p: 2 }}>
- <Stack spacing={1.5}>
- <Typography variant="h6">2.7 ADJ:對齊最後餘額與 onHandQty</Typography>
- <Typography variant="body2" color="text.secondary">
- 選 ADJ 日期後直接執行。預設昨天;freeze dump 夜可選今天。最後餘額低於 onHandQty
- 的桶補一筆入倉 ADJ,高於 onHandQty 的桶補一筆出倉 ADJ,掛在既有 lot 上。不改 lot
- line、不改 inventory。一次執行共用一張入倉單、一張出倉單;已對齊的桶再跑不會再寫。
- </Typography>
- <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
- <TextField
- size="small"
- type="date"
- label="ADJ 日期"
- value={adjDate}
- onChange={(e) => {
- setAdjDate(e.target.value);
- setAdjMessage(null);
- }}
- disabled={adjRunning}
- InputLabelProps={{ shrink: true }}
- inputProps={{ max: dayjs().format("YYYY-MM-DD") }}
- />
- <Button
- size="small"
- disabled={adjRunning}
- onClick={() => {
- setAdjDate(dayjs().format("YYYY-MM-DD"));
- setAdjMessage(null);
- }}
- >
- 今天(freeze 夜)
- </Button>
- <Button
- size="small"
- disabled={adjRunning}
- onClick={() => {
- setAdjDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"));
- setAdjMessage(null);
- }}
- >
- 昨天
- </Button>
- </Stack>
- {adjError && <Alert severity="error">{adjError}</Alert>}
- {adjMessage && <Alert severity="success">{adjMessage}</Alert>}
- {adjRunning && <CircularProgress size={24} />}
- <Stack direction="row" spacing={1}>
- <Button
- variant="contained"
- color="warning"
- disabled={adjRunning}
- onClick={() => void onAdjApply()}
- >
- {adjRunning ? "寫入 ADJ 中…" : "Apply ADJ"}
- </Button>
- </Stack>
- </Stack>
- </Paper>
-
- <Paper sx={{ px: 2, pt: 1 }}>
- <Tabs
- value={tab}
- onChange={(_, v: "day" | "inventory" | "lot") => {
- setTab(v);
- setFixError(null);
- setFixMessage(null);
- setSearchError(null);
- }}
- >
- <Tab label="日曆" value="day" />
- <Tab label="Inventory" value="inventory" />
- <Tab label="Lot line" value="lot" />
- </Tabs>
- </Paper>
-
- {tab === "day" && (
- <Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="flex-start">
- <Paper sx={{ p: 1 }}>
- <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
- <DateCalendar
- value={selected}
- onChange={(v) => setSelected(v)}
- views={["year", "month", "day"]}
- openTo="day"
- minDate={FIRST_LEDGER_DAY}
- maxDate={dayjs()}
- />
- </LocalizationProvider>
- <Typography variant="caption" color="text.secondary" sx={{ px: 2, pb: 1, display: "block" }}>
- 點左上標題可先選年再選月。換月不會查資料;點某一天才檢查當天。今天可修(freeze dump 夜)。
- </Typography>
- </Paper>
-
- <Paper sx={{ p: 2, flex: 1, minWidth: 0, width: "100%" }}>
- <Stack spacing={2}>
- <Typography variant="h6">
- {selected ? selected.format("YYYY-MM-DD") : "請選日期"}
- </Typography>
- {detailError && <Alert severity="error">{detailError}</Alert>}
- {fixError && <Alert severity="error">{fixError}</Alert>}
- {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
- {chainProgress && <Alert severity="info">{chainProgress}</Alert>}
- {detailLoading && <CircularProgress size={24} />}
- {detail && !detailLoading && (
- <>
- <Typography variant="body2" color="text.secondary">
- 當天 ledger {detail.cnt} 列
- </Typography>
- <CheckPartsTable parts={detail.parts} />
- </>
- )}
- <Box>
- <Typography variant="subtitle2" sx={{ mb: 0.5 }}>
- 步驟(預設全跑)
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
- 套 SQL 後只改 inventoryId 時只勾 2.3。只跑部分時未勾的不會重算。
- </Typography>
- <Stack direction="row" spacing={0} flexWrap="wrap" useFlexGap>
- {DAY_FIX_STEPS.map((step) => (
- <FormControlLabel
- key={step}
- control={
- <Checkbox
- size="small"
- checked={daySteps[step]}
- disabled={fixing || chainRunning}
- onChange={(_, checked) =>
- setDaySteps((prev) => ({ ...prev, [step]: checked }))
- }
- />
- }
- label={DAY_STEP_LABEL[step]}
- />
- ))}
- <Button
- size="small"
- disabled={fixing || chainRunning}
- onClick={() => setDaySteps(ALL_DAY_STEPS)}
- >
- 全選
- </Button>
- </Stack>
- </Box>
- <Box>
- <Button
- variant="contained"
- disabled={!canFixDay || fixing || chainRunning}
- onClick={() => void onFixDay()}
- >
- {fixing && !chainRunning ? "修復中…" : "Fix this day"}
- </Button>
- </Box>
- <Box sx={{ pt: 1 }}>
- <Typography variant="subtitle2" sx={{ mb: 1 }}>
- 區間修(2.1–2.6 一次)
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
- 使用上面勾的步驟,一次 API 但改為逐日(與單日修相同 SQL,每天 commit)。可含今天(freeze dump 夜)。前端最長約 20 分鐘;大區間仍建議匯出 SQL。
- </Typography>
- <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
- <TextField
- size="small"
- type="date"
- label="from"
- value={chainFrom}
- onChange={(e) => setChainFrom(e.target.value)}
- disabled={chainRunning}
- InputLabelProps={{ shrink: true }}
- />
- <TextField
- size="small"
- type="date"
- label="to"
- value={chainTo}
- onChange={(e) => setChainTo(e.target.value)}
- disabled={chainRunning}
- InputLabelProps={{ shrink: true }}
- />
- <Button
- variant="contained"
- disabled={fixing || chainRunning}
- onClick={() => void onFixDayRange()}
- >
- {chainRunning ? "區間修中…" : "區間修"}
- </Button>
- </Stack>
- </Box>
- <Box sx={{ pt: 1 }}>
- <Typography variant="subtitle2" sx={{ mb: 1 }}>
- 匯出已修 SQL(新庫用)
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
- 勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → 2.1 → ledger(±2.3)→ 2.7 入出倉單+調整記錄+ADJ →
- 2.6。完整檔且正式庫 freeze/id 同源、日期蓋過全部已修日子時,結果與已修庫相同。預設只匯
- ledger+日結(舊行為)。
- </Typography>
- <Stack direction="row" spacing={0} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
- {EXPORT_PARTS.map((part) => (
- <FormControlLabel
- key={part}
- control={
- <Checkbox
- size="small"
- checked={exportParts[part]}
- disabled={exporting}
- onChange={(_, checked) =>
- setExportParts((prev) => ({ ...prev, [part]: checked }))
- }
- />
- }
- label={EXPORT_PART_LABEL[part]}
- />
- ))}
- <Button
- size="small"
- disabled={exporting}
- onClick={() => setExportParts(DEFAULT_EXPORT_PARTS)}
- >
- 預設
- </Button>
- <Button
- size="small"
- disabled={exporting}
- onClick={() => setExportParts(FULL_EXPORT_PARTS)}
- >
- 完整檔
- </Button>
- </Stack>
- {exportError && <Alert severity="error">{exportError}</Alert>}
- <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
- <TextField
- size="small"
- type="date"
- label="from"
- value={exportFrom}
- onChange={(e) => setExportFrom(e.target.value)}
- InputLabelProps={{ shrink: true }}
- />
- <TextField
- size="small"
- type="date"
- label="to"
- value={exportTo}
- onChange={(e) => setExportTo(e.target.value)}
- InputLabelProps={{ shrink: true }}
- />
- <Button
- variant="outlined"
- disabled={exporting}
- onClick={() => void onExportSql()}
- >
- {exporting ? "匯出中…" : "匯出 .sql"}
- </Button>
- </Stack>
- </Box>
- </Stack>
- </Paper>
- </Stack>
- )}
-
- {tab === "inventory" && (
- <Paper sx={{ p: 2 }}>
- <Stack spacing={2}>
- <Typography variant="body2" color="text.secondary">
- 搜 itemCode 或 inventoryId。Fix 會重算這顆料到今天為止全部流水的 lotQty、日結與
- balance,不必逐日點日曆。白天倉還在寫這顆料時,修完後新流水仍由 live writer 接。
- </Typography>
- <Stack direction="row" spacing={1}>
- <TextField
- size="small"
- label="itemCode / inventoryId"
- value={invQuery}
- onChange={(e) => setInvQuery(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") void onSearchInventory();
- }}
- />
- <Button
- variant="outlined"
- disabled={searching || !invQuery.trim()}
- onClick={() => void onSearchInventory()}
- >
- {searching ? "搜尋中…" : "搜尋"}
- </Button>
- </Stack>
- {searchError && <Alert severity="error">{searchError}</Alert>}
- {invHits.length > 0 && (
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>inventoryId</TableCell>
- <TableCell>itemCode</TableCell>
- <TableCell>uomId</TableCell>
- <TableCell align="right">ledger 列</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {invHits.map((h) => (
- <TableRow
- key={h.inventoryId}
- hover
- selected={scope?.kind === "inventory" && scope.id === h.inventoryId}
- onClick={() => void loadInventoryScope(h.inventoryId)}
- sx={{ cursor: "pointer" }}
- >
- <TableCell>{h.inventoryId}</TableCell>
- <TableCell>{h.itemCode ?? "—"}</TableCell>
- <TableCell>{h.uomId ?? "—"}</TableCell>
- <TableCell align="right">{h.ledgerCnt}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )}
- {fixError && <Alert severity="error">{fixError}</Alert>}
- {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
- {scopeLoading && <CircularProgress size={24} />}
- {scope?.kind === "inventory" && !scopeLoading && (
- <>
- <Typography variant="body2">
- {scope.itemCode ?? "?"} / inventory {scope.id} / uom {scope.uomId ?? "?"}
- ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後
- balance {scope.lastBalance ?? "—"}
- </Typography>
- <CheckPartsTable parts={scope.parts} />
- <Box>
- <Button
- variant="contained"
- disabled={fixing || scope.cnt === 0}
- onClick={() => void onFixInventoryScope()}
- >
- {fixing ? "修復中…" : "Fix this inventory"}
- </Button>
- </Box>
- </>
- )}
- </Stack>
- </Paper>
- )}
-
- {tab === "lot" && (
- <Paper sx={{ p: 2 }}>
- <Stack spacing={2}>
- <Typography variant="body2" color="text.secondary">
- 搜 lotNo 或 inventoryLotLineId。Fix 只重算這張 lot 的 lotQty 與日結,不改
- balance。
- </Typography>
- <Stack direction="row" spacing={1}>
- <TextField
- size="small"
- label="lotNo / lot line id"
- value={lotQuery}
- onChange={(e) => setLotQuery(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") void onSearchLot();
- }}
- />
- <Button
- variant="outlined"
- disabled={searching || !lotQuery.trim()}
- onClick={() => void onSearchLot()}
- >
- {searching ? "搜尋中…" : "搜尋"}
- </Button>
- </Stack>
- {searchError && <Alert severity="error">{searchError}</Alert>}
- {lotHits.length > 0 && (
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>lot line id</TableCell>
- <TableCell>lotNo</TableCell>
- <TableCell>itemCode</TableCell>
- <TableCell>inventoryId</TableCell>
- <TableCell align="right">ledger 列</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {lotHits.map((h) => (
- <TableRow
- key={h.inventoryLotLineId}
- hover
- selected={scope?.kind === "lot" && scope.id === h.inventoryLotLineId}
- onClick={() => void loadLotScope(h.inventoryLotLineId)}
- sx={{ cursor: "pointer" }}
- >
- <TableCell>{h.inventoryLotLineId}</TableCell>
- <TableCell>{h.lotNo ?? "—"}</TableCell>
- <TableCell>{h.itemCode ?? "—"}</TableCell>
- <TableCell>{h.inventoryId ?? "—"}</TableCell>
- <TableCell align="right">{h.ledgerCnt}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )}
- {fixError && <Alert severity="error">{fixError}</Alert>}
- {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
- {scopeLoading && <CircularProgress size={24} />}
- {scope?.kind === "lot" && !scopeLoading && (
- <>
- <Typography variant="body2">
- {scope.lotNo ?? "?"} / lot {scope.id} / {scope.itemCode ?? "?"}
- ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後
- lotQtyAfter {scope.lastLotQtyAfter ?? "—"}
- </Typography>
- <CheckPartsTable parts={scope.parts} />
- <Box>
- <Button
- variant="contained"
- disabled={fixing || scope.cnt === 0}
- onClick={() => void onFixLotScope()}
- >
- {fixing ? "修復中…" : "Fix this lot"}
- </Button>
- </Box>
- </>
- )}
- </Stack>
- </Paper>
- )}
- </Stack>
- );
- };
-
- export default StockLedgerFixPageClient;
|