diff --git a/src/app/(main)/settings/stockLedgerFix/page.tsx b/src/app/(main)/settings/stockLedgerFix/page.tsx
new file mode 100644
index 00000000..1602d68c
--- /dev/null
+++ b/src/app/(main)/settings/stockLedgerFix/page.tsx
@@ -0,0 +1,18 @@
+import { Metadata } from "next";
+import PageTitleBar from "@/components/PageTitleBar";
+import StockLedgerFixPageClient from "@/components/StockLedgerFix/StockLedgerFixPageClient";
+
+export const metadata: Metadata = {
+ title: "Stock Ledger Fix",
+};
+
+const StockLedgerFixPage: React.FC = () => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default StockLedgerFixPage;
diff --git a/src/app/api/stockLedgerFix/client.ts b/src/app/api/stockLedgerFix/client.ts
new file mode 100644
index 00000000..5c72dd55
--- /dev/null
+++ b/src/app/api/stockLedgerFix/client.ts
@@ -0,0 +1,311 @@
+"use client";
+
+import axiosInstance from "@/app/(main)/axios/axiosInstance";
+import { NEXT_PUBLIC_API_URL } from "@/config/api";
+
+export type StockLedgerFixDayStatus = {
+ date: string;
+ cnt: number;
+ missLot: number;
+ missUom: number;
+ missInventoryId: number;
+ missLotQty: number;
+ dayTableLots: number;
+};
+
+export type StockLedgerFixCalendarResponse = {
+ from: string;
+ to: string;
+ days: StockLedgerFixDayStatus[];
+};
+
+export type StockLedgerFixCheckPart = {
+ key: string;
+ label: string;
+ ok: number;
+ miss: number;
+ incorrect: number;
+ group?: string;
+};
+
+export type StockLedgerFixDayDetail = {
+ date: string;
+ cnt: number;
+ parts: StockLedgerFixCheckPart[];
+};
+
+export type StockLedgerFixRunResponse = {
+ date: string;
+ filledLotLineId: number;
+ filledUomId: number;
+ filledInventoryId: number;
+ filledLotQty: number;
+ filledBalance: number;
+ dayRowsWritten: number;
+ stillMissLot: number;
+ stillMissUom: number;
+ stillMissInventoryId: number;
+ stillMissLotQty: number;
+};
+
+export type StockLedgerFixInventoryPreview = {
+ inventoryRows: number;
+ lotUomPairs: number;
+ missingUomPairs: number;
+};
+
+export type StockLedgerFixInventoryResponse = {
+ inserted: number;
+ updated: number;
+ missingUomPairsAfter: number;
+};
+
+export type StockLedgerFixSearchInventoryHit = {
+ inventoryId: number;
+ itemId: number | null;
+ itemCode: string | null;
+ uomId: number | null;
+ ledgerCnt: number;
+};
+
+export type StockLedgerFixSearchLotHit = {
+ inventoryLotLineId: number;
+ lotNo: string | null;
+ itemCode: string | null;
+ inventoryId: number | null;
+ ledgerCnt: number;
+};
+
+export type StockLedgerFixScopeDetail = {
+ kind: string;
+ id: number;
+ itemCode: string | null;
+ lotNo: string | null;
+ inventoryId: number | null;
+ uomId: number | null;
+ firstDate: string | null;
+ lastDate: string | null;
+ cnt: number;
+ lastBalance: string | null;
+ lastLotQtyAfter: string | null;
+ parts: StockLedgerFixCheckPart[];
+};
+
+export type StockLedgerFixAdjRow = {
+ lotLineId: number;
+ inventoryId: number | null;
+ itemCode: string | null;
+ lineIn: string;
+ lineOut: string;
+ ledgerIn: string;
+ ledgerOut: string;
+ missIn: string;
+ missOut: string;
+};
+
+export type StockLedgerFixAdjPreview = {
+ adjDate: string;
+ lotCount: number;
+ adjInCount: number;
+ adjOutCount: number;
+ skippedNegCount: number;
+ sumMissIn: string;
+ sumMissOut: string;
+ skuNet: string;
+ rows: StockLedgerFixAdjRow[];
+};
+
+export type StockLedgerFixAdjResponse = {
+ adjDate: string;
+ insertedIn: number;
+ insertedOut: number;
+ filledLotQty: number;
+ filledBalance: number;
+ dayRowsWritten: number;
+};
+
+export async function fetchStockLedgerFixAdjPreview(
+ adjDate?: string,
+): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/adj`,
+ {
+ params: {
+ rowLimit: 20,
+ ...(adjDate ? { adjDate } : {}),
+ },
+ timeout: 600000,
+ },
+ );
+ return response.data;
+}
+
+export async function runStockLedgerFixAdj(
+ adjDate?: string,
+): Promise {
+ const response = await axiosInstance.post(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/adj`,
+ adjDate ? { adjDate } : {},
+ { timeout: 1200000 },
+ );
+ return response.data;
+}
+
+export async function fetchStockLedgerFixCalendar(
+ from: string,
+ to: string,
+): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/calendar`,
+ { params: { from, to } },
+ );
+ return response.data;
+}
+
+export async function fetchStockLedgerFixDay(
+ date: string,
+): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/day`,
+ { params: { date } },
+ );
+ return response.data;
+}
+
+export async function runStockLedgerFixDay(
+ date: string,
+ steps?: string[],
+): Promise {
+ const response = await axiosInstance.post(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`,
+ { date, ...(steps && steps.length > 0 ? { steps } : {}) },
+ );
+ return response.data;
+}
+
+export async function runStockLedgerFixRange(
+ from: string,
+ to: string,
+ steps?: string[],
+): Promise {
+ const response = await axiosInstance.post(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`,
+ {
+ mode: "range",
+ from,
+ to,
+ ...(steps && steps.length > 0 ? { steps } : {}),
+ },
+ { timeout: 1200000 },
+ );
+ return response.data;
+}
+
+export async function fetchStockLedgerFixInventory(): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/inventory`,
+ );
+ return response.data;
+}
+
+export async function runStockLedgerFixInventory(): Promise {
+ const response = await axiosInstance.post(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/inventory`,
+ );
+ return response.data;
+}
+
+export async function searchStockLedgerFixInventory(
+ q: string,
+): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/lookup/inventory`,
+ { params: { q } },
+ );
+ return response.data;
+}
+
+export async function searchStockLedgerFixLot(
+ q: string,
+): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/lookup/lot`,
+ { params: { q } },
+ );
+ return response.data;
+}
+
+export async function fetchStockLedgerFixInventoryScope(
+ id: number,
+): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/scope/inventory`,
+ { params: { id } },
+ );
+ return response.data;
+}
+
+export async function fetchStockLedgerFixLotScope(
+ id: number,
+): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/scope/lot`,
+ { params: { id } },
+ );
+ return response.data;
+}
+
+export async function runStockLedgerFixInventoryScope(
+ inventoryId: number,
+): Promise {
+ const response = await axiosInstance.post(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`,
+ { mode: "inventory", inventoryId },
+ );
+ return response.data;
+}
+
+export async function runStockLedgerFixLotScope(
+ lotLineId: number,
+): Promise {
+ const response = await axiosInstance.post(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`,
+ { mode: "lot", lotLineId },
+ );
+ return response.data;
+}
+
+export async function downloadStockLedgerFixSql(
+ from: string,
+ to: string,
+ parts?: string[],
+): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/export`,
+ {
+ params: {
+ from,
+ to,
+ ...(parts && parts.length > 0 ? { parts } : {}),
+ },
+ paramsSerializer: {
+ indexes: null,
+ },
+ responseType: "blob",
+ timeout: 600000,
+ },
+ );
+ const blob = new Blob([response.data], { type: "application/sql;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ const partTag =
+ parts && parts.length > 0 && parts.length < 5
+ ? `_${parts.join("-").replaceAll(".", "")}`
+ : "";
+ a.download = `stock_ledger_fix_${from.replaceAll("-", "")}_${to.replaceAll("-", "")}${partTag}.sql`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx
new file mode 100644
index 00000000..60d5f9e7
--- /dev/null
+++ b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx
@@ -0,0 +1,1220 @@
+"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 {
+ fetchStockLedgerFixAdjPreview,
+ runStockLedgerFixAdj,
+ fetchStockLedgerFixDay,
+ fetchStockLedgerFixInventory,
+ fetchStockLedgerFixInventoryScope,
+ fetchStockLedgerFixLotScope,
+ runStockLedgerFixDay,
+ runStockLedgerFixRange,
+ runStockLedgerFixInventory,
+ runStockLedgerFixInventoryScope,
+ runStockLedgerFixLotScope,
+ searchStockLedgerFixInventory,
+ searchStockLedgerFixLot,
+ downloadStockLedgerFixSql,
+ type StockLedgerFixAdjPreview,
+ type StockLedgerFixCheckPart,
+ type StockLedgerFixDayDetail,
+ type StockLedgerFixInventoryPreview,
+ 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 = {
+ "2.1": true,
+ "2.2": true,
+ "2.3": true,
+ "2.4": true,
+ "2.5": true,
+ "2.6": true,
+};
+const DAY_STEP_LABEL: Record = {
+ "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.3", "ledger", "2.6", "2.7"] as const;
+type ExportPart = (typeof EXPORT_PARTS)[number];
+const DEFAULT_EXPORT_PARTS: Record = {
+ "1.0": false,
+ "2.3": false,
+ ledger: true,
+ "2.6": true,
+ "2.7": false,
+};
+const FULL_EXPORT_PARTS: Record = {
+ "1.0": true,
+ "2.3": true,
+ ledger: true,
+ "2.6": true,
+ "2.7": true,
+};
+const EXPORT_PART_LABEL: Record = {
+ "1.0": "1.0 inventory",
+ "2.3": "2.3 inventoryId",
+ ledger: "ledger lot/uom/lotQty/balance",
+ "2.6": "2.6 日結",
+ "2.7": "2.7 ADJ INSERT",
+};
+
+function selectedExportParts(flags: Record): ExportPart[] {
+ return EXPORT_PARTS.filter((p) => flags[p]);
+}
+
+function exportPartsPayload(flags: Record): 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[] {
+ return DAY_FIX_STEPS.filter((s) => flags[s]);
+}
+
+function stepsPayload(flags: Record): string[] | undefined {
+ const selected = selectedDaySteps(flags);
+ if (selected.length === 0 || selected.length === DAY_FIX_STEPS.length) return undefined;
+ return selected;
+}
+
+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 (
+
+ {p.label}
+
+
+
+ {p.ok}
+ {p.miss}
+ {p.incorrect}
+
+ );
+ });
+ return (
+
+
+
+
+ 項目
+ 狀態
+ 正確
+ 缺
+ 不正確
+
+
+ {renderRows(field)}
+
+ {canFix.length > 0 && (
+ <>
+ 可自動修(按 Fix 會處理)
+
+
+
+ 原因
+ 狀態
+ 正確
+ 列數
+ —
+
+
+ {renderRows(canFix)}
+
+ >
+ )}
+ {cannotFix.length > 0 && (
+ <>
+
+ 不能自動修(Fix 不會消;舊 TKE 開新批/真超發/沒有 lot 來源)
+
+
+
+
+ 原因
+ 狀態
+ 正確
+ —
+ 列數
+
+
+ {renderRows(cannotFix)}
+
+ >
+ )}
+
+ );
+}
+
+const StockLedgerFixPageClient: React.FC = () => {
+ const [tab, setTab] = useState<"day" | "inventory" | "lot">("day");
+ const [selected, setSelected] = useState(() =>
+ dayjs().subtract(1, "day"),
+ );
+ const [detail, setDetail] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [detailError, setDetailError] = useState(null);
+ const [fixing, setFixing] = useState(false);
+ const [fixError, setFixError] = useState(null);
+ const [fixMessage, setFixMessage] = useState(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(null);
+ const [daySteps, setDaySteps] = useState>(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>(DEFAULT_EXPORT_PARTS);
+ const [exporting, setExporting] = useState(false);
+ const [exportError, setExportError] = useState(null);
+ const [inventoryPreview, setInventoryPreview] =
+ useState(null);
+ const [inventoryLoading, setInventoryLoading] = useState(false);
+ const [inventoryRunning, setInventoryRunning] = useState(false);
+ const [inventoryError, setInventoryError] = useState(null);
+ const [inventoryMessage, setInventoryMessage] = useState(null);
+ const adjLoadInFlight = useRef(false);
+ const adjRunInFlight = useRef(false);
+ const [adjPreview, setAdjPreview] = useState(null);
+ const [adjLoading, setAdjLoading] = useState(false);
+ const [adjRunning, setAdjRunning] = useState(false);
+ const [adjError, setAdjError] = useState(null);
+ const [adjMessage, setAdjMessage] = useState(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([]);
+ const [lotHits, setLotHits] = useState([]);
+ const [searchError, setSearchError] = useState(null);
+ const [searching, setSearching] = useState(false);
+ const [scope, setScope] = useState(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;
+ }
+ }, []);
+
+ const loadAdjPreview = useCallback(async () => {
+ if (adjLoadInFlight.current) return;
+ const d = adjDate.trim();
+ if (!d) {
+ setAdjError("請填 ADJ 日期");
+ return;
+ }
+ if (d > dayjs().format("YYYY-MM-DD")) {
+ setAdjError("ADJ 日期不能是未來");
+ return;
+ }
+ adjLoadInFlight.current = true;
+ setAdjLoading(true);
+ setAdjError(null);
+ try {
+ setAdjPreview(await fetchStockLedgerFixAdjPreview(d));
+ } catch (e) {
+ console.error(e);
+ setAdjError(apiErrorMessage(e, "無法載入 ADJ 預覽(需要 ADMIN / TESTING)"));
+ setAdjPreview(null);
+ } finally {
+ setAdjLoading(false);
+ adjLoadInFlight.current = false;
+ }
+ }, [adjDate]);
+
+ 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}`,
+ );
+ 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,補缺 (itemId, uomId),並用 lot 的 in−out 重算全部數量。沒有 lot 的 UOM 會變成 0。不會改 inventory_lot_line。確定執行?",
+ );
+ if (!ok) return;
+ inventoryRunInFlight.current = true;
+ setInventoryRunning(true);
+ setInventoryError(null);
+ setInventoryMessage(null);
+ try {
+ const res = await runStockLedgerFixInventory();
+ setInventoryMessage(
+ `inventory 1.0 完成:新增 ${res.inserted} 列、重算 ${res.updated} 列、之後仍缺 UOM ${res.missingUomPairsAfter}`,
+ );
+ 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() || adjPreview?.adjDate;
+ if (!d) {
+ setAdjError("請填 ADJ 日期並先預覽");
+ return;
+ }
+ const ok = window.confirm(
+ `會在 ${d} 新增 ADJ:入向 ${adjPreview?.adjInCount ?? 0} 筆、出向 ${adjPreview?.adjOutCount ?? 0} 筆(含反向調整)。缺入合計 ${adjPreview?.sumMissIn ?? "?"}、缺出合計 ${adjPreview?.sumMissOut ?? "?"}。目標:每 lot 最後 lotQtyAfter = line remain。已對齊的 lot 不會再動。確定?`,
+ );
+ 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}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`,
+ );
+ await loadAdjPreview();
+ } 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}`,
+ );
+ 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}`,
+ );
+ 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})。2.1–2.5 整段一次算、2.6 仍依日寫日結。過程中無法中停。確定?`,
+ );
+ 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}`,
+ );
+ 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 (
+
+
+
+ 1.0 inventory(方案 A)
+
+ 保留現有 id;lot 有、inventory 沒有的 UOM 會新增;onHand = Σ(in−out) 按
+ item+UOM。沒有 lot 的列會變成 0。請在進出倉暫停時執行,再逐日 Fix。
+
+ {inventoryError && {inventoryError}}
+ {inventoryMessage && (
+ {inventoryMessage}
+ )}
+ {inventoryLoading && !inventoryPreview && }
+ {inventoryPreview && (
+
+ 現有 inventory {inventoryPreview.inventoryRows} 列;lot 的
+ item+UOM {inventoryPreview.lotUomPairs} 組;缺{" "}
+ {inventoryPreview.missingUomPairs} 組。
+
+ )}
+
+
+
+
+
+
+
+
+ 2.7 ADJ:line in/out 對齊 ledger
+
+ 請先選 ADJ 日期再預覽。預設昨天;freeze dump 夜可選今天(例如 31/8 23:17 寫 ADJ 到
+ 31/8)。比對每 lot 的 line in/out 與 ledger 加總;有差就 ADJ。目標:最後一筆
+ lotQtyAfter = line remain。明細最多 20 列。
+
+
+ {
+ setAdjDate(e.target.value);
+ setAdjPreview(null);
+ setAdjMessage(null);
+ }}
+ disabled={adjLoading || adjRunning}
+ InputLabelProps={{ shrink: true }}
+ inputProps={{ max: dayjs().format("YYYY-MM-DD") }}
+ />
+
+
+
+ {adjError && {adjError}}
+ {adjMessage && {adjMessage}}
+ {adjLoading && !adjPreview && }
+ {adjPreview && (
+
+ ADJ 日期 {adjPreview.adjDate};可補 {adjPreview.lotCount} 條
+ lot(入 {adjPreview.adjInCount}、出 {adjPreview.adjOutCount});缺入合計{" "}
+ {adjPreview.sumMissIn}、缺出合計 {adjPreview.sumMissOut};SKU 淨額{" "}
+ {adjPreview.skuNet}
+ {adjPreview.skuNet !== "0" ? "(非 0 表示有缺入沒有對應缺出)" : ""}
+ {adjPreview.skippedNegCount > 0
+ ? `;含反向 ADJ ${adjPreview.skippedNegCount} 條(ledger 多記)`
+ : ""}
+ 。下列最多 20 列。
+
+ )}
+ {adjPreview && adjPreview.rows.length > 0 && (
+
+
+
+ lotLineId
+ itemCode
+ line in/out
+ ledger in/out
+ 缺入
+ 缺出
+
+
+
+ {adjPreview.rows.map((r) => (
+
+ {r.lotLineId}
+ {r.itemCode ?? "—"}
+
+ {r.lineIn} / {r.lineOut}
+
+
+ {r.ledgerIn} / {r.ledgerOut}
+
+ {r.missIn}
+ {r.missOut}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+ {
+ setTab(v);
+ setFixError(null);
+ setFixMessage(null);
+ setSearchError(null);
+ }}
+ >
+
+
+
+
+
+
+ {tab === "day" && (
+
+
+
+ setSelected(v)}
+ views={["year", "month", "day"]}
+ openTo="day"
+ minDate={FIRST_LEDGER_DAY}
+ maxDate={dayjs()}
+ />
+
+
+ 點左上標題可先選年再選月。換月不會查資料;點某一天才檢查當天。今天可修(freeze dump 夜)。
+
+
+
+
+
+
+ {selected ? selected.format("YYYY-MM-DD") : "請選日期"}
+
+ {detailError && {detailError}}
+ {fixError && {fixError}}
+ {fixMessage && {fixMessage}}
+ {chainProgress && {chainProgress}}
+ {detailLoading && }
+ {detail && !detailLoading && (
+ <>
+
+ 當天 ledger {detail.cnt} 列
+
+
+ >
+ )}
+
+
+ 步驟(預設全跑)
+
+
+ 套 SQL 後只改 inventoryId 時只勾 2.3。只跑部分時未勾的不會重算。
+
+
+ {DAY_FIX_STEPS.map((step) => (
+
+ setDaySteps((prev) => ({ ...prev, [step]: checked }))
+ }
+ />
+ }
+ label={DAY_STEP_LABEL[step]}
+ />
+ ))}
+
+
+
+
+
+
+
+
+ 區間修(2.1–2.6 一次)
+
+
+ 使用上面勾的步驟,整段日期一次 API。2.1–2.5 用同一視窗算完;2.6 日結仍依日寫入。可含今天(freeze dump 夜)。最長約 20 分鐘。
+
+
+ setChainFrom(e.target.value)}
+ disabled={chainRunning}
+ InputLabelProps={{ shrink: true }}
+ />
+ setChainTo(e.target.value)}
+ disabled={chainRunning}
+ InputLabelProps={{ shrink: true }}
+ />
+
+
+
+
+
+ 匯出已修 SQL(新庫用)
+
+
+ 勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → ledger(±2.3)→ 2.7 INSERT →
+ 2.6。完整檔(含 1.0+2.3)且正式庫 freeze/id 同源時,可跳過正式庫 1.0/2.3。預設只匯
+ ledger+日結(舊行為)。
+
+
+ {EXPORT_PARTS.map((part) => (
+
+ setExportParts((prev) => ({ ...prev, [part]: checked }))
+ }
+ />
+ }
+ label={EXPORT_PART_LABEL[part]}
+ />
+ ))}
+
+
+
+ {exportError && {exportError}}
+
+ setExportFrom(e.target.value)}
+ InputLabelProps={{ shrink: true }}
+ />
+ setExportTo(e.target.value)}
+ InputLabelProps={{ shrink: true }}
+ />
+
+
+
+
+
+
+ )}
+
+ {tab === "inventory" && (
+
+
+
+ 搜 itemCode 或 inventoryId。Fix 會重算這顆料今天以前全部流水的 lotQty、日結與
+ balance,不必逐日點日曆。
+
+
+ setInvQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") void onSearchInventory();
+ }}
+ />
+
+
+ {searchError && {searchError}}
+ {invHits.length > 0 && (
+
+
+
+ inventoryId
+ itemCode
+ uomId
+ ledger 列
+
+
+
+ {invHits.map((h) => (
+ void loadInventoryScope(h.inventoryId)}
+ sx={{ cursor: "pointer" }}
+ >
+ {h.inventoryId}
+ {h.itemCode ?? "—"}
+ {h.uomId ?? "—"}
+ {h.ledgerCnt}
+
+ ))}
+
+
+ )}
+ {fixError && {fixError}}
+ {fixMessage && {fixMessage}}
+ {scopeLoading && }
+ {scope?.kind === "inventory" && !scopeLoading && (
+ <>
+
+ {scope.itemCode ?? "?"} / inventory {scope.id} / uom {scope.uomId ?? "?"}
+ ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後
+ balance {scope.lastBalance ?? "—"}
+
+
+
+
+
+ >
+ )}
+
+
+ )}
+
+ {tab === "lot" && (
+
+
+
+ 搜 lotNo 或 inventoryLotLineId。Fix 只重算這張 lot 的 lotQty 與日結,不改
+ balance。
+
+
+ setLotQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") void onSearchLot();
+ }}
+ />
+
+
+ {searchError && {searchError}}
+ {lotHits.length > 0 && (
+
+
+
+ lot line id
+ lotNo
+ itemCode
+ inventoryId
+ ledger 列
+
+
+
+ {lotHits.map((h) => (
+ void loadLotScope(h.inventoryLotLineId)}
+ sx={{ cursor: "pointer" }}
+ >
+ {h.inventoryLotLineId}
+ {h.lotNo ?? "—"}
+ {h.itemCode ?? "—"}
+ {h.inventoryId ?? "—"}
+ {h.ledgerCnt}
+
+ ))}
+
+
+ )}
+ {fixError && {fixError}}
+ {fixMessage && {fixMessage}}
+ {scopeLoading && }
+ {scope?.kind === "lot" && !scopeLoading && (
+ <>
+
+ {scope.lotNo ?? "?"} / lot {scope.id} / {scope.itemCode ?? "?"}
+ ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後
+ lotQtyAfter {scope.lastLotQtyAfter ?? "—"}
+
+
+
+
+
+ >
+ )}
+
+
+ )}
+
+ );
+};
+
+export default StockLedgerFixPageClient;