FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

1158 строки
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 () => {
  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. `會在 ${d} 對最後餘額不等於 onHandQty 的庫存桶各寫一筆 ADJ(入或出),掛在既有 lot 上,並寫入庫存調整記錄。不改 lot line、不改 inventory。已對齊的桶不會再寫;缺調整記錄的舊 ADJ 會補上。確定?`,
  427. );
  428. if (!ok) return;
  429. adjRunInFlight.current = true;
  430. setAdjRunning(true);
  431. setAdjError(null);
  432. setAdjMessage(null);
  433. try {
  434. const res = await runStockLedgerFixAdj(d);
  435. setAdjMessage(
  436. `ADJ ${res.adjDate}:入 ${res.insertedIn}、出 ${res.insertedOut}、調整記錄 ${res.adjustmentRecords ?? 0}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`,
  437. );
  438. } catch (e) {
  439. console.error(e);
  440. setAdjError(apiErrorMessage(e, "ADJ 對齊失敗"));
  441. } finally {
  442. setAdjRunning(false);
  443. adjRunInFlight.current = false;
  444. }
  445. };
  446. const onSearchInventory = async () => {
  447. if (searchInFlight.current || !invQuery.trim()) return;
  448. searchInFlight.current = true;
  449. setSearching(true);
  450. setSearchError(null);
  451. try {
  452. const hits = await searchStockLedgerFixInventory(invQuery.trim());
  453. setInvHits(hits);
  454. setScope(null);
  455. } catch (e) {
  456. console.error(e);
  457. setSearchError(apiErrorMessage(e, "搜尋失敗"));
  458. } finally {
  459. setSearching(false);
  460. searchInFlight.current = false;
  461. }
  462. };
  463. const onSearchLot = async () => {
  464. if (searchInFlight.current || !lotQuery.trim()) return;
  465. searchInFlight.current = true;
  466. setSearching(true);
  467. setSearchError(null);
  468. try {
  469. const hits = await searchStockLedgerFixLot(lotQuery.trim());
  470. setLotHits(hits);
  471. setScope(null);
  472. } catch (e) {
  473. console.error(e);
  474. setSearchError(apiErrorMessage(e, "搜尋失敗"));
  475. } finally {
  476. setSearching(false);
  477. searchInFlight.current = false;
  478. }
  479. };
  480. const loadInventoryScope = async (id: number) => {
  481. if (scopeInFlight.current) return;
  482. scopeInFlight.current = true;
  483. setScopeLoading(true);
  484. setFixError(null);
  485. setFixMessage(null);
  486. try {
  487. setScope(await fetchStockLedgerFixInventoryScope(id));
  488. } catch (e) {
  489. console.error(e);
  490. setFixError(apiErrorMessage(e, "無法載入 inventory"));
  491. setScope(null);
  492. } finally {
  493. setScopeLoading(false);
  494. scopeInFlight.current = false;
  495. }
  496. };
  497. const loadLotScope = async (id: number) => {
  498. if (scopeInFlight.current) return;
  499. scopeInFlight.current = true;
  500. setScopeLoading(true);
  501. setFixError(null);
  502. setFixMessage(null);
  503. try {
  504. setScope(await fetchStockLedgerFixLotScope(id));
  505. } catch (e) {
  506. console.error(e);
  507. setFixError(apiErrorMessage(e, "無法載入 lot"));
  508. setScope(null);
  509. } finally {
  510. setScopeLoading(false);
  511. scopeInFlight.current = false;
  512. }
  513. };
  514. const onFixInventoryScope = async () => {
  515. if (!scope || scope.kind !== "inventory" || fixInFlight.current) return;
  516. const ok = window.confirm(
  517. "會重算這顆 inventory 到今天為止全部 ledger 的 lotQty、日結,以及這顆料的 balance。確定?",
  518. );
  519. if (!ok) return;
  520. fixInFlight.current = true;
  521. setFixing(true);
  522. setFixError(null);
  523. setFixMessage(null);
  524. try {
  525. const res = await runStockLedgerFixInventoryScope(scope.id);
  526. setFixMessage(
  527. `已修 inventory ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
  528. );
  529. await loadInventoryScope(scope.id);
  530. } catch (e) {
  531. console.error(e);
  532. setFixError(apiErrorMessage(e, "修復失敗"));
  533. } finally {
  534. setFixing(false);
  535. fixInFlight.current = false;
  536. }
  537. };
  538. const onFixLotScope = async () => {
  539. if (!scope || scope.kind !== "lot" || fixInFlight.current) return;
  540. const ok = window.confirm(
  541. "只修這張 lot 的 lotQty 與日結,不會改整顆料的 balance。確定?",
  542. );
  543. if (!ok) return;
  544. fixInFlight.current = true;
  545. setFixing(true);
  546. setFixError(null);
  547. setFixMessage(null);
  548. try {
  549. const res = await runStockLedgerFixLotScope(scope.id);
  550. setFixMessage(
  551. `已修 lot ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
  552. );
  553. await loadLotScope(scope.id);
  554. } catch (e) {
  555. console.error(e);
  556. setFixError(apiErrorMessage(e, "修復失敗"));
  557. } finally {
  558. setFixing(false);
  559. fixInFlight.current = false;
  560. }
  561. };
  562. const canFixDay = Boolean(selected && !selected.isAfter(dayjs(), "day"));
  563. const onFixDayRange = async () => {
  564. if (fixInFlight.current) return;
  565. const from = chainFrom.trim();
  566. const to = chainTo.trim();
  567. const today = dayjs().format("YYYY-MM-DD");
  568. if (!from || !to) {
  569. setFixError("請填區間修 from / to");
  570. return;
  571. }
  572. if (to < from) {
  573. setFixError("to 必須 ≥ from");
  574. return;
  575. }
  576. if (from < FIRST_LEDGER_DAY.format("YYYY-MM-DD")) {
  577. setFixError(`from 不能早於 ${FIRST_LEDGER_DAY.format("YYYY-MM-DD")}`);
  578. return;
  579. }
  580. if (to > today) {
  581. setFixError("不能修未來日期(今天可以,給 freeze dump 夜修)");
  582. return;
  583. }
  584. const picked = selectedDaySteps(daySteps);
  585. if (picked.length === 0) {
  586. setFixError("請至少勾一個步驟(預設全跑 2.1–2.6)");
  587. return;
  588. }
  589. const steps = stepsPayload(daySteps);
  590. const stepLabel = steps?.join("、") ?? "全部 2.1–2.6";
  591. const ok = window.confirm(
  592. `一次修 ${from} → ${to}(步驟:${stepLabel})。改為逐日跑(每天 commit)。中途失敗時已跑完的天會留下。確定?`,
  593. );
  594. if (!ok) return;
  595. fixInFlight.current = true;
  596. setChainRunning(true);
  597. setFixing(true);
  598. setFixError(null);
  599. setFixMessage(null);
  600. setChainProgress(`區間修 ${from} → ${to}(${stepLabel})進行中…`);
  601. try {
  602. const res = await runStockLedgerFixRange(from, to, steps);
  603. setFixMessage(
  604. `區間修完成 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`,
  605. );
  606. setSelected(dayjs(to));
  607. setChainProgress(null);
  608. } catch (e) {
  609. console.error(e);
  610. setFixError(apiErrorMessage(e, "區間修失敗"));
  611. } finally {
  612. setFixing(false);
  613. setChainRunning(false);
  614. fixInFlight.current = false;
  615. }
  616. };
  617. const onExportSql = async () => {
  618. if (exportInFlight.current) return;
  619. const from = exportFrom.trim();
  620. const to = exportTo.trim();
  621. if (!from || !to) {
  622. setExportError("請填 from / to");
  623. return;
  624. }
  625. if (to < from) {
  626. setExportError("to 必須 ≥ from");
  627. return;
  628. }
  629. const picked = selectedExportParts(exportParts);
  630. if (picked.length === 0) {
  631. setExportError("請至少勾一個匯出項目");
  632. return;
  633. }
  634. if (exportParts["2.3"] && !exportParts["1.0"]) {
  635. const ok = window.confirm(
  636. "已勾 2.3 inventoryId 但未勾 1.0 inventory:正式庫若缺新 id,UPDATE 會指到空列。建議一併勾 1.0(完整檔)。仍要匯出?",
  637. );
  638. if (!ok) return;
  639. }
  640. const parts = exportPartsPayload(exportParts);
  641. exportInFlight.current = true;
  642. setExporting(true);
  643. setExportError(null);
  644. try {
  645. await downloadStockLedgerFixSql(from, to, parts);
  646. } catch (e) {
  647. console.error(e);
  648. const data = (e as { response?: { data?: unknown } })?.response?.data;
  649. if (data instanceof Blob) {
  650. try {
  651. const text = (await data.text()).trim().slice(0, 400);
  652. setExportError(text || "匯出失敗");
  653. } catch {
  654. setExportError(apiErrorMessage(e, "匯出失敗"));
  655. }
  656. } else {
  657. setExportError(apiErrorMessage(e, "匯出失敗"));
  658. }
  659. } finally {
  660. setExporting(false);
  661. exportInFlight.current = false;
  662. }
  663. };
  664. return (
  665. <Stack spacing={3}>
  666. <Paper sx={{ p: 2 }}>
  667. <Stack spacing={1.5}>
  668. <Typography variant="h6">1.0 inventory(方案 A)</Typography>
  669. <Typography variant="body2" color="text.secondary">
  670. 保留現有 id;lot 有、inventory 沒有的 UOM 會新增;onHand = Σ(in−out) 按
  671. item+UOM。沒有 lot 的列會變成 0。同一料多顆舊列時只留一顆 stock UOM
  672. 桶,其餘 deleted=1。請在進出倉暫停時執行,再逐日 Fix。
  673. </Typography>
  674. {inventoryError && <Alert severity="error">{inventoryError}</Alert>}
  675. {inventoryMessage && (
  676. <Alert severity="success">{inventoryMessage}</Alert>
  677. )}
  678. {inventoryLoading && !inventoryPreview && <CircularProgress size={24} />}
  679. {inventoryPreview && (
  680. <Typography variant="body2">
  681. 現有 inventory {inventoryPreview.inventoryRows} 列;lot 的
  682. item+UOM {inventoryPreview.lotUomPairs} 組;缺{" "}
  683. {inventoryPreview.missingUomPairs} 組;stockUomId 仍 NULL{" "}
  684. {inventoryPreview.nullStockUomId} 列。
  685. </Typography>
  686. )}
  687. <Box>
  688. <Button
  689. variant="contained"
  690. color="warning"
  691. disabled={inventoryRunning || inventoryLoading}
  692. onClick={() => void onInventory()}
  693. >
  694. {inventoryRunning ? "重算中…" : "Run inventory 1.0 (A)"}
  695. </Button>
  696. </Box>
  697. </Stack>
  698. </Paper>
  699. <Paper sx={{ p: 2 }}>
  700. <Stack spacing={1.5}>
  701. <Typography variant="h6">2.7 ADJ:對齊最後餘額與 onHandQty</Typography>
  702. <Typography variant="body2" color="text.secondary">
  703. 選 ADJ 日期後直接執行。預設昨天;freeze dump 夜可選今天。最後餘額低於 onHandQty
  704. 的桶補一筆入倉 ADJ,高於 onHandQty 的桶補一筆出倉 ADJ,掛在既有 lot 上。不改 lot
  705. line、不改 inventory。一次執行共用一張入倉單、一張出倉單;已對齊的桶再跑不會再寫。
  706. </Typography>
  707. <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
  708. <TextField
  709. size="small"
  710. type="date"
  711. label="ADJ 日期"
  712. value={adjDate}
  713. onChange={(e) => {
  714. setAdjDate(e.target.value);
  715. setAdjMessage(null);
  716. }}
  717. disabled={adjRunning}
  718. InputLabelProps={{ shrink: true }}
  719. inputProps={{ max: dayjs().format("YYYY-MM-DD") }}
  720. />
  721. <Button
  722. size="small"
  723. disabled={adjRunning}
  724. onClick={() => {
  725. setAdjDate(dayjs().format("YYYY-MM-DD"));
  726. setAdjMessage(null);
  727. }}
  728. >
  729. 今天(freeze 夜)
  730. </Button>
  731. <Button
  732. size="small"
  733. disabled={adjRunning}
  734. onClick={() => {
  735. setAdjDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"));
  736. setAdjMessage(null);
  737. }}
  738. >
  739. 昨天
  740. </Button>
  741. </Stack>
  742. {adjError && <Alert severity="error">{adjError}</Alert>}
  743. {adjMessage && <Alert severity="success">{adjMessage}</Alert>}
  744. {adjRunning && <CircularProgress size={24} />}
  745. <Stack direction="row" spacing={1}>
  746. <Button
  747. variant="contained"
  748. color="warning"
  749. disabled={adjRunning}
  750. onClick={() => void onAdjApply()}
  751. >
  752. {adjRunning ? "寫入 ADJ 中…" : "Apply ADJ"}
  753. </Button>
  754. </Stack>
  755. </Stack>
  756. </Paper>
  757. <Paper sx={{ px: 2, pt: 1 }}>
  758. <Tabs
  759. value={tab}
  760. onChange={(_, v: "day" | "inventory" | "lot") => {
  761. setTab(v);
  762. setFixError(null);
  763. setFixMessage(null);
  764. setSearchError(null);
  765. }}
  766. >
  767. <Tab label="日曆" value="day" />
  768. <Tab label="Inventory" value="inventory" />
  769. <Tab label="Lot line" value="lot" />
  770. </Tabs>
  771. </Paper>
  772. {tab === "day" && (
  773. <Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="flex-start">
  774. <Paper sx={{ p: 1 }}>
  775. <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
  776. <DateCalendar
  777. value={selected}
  778. onChange={(v) => setSelected(v)}
  779. views={["year", "month", "day"]}
  780. openTo="day"
  781. minDate={FIRST_LEDGER_DAY}
  782. maxDate={dayjs()}
  783. />
  784. </LocalizationProvider>
  785. <Typography variant="caption" color="text.secondary" sx={{ px: 2, pb: 1, display: "block" }}>
  786. 點左上標題可先選年再選月。換月不會查資料;點某一天才檢查當天。今天可修(freeze dump 夜)。
  787. </Typography>
  788. </Paper>
  789. <Paper sx={{ p: 2, flex: 1, minWidth: 0, width: "100%" }}>
  790. <Stack spacing={2}>
  791. <Typography variant="h6">
  792. {selected ? selected.format("YYYY-MM-DD") : "請選日期"}
  793. </Typography>
  794. {detailError && <Alert severity="error">{detailError}</Alert>}
  795. {fixError && <Alert severity="error">{fixError}</Alert>}
  796. {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
  797. {chainProgress && <Alert severity="info">{chainProgress}</Alert>}
  798. {detailLoading && <CircularProgress size={24} />}
  799. {detail && !detailLoading && (
  800. <>
  801. <Typography variant="body2" color="text.secondary">
  802. 當天 ledger {detail.cnt} 列
  803. </Typography>
  804. <CheckPartsTable parts={detail.parts} />
  805. </>
  806. )}
  807. <Box>
  808. <Typography variant="subtitle2" sx={{ mb: 0.5 }}>
  809. 步驟(預設全跑)
  810. </Typography>
  811. <Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
  812. 套 SQL 後只改 inventoryId 時只勾 2.3。只跑部分時未勾的不會重算。
  813. </Typography>
  814. <Stack direction="row" spacing={0} flexWrap="wrap" useFlexGap>
  815. {DAY_FIX_STEPS.map((step) => (
  816. <FormControlLabel
  817. key={step}
  818. control={
  819. <Checkbox
  820. size="small"
  821. checked={daySteps[step]}
  822. disabled={fixing || chainRunning}
  823. onChange={(_, checked) =>
  824. setDaySteps((prev) => ({ ...prev, [step]: checked }))
  825. }
  826. />
  827. }
  828. label={DAY_STEP_LABEL[step]}
  829. />
  830. ))}
  831. <Button
  832. size="small"
  833. disabled={fixing || chainRunning}
  834. onClick={() => setDaySteps(ALL_DAY_STEPS)}
  835. >
  836. 全選
  837. </Button>
  838. </Stack>
  839. </Box>
  840. <Box>
  841. <Button
  842. variant="contained"
  843. disabled={!canFixDay || fixing || chainRunning}
  844. onClick={() => void onFixDay()}
  845. >
  846. {fixing && !chainRunning ? "修復中…" : "Fix this day"}
  847. </Button>
  848. </Box>
  849. <Box sx={{ pt: 1 }}>
  850. <Typography variant="subtitle2" sx={{ mb: 1 }}>
  851. 區間修(2.1–2.6 一次)
  852. </Typography>
  853. <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
  854. 使用上面勾的步驟,一次 API 但改為逐日(與單日修相同 SQL,每天 commit)。可含今天(freeze dump 夜)。前端最長約 20 分鐘;大區間仍建議匯出 SQL。
  855. </Typography>
  856. <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
  857. <TextField
  858. size="small"
  859. type="date"
  860. label="from"
  861. value={chainFrom}
  862. onChange={(e) => setChainFrom(e.target.value)}
  863. disabled={chainRunning}
  864. InputLabelProps={{ shrink: true }}
  865. />
  866. <TextField
  867. size="small"
  868. type="date"
  869. label="to"
  870. value={chainTo}
  871. onChange={(e) => setChainTo(e.target.value)}
  872. disabled={chainRunning}
  873. InputLabelProps={{ shrink: true }}
  874. />
  875. <Button
  876. variant="contained"
  877. disabled={fixing || chainRunning}
  878. onClick={() => void onFixDayRange()}
  879. >
  880. {chainRunning ? "區間修中…" : "區間修"}
  881. </Button>
  882. </Stack>
  883. </Box>
  884. <Box sx={{ pt: 1 }}>
  885. <Typography variant="subtitle2" sx={{ mb: 1 }}>
  886. 匯出已修 SQL(新庫用)
  887. </Typography>
  888. <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
  889. 勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → 2.1 → ledger(±2.3)→ 2.7 入出倉單+調整記錄+ADJ →
  890. 2.6。完整檔且正式庫 freeze/id 同源、日期蓋過全部已修日子時,結果與已修庫相同。預設只匯
  891. ledger+日結(舊行為)。
  892. </Typography>
  893. <Stack direction="row" spacing={0} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
  894. {EXPORT_PARTS.map((part) => (
  895. <FormControlLabel
  896. key={part}
  897. control={
  898. <Checkbox
  899. size="small"
  900. checked={exportParts[part]}
  901. disabled={exporting}
  902. onChange={(_, checked) =>
  903. setExportParts((prev) => ({ ...prev, [part]: checked }))
  904. }
  905. />
  906. }
  907. label={EXPORT_PART_LABEL[part]}
  908. />
  909. ))}
  910. <Button
  911. size="small"
  912. disabled={exporting}
  913. onClick={() => setExportParts(DEFAULT_EXPORT_PARTS)}
  914. >
  915. 預設
  916. </Button>
  917. <Button
  918. size="small"
  919. disabled={exporting}
  920. onClick={() => setExportParts(FULL_EXPORT_PARTS)}
  921. >
  922. 完整檔
  923. </Button>
  924. </Stack>
  925. {exportError && <Alert severity="error">{exportError}</Alert>}
  926. <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
  927. <TextField
  928. size="small"
  929. type="date"
  930. label="from"
  931. value={exportFrom}
  932. onChange={(e) => setExportFrom(e.target.value)}
  933. InputLabelProps={{ shrink: true }}
  934. />
  935. <TextField
  936. size="small"
  937. type="date"
  938. label="to"
  939. value={exportTo}
  940. onChange={(e) => setExportTo(e.target.value)}
  941. InputLabelProps={{ shrink: true }}
  942. />
  943. <Button
  944. variant="outlined"
  945. disabled={exporting}
  946. onClick={() => void onExportSql()}
  947. >
  948. {exporting ? "匯出中…" : "匯出 .sql"}
  949. </Button>
  950. </Stack>
  951. </Box>
  952. </Stack>
  953. </Paper>
  954. </Stack>
  955. )}
  956. {tab === "inventory" && (
  957. <Paper sx={{ p: 2 }}>
  958. <Stack spacing={2}>
  959. <Typography variant="body2" color="text.secondary">
  960. 搜 itemCode 或 inventoryId。Fix 會重算這顆料到今天為止全部流水的 lotQty、日結與
  961. balance,不必逐日點日曆。白天倉還在寫這顆料時,修完後新流水仍由 live writer 接。
  962. </Typography>
  963. <Stack direction="row" spacing={1}>
  964. <TextField
  965. size="small"
  966. label="itemCode / inventoryId"
  967. value={invQuery}
  968. onChange={(e) => setInvQuery(e.target.value)}
  969. onKeyDown={(e) => {
  970. if (e.key === "Enter") void onSearchInventory();
  971. }}
  972. />
  973. <Button
  974. variant="outlined"
  975. disabled={searching || !invQuery.trim()}
  976. onClick={() => void onSearchInventory()}
  977. >
  978. {searching ? "搜尋中…" : "搜尋"}
  979. </Button>
  980. </Stack>
  981. {searchError && <Alert severity="error">{searchError}</Alert>}
  982. {invHits.length > 0 && (
  983. <Table size="small">
  984. <TableHead>
  985. <TableRow>
  986. <TableCell>inventoryId</TableCell>
  987. <TableCell>itemCode</TableCell>
  988. <TableCell>uomId</TableCell>
  989. <TableCell align="right">ledger 列</TableCell>
  990. </TableRow>
  991. </TableHead>
  992. <TableBody>
  993. {invHits.map((h) => (
  994. <TableRow
  995. key={h.inventoryId}
  996. hover
  997. selected={scope?.kind === "inventory" && scope.id === h.inventoryId}
  998. onClick={() => void loadInventoryScope(h.inventoryId)}
  999. sx={{ cursor: "pointer" }}
  1000. >
  1001. <TableCell>{h.inventoryId}</TableCell>
  1002. <TableCell>{h.itemCode ?? "—"}</TableCell>
  1003. <TableCell>{h.uomId ?? "—"}</TableCell>
  1004. <TableCell align="right">{h.ledgerCnt}</TableCell>
  1005. </TableRow>
  1006. ))}
  1007. </TableBody>
  1008. </Table>
  1009. )}
  1010. {fixError && <Alert severity="error">{fixError}</Alert>}
  1011. {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
  1012. {scopeLoading && <CircularProgress size={24} />}
  1013. {scope?.kind === "inventory" && !scopeLoading && (
  1014. <>
  1015. <Typography variant="body2">
  1016. {scope.itemCode ?? "?"} / inventory {scope.id} / uom {scope.uomId ?? "?"}
  1017. ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後
  1018. balance {scope.lastBalance ?? "—"}
  1019. </Typography>
  1020. <CheckPartsTable parts={scope.parts} />
  1021. <Box>
  1022. <Button
  1023. variant="contained"
  1024. disabled={fixing || scope.cnt === 0}
  1025. onClick={() => void onFixInventoryScope()}
  1026. >
  1027. {fixing ? "修復中…" : "Fix this inventory"}
  1028. </Button>
  1029. </Box>
  1030. </>
  1031. )}
  1032. </Stack>
  1033. </Paper>
  1034. )}
  1035. {tab === "lot" && (
  1036. <Paper sx={{ p: 2 }}>
  1037. <Stack spacing={2}>
  1038. <Typography variant="body2" color="text.secondary">
  1039. 搜 lotNo 或 inventoryLotLineId。Fix 只重算這張 lot 的 lotQty 與日結,不改
  1040. balance。
  1041. </Typography>
  1042. <Stack direction="row" spacing={1}>
  1043. <TextField
  1044. size="small"
  1045. label="lotNo / lot line id"
  1046. value={lotQuery}
  1047. onChange={(e) => setLotQuery(e.target.value)}
  1048. onKeyDown={(e) => {
  1049. if (e.key === "Enter") void onSearchLot();
  1050. }}
  1051. />
  1052. <Button
  1053. variant="outlined"
  1054. disabled={searching || !lotQuery.trim()}
  1055. onClick={() => void onSearchLot()}
  1056. >
  1057. {searching ? "搜尋中…" : "搜尋"}
  1058. </Button>
  1059. </Stack>
  1060. {searchError && <Alert severity="error">{searchError}</Alert>}
  1061. {lotHits.length > 0 && (
  1062. <Table size="small">
  1063. <TableHead>
  1064. <TableRow>
  1065. <TableCell>lot line id</TableCell>
  1066. <TableCell>lotNo</TableCell>
  1067. <TableCell>itemCode</TableCell>
  1068. <TableCell>inventoryId</TableCell>
  1069. <TableCell align="right">ledger 列</TableCell>
  1070. </TableRow>
  1071. </TableHead>
  1072. <TableBody>
  1073. {lotHits.map((h) => (
  1074. <TableRow
  1075. key={h.inventoryLotLineId}
  1076. hover
  1077. selected={scope?.kind === "lot" && scope.id === h.inventoryLotLineId}
  1078. onClick={() => void loadLotScope(h.inventoryLotLineId)}
  1079. sx={{ cursor: "pointer" }}
  1080. >
  1081. <TableCell>{h.inventoryLotLineId}</TableCell>
  1082. <TableCell>{h.lotNo ?? "—"}</TableCell>
  1083. <TableCell>{h.itemCode ?? "—"}</TableCell>
  1084. <TableCell>{h.inventoryId ?? "—"}</TableCell>
  1085. <TableCell align="right">{h.ledgerCnt}</TableCell>
  1086. </TableRow>
  1087. ))}
  1088. </TableBody>
  1089. </Table>
  1090. )}
  1091. {fixError && <Alert severity="error">{fixError}</Alert>}
  1092. {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
  1093. {scopeLoading && <CircularProgress size={24} />}
  1094. {scope?.kind === "lot" && !scopeLoading && (
  1095. <>
  1096. <Typography variant="body2">
  1097. {scope.lotNo ?? "?"} / lot {scope.id} / {scope.itemCode ?? "?"}
  1098. ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後
  1099. lotQtyAfter {scope.lastLotQtyAfter ?? "—"}
  1100. </Typography>
  1101. <CheckPartsTable parts={scope.parts} />
  1102. <Box>
  1103. <Button
  1104. variant="contained"
  1105. disabled={fixing || scope.cnt === 0}
  1106. onClick={() => void onFixLotScope()}
  1107. >
  1108. {fixing ? "修復中…" : "Fix this lot"}
  1109. </Button>
  1110. </Box>
  1111. </>
  1112. )}
  1113. </Stack>
  1114. </Paper>
  1115. )}
  1116. </Stack>
  1117. );
  1118. };
  1119. export default StockLedgerFixPageClient;