| @@ -39,6 +39,40 @@ export function roundDownPercent(value: number): number { | |||
| return Math.trunc(value); | |||
| } | |||
| /** 與 stocktakerecord DECIMAL(14,2) 整數部分上限一致 */ | |||
| export const MAX_STOCK_TAKE_QTY = 999_999_999_999; | |||
| const MAX_STOCK_TAKE_QTY_DIGITS = 12; | |||
| /** 盤點數量輸入:僅數字,最多 12 位 */ | |||
| export function sanitizeStockTakeQtyInput(value: string): string { | |||
| return value.replace(/[^\d]/g, "").slice(0, MAX_STOCK_TAKE_QTY_DIGITS); | |||
| } | |||
| export type StockTakeQtyValidation = | |||
| | { ok: true; qty: number } | |||
| | { ok: false; errorKey: "Invalid QTY" | "Stock take qty exceeds maximum" }; | |||
| export function validateStockTakeQtyString( | |||
| value: string | undefined, | |||
| options?: { allowEmpty?: boolean }, | |||
| ): StockTakeQtyValidation { | |||
| const trimmed = (value ?? "").trim(); | |||
| if (!trimmed) { | |||
| return options?.allowEmpty ? { ok: true, qty: 0 } : { ok: false, errorKey: "Invalid QTY" }; | |||
| } | |||
| if (!/^\d+$/.test(trimmed)) { | |||
| return { ok: false, errorKey: "Invalid QTY" }; | |||
| } | |||
| if (trimmed.length > MAX_STOCK_TAKE_QTY_DIGITS) { | |||
| return { ok: false, errorKey: "Stock take qty exceeds maximum" }; | |||
| } | |||
| const qty = Number(trimmed); | |||
| if (!Number.isFinite(qty) || qty > MAX_STOCK_TAKE_QTY) { | |||
| return { ok: false, errorKey: "Stock take qty exceeds maximum" }; | |||
| } | |||
| return { ok: true, qty }; | |||
| } | |||
| export const INPUT_DATE_FORMAT = "YYYY-MM-DD"; | |||
| export const OUTPUT_DATE_FORMAT = "YYYY-MM-DD"; | |||
| @@ -1424,54 +1424,43 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| </Grid> | |||
| )} | |||
| <Grid item xs={12} md={4}> | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| type="number" | |||
| label={t("Variance %")} | |||
| value={searchVariancePercentTolerance} | |||
| onChange={(e) => | |||
| setSearchVariancePercentTolerance(sanitizePositiveDecimalInput(e.target.value)) | |||
| } | |||
| inputProps={{ min: 0, step: 0.1 }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} md={4} sx={{ display: "flex", alignItems: "center" }}> | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={searchVarianceFilterInclusive} | |||
| onChange={(e) => setSearchVarianceFilterInclusive(e.target.checked)} | |||
| /> | |||
| } | |||
| label={t("Variance filter inclusive only")} | |||
| /> | |||
| </Grid> | |||
| {/* | |||
| <Grid item xs={12} md={4} sx={{ display: "flex", alignItems: "center" }}> | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={searchVarianceFilterStrict} | |||
| onChange={(e) => setSearchVarianceFilterStrict(e.target.checked)} | |||
| /> | |||
| } | |||
| label={t("Variance filter strict bounds")} | |||
| /> | |||
| </Grid> | |||
| */} | |||
| <Grid item xs={12}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {searchVarianceFilterInclusive | |||
| ? t("Variance filter inclusive range hint", { | |||
| value: searchVariancePercentTolerance || "0", | |||
| op: searchVarianceFilterStrict ? "<" : "≤", | |||
| }) | |||
| : t("Variance filter exclusive range hint", { | |||
| value: searchVariancePercentTolerance || "0", | |||
| op: searchVarianceFilterStrict ? ">" : "≥", | |||
| })} | |||
| </Typography> | |||
| <Stack spacing={0.5}> | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| type="number" | |||
| label={t("Variance %")} | |||
| value={searchVariancePercentTolerance} | |||
| onChange={(e) => | |||
| setSearchVariancePercentTolerance(sanitizePositiveDecimalInput(e.target.value)) | |||
| } | |||
| inputProps={{ min: 0, step: 0.1 }} | |||
| /> | |||
| <FormControlLabel | |||
| sx={{ ml: 0, mr: 0 }} | |||
| control={ | |||
| <Checkbox | |||
| size="small" | |||
| checked={searchVarianceFilterInclusive} | |||
| onChange={(e) => setSearchVarianceFilterInclusive(e.target.checked)} | |||
| /> | |||
| } | |||
| label={ | |||
| <Typography variant="body2">{t("Variance filter inclusive only")}</Typography> | |||
| } | |||
| /> | |||
| <Typography variant="caption" color="text.secondary" sx={{ pl: 0.5 }}> | |||
| {searchVarianceFilterInclusive | |||
| ? t("Variance filter inclusive range hint", { | |||
| value: searchVariancePercentTolerance || "0", | |||
| op: searchVarianceFilterStrict ? "<" : "≤", | |||
| }) | |||
| : t("Variance filter exclusive range hint", { | |||
| value: searchVariancePercentTolerance || "0", | |||
| op: searchVarianceFilterStrict ? ">" : "≥", | |||
| })} | |||
| </Typography> | |||
| </Stack> | |||
| </Grid> | |||
| </Grid> | |||
| <CardActions sx={{ px: 0, pt: 2, gap: 1 }}> | |||
| @@ -39,7 +39,11 @@ import PickerBatchSaveFab from "./PickerBatchSaveFab"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { | |||
| OUTPUT_DATE_FORMAT, | |||
| sanitizeStockTakeQtyInput, | |||
| validateStockTakeQtyString, | |||
| } from "@/app/utils/formatUtil"; | |||
| interface PickerStockTakeProps { | |||
| selectedSession: AllPickedStockTakeListReponse; | |||
| @@ -198,28 +202,36 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| const totalQty = parseFloat(totalQtyStr || "0") || 0; | |||
| const badQty = parseFloat(badQtyStr || "0") || 0; // 空字串時為 0 | |||
| if (Number.isNaN(totalQty)) { | |||
| onSnackbar(t("Invalid QTY"), "error"); | |||
| const totalValidated = validateStockTakeQtyString(totalQtyStr); | |||
| if (!totalValidated.ok) { | |||
| onSnackbar(t(totalValidated.errorKey), "error"); | |||
| return; | |||
| } | |||
| const badValidated = validateStockTakeQtyString(badQtyStr, { allowEmpty: true }); | |||
| if (!badValidated.ok) { | |||
| onSnackbar(t(badValidated.errorKey), "error"); | |||
| return; | |||
| } | |||
| const availableQty = totalQty - badQty; | |||
| const availableQty = totalValidated.qty - badValidated.qty; | |||
| if (availableQty < 0) { | |||
| onSnackbar(t("Available QTY cannot be negative"), "error"); | |||
| return; | |||
| } | |||
| const availableValidated = validateStockTakeQtyString(String(availableQty)); | |||
| if (!availableValidated.ok) { | |||
| onSnackbar(t(availableValidated.errorKey), "error"); | |||
| return; | |||
| } | |||
| setSaving(true); | |||
| try { | |||
| const request: SaveStockTakeRecordRequest = { | |||
| stockTakeRecordId: detail.stockTakeRecordId || null, | |||
| inventoryLotLineId: detail.id, | |||
| qty: availableQty, // 保存 available qty | |||
| badQty: badQty, // 保存 bad qty | |||
| qty: availableValidated.qty, | |||
| badQty: badValidated.qty, | |||
| remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null, | |||
| }; | |||
| console.log("handleSaveStockTake: request:", request); | |||
| @@ -237,10 +249,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| ? { | |||
| ...d, | |||
| stockTakeRecordId: d.stockTakeRecordId ?? null, // 首次儲存後可從 response 取得,此處先保留 | |||
| firstStockTakeQty: isFirstSubmit ? availableQty : d.firstStockTakeQty, | |||
| firstBadQty: isFirstSubmit ? badQty : d.firstBadQty ?? null, | |||
| secondStockTakeQty: isSecondSubmit ? availableQty : d.secondStockTakeQty, | |||
| secondBadQty: isSecondSubmit ? badQty : d.secondBadQty ?? null, | |||
| firstStockTakeQty: isFirstSubmit ? availableValidated.qty : d.firstStockTakeQty, | |||
| firstBadQty: isFirstSubmit ? badValidated.qty : d.firstBadQty ?? null, | |||
| secondStockTakeQty: isSecondSubmit ? availableValidated.qty : d.secondStockTakeQty, | |||
| secondBadQty: isSecondSubmit ? badValidated.qty : d.secondBadQty ?? null, | |||
| remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks, | |||
| stockTakeRecordStatus: "pass", | |||
| } | |||
| @@ -382,12 +394,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| } | |||
| }; | |||
| const sanitizeIntegerInput = (value: string) => { | |||
| // 只保留数字 | |||
| return value.replace(/[^\d]/g, ""); | |||
| }; | |||
| const isIntegerString = (value: string) => /^\d+$/.test(value); | |||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||
| return true; | |||
| @@ -599,7 +605,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| const clean = sanitizeIntegerInput(e.target.value); | |||
| const clean = sanitizeStockTakeQtyInput(e.target.value); | |||
| const val = clean; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstQty: val } })); | |||
| @@ -626,7 +632,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| const clean = sanitizeIntegerInput(e.target.value); | |||
| const clean = sanitizeStockTakeQtyInput(e.target.value); | |||
| const val = clean; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstBadQty: val } })); | |||
| @@ -683,7 +689,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| const clean = sanitizeIntegerInput(e.target.value); | |||
| const clean = sanitizeStockTakeQtyInput(e.target.value); | |||
| const val = clean; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondQty: val } })); | |||
| @@ -706,7 +712,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| const clean = sanitizeIntegerInput(e.target.value); | |||
| const clean = sanitizeStockTakeQtyInput(e.target.value); | |||
| const val = clean; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondBadQty: val } })); | |||
| @@ -2,6 +2,7 @@ import type { | |||
| InventoryLotDetailResponse, | |||
| SaveStockTakeRecordRequest, | |||
| } from "@/app/api/stockTake/actions"; | |||
| import { validateStockTakeQtyString } from "@/app/utils/formatUtil"; | |||
| export type PickerRecordInputs = Record< | |||
| number, | |||
| @@ -47,17 +48,26 @@ export function buildPickerBatchSaveRequests( | |||
| if (!totalQtyStr || totalQtyStr.trim() === "") continue; | |||
| const totalQty = parseFloat(totalQtyStr); | |||
| const badQty = parseFloat(badQtyStr || "0") || 0; | |||
| if (Number.isNaN(totalQty)) { | |||
| return { ok: false, message: "Invalid QTY" }; | |||
| const totalValidated = validateStockTakeQtyString(totalQtyStr); | |||
| if (!totalValidated.ok) { | |||
| return { ok: false, message: totalValidated.errorKey }; | |||
| } | |||
| const badValidated = validateStockTakeQtyString(badQtyStr, { allowEmpty: true }); | |||
| if (!badValidated.ok) { | |||
| return { ok: false, message: badValidated.errorKey }; | |||
| } | |||
| const totalQty = totalValidated.qty; | |||
| const badQty = badValidated.qty; | |||
| const availableQty = totalQty - badQty; | |||
| if (availableQty < 0) { | |||
| return { ok: false, message: "Available QTY cannot be negative" }; | |||
| } | |||
| const availableValidated = validateStockTakeQtyString(String(availableQty)); | |||
| if (!availableValidated.ok) { | |||
| return { ok: false, message: availableValidated.errorKey }; | |||
| } | |||
| records.push({ | |||
| stockTakeRecordId: detail.stockTakeRecordId ?? null, | |||
| @@ -50,5 +50,7 @@ | |||
| "Warehouse missing stock take section drawer hint": "These locations have no stock take section (ST-xxx) and cannot be included in stock take. Fix in warehouse settings.", | |||
| "Warehouse missing stock take section go settings": "Go to warehouse settings", | |||
| "Warehouse missing stock take section showing": "Showing {{shown}} of {{count}}", | |||
| "Warehouse missing stock take section empty": "No warehouses missing stock take section" | |||
| "Warehouse missing stock take section empty": "No warehouses missing stock take section", | |||
| "Invalid QTY": "Invalid quantity", | |||
| "Stock take qty exceeds maximum": "Stock take quantity cannot exceed 999,999,999,999" | |||
| } | |||
| @@ -96,6 +96,8 @@ | |||
| "Total Item Kind Number": "貨品種類數量", | |||
| "Please enter QTY and Bad QTY": "請輸入盤點數量和不良數量", | |||
| "Available QTY cannot be negative": "可用數量不能為負數", | |||
| "Invalid QTY": "無效的數量", | |||
| "Stock take qty exceeds maximum": "盤點數量不可超過 999,999,999,999", | |||
| "Start Time": "開始時間", | |||
| "Difference": "差異", | |||
| "stockTaking": "盤點中", | |||