FPSMS-frontend
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 

1169 rader
43 KiB

  1. "use client";
  2. import React, { useCallback, useEffect, useRef, useState } from "react";
  3. import {
  4. Alert,
  5. Box,
  6. Button,
  7. Checkbox,
  8. Chip,
  9. CircularProgress,
  10. FormControlLabel,
  11. Paper,
  12. Stack,
  13. Tab,
  14. Table,
  15. TableBody,
  16. TableCell,
  17. TableHead,
  18. TableRow,
  19. Tabs,
  20. TextField,
  21. Typography,
  22. } from "@mui/material";
  23. import { DateCalendar } from "@mui/x-date-pickers/DateCalendar";
  24. import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
  25. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  26. import dayjs, { type Dayjs } from "dayjs";
  27. import "dayjs/locale/zh-hk";
  28. import {
  29. runStockLedgerFixAdj,
  30. fetchStockLedgerFixDay,
  31. fetchStockLedgerFixInventory,
  32. fetchStockLedgerFixInventoryScope,
  33. fetchStockLedgerFixLotScope,
  34. runStockLedgerFixDay,
  35. runStockLedgerFixRange,
  36. runStockLedgerFixInventory,
  37. runStockLedgerFixInventoryScope,
  38. runStockLedgerFixLotScope,
  39. searchStockLedgerFixInventory,
  40. searchStockLedgerFixLot,
  41. downloadStockLedgerFixSql,
  42. type StockLedgerFixCheckPart,
  43. type StockLedgerFixDayDetail,
  44. type StockLedgerFixInventoryPreview,
  45. type StockLedgerFixRunResponse,
  46. type StockLedgerFixScopeDetail,
  47. type StockLedgerFixSearchInventoryHit,
  48. type StockLedgerFixSearchLotHit,
  49. } from "@/app/api/stockLedgerFix/client";
  50. const FIRST_LEDGER_DAY = dayjs("2026-03-01");
  51. const DAY_FIX_STEPS = ["2.1", "2.2", "2.3", "2.4", "2.5", "2.6"] as const;
  52. type DayFixStep = (typeof DAY_FIX_STEPS)[number];
  53. const ALL_DAY_STEPS: Record<DayFixStep, boolean> = {
  54. "2.1": true,
  55. "2.2": true,
  56. "2.3": true,
  57. "2.4": true,
  58. "2.5": true,
  59. "2.6": true,
  60. };
  61. const DAY_STEP_LABEL: Record<DayFixStep, string> = {
  62. "2.1": "2.1 lot",
  63. "2.2": "2.2 uom",
  64. "2.3": "2.3 inventoryId",
  65. "2.4": "2.4 lotQty",
  66. "2.5": "2.5 balance",
  67. "2.6": "2.6 日結",
  68. };
  69. /** Export SQL parts (aligned with fix steps + 1.0 / 2.7). */
  70. const EXPORT_PARTS = ["1.0", "2.1", "2.3", "ledger", "2.6", "2.7"] as const;
  71. type ExportPart = (typeof EXPORT_PARTS)[number];
  72. const DEFAULT_EXPORT_PARTS: Record<ExportPart, boolean> = {
  73. "1.0": false,
  74. "2.1": false,
  75. "2.3": false,
  76. ledger: true,
  77. "2.6": true,
  78. "2.7": false,
  79. };
  80. const FULL_EXPORT_PARTS: Record<ExportPart, boolean> = {
  81. "1.0": true,
  82. "2.1": true,
  83. "2.3": true,
  84. ledger: true,
  85. "2.6": true,
  86. "2.7": true,
  87. };
  88. const EXPORT_PART_LABEL: Record<ExportPart, string> = {
  89. "1.0": "1.0 inventory",
  90. "2.1": "2.1 軟刪流水帳+出倉列批號",
  91. "2.3": "2.3 inventoryId",
  92. ledger: "ledger lot/uom/lotQty/balance",
  93. "2.6": "2.6 日結",
  94. "2.7": "2.7 ADJ+入出倉單+調整記錄",
  95. };
  96. function selectedExportParts(flags: Record<ExportPart, boolean>): ExportPart[] {
  97. return EXPORT_PARTS.filter((p) => flags[p]);
  98. }
  99. function exportPartsPayload(flags: Record<ExportPart, boolean>): string[] | undefined {
  100. const selected = selectedExportParts(flags);
  101. if (selected.length === 0) return undefined;
  102. // Always send explicit list so backend does not fall back to legacy default alone
  103. return selected;
  104. }
  105. function selectedDaySteps(flags: Record<DayFixStep, boolean>): DayFixStep[] {
  106. return DAY_FIX_STEPS.filter((s) => flags[s]);
  107. }
  108. function stepsPayload(flags: Record<DayFixStep, boolean>): string[] | undefined {
  109. const selected = selectedDaySteps(flags);
  110. if (selected.length === 0 || selected.length === DAY_FIX_STEPS.length) return undefined;
  111. return selected;
  112. }
  113. function lotLineFixNote(res: StockLedgerFixRunResponse): string {
  114. const deleted = res.deletedUnlinkedLot ?? 0;
  115. const matched = res.existingLotMatch ?? 0;
  116. const mismatched = res.existingLotMismatch ?? 0;
  117. const blankOut = res.deletedBlankOutLine ?? 0;
  118. const alignedOut = res.alignedOutLine ?? 0;
  119. if (deleted === 0 && matched === 0 && mismatched === 0 && blankOut === 0 && alignedOut === 0) return "";
  120. return `、軟刪未連批號 ${deleted}、出倉列空而軟刪 ${blankOut}、出倉列已改跟帳本 ${alignedOut}、已有批號相符 ${matched}、不相符 ${mismatched}`;
  121. }
  122. function apiErrorMessage(e: unknown, fallback: string): string {
  123. if (e && typeof e === "object" && "response" in e) {
  124. const data = (e as { response?: { data?: unknown } }).response?.data;
  125. if (typeof data === "string" && data.trim()) {
  126. return data.trim().slice(0, 400);
  127. }
  128. if (data && typeof data === "object") {
  129. const msg = (data as { message?: unknown }).message;
  130. if (typeof msg === "string" && msg.trim()) {
  131. return msg.trim().slice(0, 400);
  132. }
  133. }
  134. }
  135. if (e instanceof Error && e.message) return e.message;
  136. return fallback;
  137. }
  138. function partVerdict(
  139. part: StockLedgerFixCheckPart,
  140. ): "correct" | "miss" | "incorrect" | "over-issue" | "can-fix" | "cannot-fix" {
  141. if (part.group === "canFix") {
  142. return part.miss > 0 || part.incorrect > 0 ? "can-fix" : "correct";
  143. }
  144. if (part.group === "cannotFix") {
  145. return part.miss > 0 || part.incorrect > 0 ? "cannot-fix" : "correct";
  146. }
  147. if (part.key === "overIssue") {
  148. if (part.incorrect > 0 || part.miss > 0) return "over-issue";
  149. return "correct";
  150. }
  151. if (part.incorrect > 0) return "incorrect";
  152. if (part.miss > 0) return "miss";
  153. if (part.key === "dayTable" && part.ok === 0) return "miss";
  154. return "correct";
  155. }
  156. function CheckPartsTable({ parts }: { parts: StockLedgerFixCheckPart[] }) {
  157. const field = parts.filter((p) => !p.group || p.group === "field");
  158. const canFix = parts.filter(
  159. (p) => p.group === "canFix" && p.miss + p.incorrect > 0,
  160. );
  161. const cannotFix = parts.filter(
  162. (p) => p.group === "cannotFix" && p.miss + p.incorrect > 0,
  163. );
  164. const renderRows = (rows: StockLedgerFixCheckPart[]) =>
  165. rows.map((p) => {
  166. const v = partVerdict(p);
  167. return (
  168. <TableRow key={p.key}>
  169. <TableCell>{p.label}</TableCell>
  170. <TableCell>
  171. <Chip
  172. size="small"
  173. label={
  174. v === "correct"
  175. ? "correct"
  176. : v === "miss"
  177. ? "miss"
  178. : v === "over-issue"
  179. ? "over-issue"
  180. : v === "can-fix"
  181. ? "可修"
  182. : v === "cannot-fix"
  183. ? "不能修"
  184. : "incorrect"
  185. }
  186. color={
  187. v === "correct"
  188. ? "success"
  189. : v === "miss" || v === "over-issue" || v === "can-fix"
  190. ? "warning"
  191. : "error"
  192. }
  193. />
  194. </TableCell>
  195. <TableCell align="right">{p.ok}</TableCell>
  196. <TableCell align="right">{p.miss}</TableCell>
  197. <TableCell align="right">{p.incorrect}</TableCell>
  198. </TableRow>
  199. );
  200. });
  201. return (
  202. <Stack spacing={2}>
  203. <Table size="small">
  204. <TableHead>
  205. <TableRow>
  206. <TableCell>項目</TableCell>
  207. <TableCell>狀態</TableCell>
  208. <TableCell align="right">正確</TableCell>
  209. <TableCell align="right">缺</TableCell>
  210. <TableCell align="right">不正確</TableCell>
  211. </TableRow>
  212. </TableHead>
  213. <TableBody>{renderRows(field)}</TableBody>
  214. </Table>
  215. {canFix.length > 0 && (
  216. <>
  217. <Typography variant="subtitle2">可自動修(按 Fix 會處理)</Typography>
  218. <Table size="small">
  219. <TableHead>
  220. <TableRow>
  221. <TableCell>原因</TableCell>
  222. <TableCell>狀態</TableCell>
  223. <TableCell align="right">正確</TableCell>
  224. <TableCell align="right">列數</TableCell>
  225. <TableCell align="right">—</TableCell>
  226. </TableRow>
  227. </TableHead>
  228. <TableBody>{renderRows(canFix)}</TableBody>
  229. </Table>
  230. </>
  231. )}
  232. {cannotFix.length > 0 && (
  233. <>
  234. <Typography variant="subtitle2">
  235. 不能自動修(Fix 不會消;舊 TKE 開新批/真超發/沒有 lot 來源)
  236. </Typography>
  237. <Table size="small">
  238. <TableHead>
  239. <TableRow>
  240. <TableCell>原因</TableCell>
  241. <TableCell>狀態</TableCell>
  242. <TableCell align="right">正確</TableCell>
  243. <TableCell align="right">—</TableCell>
  244. <TableCell align="right">列數</TableCell>
  245. </TableRow>
  246. </TableHead>
  247. <TableBody>{renderRows(cannotFix)}</TableBody>
  248. </Table>
  249. </>
  250. )}
  251. </Stack>
  252. );
  253. }
  254. const StockLedgerFixPageClient: React.FC = () => {
  255. const [tab, setTab] = useState<"day" | "inventory" | "lot">("day");
  256. const [selected, setSelected] = useState<Dayjs | null>(() =>
  257. dayjs().subtract(1, "day"),
  258. );
  259. const [detail, setDetail] = useState<StockLedgerFixDayDetail | null>(null);
  260. const [detailLoading, setDetailLoading] = useState(false);
  261. const [detailError, setDetailError] = useState<string | null>(null);
  262. const [fixing, setFixing] = useState(false);
  263. const [fixError, setFixError] = useState<string | null>(null);
  264. const [fixMessage, setFixMessage] = useState<string | null>(null);
  265. const detailInFlight = useRef(false);
  266. const fixInFlight = useRef(false);
  267. const inventoryLoadInFlight = useRef(false);
  268. const inventoryRunInFlight = useRef(false);
  269. const searchInFlight = useRef(false);
  270. const scopeInFlight = useRef(false);
  271. const exportInFlight = useRef(false);
  272. const [chainFrom, setChainFrom] = useState("2026-03-15");
  273. const [chainTo, setChainTo] = useState(() =>
  274. dayjs().subtract(1, "day").format("YYYY-MM-DD"),
  275. );
  276. const [chainRunning, setChainRunning] = useState(false);
  277. const [chainProgress, setChainProgress] = useState<string | null>(null);
  278. const [daySteps, setDaySteps] = useState<Record<DayFixStep, boolean>>(ALL_DAY_STEPS);
  279. const [exportFrom, setExportFrom] = useState("2026-03-15");
  280. const [exportTo, setExportTo] = useState(() =>
  281. dayjs().subtract(1, "day").format("YYYY-MM-DD"),
  282. );
  283. const [exportParts, setExportParts] =
  284. useState<Record<ExportPart, boolean>>(DEFAULT_EXPORT_PARTS);
  285. const [exporting, setExporting] = useState(false);
  286. const [exportError, setExportError] = useState<string | null>(null);
  287. const [inventoryPreview, setInventoryPreview] =
  288. useState<StockLedgerFixInventoryPreview | null>(null);
  289. const [inventoryLoading, setInventoryLoading] = useState(false);
  290. const [inventoryRunning, setInventoryRunning] = useState(false);
  291. const [inventoryError, setInventoryError] = useState<string | null>(null);
  292. const [inventoryMessage, setInventoryMessage] = useState<string | null>(null);
  293. const adjRunInFlight = useRef(false);
  294. const [adjRunning, setAdjRunning] = useState(false);
  295. const [adjError, setAdjError] = useState<string | null>(null);
  296. const [adjMessage, setAdjMessage] = useState<string | null>(null);
  297. /** Default yesterday; freeze-night dump set to today so ADJ lands on dump day. */
  298. const [adjDate, setAdjDate] = useState(() =>
  299. dayjs().subtract(1, "day").format("YYYY-MM-DD"),
  300. );
  301. const [invQuery, setInvQuery] = useState("");
  302. const [lotQuery, setLotQuery] = useState("");
  303. const [invHits, setInvHits] = useState<StockLedgerFixSearchInventoryHit[]>([]);
  304. const [lotHits, setLotHits] = useState<StockLedgerFixSearchLotHit[]>([]);
  305. const [searchError, setSearchError] = useState<string | null>(null);
  306. const [searching, setSearching] = useState(false);
  307. const [scope, setScope] = useState<StockLedgerFixScopeDetail | null>(null);
  308. const [scopeLoading, setScopeLoading] = useState(false);
  309. const loadInventory = useCallback(async () => {
  310. if (inventoryLoadInFlight.current) return;
  311. inventoryLoadInFlight.current = true;
  312. setInventoryLoading(true);
  313. setInventoryError(null);
  314. try {
  315. const data = await fetchStockLedgerFixInventory();
  316. setInventoryPreview(data);
  317. } catch (e) {
  318. console.error(e);
  319. setInventoryError("無法載入 inventory 1.0 預覽(需要 ADMIN / TESTING)");
  320. setInventoryPreview(null);
  321. } finally {
  322. setInventoryLoading(false);
  323. inventoryLoadInFlight.current = false;
  324. }
  325. }, []);
  326. const loadDay = useCallback(async (date: string) => {
  327. if (detailInFlight.current) return;
  328. detailInFlight.current = true;
  329. setDetailLoading(true);
  330. setDetailError(null);
  331. setFixMessage(null);
  332. try {
  333. const data = await fetchStockLedgerFixDay(date);
  334. setDetail(data);
  335. } catch (e) {
  336. console.error(e);
  337. setDetailError("無法載入當日檢查");
  338. setDetail(null);
  339. } finally {
  340. setDetailLoading(false);
  341. detailInFlight.current = false;
  342. }
  343. }, []);
  344. useEffect(() => {
  345. void loadInventory();
  346. }, [loadInventory]);
  347. useEffect(() => {
  348. if (tab === "day" && selected) {
  349. void loadDay(selected.format("YYYY-MM-DD"));
  350. }
  351. }, [selected, loadDay, tab]);
  352. const onFixDay = async () => {
  353. if (!selected || fixInFlight.current) return;
  354. const date = selected.format("YYYY-MM-DD");
  355. if (selected.isAfter(dayjs(), "day")) {
  356. setFixError("不能修未來日期");
  357. return;
  358. }
  359. const picked = selectedDaySteps(daySteps);
  360. if (picked.length === 0) {
  361. setFixError("請至少勾一個步驟(預設全跑 2.1–2.6)");
  362. return;
  363. }
  364. const steps = stepsPayload(daySteps);
  365. const stepLabel = steps?.join("、") ?? "全部 2.1–2.6";
  366. if (steps) {
  367. const ok = window.confirm(
  368. `只跑 ${stepLabel}(${date})。未勾的步驟不會重算。確定?`,
  369. );
  370. if (!ok) return;
  371. }
  372. fixInFlight.current = true;
  373. setFixing(true);
  374. setFixError(null);
  375. setFixMessage(null);
  376. try {
  377. const res = await runStockLedgerFixDay(date, steps);
  378. setFixMessage(
  379. `已修 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
  380. );
  381. await loadDay(date);
  382. } catch (e) {
  383. console.error(e);
  384. setFixError(apiErrorMessage(e, "修復失敗"));
  385. } finally {
  386. setFixing(false);
  387. fixInFlight.current = false;
  388. }
  389. };
  390. const onInventory = async () => {
  391. if (inventoryRunInFlight.current) return;
  392. const ok = window.confirm(
  393. "方案 A:保留現有 inventory.id;從 lot 回填 stockUomId(舊 uomId 是 stock 則改回 base);補缺 (itemId, stockUomId);用 lot 的 in−out 重算 onHand/unavailable。不寫 onHoldQty、不改 inventory_lot_line。確定執行?",
  394. );
  395. if (!ok) return;
  396. inventoryRunInFlight.current = true;
  397. setInventoryRunning(true);
  398. setInventoryError(null);
  399. setInventoryMessage(null);
  400. try {
  401. const res = await runStockLedgerFixInventory();
  402. setInventoryMessage(
  403. `inventory 1.0 完成:回填 stockUomId ${res.patchedStockUomId} 列、新增 ${res.inserted} 列、殘列 deleted ${res.orphansDeleted ?? 0}、重算 ${res.updated} 列、之後仍缺 UOM ${res.missingUomPairsAfter}、仍 NULL stockUomId ${res.nullStockUomIdAfter}`,
  404. );
  405. await loadInventory();
  406. } catch (e) {
  407. console.error(e);
  408. setInventoryError(apiErrorMessage(e, "inventory 1.0 失敗"));
  409. } finally {
  410. setInventoryRunning(false);
  411. inventoryRunInFlight.current = false;
  412. }
  413. };
  414. const onAdjApply = async (step: "2.7a" | "2.7b") => {
  415. if (adjRunInFlight.current) return;
  416. const d = adjDate.trim();
  417. if (!d) {
  418. setAdjError("請填 ADJ 日期");
  419. return;
  420. }
  421. if (d > dayjs().format("YYYY-MM-DD")) {
  422. setAdjError("ADJ 日期不能是未來");
  423. return;
  424. }
  425. const ok = window.confirm(
  426. step === "2.7a"
  427. ? `會在 ${d} 找出出數量大於入數量的批號列,把入數量改成和出數量一樣,並在同一條批寫一筆入倉 ADJ(差額)和庫存調整記錄。已補過的列再跑不會再寫。確定?`
  428. : `會在 ${d} 對最後餘額不等於 onHandQty 的庫存桶各寫一筆 ADJ(入或出),掛在既有 lot 上,並寫入庫存調整記錄。不改 lot line、不改 inventory。已對齊的桶不會再寫;缺調整記錄的舊 ADJ 會補上。確定?`,
  429. );
  430. if (!ok) return;
  431. adjRunInFlight.current = true;
  432. setAdjRunning(true);
  433. setAdjError(null);
  434. setAdjMessage(null);
  435. try {
  436. const res = await runStockLedgerFixAdj(d, step);
  437. setAdjMessage(
  438. step === "2.7a"
  439. ? `2.7a ${res.adjDate}:批號列 ${res.insertedIn}、調整記錄 ${res.adjustmentRecords ?? 0}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`
  440. : `2.7b ${res.adjDate}:入 ${res.insertedIn}、出 ${res.insertedOut}、調整記錄 ${res.adjustmentRecords ?? 0}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`,
  441. );
  442. } catch (e) {
  443. console.error(e);
  444. setAdjError(apiErrorMessage(e, "ADJ 對齊失敗"));
  445. } finally {
  446. setAdjRunning(false);
  447. adjRunInFlight.current = false;
  448. }
  449. };
  450. const onSearchInventory = async () => {
  451. if (searchInFlight.current || !invQuery.trim()) return;
  452. searchInFlight.current = true;
  453. setSearching(true);
  454. setSearchError(null);
  455. try {
  456. const hits = await searchStockLedgerFixInventory(invQuery.trim());
  457. setInvHits(hits);
  458. setScope(null);
  459. } catch (e) {
  460. console.error(e);
  461. setSearchError(apiErrorMessage(e, "搜尋失敗"));
  462. } finally {
  463. setSearching(false);
  464. searchInFlight.current = false;
  465. }
  466. };
  467. const onSearchLot = async () => {
  468. if (searchInFlight.current || !lotQuery.trim()) return;
  469. searchInFlight.current = true;
  470. setSearching(true);
  471. setSearchError(null);
  472. try {
  473. const hits = await searchStockLedgerFixLot(lotQuery.trim());
  474. setLotHits(hits);
  475. setScope(null);
  476. } catch (e) {
  477. console.error(e);
  478. setSearchError(apiErrorMessage(e, "搜尋失敗"));
  479. } finally {
  480. setSearching(false);
  481. searchInFlight.current = false;
  482. }
  483. };
  484. const loadInventoryScope = async (id: number) => {
  485. if (scopeInFlight.current) return;
  486. scopeInFlight.current = true;
  487. setScopeLoading(true);
  488. setFixError(null);
  489. setFixMessage(null);
  490. try {
  491. setScope(await fetchStockLedgerFixInventoryScope(id));
  492. } catch (e) {
  493. console.error(e);
  494. setFixError(apiErrorMessage(e, "無法載入 inventory"));
  495. setScope(null);
  496. } finally {
  497. setScopeLoading(false);
  498. scopeInFlight.current = false;
  499. }
  500. };
  501. const loadLotScope = async (id: number) => {
  502. if (scopeInFlight.current) return;
  503. scopeInFlight.current = true;
  504. setScopeLoading(true);
  505. setFixError(null);
  506. setFixMessage(null);
  507. try {
  508. setScope(await fetchStockLedgerFixLotScope(id));
  509. } catch (e) {
  510. console.error(e);
  511. setFixError(apiErrorMessage(e, "無法載入 lot"));
  512. setScope(null);
  513. } finally {
  514. setScopeLoading(false);
  515. scopeInFlight.current = false;
  516. }
  517. };
  518. const onFixInventoryScope = async () => {
  519. if (!scope || scope.kind !== "inventory" || fixInFlight.current) return;
  520. const ok = window.confirm(
  521. "會重算這顆 inventory 到今天為止全部 ledger 的 lotQty、日結,以及這顆料的 balance。確定?",
  522. );
  523. if (!ok) return;
  524. fixInFlight.current = true;
  525. setFixing(true);
  526. setFixError(null);
  527. setFixMessage(null);
  528. try {
  529. const res = await runStockLedgerFixInventoryScope(scope.id);
  530. setFixMessage(
  531. `已修 inventory ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
  532. );
  533. await loadInventoryScope(scope.id);
  534. } catch (e) {
  535. console.error(e);
  536. setFixError(apiErrorMessage(e, "修復失敗"));
  537. } finally {
  538. setFixing(false);
  539. fixInFlight.current = false;
  540. }
  541. };
  542. const onFixLotScope = async () => {
  543. if (!scope || scope.kind !== "lot" || fixInFlight.current) return;
  544. const ok = window.confirm(
  545. "只修這張 lot 的 lotQty 與日結,不會改整顆料的 balance。確定?",
  546. );
  547. if (!ok) return;
  548. fixInFlight.current = true;
  549. setFixing(true);
  550. setFixError(null);
  551. setFixMessage(null);
  552. try {
  553. const res = await runStockLedgerFixLotScope(scope.id);
  554. setFixMessage(
  555. `已修 lot ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
  556. );
  557. await loadLotScope(scope.id);
  558. } catch (e) {
  559. console.error(e);
  560. setFixError(apiErrorMessage(e, "修復失敗"));
  561. } finally {
  562. setFixing(false);
  563. fixInFlight.current = false;
  564. }
  565. };
  566. const canFixDay = Boolean(selected && !selected.isAfter(dayjs(), "day"));
  567. const onFixDayRange = async () => {
  568. if (fixInFlight.current) return;
  569. const from = chainFrom.trim();
  570. const to = chainTo.trim();
  571. const today = dayjs().format("YYYY-MM-DD");
  572. if (!from || !to) {
  573. setFixError("請填區間修 from / to");
  574. return;
  575. }
  576. if (to < from) {
  577. setFixError("to 必須 ≥ from");
  578. return;
  579. }
  580. if (from < FIRST_LEDGER_DAY.format("YYYY-MM-DD")) {
  581. setFixError(`from 不能早於 ${FIRST_LEDGER_DAY.format("YYYY-MM-DD")}`);
  582. return;
  583. }
  584. if (to > today) {
  585. setFixError("不能修未來日期(今天可以,給 freeze dump 夜修)");
  586. return;
  587. }
  588. const picked = selectedDaySteps(daySteps);
  589. if (picked.length === 0) {
  590. setFixError("請至少勾一個步驟(預設全跑 2.1–2.6)");
  591. return;
  592. }
  593. const steps = stepsPayload(daySteps);
  594. const stepLabel = steps?.join("、") ?? "全部 2.1–2.6";
  595. const ok = window.confirm(
  596. `一次修 ${from} → ${to}(步驟:${stepLabel})。改為逐日跑(每天 commit)。中途失敗時已跑完的天會留下。確定?`,
  597. );
  598. if (!ok) return;
  599. fixInFlight.current = true;
  600. setChainRunning(true);
  601. setFixing(true);
  602. setFixError(null);
  603. setFixMessage(null);
  604. setChainProgress(`區間修 ${from} → ${to}(${stepLabel})進行中…`);
  605. try {
  606. const res = await runStockLedgerFixRange(from, to, steps);
  607. setFixMessage(
  608. `區間修完成 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
  609. );
  610. setSelected(dayjs(to));
  611. setChainProgress(null);
  612. } catch (e) {
  613. console.error(e);
  614. setFixError(apiErrorMessage(e, "區間修失敗"));
  615. } finally {
  616. setFixing(false);
  617. setChainRunning(false);
  618. fixInFlight.current = false;
  619. }
  620. };
  621. const onExportSql = async () => {
  622. if (exportInFlight.current) return;
  623. const from = exportFrom.trim();
  624. const to = exportTo.trim();
  625. if (!from || !to) {
  626. setExportError("請填 from / to");
  627. return;
  628. }
  629. if (to < from) {
  630. setExportError("to 必須 ≥ from");
  631. return;
  632. }
  633. const picked = selectedExportParts(exportParts);
  634. if (picked.length === 0) {
  635. setExportError("請至少勾一個匯出項目");
  636. return;
  637. }
  638. if (exportParts["2.3"] && !exportParts["1.0"]) {
  639. const ok = window.confirm(
  640. "已勾 2.3 inventoryId 但未勾 1.0 inventory:正式庫若缺新 id,UPDATE 會指到空列。建議一併勾 1.0(完整檔)。仍要匯出?",
  641. );
  642. if (!ok) return;
  643. }
  644. const parts = exportPartsPayload(exportParts);
  645. exportInFlight.current = true;
  646. setExporting(true);
  647. setExportError(null);
  648. try {
  649. await downloadStockLedgerFixSql(from, to, parts);
  650. } catch (e) {
  651. console.error(e);
  652. const data = (e as { response?: { data?: unknown } })?.response?.data;
  653. if (data instanceof Blob) {
  654. try {
  655. const text = (await data.text()).trim().slice(0, 400);
  656. setExportError(text || "匯出失敗");
  657. } catch {
  658. setExportError(apiErrorMessage(e, "匯出失敗"));
  659. }
  660. } else {
  661. setExportError(apiErrorMessage(e, "匯出失敗"));
  662. }
  663. } finally {
  664. setExporting(false);
  665. exportInFlight.current = false;
  666. }
  667. };
  668. return (
  669. <Stack spacing={3}>
  670. <Paper sx={{ p: 2 }}>
  671. <Stack spacing={1.5}>
  672. <Typography variant="h6">1.0 inventory(方案 A)</Typography>
  673. <Typography variant="body2" color="text.secondary">
  674. 保留現有 id;lot 有、inventory 沒有的 UOM 會新增;onHand = Σ(in−out) 按
  675. item+UOM。沒有 lot 的列會變成 0。同一料多顆舊列時只留一顆 stock UOM
  676. 桶,其餘 deleted=1。請在進出倉暫停時執行,再逐日 Fix。
  677. </Typography>
  678. {inventoryError && <Alert severity="error">{inventoryError}</Alert>}
  679. {inventoryMessage && (
  680. <Alert severity="success">{inventoryMessage}</Alert>
  681. )}
  682. {inventoryLoading && !inventoryPreview && <CircularProgress size={24} />}
  683. {inventoryPreview && (
  684. <Typography variant="body2">
  685. 現有 inventory {inventoryPreview.inventoryRows} 列;lot 的
  686. item+UOM {inventoryPreview.lotUomPairs} 組;缺{" "}
  687. {inventoryPreview.missingUomPairs} 組;stockUomId 仍 NULL{" "}
  688. {inventoryPreview.nullStockUomId} 列。
  689. </Typography>
  690. )}
  691. <Box>
  692. <Button
  693. variant="contained"
  694. color="warning"
  695. disabled={inventoryRunning || inventoryLoading}
  696. onClick={() => void onInventory()}
  697. >
  698. {inventoryRunning ? "重算中…" : "Run inventory 1.0 (A)"}
  699. </Button>
  700. </Box>
  701. </Stack>
  702. </Paper>
  703. <Paper sx={{ p: 2 }}>
  704. <Stack spacing={1.5}>
  705. <Typography variant="h6">2.7 ADJ</Typography>
  706. <Typography variant="body2" color="text.secondary">
  707. 先跑 2.7a,再跑 2.7b。2.7a 只處理出數量大於入數量的批號列:入數量改成和出數量一樣,並在同一條批寫入倉
  708. ADJ。2.7b 對最後餘額不等於 onHandQty 的庫存桶補一筆入或出,不改批號列。兩邊再跑都不會重複寫已對齊的資料。
  709. </Typography>
  710. <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
  711. <TextField
  712. size="small"
  713. type="date"
  714. label="ADJ 日期"
  715. value={adjDate}
  716. onChange={(e) => {
  717. setAdjDate(e.target.value);
  718. setAdjMessage(null);
  719. }}
  720. disabled={adjRunning}
  721. InputLabelProps={{ shrink: true }}
  722. inputProps={{ max: dayjs().format("YYYY-MM-DD") }}
  723. />
  724. <Button
  725. size="small"
  726. disabled={adjRunning}
  727. onClick={() => {
  728. setAdjDate(dayjs().format("YYYY-MM-DD"));
  729. setAdjMessage(null);
  730. }}
  731. >
  732. 今天(freeze 夜)
  733. </Button>
  734. <Button
  735. size="small"
  736. disabled={adjRunning}
  737. onClick={() => {
  738. setAdjDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"));
  739. setAdjMessage(null);
  740. }}
  741. >
  742. 昨天
  743. </Button>
  744. </Stack>
  745. {adjError && <Alert severity="error">{adjError}</Alert>}
  746. {adjMessage && <Alert severity="success">{adjMessage}</Alert>}
  747. {adjRunning && <CircularProgress size={24} />}
  748. <Stack direction="row" spacing={1}>
  749. <Button
  750. variant="contained"
  751. color="warning"
  752. disabled={adjRunning}
  753. onClick={() => void onAdjApply("2.7a")}
  754. >
  755. {adjRunning ? "寫入 ADJ 中…" : "Apply 2.7a"}
  756. </Button>
  757. <Button
  758. variant="outlined"
  759. color="warning"
  760. disabled={adjRunning}
  761. onClick={() => void onAdjApply("2.7b")}
  762. >
  763. {adjRunning ? "寫入 ADJ 中…" : "Apply 2.7b"}
  764. </Button>
  765. </Stack>
  766. </Stack>
  767. </Paper>
  768. <Paper sx={{ px: 2, pt: 1 }}>
  769. <Tabs
  770. value={tab}
  771. onChange={(_, v: "day" | "inventory" | "lot") => {
  772. setTab(v);
  773. setFixError(null);
  774. setFixMessage(null);
  775. setSearchError(null);
  776. }}
  777. >
  778. <Tab label="日曆" value="day" />
  779. <Tab label="Inventory" value="inventory" />
  780. <Tab label="Lot line" value="lot" />
  781. </Tabs>
  782. </Paper>
  783. {tab === "day" && (
  784. <Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="flex-start">
  785. <Paper sx={{ p: 1 }}>
  786. <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
  787. <DateCalendar
  788. value={selected}
  789. onChange={(v) => setSelected(v)}
  790. views={["year", "month", "day"]}
  791. openTo="day"
  792. minDate={FIRST_LEDGER_DAY}
  793. maxDate={dayjs()}
  794. />
  795. </LocalizationProvider>
  796. <Typography variant="caption" color="text.secondary" sx={{ px: 2, pb: 1, display: "block" }}>
  797. 點左上標題可先選年再選月。換月不會查資料;點某一天才檢查當天。今天可修(freeze dump 夜)。
  798. </Typography>
  799. </Paper>
  800. <Paper sx={{ p: 2, flex: 1, minWidth: 0, width: "100%" }}>
  801. <Stack spacing={2}>
  802. <Typography variant="h6">
  803. {selected ? selected.format("YYYY-MM-DD") : "請選日期"}
  804. </Typography>
  805. {detailError && <Alert severity="error">{detailError}</Alert>}
  806. {fixError && <Alert severity="error">{fixError}</Alert>}
  807. {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
  808. {chainProgress && <Alert severity="info">{chainProgress}</Alert>}
  809. {detailLoading && <CircularProgress size={24} />}
  810. {detail && !detailLoading && (
  811. <>
  812. <Typography variant="body2" color="text.secondary">
  813. 當天 ledger {detail.cnt} 列
  814. </Typography>
  815. <CheckPartsTable parts={detail.parts} />
  816. </>
  817. )}
  818. <Box>
  819. <Typography variant="subtitle2" sx={{ mb: 0.5 }}>
  820. 步驟(預設全跑)
  821. </Typography>
  822. <Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
  823. 套 SQL 後只改 inventoryId 時只勾 2.3。只跑部分時未勾的不會重算。
  824. </Typography>
  825. <Stack direction="row" spacing={0} flexWrap="wrap" useFlexGap>
  826. {DAY_FIX_STEPS.map((step) => (
  827. <FormControlLabel
  828. key={step}
  829. control={
  830. <Checkbox
  831. size="small"
  832. checked={daySteps[step]}
  833. disabled={fixing || chainRunning}
  834. onChange={(_, checked) =>
  835. setDaySteps((prev) => ({ ...prev, [step]: checked }))
  836. }
  837. />
  838. }
  839. label={DAY_STEP_LABEL[step]}
  840. />
  841. ))}
  842. <Button
  843. size="small"
  844. disabled={fixing || chainRunning}
  845. onClick={() => setDaySteps(ALL_DAY_STEPS)}
  846. >
  847. 全選
  848. </Button>
  849. </Stack>
  850. </Box>
  851. <Box>
  852. <Button
  853. variant="contained"
  854. disabled={!canFixDay || fixing || chainRunning}
  855. onClick={() => void onFixDay()}
  856. >
  857. {fixing && !chainRunning ? "修復中…" : "Fix this day"}
  858. </Button>
  859. </Box>
  860. <Box sx={{ pt: 1 }}>
  861. <Typography variant="subtitle2" sx={{ mb: 1 }}>
  862. 區間修(2.1–2.6 一次)
  863. </Typography>
  864. <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
  865. 使用上面勾的步驟,一次 API 但改為逐日(與單日修相同 SQL,每天 commit)。可含今天(freeze dump 夜)。前端最長約 20 分鐘;大區間仍建議匯出 SQL。
  866. </Typography>
  867. <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
  868. <TextField
  869. size="small"
  870. type="date"
  871. label="from"
  872. value={chainFrom}
  873. onChange={(e) => setChainFrom(e.target.value)}
  874. disabled={chainRunning}
  875. InputLabelProps={{ shrink: true }}
  876. />
  877. <TextField
  878. size="small"
  879. type="date"
  880. label="to"
  881. value={chainTo}
  882. onChange={(e) => setChainTo(e.target.value)}
  883. disabled={chainRunning}
  884. InputLabelProps={{ shrink: true }}
  885. />
  886. <Button
  887. variant="contained"
  888. disabled={fixing || chainRunning}
  889. onClick={() => void onFixDayRange()}
  890. >
  891. {chainRunning ? "區間修中…" : "區間修"}
  892. </Button>
  893. </Stack>
  894. </Box>
  895. <Box sx={{ pt: 1 }}>
  896. <Typography variant="subtitle2" sx={{ mb: 1 }}>
  897. 匯出已修 SQL(新庫用)
  898. </Typography>
  899. <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
  900. 勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → 2.1 → ledger(±2.3)→ 2.7 入出倉單+調整記錄+ADJ →
  901. 2.6。完整檔且正式庫 freeze/id 同源、日期蓋過全部已修日子時,結果與已修庫相同。預設只匯
  902. ledger+日結(舊行為)。
  903. </Typography>
  904. <Stack direction="row" spacing={0} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
  905. {EXPORT_PARTS.map((part) => (
  906. <FormControlLabel
  907. key={part}
  908. control={
  909. <Checkbox
  910. size="small"
  911. checked={exportParts[part]}
  912. disabled={exporting}
  913. onChange={(_, checked) =>
  914. setExportParts((prev) => ({ ...prev, [part]: checked }))
  915. }
  916. />
  917. }
  918. label={EXPORT_PART_LABEL[part]}
  919. />
  920. ))}
  921. <Button
  922. size="small"
  923. disabled={exporting}
  924. onClick={() => setExportParts(DEFAULT_EXPORT_PARTS)}
  925. >
  926. 預設
  927. </Button>
  928. <Button
  929. size="small"
  930. disabled={exporting}
  931. onClick={() => setExportParts(FULL_EXPORT_PARTS)}
  932. >
  933. 完整檔
  934. </Button>
  935. </Stack>
  936. {exportError && <Alert severity="error">{exportError}</Alert>}
  937. <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
  938. <TextField
  939. size="small"
  940. type="date"
  941. label="from"
  942. value={exportFrom}
  943. onChange={(e) => setExportFrom(e.target.value)}
  944. InputLabelProps={{ shrink: true }}
  945. />
  946. <TextField
  947. size="small"
  948. type="date"
  949. label="to"
  950. value={exportTo}
  951. onChange={(e) => setExportTo(e.target.value)}
  952. InputLabelProps={{ shrink: true }}
  953. />
  954. <Button
  955. variant="outlined"
  956. disabled={exporting}
  957. onClick={() => void onExportSql()}
  958. >
  959. {exporting ? "匯出中…" : "匯出 .sql"}
  960. </Button>
  961. </Stack>
  962. </Box>
  963. </Stack>
  964. </Paper>
  965. </Stack>
  966. )}
  967. {tab === "inventory" && (
  968. <Paper sx={{ p: 2 }}>
  969. <Stack spacing={2}>
  970. <Typography variant="body2" color="text.secondary">
  971. 搜 itemCode 或 inventoryId。Fix 會重算這顆料到今天為止全部流水的 lotQty、日結與
  972. balance,不必逐日點日曆。白天倉還在寫這顆料時,修完後新流水仍由 live writer 接。
  973. </Typography>
  974. <Stack direction="row" spacing={1}>
  975. <TextField
  976. size="small"
  977. label="itemCode / inventoryId"
  978. value={invQuery}
  979. onChange={(e) => setInvQuery(e.target.value)}
  980. onKeyDown={(e) => {
  981. if (e.key === "Enter") void onSearchInventory();
  982. }}
  983. />
  984. <Button
  985. variant="outlined"
  986. disabled={searching || !invQuery.trim()}
  987. onClick={() => void onSearchInventory()}
  988. >
  989. {searching ? "搜尋中…" : "搜尋"}
  990. </Button>
  991. </Stack>
  992. {searchError && <Alert severity="error">{searchError}</Alert>}
  993. {invHits.length > 0 && (
  994. <Table size="small">
  995. <TableHead>
  996. <TableRow>
  997. <TableCell>inventoryId</TableCell>
  998. <TableCell>itemCode</TableCell>
  999. <TableCell>uomId</TableCell>
  1000. <TableCell align="right">ledger 列</TableCell>
  1001. </TableRow>
  1002. </TableHead>
  1003. <TableBody>
  1004. {invHits.map((h) => (
  1005. <TableRow
  1006. key={h.inventoryId}
  1007. hover
  1008. selected={scope?.kind === "inventory" && scope.id === h.inventoryId}
  1009. onClick={() => void loadInventoryScope(h.inventoryId)}
  1010. sx={{ cursor: "pointer" }}
  1011. >
  1012. <TableCell>{h.inventoryId}</TableCell>
  1013. <TableCell>{h.itemCode ?? "—"}</TableCell>
  1014. <TableCell>{h.uomId ?? "—"}</TableCell>
  1015. <TableCell align="right">{h.ledgerCnt}</TableCell>
  1016. </TableRow>
  1017. ))}
  1018. </TableBody>
  1019. </Table>
  1020. )}
  1021. {fixError && <Alert severity="error">{fixError}</Alert>}
  1022. {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
  1023. {scopeLoading && <CircularProgress size={24} />}
  1024. {scope?.kind === "inventory" && !scopeLoading && (
  1025. <>
  1026. <Typography variant="body2">
  1027. {scope.itemCode ?? "?"} / inventory {scope.id} / uom {scope.uomId ?? "?"}
  1028. ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後
  1029. balance {scope.lastBalance ?? "—"}
  1030. </Typography>
  1031. <CheckPartsTable parts={scope.parts} />
  1032. <Box>
  1033. <Button
  1034. variant="contained"
  1035. disabled={fixing || scope.cnt === 0}
  1036. onClick={() => void onFixInventoryScope()}
  1037. >
  1038. {fixing ? "修復中…" : "Fix this inventory"}
  1039. </Button>
  1040. </Box>
  1041. </>
  1042. )}
  1043. </Stack>
  1044. </Paper>
  1045. )}
  1046. {tab === "lot" && (
  1047. <Paper sx={{ p: 2 }}>
  1048. <Stack spacing={2}>
  1049. <Typography variant="body2" color="text.secondary">
  1050. 搜 lotNo 或 inventoryLotLineId。Fix 只重算這張 lot 的 lotQty 與日結,不改
  1051. balance。
  1052. </Typography>
  1053. <Stack direction="row" spacing={1}>
  1054. <TextField
  1055. size="small"
  1056. label="lotNo / lot line id"
  1057. value={lotQuery}
  1058. onChange={(e) => setLotQuery(e.target.value)}
  1059. onKeyDown={(e) => {
  1060. if (e.key === "Enter") void onSearchLot();
  1061. }}
  1062. />
  1063. <Button
  1064. variant="outlined"
  1065. disabled={searching || !lotQuery.trim()}
  1066. onClick={() => void onSearchLot()}
  1067. >
  1068. {searching ? "搜尋中…" : "搜尋"}
  1069. </Button>
  1070. </Stack>
  1071. {searchError && <Alert severity="error">{searchError}</Alert>}
  1072. {lotHits.length > 0 && (
  1073. <Table size="small">
  1074. <TableHead>
  1075. <TableRow>
  1076. <TableCell>lot line id</TableCell>
  1077. <TableCell>lotNo</TableCell>
  1078. <TableCell>itemCode</TableCell>
  1079. <TableCell>inventoryId</TableCell>
  1080. <TableCell align="right">ledger 列</TableCell>
  1081. </TableRow>
  1082. </TableHead>
  1083. <TableBody>
  1084. {lotHits.map((h) => (
  1085. <TableRow
  1086. key={h.inventoryLotLineId}
  1087. hover
  1088. selected={scope?.kind === "lot" && scope.id === h.inventoryLotLineId}
  1089. onClick={() => void loadLotScope(h.inventoryLotLineId)}
  1090. sx={{ cursor: "pointer" }}
  1091. >
  1092. <TableCell>{h.inventoryLotLineId}</TableCell>
  1093. <TableCell>{h.lotNo ?? "—"}</TableCell>
  1094. <TableCell>{h.itemCode ?? "—"}</TableCell>
  1095. <TableCell>{h.inventoryId ?? "—"}</TableCell>
  1096. <TableCell align="right">{h.ledgerCnt}</TableCell>
  1097. </TableRow>
  1098. ))}
  1099. </TableBody>
  1100. </Table>
  1101. )}
  1102. {fixError && <Alert severity="error">{fixError}</Alert>}
  1103. {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
  1104. {scopeLoading && <CircularProgress size={24} />}
  1105. {scope?.kind === "lot" && !scopeLoading && (
  1106. <>
  1107. <Typography variant="body2">
  1108. {scope.lotNo ?? "?"} / lot {scope.id} / {scope.itemCode ?? "?"}
  1109. ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後
  1110. lotQtyAfter {scope.lastLotQtyAfter ?? "—"}
  1111. </Typography>
  1112. <CheckPartsTable parts={scope.parts} />
  1113. <Box>
  1114. <Button
  1115. variant="contained"
  1116. disabled={fixing || scope.cnt === 0}
  1117. onClick={() => void onFixLotScope()}
  1118. >
  1119. {fixing ? "修復中…" : "Fix this lot"}
  1120. </Button>
  1121. </Box>
  1122. </>
  1123. )}
  1124. </Stack>
  1125. </Paper>
  1126. )}
  1127. </Stack>
  1128. );
  1129. };
  1130. export default StockLedgerFixPageClient;