|
- "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 { useTranslation } from "react-i18next";
- import dayjs, { type Dayjs } from "dayjs";
- import "dayjs/locale/zh-hk";
- import "dayjs/locale/en";
- 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<DayFixStep, boolean> = {
- "2.1": true,
- "2.2": true,
- "2.3": true,
- "2.4": true,
- "2.5": true,
- "2.6": true,
- };
- const DAY_STEP_I18N: Record<DayFixStep, string> = {
- "2.1": "step21",
- "2.2": "step22",
- "2.3": "step23",
- "2.4": "step24",
- "2.5": "step25",
- "2.6": "step26",
- };
-
- /** 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<ExportPart, boolean> = {
- "1.0": false,
- "2.3": false,
- ledger: true,
- "2.6": true,
- "2.7": false,
- };
- const FULL_EXPORT_PARTS: Record<ExportPart, boolean> = {
- "1.0": true,
- "2.3": true,
- ledger: true,
- "2.6": true,
- "2.7": true,
- };
- const EXPORT_PART_I18N: Record<ExportPart, string> = {
- "1.0": "export10",
- "2.3": "export23",
- ledger: "exportLedger",
- "2.6": "export26",
- "2.7": "export27",
- };
-
- function selectedExportParts(flags: Record<ExportPart, boolean>): ExportPart[] {
- return EXPORT_PARTS.filter((p) => flags[p]);
- }
-
- function exportPartsPayload(flags: Record<ExportPart, boolean>): string[] | undefined {
- const selected = selectedExportParts(flags);
- if (selected.length === 0) return undefined;
- // Always send explicit list so backend does not fall back to legacy default alone
- return selected;
- }
-
- function selectedDaySteps(flags: Record<DayFixStep, boolean>): DayFixStep[] {
- return DAY_FIX_STEPS.filter((s) => flags[s]);
- }
-
- function stepsPayload(flags: Record<DayFixStep, boolean>): string[] | undefined {
- const selected = selectedDaySteps(flags);
- if (selected.length === 0 || selected.length === DAY_FIX_STEPS.length) return undefined;
- return selected;
- }
-
- function 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";
- }
-
- const VERDICT_LABEL: Record<
- ReturnType<typeof partVerdict>,
- "verdictCorrect" | "verdictMiss" | "verdictOverIssue" | "verdictCanFix" | "verdictCannotFix" | "verdictIncorrect"
- > = {
- correct: "verdictCorrect",
- miss: "verdictMiss",
- "over-issue": "verdictOverIssue",
- "can-fix": "verdictCanFix",
- "cannot-fix": "verdictCannotFix",
- incorrect: "verdictIncorrect",
- };
-
- function CheckPartsTable({ parts }: { parts: StockLedgerFixCheckPart[] }) {
- const { t } = useTranslation("stockLedgerFix");
- const field = parts.filter((p) => !p.group || p.group === "field");
- const canFix = parts.filter(
- (p) => p.group === "canFix" && p.miss + p.incorrect > 0,
- );
- const cannotFix = parts.filter(
- (p) => p.group === "cannotFix" && p.miss + p.incorrect > 0,
- );
- const renderRows = (rows: StockLedgerFixCheckPart[]) =>
- rows.map((p) => {
- const v = partVerdict(p);
- return (
- <TableRow key={p.key}>
- <TableCell sx={{ whiteSpace: "normal", wordBreak: "break-word" }}>
- {t(`part.${p.key}`, { defaultValue: p.label })}
- </TableCell>
- <TableCell>
- <Chip
- size="small"
- label={t(VERDICT_LABEL[v])}
- color={
- v === "correct"
- ? "success"
- : v === "miss" || v === "over-issue" || v === "can-fix"
- ? "warning"
- : "error"
- }
- />
- </TableCell>
- <TableCell align="right">{p.ok}</TableCell>
- <TableCell align="right">{p.miss}</TableCell>
- <TableCell align="right">{p.incorrect}</TableCell>
- </TableRow>
- );
- });
- return (
- <Stack spacing={2}>
- <Table size="small" sx={{ width: "100%" }}>
- <TableHead>
- <TableRow>
- <TableCell sx={{ whiteSpace: "normal" }}>{t("checkItem")}</TableCell>
- <TableCell>{t("checkStatus")}</TableCell>
- <TableCell align="right">{t("checkCorrect")}</TableCell>
- <TableCell align="right">{t("checkMiss")}</TableCell>
- <TableCell align="right">{t("checkIncorrect")}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>{renderRows(field)}</TableBody>
- </Table>
- {canFix.length > 0 && (
- <>
- <Typography variant="subtitle2" sx={{ whiteSpace: "normal" }}>
- {t("canAutoFix")}
- </Typography>
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>{t("checkReason")}</TableCell>
- <TableCell>{t("checkStatus")}</TableCell>
- <TableCell align="right">{t("checkCorrect")}</TableCell>
- <TableCell align="right">{t("checkRows")}</TableCell>
- <TableCell align="right">{t("checkDash")}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>{renderRows(canFix)}</TableBody>
- </Table>
- </>
- )}
- {cannotFix.length > 0 && (
- <>
- <Typography variant="subtitle2" sx={{ whiteSpace: "normal" }}>
- {t("cannotAutoFix")}
- </Typography>
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>{t("checkReason")}</TableCell>
- <TableCell>{t("checkStatus")}</TableCell>
- <TableCell align="right">{t("checkCorrect")}</TableCell>
- <TableCell align="right">{t("checkDash")}</TableCell>
- <TableCell align="right">{t("checkRows")}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>{renderRows(cannotFix)}</TableBody>
- </Table>
- </>
- )}
- </Stack>
- );
- }
-
- const StockLedgerFixPageClient: React.FC = () => {
- const { t, i18n } = useTranslation("stockLedgerFix");
- const isZh = (i18n.language || "zh").startsWith("zh");
- const listSep = isZh ? "、" : ", ";
- const [tab, setTab] = useState<"day" | "inventory" | "lot">("day");
- const [selected, setSelected] = useState<Dayjs | null>(() =>
- dayjs().subtract(1, "day"),
- );
- const [detail, setDetail] = useState<StockLedgerFixDayDetail | null>(null);
- const [detailLoading, setDetailLoading] = useState(false);
- const [detailError, setDetailError] = useState<string | null>(null);
- const [fixing, setFixing] = useState(false);
- const [fixError, setFixError] = useState<string | null>(null);
- const [fixMessage, setFixMessage] = useState<string | null>(null);
- const detailInFlight = useRef(false);
- const fixInFlight = useRef(false);
- const inventoryLoadInFlight = useRef(false);
- const inventoryRunInFlight = useRef(false);
- const searchInFlight = useRef(false);
- const scopeInFlight = useRef(false);
- const exportInFlight = useRef(false);
- const [chainFrom, setChainFrom] = useState("2026-03-15");
- const [chainTo, setChainTo] = useState(() =>
- dayjs().subtract(1, "day").format("YYYY-MM-DD"),
- );
- const [chainRunning, setChainRunning] = useState(false);
- const [chainProgress, setChainProgress] = useState<string | null>(null);
- const [daySteps, setDaySteps] = useState<Record<DayFixStep, boolean>>(ALL_DAY_STEPS);
- const [exportFrom, setExportFrom] = useState("2026-03-15");
- const [exportTo, setExportTo] = useState(() =>
- dayjs().subtract(1, "day").format("YYYY-MM-DD"),
- );
- const [exportParts, setExportParts] =
- useState<Record<ExportPart, boolean>>(DEFAULT_EXPORT_PARTS);
- const [exporting, setExporting] = useState(false);
- const [exportError, setExportError] = useState<string | null>(null);
- const [inventoryPreview, setInventoryPreview] =
- useState<StockLedgerFixInventoryPreview | null>(null);
- const [inventoryLoading, setInventoryLoading] = useState(false);
- const [inventoryRunning, setInventoryRunning] = useState(false);
- const [inventoryError, setInventoryError] = useState<string | null>(null);
- const [inventoryMessage, setInventoryMessage] = useState<string | null>(null);
- const adjLoadInFlight = useRef(false);
- const adjRunInFlight = useRef(false);
- const [adjPreview, setAdjPreview] = useState<StockLedgerFixAdjPreview | null>(null);
- const [adjLoading, setAdjLoading] = useState(false);
- const [adjRunning, setAdjRunning] = useState(false);
- const [adjError, setAdjError] = useState<string | null>(null);
- const [adjMessage, setAdjMessage] = useState<string | null>(null);
- /** Default yesterday; freeze-night dump set to today so ADJ lands on dump day. */
- const [adjDate, setAdjDate] = useState(() =>
- dayjs().subtract(1, "day").format("YYYY-MM-DD"),
- );
- const [invQuery, setInvQuery] = useState("");
- const [lotQuery, setLotQuery] = useState("");
- const [invHits, setInvHits] = useState<StockLedgerFixSearchInventoryHit[]>([]);
- const [lotHits, setLotHits] = useState<StockLedgerFixSearchLotHit[]>([]);
- const [searchError, setSearchError] = useState<string | null>(null);
- const [searching, setSearching] = useState(false);
- const [scope, setScope] = useState<StockLedgerFixScopeDetail | null>(null);
- const [scopeLoading, setScopeLoading] = useState(false);
-
- const loadInventory = useCallback(async () => {
- if (inventoryLoadInFlight.current) return;
- inventoryLoadInFlight.current = true;
- setInventoryLoading(true);
- setInventoryError(null);
- try {
- const data = await fetchStockLedgerFixInventory();
- setInventoryPreview(data);
- } catch (e) {
- console.error(e);
- setInventoryError(t("inventory10LoadError"));
- setInventoryPreview(null);
- } finally {
- setInventoryLoading(false);
- inventoryLoadInFlight.current = false;
- }
- }, [t]);
-
- 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(t("dayLoadError"));
- setDetail(null);
- } finally {
- setDetailLoading(false);
- detailInFlight.current = false;
- }
- }, [t]);
-
- const loadAdjPreview = useCallback(async () => {
- if (adjLoadInFlight.current) return;
- const d = adjDate.trim();
- if (!d) {
- setAdjError(t("adjDateRequired"));
- return;
- }
- if (d > dayjs().format("YYYY-MM-DD")) {
- setAdjError(t("adjDateFuture"));
- return;
- }
- adjLoadInFlight.current = true;
- setAdjLoading(true);
- setAdjError(null);
- try {
- setAdjPreview(await fetchStockLedgerFixAdjPreview(d));
- } catch (e) {
- console.error(e);
- setAdjError(apiErrorMessage(e, t("adjLoadError")));
- setAdjPreview(null);
- } finally {
- setAdjLoading(false);
- adjLoadInFlight.current = false;
- }
- }, [adjDate, t]);
-
- 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(t("cannotFixFuture"));
- return;
- }
- const picked = selectedDaySteps(daySteps);
- if (picked.length === 0) {
- setFixError(t("pickAtLeastOneStep"));
- return;
- }
- const steps = stepsPayload(daySteps);
- const stepLabel = steps?.join(listSep) ?? t("allSteps216");
- if (steps) {
- const ok = window.confirm(
- t("confirmPartialSteps", { steps: stepLabel, date }),
- );
- if (!ok) return;
- }
- fixInFlight.current = true;
- setFixing(true);
- setFixError(null);
- setFixMessage(null);
- try {
- const res = await runStockLedgerFixDay(date, steps);
- setFixMessage(
- t("fixDayDone", {
- date: res.date,
- steps: stepLabel,
- lot: res.filledLotLineId,
- uom: res.filledUomId,
- inventory: res.filledInventoryId,
- lotQty: res.filledLotQty,
- balance: res.filledBalance,
- dayRows: res.dayRowsWritten,
- }),
- );
- await loadDay(date);
- } catch (e) {
- console.error(e);
- setFixError(apiErrorMessage(e, t("fixFailed")));
- } finally {
- setFixing(false);
- fixInFlight.current = false;
- }
- };
-
- const onInventory = async () => {
- if (inventoryRunInFlight.current) return;
- const ok = window.confirm(t("inventory10Confirm"));
- if (!ok) return;
- inventoryRunInFlight.current = true;
- setInventoryRunning(true);
- setInventoryError(null);
- setInventoryMessage(null);
- try {
- const res = await runStockLedgerFixInventory();
- setInventoryMessage(
- t("inventory10Done", {
- patched: res.patchedStockUomId,
- inserted: res.inserted,
- orphans: res.orphansDeleted ?? 0,
- updated: res.updated,
- missingAfter: res.missingUomPairsAfter,
- nullAfter: res.nullStockUomIdAfter,
- }),
- );
- await loadInventory();
- } catch (e) {
- console.error(e);
- setInventoryError(apiErrorMessage(e, t("inventory10Fail")));
- } finally {
- setInventoryRunning(false);
- inventoryRunInFlight.current = false;
- }
- };
-
- const onAdjApply = async () => {
- if (adjRunInFlight.current) return;
- const d = adjDate.trim() || adjPreview?.adjDate;
- if (!d) {
- setAdjError(t("adjDateAndPreviewRequired"));
- return;
- }
- const ok = window.confirm(
- t("adjConfirm", {
- date: d,
- overIssueCount: adjPreview?.overIssueCount ?? 0,
- sumOverIssue: adjPreview?.sumOverIssue ?? "?",
- adjInCount: adjPreview?.adjInCount ?? 0,
- adjOutCount: adjPreview?.adjOutCount ?? 0,
- sumMissIn: adjPreview?.sumMissIn ?? "?",
- sumMissOut: adjPreview?.sumMissOut ?? "?",
- }),
- );
- if (!ok) return;
- adjRunInFlight.current = true;
- setAdjRunning(true);
- setAdjError(null);
- setAdjMessage(null);
- try {
- const res = await runStockLedgerFixAdj(d);
- setAdjMessage(
- t("adjDone", {
- date: res.adjDate,
- overIssuePatched: res.overIssuePatched,
- insertedIn: res.insertedIn,
- insertedOut: res.insertedOut,
- filledLotQty: res.filledLotQty,
- filledBalance: res.filledBalance,
- dayRowsWritten: res.dayRowsWritten,
- }),
- );
- await loadAdjPreview();
- } catch (e) {
- console.error(e);
- setAdjError(apiErrorMessage(e, t("adjFail")));
- } 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, t("searchFailed")));
- } 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, t("searchFailed")));
- } 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, t("invLoadError")));
- 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, t("lotLoadError")));
- setScope(null);
- } finally {
- setScopeLoading(false);
- scopeInFlight.current = false;
- }
- };
-
- const onFixInventoryScope = async () => {
- if (!scope || scope.kind !== "inventory" || fixInFlight.current) return;
- const ok = window.confirm(t("invFixConfirm"));
- if (!ok) return;
- fixInFlight.current = true;
- setFixing(true);
- setFixError(null);
- setFixMessage(null);
- try {
- const res = await runStockLedgerFixInventoryScope(scope.id);
- setFixMessage(
- t("invFixDone", {
- id: scope.id,
- lot: res.filledLotLineId,
- uom: res.filledUomId,
- inventory: res.filledInventoryId,
- lotQty: res.filledLotQty,
- balance: res.filledBalance,
- dayRows: res.dayRowsWritten,
- }),
- );
- await loadInventoryScope(scope.id);
- } catch (e) {
- console.error(e);
- setFixError(apiErrorMessage(e, t("fixFailed")));
- } finally {
- setFixing(false);
- fixInFlight.current = false;
- }
- };
-
- const onFixLotScope = async () => {
- if (!scope || scope.kind !== "lot" || fixInFlight.current) return;
- const ok = window.confirm(t("lotFixConfirm"));
- if (!ok) return;
- fixInFlight.current = true;
- setFixing(true);
- setFixError(null);
- setFixMessage(null);
- try {
- const res = await runStockLedgerFixLotScope(scope.id);
- setFixMessage(
- t("lotFixDone", {
- id: scope.id,
- lot: res.filledLotLineId,
- uom: res.filledUomId,
- inventory: res.filledInventoryId,
- lotQty: res.filledLotQty,
- dayRows: res.dayRowsWritten,
- }),
- );
- await loadLotScope(scope.id);
- } catch (e) {
- console.error(e);
- setFixError(apiErrorMessage(e, t("fixFailed")));
- } 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(t("rangeFromRequired"));
- return;
- }
- if (to < from) {
- setFixError(t("toMustBeGteFrom"));
- return;
- }
- if (from < FIRST_LEDGER_DAY.format("YYYY-MM-DD")) {
- setFixError(t("fromTooEarly", { date: FIRST_LEDGER_DAY.format("YYYY-MM-DD") }));
- return;
- }
- if (to > today) {
- setFixError(t("cannotFixFutureRange"));
- return;
- }
- const picked = selectedDaySteps(daySteps);
- if (picked.length === 0) {
- setFixError(t("pickAtLeastOneStep"));
- return;
- }
- const steps = stepsPayload(daySteps);
- const stepLabel = steps?.join(listSep) ?? t("allSteps216");
- const ok = window.confirm(
- t("rangeConfirm", { from, to, steps: stepLabel }),
- );
- if (!ok) return;
-
- fixInFlight.current = true;
- setChainRunning(true);
- setFixing(true);
- setFixError(null);
- setFixMessage(null);
- setChainProgress(t("rangeProgress", { from, to, steps: stepLabel }));
- try {
- const res = await runStockLedgerFixRange(from, to, steps);
- setFixMessage(
- t("rangeDone", {
- date: res.date,
- steps: stepLabel,
- lot: res.filledLotLineId,
- uom: res.filledUomId,
- inventory: res.filledInventoryId,
- lotQty: res.filledLotQty,
- balance: res.filledBalance,
- dayRows: res.dayRowsWritten,
- }),
- );
- setSelected(dayjs(to));
- setChainProgress(null);
- } catch (e) {
- console.error(e);
- setFixError(apiErrorMessage(e, t("rangeFail")));
- } 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(t("exportFromToRequired"));
- return;
- }
- if (to < from) {
- setExportError(t("toMustBeGteFrom"));
- return;
- }
- const picked = selectedExportParts(exportParts);
- if (picked.length === 0) {
- setExportError(t("exportPickAtLeastOne"));
- return;
- }
- if (exportParts["2.3"] && !exportParts["1.0"]) {
- const ok = window.confirm(t("export23Without10"));
- 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 || t("exportFail"));
- } catch {
- setExportError(apiErrorMessage(e, t("exportFail")));
- }
- } else {
- setExportError(apiErrorMessage(e, t("exportFail")));
- }
- } finally {
- setExporting(false);
- exportInFlight.current = false;
- }
- };
-
- return (
- <Stack spacing={3}>
- <Paper sx={{ p: 2 }}>
- <Stack spacing={1.5}>
- <Typography variant="h6">{t("inventory10Title")}</Typography>
- <Typography variant="body2" color="text.secondary">
- {t("inventory10Description")}
- </Typography>
- {inventoryError && <Alert severity="error">{inventoryError}</Alert>}
- {inventoryMessage && (
- <Alert severity="success">{inventoryMessage}</Alert>
- )}
- {inventoryLoading && !inventoryPreview && <CircularProgress size={24} />}
- {inventoryPreview && (
- <Typography variant="body2">
- {t("inventory10Preview", {
- rows: inventoryPreview.inventoryRows,
- lotPairs: inventoryPreview.lotUomPairs,
- missing: inventoryPreview.missingUomPairs,
- nullUom: inventoryPreview.nullStockUomId,
- })}
- </Typography>
- )}
- <Box>
- <Button
- variant="contained"
- color="warning"
- disabled={inventoryRunning || inventoryLoading}
- onClick={() => void onInventory()}
- >
- {inventoryRunning ? t("inventory10Running") : t("inventory10Run")}
- </Button>
- </Box>
- </Stack>
- </Paper>
-
- <Paper sx={{ p: 2 }}>
- <Stack spacing={1.5}>
- <Typography variant="h6">{t("adjTitle")}</Typography>
- <Typography variant="body2" color="text.secondary">
- {t("adjDescription")}
- </Typography>
- <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
- <TextField
- size="small"
- type="date"
- label={t("adjDateLabel")}
- value={adjDate}
- onChange={(e) => {
- setAdjDate(e.target.value);
- setAdjPreview(null);
- setAdjMessage(null);
- }}
- disabled={adjLoading || adjRunning}
- InputLabelProps={{ shrink: true }}
- inputProps={{ max: dayjs().format("YYYY-MM-DD") }}
- />
- <Button
- size="small"
- disabled={adjLoading || adjRunning}
- onClick={() => {
- setAdjDate(dayjs().format("YYYY-MM-DD"));
- setAdjPreview(null);
- }}
- >
- {t("adjTodayFreeze")}
- </Button>
- <Button
- size="small"
- disabled={adjLoading || adjRunning}
- onClick={() => {
- setAdjDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"));
- setAdjPreview(null);
- }}
- >
- {t("adjYesterday")}
- </Button>
- </Stack>
- {adjError && <Alert severity="error">{adjError}</Alert>}
- {adjMessage && <Alert severity="success">{adjMessage}</Alert>}
- {adjLoading && !adjPreview && <CircularProgress size={24} />}
- {adjPreview && (
- <Typography variant="body2">
- {t("adjPreviewSummary", {
- date: adjPreview.adjDate,
- lotCount: adjPreview.lotCount,
- overIssueCount: adjPreview.overIssueCount,
- sumOverIssue: adjPreview.sumOverIssue,
- adjInCount: adjPreview.adjInCount,
- adjOutCount: adjPreview.adjOutCount,
- sumMissIn: adjPreview.sumMissIn,
- sumMissOut: adjPreview.sumMissOut,
- skuNet: adjPreview.skuNet,
- skuNetNote:
- adjPreview.skuNet !== "0" ? t("adjSkuNetNote") : "",
- revNote:
- adjPreview.skippedNegCount > 0
- ? t("adjRevNote", { count: adjPreview.skippedNegCount })
- : "",
- })}
- </Typography>
- )}
- {adjPreview && adjPreview.rows.length > 0 && (
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>{t("adjColLotLineId")}</TableCell>
- <TableCell>{t("adjColItemCode")}</TableCell>
- <TableCell align="right">{t("adjColLineInOut")}</TableCell>
- <TableCell align="right">{t("adjColLedgerInOut")}</TableCell>
- <TableCell align="right">{t("adjColMissIn")}</TableCell>
- <TableCell align="right">{t("adjColMissOut")}</TableCell>
- <TableCell align="right">{t("adjColOverIssue")}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {adjPreview.rows.map((r) => (
- <TableRow key={r.lotLineId}>
- <TableCell>{r.lotLineId}</TableCell>
- <TableCell>{r.itemCode ?? t("checkDash")}</TableCell>
- <TableCell align="right">
- {r.lineIn} / {r.lineOut}
- </TableCell>
- <TableCell align="right">
- {r.ledgerIn} / {r.ledgerOut}
- </TableCell>
- <TableCell align="right">{r.missIn}</TableCell>
- <TableCell align="right">{r.missOut}</TableCell>
- <TableCell align="right">{r.overIssue}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )}
- <Stack direction="row" spacing={1}>
- <Button
- variant="outlined"
- disabled={adjLoading || adjRunning}
- onClick={() => void loadAdjPreview()}
- >
- {adjLoading
- ? t("adjPreviewing")
- : adjPreview
- ? t("adjPreviewAgain")
- : t("adjPreview")}
- </Button>
- <Button
- variant="contained"
- color="warning"
- disabled={adjRunning || adjLoading || !adjPreview}
- onClick={() => void onAdjApply()}
- >
- {adjRunning ? t("adjApplying") : t("adjApply")}
- </Button>
- </Stack>
- </Stack>
- </Paper>
-
- <Paper sx={{ px: 2, pt: 1 }}>
- <Tabs
- value={tab}
- onChange={(_, v: "day" | "inventory" | "lot") => {
- setTab(v);
- setFixError(null);
- setFixMessage(null);
- setSearchError(null);
- }}
- >
- <Tab label={t("tabCalendar")} value="day" />
- <Tab label={t("tabInventory")} value="inventory" />
- <Tab label={t("tabLot")} value="lot" />
- </Tabs>
- </Paper>
-
- {tab === "day" && (
- <Stack
- direction={{ xs: "column", md: "row" }}
- spacing={3}
- alignItems="flex-start"
- sx={{ width: "100%", minWidth: 0 }}
- >
- <Paper sx={{ p: 1, maxWidth: 360, width: "100%", flex: "0 0 auto" }}>
- <LocalizationProvider
- dateAdapter={AdapterDayjs}
- adapterLocale={isZh ? "zh-hk" : "en"}
- >
- <DateCalendar
- value={selected}
- onChange={(v) => setSelected(v)}
- views={["year", "month", "day"]}
- openTo="day"
- minDate={FIRST_LEDGER_DAY}
- maxDate={dayjs()}
- />
- </LocalizationProvider>
- <Typography
- variant="caption"
- color="text.secondary"
- sx={{ px: 2, pb: 1, display: "block", whiteSpace: "pre-line" }}
- >
- {t("calendarHint")}
- </Typography>
- </Paper>
-
- <Paper sx={{ p: 2, flex: 1, minWidth: 0, maxWidth: "100%", width: { xs: "100%", md: "auto" } }}>
- <Stack spacing={2}>
- <Typography variant="h6">
- {selected ? selected.format("YYYY-MM-DD") : t("selectDate")}
- </Typography>
- {detailError && <Alert severity="error">{detailError}</Alert>}
- {fixError && <Alert severity="error">{fixError}</Alert>}
- {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
- {chainProgress && <Alert severity="info">{chainProgress}</Alert>}
- {detailLoading && <CircularProgress size={24} />}
- {detail && !detailLoading && (
- <>
- <Typography variant="body2" color="text.secondary">
- {t("dayLedgerCount", { cnt: detail.cnt })}
- </Typography>
- <CheckPartsTable parts={detail.parts} />
- </>
- )}
- <Box>
- <Typography variant="subtitle2" sx={{ mb: 0.5 }}>
- {t("stepsTitle")}
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
- {t("stepsHint")}
- </Typography>
- <Stack direction="row" spacing={0} flexWrap="wrap" useFlexGap>
- {DAY_FIX_STEPS.map((step) => (
- <FormControlLabel
- key={step}
- sx={{
- mr: 1,
- "& .MuiFormControlLabel-label": { whiteSpace: "normal" },
- }}
- control={
- <Checkbox
- size="small"
- checked={daySteps[step]}
- disabled={fixing || chainRunning}
- onChange={(_, checked) =>
- setDaySteps((prev) => ({ ...prev, [step]: checked }))
- }
- />
- }
- label={t(DAY_STEP_I18N[step])}
- />
- ))}
- <Button
- size="small"
- disabled={fixing || chainRunning}
- onClick={() => setDaySteps(ALL_DAY_STEPS)}
- >
- {t("selectAll")}
- </Button>
- </Stack>
- </Box>
- <Box>
- <Button
- variant="contained"
- disabled={!canFixDay || fixing || chainRunning}
- onClick={() => void onFixDay()}
- >
- {fixing && !chainRunning ? t("fixing") : t("fixThisDay")}
- </Button>
- </Box>
- <Box sx={{ pt: 1 }}>
- <Typography variant="subtitle2" sx={{ mb: 1 }}>
- {t("rangeTitle")}
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
- {t("rangeHint")}
- </Typography>
- <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
- <TextField
- size="small"
- type="date"
- label={t("from")}
- value={chainFrom}
- onChange={(e) => setChainFrom(e.target.value)}
- disabled={chainRunning}
- InputLabelProps={{ shrink: true }}
- />
- <TextField
- size="small"
- type="date"
- label={t("to")}
- value={chainTo}
- onChange={(e) => setChainTo(e.target.value)}
- disabled={chainRunning}
- InputLabelProps={{ shrink: true }}
- />
- <Button
- variant="contained"
- disabled={fixing || chainRunning}
- onClick={() => void onFixDayRange()}
- >
- {chainRunning ? t("rangeRunning") : t("rangeRun")}
- </Button>
- </Stack>
- </Box>
- <Box sx={{ pt: 1 }}>
- <Typography variant="subtitle2" sx={{ mb: 1 }}>
- {t("exportTitle")}
- </Typography>
- <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
- {t("exportHint")}
- </Typography>
- <Stack direction="row" spacing={0} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
- {EXPORT_PARTS.map((part) => (
- <FormControlLabel
- key={part}
- sx={{
- mr: 1,
- maxWidth: "100%",
- "& .MuiFormControlLabel-label": { whiteSpace: "normal" },
- }}
- control={
- <Checkbox
- size="small"
- checked={exportParts[part]}
- disabled={exporting}
- onChange={(_, checked) =>
- setExportParts((prev) => ({ ...prev, [part]: checked }))
- }
- />
- }
- label={t(EXPORT_PART_I18N[part])}
- />
- ))}
- <Button
- size="small"
- disabled={exporting}
- onClick={() => setExportParts(DEFAULT_EXPORT_PARTS)}
- >
- {t("exportDefault")}
- </Button>
- <Button
- size="small"
- disabled={exporting}
- onClick={() => setExportParts(FULL_EXPORT_PARTS)}
- >
- {t("exportFull")}
- </Button>
- </Stack>
- {exportError && <Alert severity="error">{exportError}</Alert>}
- <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
- <TextField
- size="small"
- type="date"
- label={t("from")}
- value={exportFrom}
- onChange={(e) => setExportFrom(e.target.value)}
- InputLabelProps={{ shrink: true }}
- />
- <TextField
- size="small"
- type="date"
- label={t("to")}
- value={exportTo}
- onChange={(e) => setExportTo(e.target.value)}
- InputLabelProps={{ shrink: true }}
- />
- <Button
- variant="outlined"
- disabled={exporting}
- onClick={() => void onExportSql()}
- >
- {exporting ? t("exporting") : t("exportSql")}
- </Button>
- </Stack>
- </Box>
- </Stack>
- </Paper>
- </Stack>
- )}
-
- {tab === "inventory" && (
- <Paper sx={{ p: 2 }}>
- <Stack spacing={2}>
- <Typography variant="body2" color="text.secondary">
- {t("invTabHint")}
- </Typography>
- <Stack direction="row" spacing={1}>
- <TextField
- size="small"
- label={t("invSearchLabel")}
- value={invQuery}
- onChange={(e) => setInvQuery(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") void onSearchInventory();
- }}
- />
- <Button
- variant="outlined"
- disabled={searching || !invQuery.trim()}
- onClick={() => void onSearchInventory()}
- >
- {searching ? t("searching") : t("search")}
- </Button>
- </Stack>
- {searchError && <Alert severity="error">{searchError}</Alert>}
- {invHits.length > 0 && (
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>{t("colInventoryId")}</TableCell>
- <TableCell>{t("colItemCode")}</TableCell>
- <TableCell>{t("colUomId")}</TableCell>
- <TableCell align="right">{t("colLedgerRows")}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {invHits.map((h) => (
- <TableRow
- key={h.inventoryId}
- hover
- selected={scope?.kind === "inventory" && scope.id === h.inventoryId}
- onClick={() => void loadInventoryScope(h.inventoryId)}
- sx={{ cursor: "pointer" }}
- >
- <TableCell>{h.inventoryId}</TableCell>
- <TableCell>{h.itemCode ?? t("checkDash")}</TableCell>
- <TableCell>{h.uomId ?? t("checkDash")}</TableCell>
- <TableCell align="right">{h.ledgerCnt}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )}
- {fixError && <Alert severity="error">{fixError}</Alert>}
- {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
- {scopeLoading && <CircularProgress size={24} />}
- {scope?.kind === "inventory" && !scopeLoading && (
- <>
- <Typography variant="body2">
- {t("invScopeSummary", {
- itemCode: scope.itemCode ?? "?",
- id: scope.id,
- uomId: scope.uomId ?? "?",
- firstDate: scope.firstDate,
- lastDate: scope.lastDate,
- cnt: scope.cnt,
- lastBalance: scope.lastBalance ?? t("checkDash"),
- })}
- </Typography>
- <CheckPartsTable parts={scope.parts} />
- <Box>
- <Button
- variant="contained"
- disabled={fixing || scope.cnt === 0}
- onClick={() => void onFixInventoryScope()}
- >
- {fixing ? t("fixing") : t("fixThisInventory")}
- </Button>
- </Box>
- </>
- )}
- </Stack>
- </Paper>
- )}
-
- {tab === "lot" && (
- <Paper sx={{ p: 2 }}>
- <Stack spacing={2}>
- <Typography variant="body2" color="text.secondary">
- {t("lotTabHint")}
- </Typography>
- <Stack direction="row" spacing={1}>
- <TextField
- size="small"
- label={t("lotSearchLabel")}
- value={lotQuery}
- onChange={(e) => setLotQuery(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") void onSearchLot();
- }}
- />
- <Button
- variant="outlined"
- disabled={searching || !lotQuery.trim()}
- onClick={() => void onSearchLot()}
- >
- {searching ? t("searching") : t("search")}
- </Button>
- </Stack>
- {searchError && <Alert severity="error">{searchError}</Alert>}
- {lotHits.length > 0 && (
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>{t("colLotLineId")}</TableCell>
- <TableCell>{t("colLotNo")}</TableCell>
- <TableCell>{t("colItemCode")}</TableCell>
- <TableCell>{t("colInventoryId")}</TableCell>
- <TableCell align="right">{t("colLedgerRows")}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {lotHits.map((h) => (
- <TableRow
- key={h.inventoryLotLineId}
- hover
- selected={scope?.kind === "lot" && scope.id === h.inventoryLotLineId}
- onClick={() => void loadLotScope(h.inventoryLotLineId)}
- sx={{ cursor: "pointer" }}
- >
- <TableCell>{h.inventoryLotLineId}</TableCell>
- <TableCell>{h.lotNo ?? t("checkDash")}</TableCell>
- <TableCell>{h.itemCode ?? t("checkDash")}</TableCell>
- <TableCell>{h.inventoryId ?? t("checkDash")}</TableCell>
- <TableCell align="right">{h.ledgerCnt}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- )}
- {fixError && <Alert severity="error">{fixError}</Alert>}
- {fixMessage && <Alert severity="success">{fixMessage}</Alert>}
- {scopeLoading && <CircularProgress size={24} />}
- {scope?.kind === "lot" && !scopeLoading && (
- <>
- <Typography variant="body2">
- {t("lotScopeSummary", {
- lotNo: scope.lotNo ?? "?",
- id: scope.id,
- itemCode: scope.itemCode ?? "?",
- firstDate: scope.firstDate,
- lastDate: scope.lastDate,
- cnt: scope.cnt,
- lastLotQtyAfter: scope.lastLotQtyAfter ?? t("checkDash"),
- })}
- </Typography>
- <CheckPartsTable parts={scope.parts} />
- <Box>
- <Button
- variant="contained"
- disabled={fixing || scope.cnt === 0}
- onClick={() => void onFixLotScope()}
- >
- {fixing ? t("fixing") : t("fixThisLot")}
- </Button>
- </Box>
- </>
- )}
- </Stack>
- </Paper>
- )}
- </Stack>
- );
- };
-
- export default StockLedgerFixPageClient;
|