From 43edd62256f5c7f73b15a5ad89d8a265d257cd61 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 1 Sep 2026 13:20:22 +0800 Subject: [PATCH] update --- .../(main)/settings/stockLedgerFix/page.tsx | 18 + src/app/api/stockLedgerFix/client.ts | 311 +++++ .../StockLedgerFixPageClient.tsx | 1220 +++++++++++++++++ 3 files changed, 1549 insertions(+) create mode 100644 src/app/(main)/settings/stockLedgerFix/page.tsx create mode 100644 src/app/api/stockLedgerFix/client.ts create mode 100644 src/components/StockLedgerFix/StockLedgerFixPageClient.tsx 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;