Browse Source

stock take input max limit

production
CANCERYS\kw093 3 weeks ago
parent
commit
92675215dd
6 changed files with 120 additions and 77 deletions
  1. +34
    -0
      src/app/utils/formatUtil.ts
  2. +37
    -48
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  3. +29
    -23
      src/components/StockTakeManagement/PickerStockTake.tsx
  4. +15
    -5
      src/components/StockTakeManagement/buildPickerBatchSaveRequests.ts
  5. +3
    -1
      src/i18n/en/inventory.json
  6. +2
    -0
      src/i18n/zh/inventory.json

+ 34
- 0
src/app/utils/formatUtil.ts View File

@@ -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";


+ 37
- 48
src/components/StockTakeManagement/ApproverStockTakeAll.tsx View File

@@ -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 }}>


+ 29
- 23
src/components/StockTakeManagement/PickerStockTake.tsx View File

@@ -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 } }));


+ 15
- 5
src/components/StockTakeManagement/buildPickerBatchSaveRequests.ts View File

@@ -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,


+ 3
- 1
src/i18n/en/inventory.json View File

@@ -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"
} }

+ 2
- 0
src/i18n/zh/inventory.json View File

@@ -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": "盤點中",


Loading…
Cancel
Save