diff --git a/src/app/api/stockIn/actions.ts b/src/app/api/stockIn/actions.ts index 9f74ae87..159c564a 100644 --- a/src/app/api/stockIn/actions.ts +++ b/src/app/api/stockIn/actions.ts @@ -38,6 +38,10 @@ export interface StockInLineEntry { receiptDate?: string; dnDate?: string; dnNo?: string; + stockQtyRoundMode?: "CEILING" | "FLOOR" | "HALF_UP" | "CUSTOM"; + stockQtyCustomQty?: number; + stockQtyRoundSource?: "CREATE" | "QC"; + stockQtyCustomReason?: string; } export interface QcResult{ @@ -66,6 +70,7 @@ export interface StockInInput { productionDate?: string; expiryDate: string; uom: Uom; + stockQtyRoundMode?: "CEILING" | "FLOOR"; } export interface QCInput { status: string; diff --git a/src/app/api/stockIn/index.ts b/src/app/api/stockIn/index.ts index b4951bcc..94d1aeec 100644 --- a/src/app/api/stockIn/index.ts +++ b/src/app/api/stockIn/index.ts @@ -50,6 +50,7 @@ export interface StockInInput { productionDate?: string; expiryDate: string; uom?: Uom; + stockQtyRoundMode?: "CEILING" | "FLOOR"; } export interface PoResult { diff --git a/src/components/PoDetail/PoDetail.tsx b/src/components/PoDetail/PoDetail.tsx index 1b1fb889..97c32260 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -9,9 +9,7 @@ import { Box, Button, ButtonProps, - Collapse, Grid, - IconButton, Paper, Stack, Tab, @@ -29,16 +27,14 @@ import { FormControlLabel, Card, CardContent, - Radio, - alpha, Dialog, DialogActions, DialogContent, DialogTitle, } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { submitDialogWithWarning } from "../Swal/CustomAlerts"; import PrinterSelect from "@/components/common/PrinterSelect"; +import { PoDetailRow } from "./PoDetailRow"; // import InputDataGrid, { TableRow } from "../InputDataGrid/InputDataGrid"; import { GridColDef, @@ -52,9 +48,6 @@ import { fetchPoSummariesClient, startPo, } from "@/app/api/po/actions"; -import { - createStockInLine -} from "@/app/api/stockIn/actions"; import { useCallback, useContext, @@ -63,20 +56,16 @@ import { useRef, useState, } from "react"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import PoInputGrid from "./PoInputGrid"; // import { QcItemWithChecks } from "@/app/api/qc"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { WarehouseResult } from "@/app/api/warehouse"; -import { calculateWeight, dateStringToDayjs, dayjsToDateString, OUTPUT_DATE_FORMAT, outputDateStringToInputDateString, returnWeightUnit } from "@/app/utils/formatUtil"; +import { dateStringToDayjs, dayjsToDateString, OUTPUT_DATE_FORMAT, outputDateStringToInputDateString, decimalFormatter, arrayToDateString } from "@/app/utils/formatUtil"; import { CameraContext } from "../Cameras/CameraProvider"; import QrModal from "./QrModal"; import { PlayArrow } from "@mui/icons-material"; import DoneIcon from "@mui/icons-material/Done"; import { downloadFile, getCustomWidth } from "@/app/utils/commonUtil"; -import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; -import { arrayToDateString } from "@/app/utils/formatUtil"; import { List, ListItem, ListItemButton, ListItemText, Divider } from "@mui/material"; import { Controller, FormProvider, useForm } from "react-hook-form"; import dayjs, { Dayjs } from "dayjs"; @@ -100,41 +89,6 @@ type Props = { printerCombo: PrinterCombo[]; }; -/** PO stock-in lines still in pre-complete workflow (align with nav alert: pending / receiving). */ -const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); - -/** Sum of put-away in stock units (matches StockInForm「已上架數量」stockQty). */ -function totalPutAwayStockQtyForPol(row: PurchaseOrderLine): number { - return row.stockInLine - .filter((sil) => sil.purchaseOrderLineId === row.id) - .reduce((acc, sil) => { - const lineSum = - sil.putAwayLines?.reduce( - (s, p) => s + Number(p.stockQty ?? p.qty ?? 0), - 0, - ) ?? 0; - return acc + lineSum; - }, 0); -} - -/** POL order demand in stock units (same basis as PoDetail processed / backend PO detail). */ -function polOrderStockQty(row: PurchaseOrderLine): number { - return Number(row.stockUom?.stockQty ?? row.qty ?? 0); -} - -function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean { - const orderStock = polOrderStockQty(row); - const putAway = totalPutAwayStockQtyForPol(row); - if (orderStock > 0 && putAway >= orderStock) { - return false; - } - return row.stockInLine - .filter((sil) => sil.purchaseOrderLineId === row.id) - .some((sil) => - PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()), - ); -} - type EntryError = | { [field in keyof StockInLine]?: string; @@ -567,6 +521,48 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { setPurchaseOrder(newPo); }, [purchaseOrder.id]); + const getDnValues = useCallback(() => dnFormProps.getValues(), [dnFormProps]); + + const formatReceiptDate = useCallback((receiptDate?: string) => { + return outputDateStringToInputDateString(receiptDate ?? ""); + }, []); + + const handleSelectPol = useCallback((row: PurchaseOrderLine) => { + selectedPolIdRef.current = row.id; + setSelectedRow(row); + setStockInLine(row.stockInLine ?? []); + setProcessedQty(row.processed); + patchPoEditQuery((params) => { + params.set("polId", String(row.id)); + params.delete("stockInLineId"); + }); + }, [patchPoEditQuery]); + + const handleRowInputBlur = useCallback((rowId: number, lotNo: string, dnQty: string) => { + setPolInputList((prev) => { + const current = prev[rowId] ?? { lotNo: "", dnQty: "" }; + if (current.lotNo === lotNo && current.dnQty === dnQty) return prev; + return { + ...prev, + [rowId]: { lotNo, dnQty }, + }; + }); + }, []); + + const handleRowSubmitted = useCallback((row: PurchaseOrderLine) => { + setPolInputList((prev) => ({ + ...prev, + [row.id]: { lotNo: "", dnQty: "" }, + })); + selectedPolIdRef.current = row.id; + setSelectedRow(row); + patchPoEditQuery((params) => { + params.set("polId", String(row.id)); + params.delete("stockInLineId"); + }); + fetchPoDetail(selectedPoId.toString(), true, row.id); + }, [fetchPoDetail, patchPoEditQuery, selectedPoId]); + const handleMailTemplateForStockInLine = useCallback(async (stockInLineId: number) => { const response = await getMailTemplatePdfForStockInLine(stockInLineId) if (response) { @@ -582,298 +578,7 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { // setStockInLine([]) // }, []); - function Row(props: { row: PurchaseOrderLine }) { - const { row } = props; - // const [firstReceiveQty, setFirstReceiveQty] = useState() - // const [secondReceiveQty, setSecondReceiveQty] = useState() - // const [open, setOpen] = useState(false); - const [processedQty, setProcessedQty] = useState(row.processed); - const [currStatus, setCurrStatus] = useState(row.status); - const [lotNoInput, setLotNoInput] = useState(polInputList[row.id]?.lotNo ?? ""); - const [dnQtyInput, setDnQtyInput] = useState(polInputList[row.id]?.dnQty ?? ""); - // const [stockInLine, setStockInLine] = useState(row.stockInLine); - const totalWeight = useMemo( - () => calculateWeight(row.qty, row.uom), - [row.qty, row.uom], - ); - const weightUnit = useMemo( - () => returnWeightUnit(row.uom), - [row.uom], - ); - useEffect(() => { - // `processedQty` comes from putAwayLines (stock unit). - // After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand. - const targetStockQty = Number(row.stockUom?.stockQty ?? row.qty ?? 0); - if (targetStockQty > 0 && processedQty >= targetStockQty) { - setCurrStatus("completed".toUpperCase()); - } else if (processedQty > 0) { - setCurrStatus("receiving".toUpperCase()); - } else { - setCurrStatus("pending".toUpperCase()); - } - }, [processedQty, row.qty, row.stockUom?.stockQty]); - - useEffect(() => { - setLotNoInput(polInputList[row.id]?.lotNo ?? ""); - setDnQtyInput(polInputList[row.id]?.dnQty ?? ""); - }, [polInputList, row.id]); - const changeStockInLines = useCallback( - (id: number) => { - const target = rows.find((r) => r.id === id); - if (!target) return; - selectedPolIdRef.current = id; - setSelectedRow(target); - setStockInLine(target.stockInLine ?? []); - setProcessedQty(target.processed); - - // history.replaceState: keep URL in sync without scrolling to top - patchPoEditQuery((params) => { - params.set("polId", String(id)); - params.delete("stockInLineId"); - }); - }, - [rows, patchPoEditQuery], - ); - - const handleStart = useCallback( - () => { - const orderQty = Number(row?.qty) ?? 0; - const acceptedQty = Number(dnQtyInput.trim()); - - if (isNaN(acceptedQty) || acceptedQty <= 0) { - alert("來貨數量必須大於0!"); - return; - } - const doSubmit = () => { - setTimeout(async () => { - const currentDnNo = dnFormProps.watch("dnNo"); - const postData = { - dnNo: dnFormProps.watch("dnNo"), - receiptDate: outputDateStringToInputDateString(dnFormProps.watch("receiptDate")), - itemId: row.itemId, - itemNo: row.itemNo, - itemName: row.itemName, - purchaseOrderLineId: row.id, - acceptedQty: acceptedQty, - productLotNo: lotNoInput || "", - }; - const res = await createStockInLine(postData); - if (res) { - setLotNoInput(""); - setDnQtyInput(""); - setPolInputList((prev) => ({ - ...prev, - [row.id]: { lotNo: "", dnQty: "" }, - })); - selectedPolIdRef.current = row.id; - setSelectedRow(row); - patchPoEditQuery((params) => { - params.set("polId", String(row.id)); - params.delete("stockInLineId"); - }); - fetchPoDetail(selectedPoId.toString(), true, row.id); - } - console.log(res); - }, 200); - }; - - const exceedOrderBy10Percent = orderQty > 0 && acceptedQty > orderQty * 1.1; - if (exceedOrderBy10Percent) { - submitDialogWithWarning(doSubmit, t, { - title: t("Confirm submit"), - html: t("This batch quantity exceeds order quantity. Do you still want to submit?"), - confirmButtonText: t("Submit"), - }); - } else { - doSubmit(); - } - }, - [dnQtyInput, row, dnFormProps, selectedPoId, fetchPoDetail, t, lotNoInput, patchPoEditQuery], - ); - - const syncRowInputToParent = useCallback((lotNo: string, dnQty: string) => { - setPolInputList((prev) => { - const current = prev[row.id] ?? { lotNo: "", dnQty: "" }; - if (current.lotNo === lotNo && current.dnQty === dnQty) return prev; - return { - ...prev, - [row.id]: { lotNo, dnQty }, - }; - }); - }, [row.id]); - - // const [focusField, setFocusField] = useState(); - - // 本批收貨數量(訂單單位): 使用者在該行輸入的 dnQty - const batchPurchaseQty = Number(dnQtyInput.trim()) || 0; - - // 已來貨總數(庫存單位): 同一 POL 底下所有 stock_in_line.acceptedQty 的合計 - const totalStockReceived = row.stockInLine - .filter((sil) => sil.purchaseOrderLineId === row.id) - .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); - const receivedTotalText = decimalFormatter.format(totalStockReceived); - const highlightColor = - Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; - const needsStockInAttention = - canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); - return ( - <> - - - *": { borderBottom: "unset" }, - color: "black", - ...(needsStockInAttention - ? (theme) => ({ - boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`, - backgroundColor: alpha(theme.palette.error.main, 0.07), - }) - : {}), - }} - onClick={() => changeStockInLines(row.id)} - > - - {/* - setOpen(!open)} - > - {open ? : } - - */} - - {needsStockInAttention && ( - `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`, - zIndex: 1, - }} - /> - )} - - - - {row.itemNo} - - - {row.itemName} - - {integerFormatter.format(row.qty)} - {integerFormatter.format(row.processed)} - {row.uom?.udfudesc} - {/* {decimalFormatter.format(row.stockUom.stockQty)} */} - {/* {receivedTotal} */} - - {decimalFormatter.format(totalStockReceived)} - - {row.stockUom.stockUomDesc} - {/* - {decimalFormatter.format(totalWeight)} {weightUnit} - */} - {/* {weightUnit} */} - {/* {decimalFormatter.format(row.price)} */} - {/* {row.expiryDate} */} - {t(`${row.status.toLowerCase()}`)} - {/* {t(`${currStatus.toLowerCase()}`)} */} - {/* {integerFormatter.format(row.receivedQty)} */} - - setLotNoInput(e.target.value)} - onBlur={() => syncRowInputToParent(lotNoInput, dnQtyInput)} - onClick={(e) => e.stopPropagation()} - // onFocus={(e) => {setFocusField(e.target as HTMLInputElement);}} - /> - - - setDnQtyInput(e.target.value)} - onBlur={() => syncRowInputToParent(lotNoInput, dnQtyInput)} - onClick={(e) => e.stopPropagation()} - InputProps={{ - inputProps: { - min: 0, // Optional: set a minimum value - step: "any", - inputMode: "decimal", - } - }} - /> - - - - - - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* - - - - - - - - - -
*/} - {/*
*/} - {/*
*/} - {/*
*/} - - ); - } -// ROW END const [tabIndex, setTabIndex] = useState(0); const handleTabChange = useCallback>( @@ -1178,7 +883,20 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {rows.map((row) => ( - + ))} diff --git a/src/components/PoDetail/PoDetailRow.tsx b/src/components/PoDetail/PoDetailRow.tsx new file mode 100644 index 00000000..0cca3b58 --- /dev/null +++ b/src/components/PoDetail/PoDetailRow.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { PurchaseOrderLine } from "@/app/api/po"; +import { + Box, + Button, + Radio, + Stack, + TableCell, + TableRow, + TextField, + Typography, + alpha, +} from "@mui/material"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; +import { submitDialogWithWarning } from "../Swal/CustomAlerts"; +import { createStockInLine } from "@/app/api/stockIn/actions"; +import { + isNotIntegerQty, + previewPoBatchStockQty, + roundStockQty, + StockQtyRoundChoice, + StockQtyRoundMode, +} from "./stockQtyRound"; + +const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); + +function totalPutAwayStockQtyForPol(row: PurchaseOrderLine): number { + return row.stockInLine + .filter((sil) => sil.purchaseOrderLineId === row.id) + .reduce((acc, sil) => { + const lineSum = + sil.putAwayLines?.reduce( + (s, p) => s + Number(p.stockQty ?? p.qty ?? 0), + 0, + ) ?? 0; + return acc + lineSum; + }, 0); +} + +function polOrderStockQty(row: PurchaseOrderLine): number { + return Number(row.stockUom?.stockQty ?? row.qty ?? 0); +} + +function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean { + const orderStock = polOrderStockQty(row); + const putAway = totalPutAwayStockQtyForPol(row); + if (orderStock > 0 && putAway >= orderStock) { + return false; + } + return row.stockInLine + .filter((sil) => sil.purchaseOrderLineId === row.id) + .some((sil) => + PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()), + ); +} + +export type PoDetailRowDnValues = { + dnNo?: string; + receiptDate?: string; +}; + +type Props = { + row: PurchaseOrderLine; + selected: boolean; + canSeeStockInReminders: boolean; + showDnQty: boolean; + savedLotNo: string; + savedDnQty: string; + onSelect: (row: PurchaseOrderLine) => void; + onInputBlur: (rowId: number, lotNo: string, dnQty: string) => void; + getDnValues: () => PoDetailRowDnValues; + formatReceiptDate: (receiptDate?: string) => string | undefined; + onSubmitted: (row: PurchaseOrderLine) => void; +}; + +export const PoDetailRow = memo(function PoDetailRow({ + row, + selected, + canSeeStockInReminders, + showDnQty, + savedLotNo, + savedDnQty, + onSelect, + onInputBlur, + getDnValues, + formatReceiptDate, + onSubmitted, +}: Props) { + const { t } = useTranslation("purchaseOrder"); + const [lotNoInput, setLotNoInput] = useState(savedLotNo); + const [dnQtyInput, setDnQtyInput] = useState(savedDnQty); + const submitInFlightRef = useRef(false); + const [isStarting, setIsStarting] = useState(false); + + useEffect(() => { + setLotNoInput(savedLotNo); + setDnQtyInput(savedDnQty); + }, [savedLotNo, savedDnQty]); + + const handleStart = useCallback( + (roundMode?: StockQtyRoundMode) => { + if (submitInFlightRef.current || isStarting) return; + const orderQty = Number(row?.qty) ?? 0; + const acceptedQty = Number(dnQtyInput.trim()); + + if (isNaN(acceptedQty) || acceptedQty <= 0) { + alert("來貨數量必須大於0!"); + return; + } + + const previewStockQty = previewPoBatchStockQty( + orderQty, + Number(row.stockUom?.stockQty ?? 0), + acceptedQty, + ); + const needsRound = isNotIntegerQty(previewStockQty); + if (needsRound && roundMode !== "CEILING" && roundMode !== "FLOOR") { + return; + } + const round: StockQtyRoundChoice | undefined = + needsRound && roundMode + ? { + mode: roundMode, + before: previewStockQty, + after: roundStockQty(previewStockQty, roundMode), + } + : undefined; + + const doSubmit = () => { + if (submitInFlightRef.current) return; + submitInFlightRef.current = true; + setIsStarting(true); + void (async () => { + try { + const dn = getDnValues(); + const postData = { + dnNo: dn.dnNo, + receiptDate: formatReceiptDate(dn.receiptDate), + itemId: row.itemId, + itemNo: row.itemNo, + itemName: row.itemName, + purchaseOrderLineId: row.id, + acceptedQty: acceptedQty, + productLotNo: lotNoInput || "", + ...(round + ? { + stockQtyRoundMode: round.mode, + stockQtyRoundSource: "CREATE" as const, + } + : {}), + }; + const res = await createStockInLine(postData); + if (res) { + setLotNoInput(""); + setDnQtyInput(""); + onSubmitted(row); + } + console.log(res); + } finally { + setIsStarting(false); + submitInFlightRef.current = false; + } + })(); + }; + + const exceedOrderBy10Percent = orderQty > 0 && acceptedQty > orderQty * 1.1; + if (exceedOrderBy10Percent) { + submitDialogWithWarning(doSubmit, t, { + title: t("Confirm submit"), + html: t("This batch quantity exceeds order quantity. Do you still want to submit?"), + confirmButtonText: t("Submit"), + }); + } else { + doSubmit(); + } + }, + [ + dnQtyInput, + formatReceiptDate, + getDnValues, + isStarting, + lotNoInput, + onSubmitted, + row, + t, + ], + ); + + const batchPurchaseQty = Number(dnQtyInput.trim()) || 0; + const previewStockQty = previewPoBatchStockQty( + Number(row?.qty) ?? 0, + Number(row.stockUom?.stockQty ?? 0), + batchPurchaseQty, + ); + const needsStockQtyRound = batchPurchaseQty > 0 && isNotIntegerQty(previewStockQty); + const roundUpQty = roundStockQty(previewStockQty, "CEILING"); + const roundDownQty = roundStockQty(previewStockQty, "FLOOR"); + const stockUomLabel = row.stockUom?.stockUomDesc?.trim() + ? ` ${row.stockUom.stockUomDesc.trim()}` + : ""; + + const totalStockReceived = row.stockInLine + .filter((sil) => sil.purchaseOrderLineId === row.id) + .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); + const receivedTotalText = decimalFormatter.format(totalStockReceived); + const highlightColor = + Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; + const needsStockInAttention = + canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); + + return ( + *": { borderBottom: "unset" }, + color: "black", + ...(needsStockInAttention + ? (theme) => ({ + boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`, + backgroundColor: alpha(theme.palette.error.main, 0.07), + }) + : {}), + }} + onClick={() => onSelect(row)} + > + + {needsStockInAttention && ( + `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`, + zIndex: 1, + }} + /> + )} + + + + {row.itemNo} + + + {row.itemName} + + {integerFormatter.format(row.qty)} + {integerFormatter.format(row.processed)} + {row.uom?.udfudesc} + + {decimalFormatter.format(totalStockReceived)} + + + {row.stockUom.stockUomDesc} + + + {t(`${row.status.toLowerCase()}`)} + + + setLotNoInput(e.target.value)} + onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} + onClick={(e) => e.stopPropagation()} + /> + + {showDnQty ? ( + + setDnQtyInput(e.target.value)} + onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} + onClick={(e) => e.stopPropagation()} + InputProps={{ + inputProps: { + min: 0, + step: "any", + inputMode: "decimal", + }, + }} + /> + + ) : null} + + {needsStockQtyRound ? ( + e.stopPropagation()}> + + {t("Converted stock qty is")} {decimalFormatter.format(previewStockQty)} + {row.stockUom?.stockUomDesc ? ` (${row.stockUom.stockUomDesc})` : ""} + + + + + ) : ( + + )} + + + ); +}); diff --git a/src/components/PoDetail/askStockQtyRoundDialog.ts b/src/components/PoDetail/askStockQtyRoundDialog.ts new file mode 100644 index 00000000..cdcef384 --- /dev/null +++ b/src/components/PoDetail/askStockQtyRoundDialog.ts @@ -0,0 +1,64 @@ +import Swal from "sweetalert2"; +import { TFunction } from "i18next"; +import { + isNotIntegerQty, + roundStockQty, + StockQtyRoundChoice, + StockQtyRoundMode, +} from "./stockQtyRound"; + +type Translate = TFunction<["translation", ...string[]], undefined>; + +export async function askStockQtyRoundDialog( + before: number, + t: Translate, + stockUomDesc?: string, +): Promise { + if (!isNotIntegerQty(before)) return null; + + const ceiling = roundStockQty(before, "CEILING"); + const floor = roundStockQty(before, "FLOOR"); + const uom = stockUomDesc?.trim() ? ` (${stockUomDesc.trim()})` : ""; + const beforeText = before.toFixed(2); + + const result = await Swal.fire({ + icon: "warning", + title: t("Stock qty is not an integer"), + html: ` +
+

${t("Converted stock qty is")} ${beforeText}${uom}${t("Choose rounding method")}

+ + +
+ `, + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + customClass: { + container: "swal-container-class", + popup: "swal-popup-class", + }, + preConfirm: () => { + const popup = Swal.getPopup(); + const selected = popup?.querySelector('input[name="stockQtyRoundMode"]:checked')?.value as StockQtyRoundMode | undefined; + if (selected !== "CEILING" && selected !== "FLOOR") { + Swal.showValidationMessage(t("Please choose a rounding method")); + return false; + } + return { + mode: selected, + before, + after: roundStockQty(before, selected), + } satisfies StockQtyRoundChoice; + }, + }); + + if (!result.isConfirmed) return null; + return (result.value as StockQtyRoundChoice) ?? null; +} diff --git a/src/components/PoDetail/stockQtyRound.ts b/src/components/PoDetail/stockQtyRound.ts new file mode 100644 index 00000000..ae2c6bf0 --- /dev/null +++ b/src/components/PoDetail/stockQtyRound.ts @@ -0,0 +1,41 @@ +export type StockQtyRoundMode = "CEILING" | "FLOOR"; +export type StockQtyRoundSource = "CREATE" | "QC"; + +export type StockQtyRoundChoice = { + mode: StockQtyRoundMode; + before: number; + after: number; +}; + +export function previewPoBatchStockQty( + orderM18Qty: number, + orderStockQty: number, + batchM18Qty: number, +): number { + if (!Number.isFinite(orderM18Qty) || orderM18Qty === 0) { + return Number(batchM18Qty.toFixed(2)); + } + return Number(((batchM18Qty * orderStockQty) / orderM18Qty).toFixed(2)); +} + +export function isNotIntegerQty(qty: number): boolean { + if (!Number.isFinite(qty)) return false; + return Math.abs(qty - Math.round(qty)) > 1e-9; +} + +/** PO 收貨/品檢:pending 或 escalated 且庫存數量非整數時需選擇 CEILING / FLOOR */ +export function needsPoQcStockQtyRound( + purchaseOrderLineId: number | null | undefined, + status: string | null | undefined, + acceptedQty: number | null | undefined, +): boolean { + if (!purchaseOrderLineId) return false; + const silStatus = (status ?? "").toLowerCase().trim(); + if (silStatus !== "pending" && silStatus !== "escalated") return false; + return isNotIntegerQty(Number(acceptedQty ?? 0)); +} + +export function roundStockQty(before: number, mode: StockQtyRoundMode): number { + if (mode === "CEILING") return Math.ceil(before); + return Math.floor(before); +} diff --git a/src/components/PoSearch/PoSearch.tsx b/src/components/PoSearch/PoSearch.tsx index 9e26723f..a4465dc0 100644 --- a/src/components/PoSearch/PoSearch.tsx +++ b/src/components/PoSearch/PoSearch.tsx @@ -260,8 +260,15 @@ const PoSearch: React.FC = ({ ); const onReset = useCallback(() => { - setFilteredPo(po); - }, [po]); + const today = dayjsToDateString(dayjs(), "input"); + setSelectedPoIds([]); + setSelectAll(false); + setPagingController(defaultPagingController); + setFilterArgs({ + estimatedArrivalDate: today, + estimatedArrivalDateTo: today, + }); + }, []); const [autoSyncStatus, setAutoSyncStatus] = useState(null); const [isM18LookupLoading, setIsM18LookupLoading] = useState(false); @@ -286,93 +293,97 @@ const PoSearch: React.FC = ({ if (typeof v === "string" && (v as string).trim() === "") return; cleanedQuery[k] = String(v); }); - const baseListResp = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`, - { method: "GET" }, - ); - if (!baseListResp.ok) { - throw new Error(`PO list fetch failed: ${baseListResp.status}`); - } - const res = await baseListResp.json(); - if (!res) return; - - if (res.records && res.records.length > 0) { - setFilteredPo(res.records); - setTotalCount(res.total); - return; - } - - const searchedCodeRaw = (filterArgs as any)?.code; - const searchedCode = - typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : ""; - - const shouldAutoSyncFromM18 = - searchedCode.length > 14 && - (searchedCode.startsWith("PP") || searchedCode.startsWith("PF")); - - if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) { - setFilteredPo(res.records); - setTotalCount(res.total); - return; - } - try { - autoSyncInProgressRef.current = true; - setIsM18LookupLoading(true); - setAutoSyncStatus("正在從M18找尋PO..."); - const syncResp = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent( - searchedCode, - )}`, + const baseListResp = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`, { method: "GET" }, ); - - if (!syncResp.ok) { - throw new Error(`M18 sync failed: ${syncResp.status}`); + if (!baseListResp.ok) { + throw new Error(`PO list fetch failed: ${baseListResp.status}`); } + const res = await baseListResp.json(); + if (!res) return; - let syncJson: any = null; - try { - syncJson = await syncResp.json(); - } catch { - // Some endpoints may respond with plain text - const txt = await syncResp.text(); - syncJson = { raw: txt }; + if (res.records && res.records.length > 0) { + setFilteredPo(res.records); + setTotalCount(res.total); + return; } - const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0); - if (syncOk) { - setAutoSyncStatus("成功找到PO"); + const searchedCodeRaw = (filterArgs as any)?.code; + const searchedCode = + typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : ""; + + const shouldAutoSyncFromM18 = + searchedCode.length > 14 && + (searchedCode.startsWith("PP") || searchedCode.startsWith("PF")); - const listResp = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( - cleanedQuery, - ).toString()}`, + if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) { + setFilteredPo(res.records); + setTotalCount(res.total); + return; + } + + try { + autoSyncInProgressRef.current = true; + setIsM18LookupLoading(true); + setAutoSyncStatus("正在從M18找尋PO..."); + const syncResp = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent( + searchedCode, + )}`, { method: "GET" }, ); - if (listResp.ok) { - const listJson = await listResp.json(); - setFilteredPo(listJson.records ?? []); - setTotalCount(listJson.total ?? 0); + + if (!syncResp.ok) { + throw new Error(`M18 sync failed: ${syncResp.status}`); + } + + let syncJson: any = null; + try { + syncJson = await syncResp.json(); + } catch { + // Some endpoints may respond with plain text + const txt = await syncResp.text(); + syncJson = { raw: txt }; + } + + const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0); + if (syncOk) { setAutoSyncStatus("成功找到PO"); - return; + + const listResp = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( + cleanedQuery, + ).toString()}`, + { method: "GET" }, + ); + if (listResp.ok) { + const listJson = await listResp.json(); + setFilteredPo(listJson.records ?? []); + setTotalCount(listJson.total ?? 0); + setAutoSyncStatus("成功找到PO"); + return; + } + setAutoSyncStatus("找不到PO"); + } else { + setAutoSyncStatus("找不到PO"); } + + // Ensure UI updates even if sync didn't change results + setFilteredPo(res.records); + setTotalCount(res.total ?? 0); + } catch (e) { + console.error("Auto sync error:", e); setAutoSyncStatus("找不到PO"); - } else { - setAutoSyncStatus("找不到PO"); + setFilteredPo(res.records); + setTotalCount(res.total ?? 0); + } finally { + setIsM18LookupLoading(false); + autoSyncInProgressRef.current = false; } - - // Ensure UI updates even if sync didn't change results - setFilteredPo(res.records); - setTotalCount(res.total ?? 0); } catch (e) { - console.error("Auto sync error:", e); - setAutoSyncStatus("找不到PO"); - setFilteredPo(res.records); - setTotalCount(res.total ?? 0); - } finally { - setIsM18LookupLoading(false); - autoSyncInProgressRef.current = false; + console.error("PO list fetch error:", e); } }, [], diff --git a/src/components/PoSearch/PoSearchWrapper.tsx b/src/components/PoSearch/PoSearchWrapper.tsx index de505552..06f238ea 100644 --- a/src/components/PoSearch/PoSearchWrapper.tsx +++ b/src/components/PoSearch/PoSearchWrapper.tsx @@ -1,45 +1,12 @@ -import { fetchAllItems } from "@/app/api/settings/item"; -// import ItemsSearch from "./ItemsSearch"; -// import ItemsSearchLoading from "./ItemsSearchLoading"; -import { SearchParams } from "@/app/utils/fetchUtil"; -import { TypeEnum } from "@/app/utils/typeEnum"; -import { notFound } from "next/navigation"; import PoSearchLoading from "./PoSearchLoading"; import PoSearch from "./PoSearch"; -import { fetchPoList, PoResult } from "@/app/api/po"; -import dayjs from "dayjs"; -import arraySupport from "dayjs/plugin/arraySupport"; -import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; -import { defaultPagingController } from "../SearchResults/SearchResults"; -dayjs.extend(arraySupport); interface SubComponents { Loading: typeof PoSearchLoading; } -type Props = { - // type: TypeEnum; -}; - -const PoSearchWrapper: React.FC & SubComponents = async ( - { - // type, - }, -) => { - // console.log(defaultPagingController) - const po = await fetchPoList({ - pageNum: 1, - pageSize: 10, - }); - const fixPoDate = po.records.map((p) => { - return { - ...p, - orderDate: dayjs(p.orderDate).add(-1, "month").format(OUTPUT_DATE_FORMAT), - }; - }); - return ( - - ); +const PoSearchWrapper: React.FC & SubComponents = () => { + return ; }; PoSearchWrapper.Loading = PoSearchLoading; diff --git a/src/components/Qc/QcComponent.tsx b/src/components/Qc/QcComponent.tsx index 3c48e137..3c111f7d 100644 --- a/src/components/Qc/QcComponent.tsx +++ b/src/components/Qc/QcComponent.tsx @@ -150,9 +150,9 @@ const QcComponent: React.FC = ({ itemDetail, disabled = false, compactLay if (isNaN(accQty) || accQty === undefined || accQty === null || typeof(accQty) != "number") { setError("acceptQty", { message: t("value must be a number") }); } else - if (!isJobOrder && accQty > itemDetail.acceptedQty) { + if (!isJobOrder && accQty > Math.ceil(itemDetail.acceptedQty)) { setError("acceptQty", { message: `${t("acceptQty must not greater than")} ${ - itemDetail.acceptedQty}` }); + Math.ceil(itemDetail.acceptedQty)}` }); } else if (accQty <= 0) { setError("acceptQty", { message: t("minimal value is 1") }); @@ -163,8 +163,8 @@ const QcComponent: React.FC = ({ itemDetail, disabled = false, compactLay },[setError, qcDecision, accQty, itemDetail, isJobOrder]) useEffect(() => { // W I P // ----- if (qcDecision == 1) { - if (!isJobOrder && validateFieldFail("acceptQty", accQty > itemDetail.acceptedQty, `${t("acceptQty must not greater than")} ${ - itemDetail.acceptedQty}`)) return; + if (!isJobOrder && validateFieldFail("acceptQty", accQty > Math.ceil(itemDetail.acceptedQty), `${t("acceptQty must not greater than")} ${ + Math.ceil(itemDetail.acceptedQty)}`)) return; if (validateFieldFail("acceptQty", accQty <= 0, t("minimal value is 1"))) return; if (validateFieldFail("acceptQty", isNaN(accQty), t("value must be a number"))) return; @@ -616,7 +616,7 @@ useEffect(() => { } e.target.value = r; }} - inputProps={isJobOrder ? { min: 0.01, step: 0.01 } : { min: 0.01, max: itemDetail.acceptedQty, step: 0.01 }} + inputProps={isJobOrder ? { min: 0.01, step: 0.01 } : { min: 0.01, max: Math.ceil(itemDetail.acceptedQty), step: 0.01 }} // onChange={(e) => { // const inputValue = e.target.value; // if (inputValue === '' || /^[0-9]*$/.test(inputValue)) { diff --git a/src/components/Qc/QcStockInModal.tsx b/src/components/Qc/QcStockInModal.tsx index 8607d15c..0161f4f9 100644 --- a/src/components/Qc/QcStockInModal.tsx +++ b/src/components/Qc/QcStockInModal.tsx @@ -14,7 +14,7 @@ import { TextField, Typography, } from "@mui/material"; -import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; +import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; import { StockInLineRow } from "../PoDetail/PoInputGrid"; import { useTranslation } from "react-i18next"; @@ -23,6 +23,7 @@ import QcComponent from "./QcComponent"; import PutAwayForm from "../PoDetail/PutAwayForm"; import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid"; import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts"; +import { needsPoQcStockQtyRound, roundStockQty } from "../PoDetail/stockQtyRound"; import { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; import dayjs from "dayjs"; import { fetchPoQrcode } from "@/app/api/pdf/actions"; @@ -99,6 +100,7 @@ const QcStockInModal: React.FC = ({ const [stockInLineInfo, setStockInLineInfo] = useState(); const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const qcSubmitInFlightRef = useRef(false); // const [skipQc, setSkipQc] = useState(false); // const [viewOnly, setViewOnly] = useState(false); @@ -241,6 +243,29 @@ const QcStockInModal: React.FC = ({ ...defaultNewValue, }, }); + const stockQtyRoundMode = formProps.watch("stockQtyRoundMode"); + const qcDecision = formProps.watch("qcDecision"); + const roundChoiceRequired = useMemo(() => { + const needsRound = needsPoQcStockQtyRound( + stockInLineInfo?.purchaseOrderLineId, + stockInLineInfo?.status, + stockInLineInfo?.acceptedQty, + ); + const willAcceptStock = Boolean(skipQc) || qcDecision == 1; + return ( + needsRound && + willAcceptStock && + stockQtyRoundMode !== "CEILING" && + stockQtyRoundMode !== "FLOOR" + ); + }, [ + stockInLineInfo?.purchaseOrderLineId, + stockInLineInfo?.status, + stockInLineInfo?.acceptedQty, + skipQc, + qcDecision, + stockQtyRoundMode, + ]); const closeWithResult = useCallback( (updatedStockInLine?: StockInLine) => { @@ -410,6 +435,34 @@ const QcStockInModal: React.FC = ({ return; } + if (qcSubmitInFlightRef.current || isSubmitting) return; + + const storedStockQty = Number(stockInLineInfo?.acceptedQty ?? 0); + const needsStockQtyRound = needsPoQcStockQtyRound( + stockInLineInfo?.purchaseOrderLineId, + stockInLineInfo?.status, + storedStockQty, + ); + const willAcceptStock = Boolean(skipQc) || qcAcceptLocal; + let roundPayload: + | { + stockQtyRoundMode: "CEILING" | "FLOOR"; + stockQtyRoundSource: "QC"; + } + | undefined; + if (willAcceptStock && needsStockQtyRound) { + const selectedMode = data.stockQtyRoundMode; + if (selectedMode !== "CEILING" && selectedMode !== "FLOOR") { + alert(t("Please choose a rounding method")); + return; + } + acceptQtyLocal = roundStockQty(storedStockQty, selectedMode); + roundPayload = { + stockQtyRoundMode: selectedMode, + stockQtyRoundSource: "QC", + }; + } + const isJobOrderSource = Boolean(stockInLineInfo?.jobOrderId) || printSource === "productionProcess"; const qcData = { dnNo : data.dnNo? data.dnNo : "DN00000", @@ -423,6 +476,7 @@ const QcStockInModal: React.FC = ({ // For Job Order QC, allow updating received qty beyond demand/accepted. // Backend uses request.acceptedQty in QC flow, so we must send it explicitly. acceptedQty: (qcAcceptLocal && isJobOrderSource) ? (acceptQtyLocal ? acceptQtyLocal : 0) : stockInLineInfo?.acceptedQty, + ...roundPayload, // qcResult: itemDetail.status != "escalated" ? qcResults.map(item => ({ qcResult: qcResultsLocal.map(item => ({ // id: item.id, @@ -431,7 +485,7 @@ const QcStockInModal: React.FC = ({ // qcDescription: item.qcDescription, qcPassed: item.qcPassed? item.qcPassed : false, failQty: (item.failQty && !item.qcPassed) ? item.failQty : 0, - // failedQty: (typeof item.failedQty === "number" && !item.isPassed) ? item.failedQty : 0, + // failedQty: (typeof item.failedQty === "number" && !item.isPassed) ? item.failQty : 0, remarks: item.remarks || '', ...(QC_MEASUREMENT_ENABLED && isMeasurableQcItem(item) ? { measurement: buildQcMeasurementPayload(item) } @@ -457,11 +511,13 @@ const QcStockInModal: React.FC = ({ } console.log("Escalation Data for submission", escalationLog); + qcSubmitInFlightRef.current = true; setIsSubmitting(true); const resEscalate = await postStockInLine({...qcData, escalationLog}); qcRes = Array.isArray(resEscalate.entity) ? resEscalate.entity[0] : (resEscalate.entity as StockInLine); } else { + qcSubmitInFlightRef.current = true; setIsSubmitting(true); const resNormal = await postStockInLine(qcData); qcRes = Array.isArray(resNormal.entity) ? resNormal.entity[0] : (resNormal.entity as StockInLine); @@ -553,7 +609,8 @@ const QcStockInModal: React.FC = ({ } else { closeWithResult(qcRes); } - setIsSubmitting(false); + setIsSubmitting(false); + qcSubmitInFlightRef.current = false; msg("已更新來貨狀態", { position: typeof window !== "undefined" && @@ -815,7 +872,7 @@ const printQrcode = useCallback( color="primary" sx={{ mt: 1 }} onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} - disabled={isSubmitting || isLoading} + disabled={isSubmitting || isLoading || roundChoiceRequired} > {isSubmitting ? (t("submitting")) : (skipQc ? t("confirm") : t("confirm qc result"))} )} diff --git a/src/components/StockIn/StockInForm.tsx b/src/components/StockIn/StockInForm.tsx index 7894f678..632b0ae7 100644 --- a/src/components/StockIn/StockInForm.tsx +++ b/src/components/StockIn/StockInForm.tsx @@ -23,6 +23,11 @@ import { useGridApiRef, } from "@mui/x-data-grid"; import { StockInLine } from "@/app/api/stockIn"; +import { + needsPoQcStockQtyRound, + roundStockQty, + StockQtyRoundMode, +} from "@/components/PoDetail/stockQtyRound"; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { dayjsToDateString, INPUT_DATE_FORMAT, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; @@ -114,6 +119,36 @@ const StockInForm: React.FC = ({ const productionDate = watch("productionDate"); const expiryDate = watch("expiryDate"); const uom = watch("uom"); + const stockQtyRoundMode = watch("stockQtyRoundMode"); + const displayedAcceptedQty = watch("acceptedQty"); + const originalAcceptedQty = Number(itemDetail.acceptedQty ?? 0); + const showStockQtyRoundButtons = + !putawayMode && + !disabled && + needsPoQcStockQtyRound( + itemDetail.purchaseOrderLineId, + itemDetail.status, + originalAcceptedQty, + ); + const stockUomLabel = itemDetail.stockUomDesc ? ` ${itemDetail.stockUomDesc}` : ""; + const roundUpQty = roundStockQty(originalAcceptedQty, "CEILING"); + const roundDownQty = roundStockQty(originalAcceptedQty, "FLOOR"); + + const selectStockQtyRound = useCallback( + (mode: StockQtyRoundMode) => { + const after = roundStockQty(Number(itemDetail.acceptedQty ?? 0), mode); + setValue("stockQtyRoundMode", mode, { shouldDirty: true, shouldTouch: true }); + setValue("acceptedQty", after, { shouldDirty: true, shouldTouch: true }); + (setValue as (name: string, value: unknown, options?: object) => void)( + "acceptQty", + after, + { shouldDirty: true, shouldTouch: true }, + ); + const accInput = document.getElementById("accQty") as HTMLInputElement | null; + if (accInput) accInput.value = String(after); + }, + [itemDetail.acceptedQty, setValue], + ); const [openModal, setOpenModal] = useState(false); const [openExpDatePicker, setOpenExpDatePicker] = useState(false); @@ -401,17 +436,47 @@ const StockInForm: React.FC = ({ ) : ( + <> + {showStockQtyRoundButtons && ( + + + + {t("Please choose a rounding method")} + + + + + + + + )} + )} {/*