diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 6dec1c10..dce43d73 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -26,6 +26,7 @@ import { import DownloadIcon from '@mui/icons-material/Download'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { REPORTS } from '@/config/reportConfig'; +import { mergePastedItemCodes, buildStockBalanceRequestBody } from './parseItemCodeTokens'; import { NEXT_PUBLIC_API_URL } from '@/config/api'; import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; @@ -80,7 +81,7 @@ const FIELD_ERROR_SX = { /** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ export default function ReportPage() { const { data: session } = useSession() as { data: SessionWithTokens | null }; - const { t, i18n, reportTitle, fieldLabel, optionLabel } = useReportLabels(); + const { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel } = useReportLabels(); const isZh = (i18n.language || 'zh').startsWith('zh'); const dateDisplayFormat = isZh ? 'DD/MM/YYYY' : 'DD/MM/YYYY'; const includeGrnFinancialColumns = @@ -358,6 +359,22 @@ export default function ReportPage() { return p.toString(); }; + const buildCriteriaQueryString = (): string => { + if (!currentReport) return ''; + if (currentReport.id === 'rep-012') return buildRep012QueryString(); + if (currentReport.id === 'rep-010') return buildRep010QueryString(); + const merged = mergePastedItemCodes(criteria); + const p = new URLSearchParams(merged); + if (currentReport.id === 'rep-016') { + const day = (merged.dateStart || '').trim(); + if (day) { + p.set('dateStart', day); + p.set('dateEnd', day); + } + } + return p.toString(); + }; + const handlePrint = async () => { if (!currentReport) return; if (!validateRequiredFields()) return; @@ -399,28 +416,24 @@ export default function ReportPage() { await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. - let queryParams = - currentReport.id === 'rep-012' - ? buildRep012QueryString() - : currentReport.id === 'rep-010' - ? buildRep010QueryString() - : new URLSearchParams(criteria).toString(); - // rep-016: single-day UI — mirror dateStart to dateEnd for backend API. - if (currentReport.id === 'rep-016') { - const p = new URLSearchParams(criteria); - const day = (criteria.dateStart || '').trim(); - if (day) { - p.set('dateStart', day); - p.set('dateEnd', day); - } - queryParams = p.toString(); - } - const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; - - const response = await clientAuthFetch(excelUrl, { - method: 'GET', - headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, - }); + const isStockBalance = currentReport.id === 'rep-007'; + const excelUrl = isStockBalance + ? `${currentReport.apiEndpoint}-excel` + : `${currentReport.apiEndpoint}-excel?${buildCriteriaQueryString()}`; + + const response = await clientAuthFetch(excelUrl, isStockBalance + ? { + method: 'POST', + headers: { + Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildStockBalanceRequestBody(criteria)), + } + : { + method: 'GET', + headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + }); if (response.status === 401 || response.status === 403) return; if (response.status === 204) { @@ -472,18 +485,24 @@ export default function ReportPage() { setLoading(true); try { - let queryParams = - currentReport.id === 'rep-012' - ? buildRep012QueryString() - : currentReport.id === 'rep-010' - ? buildRep010QueryString() - : new URLSearchParams(criteria).toString(); - const url = `${currentReport.apiEndpoint}?${queryParams}`; - - const response = await clientAuthFetch(url, { - method: 'GET', - headers: { 'Accept': 'application/pdf' }, - }); + const isStockBalance = currentReport.id === 'rep-007'; + const url = isStockBalance + ? currentReport.apiEndpoint + : `${currentReport.apiEndpoint}?${buildCriteriaQueryString()}`; + + const response = await clientAuthFetch(url, isStockBalance + ? { + method: 'POST', + headers: { + Accept: 'application/pdf', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildStockBalanceRequestBody(criteria)), + } + : { + method: 'GET', + headers: { Accept: 'application/pdf' }, + }); if (response.status === 401 || response.status === 403) return; if (!response.ok) { @@ -574,7 +593,10 @@ export default function ReportPage() { : currentValue; // Use larger grid size for 成品/半成品生產分析報告 - const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; + const gridSize = field.multiline + ? { xs: 12 } + : currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; + const hintText = fieldHint(currentReport.id, field.name); const disabledByCheckedCheckbox = currentReport.fields.some((f) => { if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false; @@ -762,9 +784,11 @@ export default function ReportPage() { fullWidth required={field.required} error={Boolean(fieldErrors[field.name])} - helperText={fieldErrors[field.name] || undefined} + helperText={fieldErrors[field.name] || hintText || undefined} label={translatedLabel} - type={field.type} + type={field.multiline ? 'text' : field.type} + multiline={field.multiline} + minRows={field.multiline ? (field.minRows ?? 4) : undefined} placeholder={field.placeholder} disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} sx={{ diff --git a/src/app/(main)/report/parseItemCodeTokens.ts b/src/app/(main)/report/parseItemCodeTokens.ts new file mode 100644 index 00000000..148a08e2 --- /dev/null +++ b/src/app/(main)/report/parseItemCodeTokens.ts @@ -0,0 +1,47 @@ +/** Split pasted / typed item codes from Excel or a text field. */ +const ITEM_CODE_SEPARATORS = /[\s,;,、\u00A0\u3000]+/; + +export function parseItemCodeTokens(raw: string | undefined | null): string[] { + if (!raw) return []; + const seen = new Set(); + const tokens: string[] = []; + for (const part of raw.split(ITEM_CODE_SEPARATORS)) { + const token = part.trim(); + if (!token) continue; + const key = token.toUpperCase(); + if (seen.has(key)) continue; + seen.add(key); + tokens.push(token); + } + return tokens; +} + +/** Merge multi-select `itemCode` with pasted `itemCodePaste`; drop the paste field from API params. */ +export function mergePastedItemCodes(criteria: Record): Record { + const next: Record = { ...criteria }; + delete next.itemCodePaste; + const merged = parseItemCodeTokens( + [criteria.itemCode, criteria.itemCodePaste].filter(Boolean).join(" "), + ); + if (merged.length > 0) { + next.itemCode = merged.join(","); + } else { + delete next.itemCode; + } + return next; +} + +/** POST body for 庫存結餘報告 so pasted codes are not limited by URL length. */ +export function buildStockBalanceRequestBody(criteria: Record): { + stockDate?: string; + itemCodes?: string[]; +} { + const itemCodes = parseItemCodeTokens( + [criteria.itemCode, criteria.itemCodePaste].filter(Boolean).join(" "), + ); + const body: { stockDate?: string; itemCodes?: string[] } = {}; + const stockDate = criteria.stockDate?.trim(); + if (stockDate) body.stockDate = stockDate; + if (itemCodes.length) body.itemCodes = itemCodes; + return body; +} diff --git a/src/app/(main)/report/reportI18n.ts b/src/app/(main)/report/reportI18n.ts index 7e8be629..7031a3af 100644 --- a/src/app/(main)/report/reportI18n.ts +++ b/src/app/(main)/report/reportI18n.ts @@ -19,6 +19,12 @@ export function useReportLabels() { defaultValue: field.label, }); + const fieldHint = (reportId: string, fieldName: string, fallback = "") => { + const key = `reports.${reportId}.fieldHints.${fieldName}`; + if (!i18n.exists(key, { ns: "report" })) return fallback; + return String(t(key, { defaultValue: fallback })); + }; + const optionLabel = ( reportId: string, fieldName: string, @@ -35,7 +41,7 @@ export function useReportLabels() { const categoryTitle = (id: string, fallback: string) => t(`categories.${id}`, { defaultValue: fallback }); - return { t, i18n, reportTitle, fieldLabel, optionLabel, categoryTitle }; + return { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel, categoryTitle }; } export function reportExcelT( diff --git a/src/app/api/po/index.ts b/src/app/api/po/index.ts index ed051581..e20cb25f 100644 --- a/src/app/api/po/index.ts +++ b/src/app/api/po/index.ts @@ -54,11 +54,15 @@ export interface StockUomForPoLine { id: number; stockUomCode: string; stockUomDesc: string; + stockUomShortDesc?: string | null; stockQty: number; stockRatioN: number; stockRatioD: number; purchaseRatioN: number; purchaseRatioD: number; + sourceRatioN?: number | null; + sourceRatioD?: number | null; + stockQtyInteger?: boolean | null; } // DEPRECIATED diff --git a/src/app/api/settings/uom/index.ts b/src/app/api/settings/uom/index.ts index e7e8526e..4f5765ca 100644 --- a/src/app/api/settings/uom/index.ts +++ b/src/app/api/settings/uom/index.ts @@ -9,6 +9,8 @@ export interface Uom { id: number; code: string; udfudesc: string; + /** Short UoM label from M18 (`udfShortDesc`), e.g. 箱. */ + udfShortDesc?: string | null; unit1: string; unit1Qty: number; unit2?: string; diff --git a/src/components/PoDetail/PoDetail.tsx b/src/components/PoDetail/PoDetail.tsx index 4b16fc53..993433dc 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -875,7 +875,13 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {t("status")} {/* {renderFieldCondition(FIRST_IN_FIELD) ? {t("receivedQty")} : undefined} */} {t("productLotNo")} - {renderFieldCondition(SECOND_IN_FIELD) ? {t("dnQty")}
(以訂單單位計算)
: undefined} + {renderFieldCondition(SECOND_IN_FIELD) ? ( + + {t("dnQty")} +
+ ({t("dnQtyOrderUnitHint")}) +
+ ) : undefined} diff --git a/src/components/PoDetail/PoDetailRow.tsx b/src/components/PoDetail/PoDetailRow.tsx index 682aa4dd..d16c5259 100644 --- a/src/components/PoDetail/PoDetailRow.tsx +++ b/src/components/PoDetail/PoDetailRow.tsx @@ -4,10 +4,13 @@ import { PurchaseOrderLine } from "@/app/api/po"; import { Box, Button, + InputAdornment, Radio, + Stack, TableCell, TableRow, TextField, + Typography, alpha, } from "@mui/material"; import { memo, useCallback, useEffect, useRef, useState } from "react"; @@ -16,6 +19,12 @@ import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; import { submitDialogWithWarning } from "../Swal/CustomAlerts"; import { createStockInLine } from "@/app/api/stockIn/actions"; import { previewPoBatchStockQty } from "./stockQtyRound"; +import { + formatQtyWithPurchaseUom, + formatQtyWithStockUom, + poLineNeedsStockQtyConversion, + purchaseUomShortDesc, +} from "./poPurchaseUom"; const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); @@ -108,6 +117,7 @@ export const PoDetailRow = memo(function PoDetailRow({ orderQty, Number(row.stockUom?.stockQty ?? 0), acceptedQty, + row.stockUom, ); const doSubmit = () => { @@ -186,6 +196,28 @@ export const PoDetailRow = memo(function PoDetailRow({ Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; const needsStockInAttention = canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); + const uomShort = purchaseUomShortDesc(row.uom); + const orderQtyWithUnit = formatQtyWithPurchaseUom(row.qty, row.uom); + const enteredBatchQty = Number(dnQtyInput.trim()); + const hasEnteredBatchQty = Number.isInteger(enteredBatchQty) && enteredBatchQty > 0; + const showStockConversion = + hasEnteredBatchQty && + poLineNeedsStockQtyConversion( + Number(row.qty ?? 0), + Number(row.stockUom?.stockQty ?? 0), + row.uom, + row.stockUom, + ); + const convertedStockQty = previewPoBatchStockQty( + Number(row.qty ?? 0), + Number(row.stockUom?.stockQty ?? 0), + enteredBatchQty, + row.stockUom, + ); + const convertedStockQtyWithUnit = formatQtyWithStockUom( + convertedStockQty, + row.stockUom, + ); return ( {row.itemName} - {integerFormatter.format(row.qty)} + + {orderQtyWithUnit} + {integerFormatter.format(row.processed)} {row.uom?.udfudesc} @@ -268,25 +312,107 @@ export const PoDetailRow = memo(function PoDetailRow({ /> {showDnQty ? ( - - setDnQtyInput(e.target.value.replace(/[^\d]/g, ""))} - onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} + + e.stopPropagation()} - InputProps={{ - inputProps: { - min: 1, - step: 1, - inputMode: "numeric", - pattern: "[0-9]*", - }, - }} - /> + > + setDnQtyInput(e.target.value.replace(/[^\d]/g, ""))} + onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} + onClick={(e) => e.stopPropagation()} + sx={{ + minWidth: 168, + width: 168, + flexShrink: 0, + "& .MuiInputLabel-root": { + maxWidth: "calc(100% - 44px)", + }, + "& .MuiInputBase-input": { + pr: 0.5, + }, + }} + InputLabelProps={{ shrink: true }} + InputProps={{ + notched: true, + endAdornment: uomShort ? ( + + + {uomShort} + + + ) : undefined, + inputProps: { + min: 1, + step: 1, + inputMode: "numeric", + pattern: "[0-9]*", + "aria-label": `${t("dnQty")} ${uomShort}`.trim(), + }, + }} + /> + {showStockConversion && Number.isFinite(convertedStockQty) && convertedStockQty > 0 ? ( + alpha(theme.palette.primary.main, 0.1), + border: 1, + borderColor: "primary.main", + textAlign: "center", + }} + title={row.stockUom?.stockUomDesc || convertedStockQtyWithUnit} + > + + {t("stockQtyRef")} + + + {convertedStockQtyWithUnit} + + + ) : null} + ) : null} diff --git a/src/components/PoDetail/PoInputGrid.tsx b/src/components/PoDetail/PoInputGrid.tsx index b4572b58..c8c038b8 100644 --- a/src/components/PoDetail/PoInputGrid.tsx +++ b/src/components/PoDetail/PoInputGrid.tsx @@ -61,7 +61,7 @@ import DoDisturbIcon from "@mui/icons-material/DoDisturb"; import { useSession } from "next-auth/react"; // import { SessionWithTokens } from "src/config/authConfig"; import QcStockInModal from "../Qc/QcStockInModal"; -import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; +import { decimalFormatter } from "@/app/utils/formatUtil"; import { PrinterCombo } from "@/app/api/settings/printer"; import { EscalationResult } from "@/app/api/escalation"; import { fetchEscalationLogsByStockInLines } from "@/app/api/escalation/actions"; @@ -70,10 +70,11 @@ import { EscalationCombo } from "@/app/api/user"; import { deleteDialog } from "../Swal/CustomAlerts"; import StockInLineRowActions from "./StockInLineRowActions"; import { StockQtyRoundMode, needsPoQcStockQtyRound } from "./stockQtyRound"; +import { formatQtyWithPurchaseUom } from "./poPurchaseUom"; // 3 buttons after QC (view + print QR + delete): 176*3 + gap + cell padding const ACTIONS_COLUMN_WIDTH = 580; -const PURCHASE_QTY_COLUMN_WIDTH = 72; +const PURCHASE_QTY_COLUMN_WIDTH = 96; const UOM_COLUMN_WIDTH = 124; const STOCK_QTY_COLUMN_WIDTH = 110; const STOCK_IN_ROW_HEIGHT = 58; @@ -719,7 +720,7 @@ function PoInputGrid({ type: "number", renderCell: (params) => { const qty = params.row.purchaseAcceptedQty ?? 0; - return integerFormatter.format(qty); + return formatQtyWithPurchaseUom(qty, itemDetail.uom); }, }, { diff --git a/src/components/PoDetail/poPurchaseUom.ts b/src/components/PoDetail/poPurchaseUom.ts new file mode 100644 index 00000000..84b07f2f --- /dev/null +++ b/src/components/PoDetail/poPurchaseUom.ts @@ -0,0 +1,96 @@ +import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; + +type PurchaseUomLike = { + code?: string | null; + udfudesc?: string | null; + udfShortDesc?: string | null; +} | null | undefined; + +type StockUomLike = { + stockUomCode?: string | null; + stockUomDesc?: string | null; + stockUomShortDesc?: string | null; +} | null | undefined; + +function isCompactUnitLabel(value: string): boolean { + return value.length > 0 && value.length <= 6 && !/[x×]/i.test(value); +} + +function compactUomLabel( + shortDesc?: string | null, + fullDesc?: string | null, + code?: string | null, +): string { + const short = shortDesc?.trim(); + if (short) return short; + const full = fullDesc?.trim() ?? ""; + if (isCompactUnitLabel(full)) return full; + const unitCode = code?.trim() ?? ""; + if (isCompactUnitLabel(unitCode)) return unitCode; + return ""; +} + +function normUnitKey(...parts: Array): string { + return parts.map((p) => p?.trim().toLowerCase()).find((p) => p) ?? ""; +} + +/** Prefer UoM short desc (箱). Do not return long packing strings like 150克X100包X1箱. */ +export function purchaseUomShortDesc(uom: PurchaseUomLike): string { + return compactUomLabel(uom?.udfShortDesc, uom?.udfudesc, uom?.code); +} + +export function stockUomShortLabel(stockUom: StockUomLike): string { + return compactUomLabel( + stockUom?.stockUomShortDesc, + stockUom?.stockUomDesc, + stockUom?.stockUomCode, + ); +} + +export function purchaseUnitDiffersFromStock( + uom: PurchaseUomLike, + stockUom: StockUomLike, +): boolean { + const purchaseKey = normUnitKey(uom?.code, uom?.udfShortDesc, uom?.udfudesc); + const stockKey = normUnitKey( + stockUom?.stockUomCode, + stockUom?.stockUomShortDesc, + stockUom?.stockUomDesc, + ); + if (!purchaseKey || !stockKey) return false; + return purchaseKey !== stockKey; +} + +export function poLineNeedsStockQtyConversion( + orderQty: number, + orderStockQty: number, + uom: PurchaseUomLike, + stockUom: StockUomLike, +): boolean { + if (purchaseUnitDiffersFromStock(uom, stockUom)) return true; + return ( + Number.isFinite(orderQty) && + Number.isFinite(orderStockQty) && + orderQty > 0 && + orderStockQty > 0 && + Math.abs(orderStockQty - orderQty) > 1e-6 + ); +} + +/** e.g. 384箱 */ +export function formatQtyWithPurchaseUom(qty: number, uom: PurchaseUomLike): string { + const n = integerFormatter.format(qty); + const unit = purchaseUomShortDesc(uom); + return unit ? `${n}${unit}` : n; +} + +/** e.g. 57,600克 */ +export function formatQtyWithStockUom(qty: number, stockUom: StockUomLike): string { + const rounded = Math.round(qty); + const n = + Number.isFinite(qty) && Math.abs(qty - rounded) < 1e-9 + ? integerFormatter.format(rounded) + : decimalFormatter.format(qty); + const unit = stockUomShortLabel(stockUom) || stockUom?.stockUomDesc?.trim() || ""; + return unit ? `${n}${unit}` : n; +} diff --git a/src/components/PoDetail/stockQtyRound.ts b/src/components/PoDetail/stockQtyRound.ts index f3dcab3a..c2d066b6 100644 --- a/src/components/PoDetail/stockQtyRound.ts +++ b/src/components/PoDetail/stockQtyRound.ts @@ -7,16 +7,95 @@ export type StockQtyRoundChoice = { after: number; }; -/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ +/** Keep in sync with ItemUomService.isIntegerCountStockUom */ +const INTEGER_COUNT_UNIT_TOKENS = new Set([ + "包", + "箱", + "件", + "個", + "PCS", + "PC", + "CTN", + "EA", + "PK", + "PKT", + "BOX", +]); + +export type PoStockQtyConvertHint = { + sourceRatioN?: number | null; + sourceRatioD?: number | null; + stockRatioN?: number | null; + stockRatioD?: number | null; + purchaseRatioN?: number | null; + purchaseRatioD?: number | null; + stockUomCode?: string | null; + stockUomShortDesc?: string | null; + stockQtyInteger?: boolean | null; +}; + +function toRatio(value: number | null | undefined): number | null { + const n = Number(value); + if (!Number.isFinite(n) || n === 0) return null; + return n; +} + +function stockQtyMustBeInteger(hint?: PoStockQtyConvertHint): boolean { + if (hint?.stockQtyInteger === true) return true; + if (hint?.stockQtyInteger === false) return false; + const code = hint?.stockUomCode?.trim() ?? ""; + if (code.toUpperCase().startsWith("PCS/CTN")) return true; + const short = hint?.stockUomShortDesc?.trim() ?? ""; + return INTEGER_COUNT_UNIT_TOKENS.has(short) || INTEGER_COUNT_UNIT_TOKENS.has(code.toUpperCase()); +} + +/** Java BigDecimal HALF_UP for non-negative qty. */ +function roundHalfUp(value: number, scale: number): number { + const factor = 10 ** scale; + return Math.round(value * factor) / factor; +} + +function finalizePoStockQty(raw: number, hint?: PoStockQtyConvertHint): number { + if (!Number.isFinite(raw)) return 0; + return stockQtyMustBeInteger(hint) ? roundHalfUp(raw, 0) : roundHalfUp(raw, 2); +} + +/** + * Same ratio path as backend convertQtyToStockQtyPrecise: + * base = qty * sourceRatioN / sourceRatioD + * stock = base * stockRatioD / stockRatioN + */ +function convertPoBatchToStockQty( + batchM18Qty: number, + hint?: PoStockQtyConvertHint, +): number | null { + if (!hint || !Number.isFinite(batchM18Qty)) return null; + const sourceN = toRatio(hint.sourceRatioN) ?? toRatio(hint.purchaseRatioN); + const sourceD = toRatio(hint.sourceRatioD) ?? toRatio(hint.purchaseRatioD); + const stockN = toRatio(hint.stockRatioN); + const stockD = toRatio(hint.stockRatioD); + if (sourceN == null || sourceD == null || stockN == null || stockD == null) return null; + const baseQty = (batchM18Qty * sourceN) / sourceD; + const stockQty = (baseQty * stockD) / stockN; + return finalizePoStockQty(stockQty, hint); +} + +/** + * Preview converted stock qty for a 來貨數 batch. + * Matches create-stock-in: ratio convert, then integer HALF_UP for 包/箱/PCS, else 2 decimals. + */ export function previewPoBatchStockQty( orderM18Qty: number, orderStockQty: number, batchM18Qty: number, + hint?: PoStockQtyConvertHint, ): number { + const fromRatios = convertPoBatchToStockQty(batchM18Qty, hint); + if (fromRatios != null) return fromRatios; if (!Number.isFinite(orderM18Qty) || orderM18Qty === 0) { - return Number(batchM18Qty.toFixed(2)); + return finalizePoStockQty(batchM18Qty, hint); } - return Number(((batchM18Qty * orderStockQty) / orderM18Qty).toFixed(2)); + return finalizePoStockQty((batchM18Qty * orderStockQty) / orderM18Qty, hint); } export function isNotIntegerQty(qty: number): boolean { diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index c6ed8b5b..f34bcd93 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -25,6 +25,10 @@ export interface ReportField { minDate?: 'today'; /** Disable the input (e.g. date locked to today) */ disabled?: boolean; + /** Render a multiline text area (for pasting many values) */ + multiline?: boolean; + /** Rows for multiline text areas. Default 4. */ + minRows?: number; } export type ReportResponseType = 'pdf' | 'excel'; @@ -340,6 +344,14 @@ export const REPORTS: ReportDefinition[] = [ fields: [ { label: "庫存日期: Stock Date", name: "stockDate", type: "date", required: true }, asyncItemCodeField(), + { + label: "貼上貨品編號 Paste Item Codes", + name: "itemCodePaste", + type: "text", + required: false, + multiline: true, + minRows: 4, + }, ] }, diff --git a/src/i18n/en/purchaseOrder.json b/src/i18n/en/purchaseOrder.json index dd920e44..f75266b5 100644 --- a/src/i18n/en/purchaseOrder.json +++ b/src/i18n/en/purchaseOrder.json @@ -126,6 +126,8 @@ "Please scan warehouse qr code.": "Please scan warehouse QR code.", "receivedQty": "Received Qty", "dnQty": "Delivery Qty (This Batch)", + "dnQtyOrderUnitHint": "in order unit", + "stockQtyRef": "Stock", "Accept submit": "Accept Delivery", "qc processing": "Delivery & QC Processing", "putaway processing": "Delivery & Put Away Processing", diff --git a/src/i18n/en/report.json b/src/i18n/en/report.json index 17af3c26..eb761d48 100644 --- a/src/i18n/en/report.json +++ b/src/i18n/en/report.json @@ -122,7 +122,11 @@ "title": "Stock Balance Report", "fields": { "stockDate": "Stock Date", - "itemCode": "Item Code" + "itemCode": "Item Code", + "itemCodePaste": "Paste Item Codes" + }, + "fieldHints": { + "itemCodePaste": "Paste item codes from Excel. Separate with spaces, commas, or new lines" } }, "rep-014": { diff --git a/src/i18n/zh/purchaseOrder.json b/src/i18n/zh/purchaseOrder.json index 0cac29af..99664477 100644 --- a/src/i18n/zh/purchaseOrder.json +++ b/src/i18n/zh/purchaseOrder.json @@ -126,6 +126,8 @@ "Please scan warehouse qr code.": "請掃描倉庫 QR 碼。", "receivedQty": "已來貨數量", "dnQty": "本批來貨數量", + "dnQtyOrderUnitHint": "以訂單單位計算", + "stockQtyRef": "庫存", "Accept submit": "接受來貨", "qc processing": "處理來貨及品檢", "putaway processing": "處理來貨及上架", diff --git a/src/i18n/zh/report.json b/src/i18n/zh/report.json index 52a2a638..c0de47f6 100644 --- a/src/i18n/zh/report.json +++ b/src/i18n/zh/report.json @@ -122,7 +122,11 @@ "title": "庫存結餘報告", "fields": { "stockDate": "庫存日期", - "itemCode": "貨品編號" + "itemCode": "貨品編號", + "itemCodePaste": "貼上貨品編號" + }, + "fieldHints": { + "itemCodePaste": "可從 Excel 貼上多個貨品編號,以空格、逗號或換行分隔" } }, "rep-014": {