| @@ -39,6 +39,40 @@ export function roundDownPercent(value: number): number { | |||||
| return Math.trunc(value); | 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 INPUT_DATE_FORMAT = "YYYY-MM-DD"; | ||||
| export const OUTPUT_DATE_FORMAT = "YYYY-MM-DD"; | export const OUTPUT_DATE_FORMAT = "YYYY-MM-DD"; | ||||
| @@ -1424,54 +1424,43 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| </Grid> | </Grid> | ||||
| )} | )} | ||||
| <Grid item xs={12} md={4}> | <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> | ||||
| </Grid> | </Grid> | ||||
| <CardActions sx={{ px: 0, pt: 2, gap: 1 }}> | <CardActions sx={{ px: 0, pt: 2, gap: 1 }}> | ||||
| @@ -39,7 +39,11 @@ import PickerBatchSaveFab from "./PickerBatchSaveFab"; | |||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||||
| import { | |||||
| OUTPUT_DATE_FORMAT, | |||||
| sanitizeStockTakeQtyInput, | |||||
| validateStockTakeQtyString, | |||||
| } from "@/app/utils/formatUtil"; | |||||
| interface PickerStockTakeProps { | interface PickerStockTakeProps { | ||||
| selectedSession: AllPickedStockTakeListReponse; | 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; | return; | ||||
| } | } | ||||
| const availableQty = totalQty - badQty; | |||||
| const availableQty = totalValidated.qty - badValidated.qty; | |||||
| if (availableQty < 0) { | if (availableQty < 0) { | ||||
| onSnackbar(t("Available QTY cannot be negative"), "error"); | onSnackbar(t("Available QTY cannot be negative"), "error"); | ||||
| return; | return; | ||||
| } | } | ||||
| const availableValidated = validateStockTakeQtyString(String(availableQty)); | |||||
| if (!availableValidated.ok) { | |||||
| onSnackbar(t(availableValidated.errorKey), "error"); | |||||
| return; | |||||
| } | |||||
| setSaving(true); | setSaving(true); | ||||
| try { | try { | ||||
| const request: SaveStockTakeRecordRequest = { | const request: SaveStockTakeRecordRequest = { | ||||
| stockTakeRecordId: detail.stockTakeRecordId || null, | stockTakeRecordId: detail.stockTakeRecordId || null, | ||||
| inventoryLotLineId: detail.id, | 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, | remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null, | ||||
| }; | }; | ||||
| console.log("handleSaveStockTake: request:", request); | console.log("handleSaveStockTake: request:", request); | ||||
| @@ -237,10 +249,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| ? { | ? { | ||||
| ...d, | ...d, | ||||
| stockTakeRecordId: d.stockTakeRecordId ?? null, // 首次儲存後可從 response 取得,此處先保留 | 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, | remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks, | ||||
| stockTakeRecordStatus: "pass", | 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 => { | const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | ||||
| if (selectedSession?.status?.toLowerCase() === "completed") { | if (selectedSession?.status?.toLowerCase() === "completed") { | ||||
| return true; | return true; | ||||
| @@ -599,7 +605,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| onKeyDown={blockNonIntegerKeys} | onKeyDown={blockNonIntegerKeys} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| const clean = sanitizeIntegerInput(e.target.value); | |||||
| const clean = sanitizeStockTakeQtyInput(e.target.value); | |||||
| const val = clean; | const val = clean; | ||||
| if (val.includes("-")) return; | if (val.includes("-")) return; | ||||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstQty: val } })); | 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]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| onKeyDown={blockNonIntegerKeys} | onKeyDown={blockNonIntegerKeys} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| const clean = sanitizeIntegerInput(e.target.value); | |||||
| const clean = sanitizeStockTakeQtyInput(e.target.value); | |||||
| const val = clean; | const val = clean; | ||||
| if (val.includes("-")) return; | if (val.includes("-")) return; | ||||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstBadQty: val } })); | 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]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| onKeyDown={blockNonIntegerKeys} | onKeyDown={blockNonIntegerKeys} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| const clean = sanitizeIntegerInput(e.target.value); | |||||
| const clean = sanitizeStockTakeQtyInput(e.target.value); | |||||
| const val = clean; | const val = clean; | ||||
| if (val.includes("-")) return; | if (val.includes("-")) return; | ||||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondQty: val } })); | 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]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| onKeyDown={blockNonIntegerKeys} | onKeyDown={blockNonIntegerKeys} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| const clean = sanitizeIntegerInput(e.target.value); | |||||
| const clean = sanitizeStockTakeQtyInput(e.target.value); | |||||
| const val = clean; | const val = clean; | ||||
| if (val.includes("-")) return; | if (val.includes("-")) return; | ||||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondBadQty: val } })); | setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondBadQty: val } })); | ||||
| @@ -2,6 +2,7 @@ import type { | |||||
| InventoryLotDetailResponse, | InventoryLotDetailResponse, | ||||
| SaveStockTakeRecordRequest, | SaveStockTakeRecordRequest, | ||||
| } from "@/app/api/stockTake/actions"; | } from "@/app/api/stockTake/actions"; | ||||
| import { validateStockTakeQtyString } from "@/app/utils/formatUtil"; | |||||
| export type PickerRecordInputs = Record< | export type PickerRecordInputs = Record< | ||||
| number, | number, | ||||
| @@ -47,17 +48,26 @@ export function buildPickerBatchSaveRequests( | |||||
| if (!totalQtyStr || totalQtyStr.trim() === "") continue; | 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; | const availableQty = totalQty - badQty; | ||||
| if (availableQty < 0) { | if (availableQty < 0) { | ||||
| return { ok: false, message: "Available QTY cannot be negative" }; | 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({ | records.push({ | ||||
| stockTakeRecordId: detail.stockTakeRecordId ?? null, | 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 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 go settings": "Go to warehouse settings", | ||||
| "Warehouse missing stock take section showing": "Showing {{shown}} of {{count}}", | "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": "貨品種類數量", | "Total Item Kind Number": "貨品種類數量", | ||||
| "Please enter QTY and Bad QTY": "請輸入盤點數量和不良數量", | "Please enter QTY and Bad QTY": "請輸入盤點數量和不良數量", | ||||
| "Available QTY cannot be negative": "可用數量不能為負數", | "Available QTY cannot be negative": "可用數量不能為負數", | ||||
| "Invalid QTY": "無效的數量", | |||||
| "Stock take qty exceeds maximum": "盤點數量不可超過 999,999,999,999", | |||||
| "Start Time": "開始時間", | "Start Time": "開始時間", | ||||
| "Difference": "差異", | "Difference": "差異", | ||||
| "stockTaking": "盤點中", | "stockTaking": "盤點中", | ||||