| @@ -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={{ | |||
| @@ -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<string>(); | |||
| 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<string, string>): Record<string, string> { | |||
| const next: Record<string, string> = { ...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<string, string>): { | |||
| 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; | |||
| } | |||
| @@ -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( | |||
| @@ -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 | |||
| @@ -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; | |||
| @@ -875,7 +875,13 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| <TableCell align="left" sx={{ width: '75px' }}>{t("status")}</TableCell> | |||
| {/* {renderFieldCondition(FIRST_IN_FIELD) ? <TableCell align="right">{t("receivedQty")}</TableCell> : undefined} */} | |||
| <TableCell align="center" sx={{ width: '150px' }}>{t("productLotNo")}</TableCell> | |||
| {renderFieldCondition(SECOND_IN_FIELD) ? <TableCell align="center" sx={{ width: '150px' }}>{t("dnQty")}<br/>(以訂單單位計算)</TableCell> : undefined} | |||
| {renderFieldCondition(SECOND_IN_FIELD) ? ( | |||
| <TableCell align="center" sx={{ width: 360, minWidth: 360 }}> | |||
| {t("dnQty")} | |||
| <br /> | |||
| ({t("dnQtyOrderUnitHint")}) | |||
| </TableCell> | |||
| ) : undefined} | |||
| <TableCell align="center" sx={{ width: '100px' }}></TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| @@ -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 ( | |||
| <TableRow | |||
| @@ -243,7 +275,19 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||
| > | |||
| {row.itemName} | |||
| </TableCell> | |||
| <TableCell align="right">{integerFormatter.format(row.qty)}</TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ | |||
| fontWeight: 800, | |||
| fontVariantNumeric: "tabular-nums", | |||
| whiteSpace: "nowrap", | |||
| fontSize: "1.05rem", | |||
| color: "text.primary", | |||
| }} | |||
| title={row.uom?.udfudesc || uomShort || undefined} | |||
| > | |||
| {orderQtyWithUnit} | |||
| </TableCell> | |||
| <TableCell align="right">{integerFormatter.format(row.processed)}</TableCell> | |||
| <TableCell align="left">{row.uom?.udfudesc}</TableCell> | |||
| <TableCell sx={{ color: highlightColor }} align="right"> | |||
| @@ -268,25 +312,107 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||
| /> | |||
| </TableCell> | |||
| {showDnQty ? ( | |||
| <TableCell align="center"> | |||
| <TextField | |||
| id={`dnQty-${row.id}`} | |||
| label="此批來貨數量" | |||
| type="text" | |||
| variant="outlined" | |||
| value={dnQtyInput} | |||
| onChange={(e) => setDnQtyInput(e.target.value.replace(/[^\d]/g, ""))} | |||
| onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} | |||
| <TableCell align="center" sx={{ py: 1, overflow: "visible" }}> | |||
| <Stack | |||
| direction="row" | |||
| spacing={1.25} | |||
| alignItems="center" | |||
| justifyContent="center" | |||
| flexWrap="nowrap" | |||
| sx={{ width: "max-content" }} | |||
| onClick={(e) => e.stopPropagation()} | |||
| InputProps={{ | |||
| inputProps: { | |||
| min: 1, | |||
| step: 1, | |||
| inputMode: "numeric", | |||
| pattern: "[0-9]*", | |||
| }, | |||
| }} | |||
| /> | |||
| > | |||
| <TextField | |||
| id={`dnQty-${row.id}`} | |||
| label={t("dnQty")} | |||
| type="text" | |||
| variant="outlined" | |||
| value={dnQtyInput} | |||
| onChange={(e) => 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 ? ( | |||
| <InputAdornment position="end" sx={{ ml: 0.5 }}> | |||
| <Typography | |||
| component="span" | |||
| sx={{ | |||
| fontWeight: 800, | |||
| fontSize: "1.05rem", | |||
| color: "primary.main", | |||
| lineHeight: 1, | |||
| }} | |||
| > | |||
| {uomShort} | |||
| </Typography> | |||
| </InputAdornment> | |||
| ) : undefined, | |||
| inputProps: { | |||
| min: 1, | |||
| step: 1, | |||
| inputMode: "numeric", | |||
| pattern: "[0-9]*", | |||
| "aria-label": `${t("dnQty")} ${uomShort}`.trim(), | |||
| }, | |||
| }} | |||
| /> | |||
| {showStockConversion && Number.isFinite(convertedStockQty) && convertedStockQty > 0 ? ( | |||
| <Box | |||
| sx={{ | |||
| flexShrink: 0, | |||
| width: "max-content", | |||
| boxSizing: "border-box", | |||
| px: 1.25, | |||
| py: 0.75, | |||
| borderRadius: 1, | |||
| bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1), | |||
| border: 1, | |||
| borderColor: "primary.main", | |||
| textAlign: "center", | |||
| }} | |||
| title={row.stockUom?.stockUomDesc || convertedStockQtyWithUnit} | |||
| > | |||
| <Typography | |||
| component="div" | |||
| sx={{ | |||
| fontSize: "0.7rem", | |||
| lineHeight: 1.2, | |||
| color: "text.secondary", | |||
| fontWeight: 600, | |||
| }} | |||
| > | |||
| {t("stockQtyRef")} | |||
| </Typography> | |||
| <Typography | |||
| component="div" | |||
| sx={{ | |||
| fontWeight: 800, | |||
| fontSize: "1rem", | |||
| lineHeight: 1.3, | |||
| color: "primary.main", | |||
| fontVariantNumeric: "tabular-nums", | |||
| whiteSpace: "nowrap", | |||
| px: 0.25, | |||
| }} | |||
| > | |||
| {convertedStockQtyWithUnit} | |||
| </Typography> | |||
| </Box> | |||
| ) : null} | |||
| </Stack> | |||
| </TableCell> | |||
| ) : null} | |||
| <TableCell align="center"> | |||
| @@ -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); | |||
| }, | |||
| }, | |||
| { | |||
| @@ -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 | null | undefined>): 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; | |||
| } | |||
| @@ -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 { | |||
| @@ -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, | |||
| }, | |||
| ] | |||
| }, | |||
| @@ -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", | |||
| @@ -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": { | |||
| @@ -126,6 +126,8 @@ | |||
| "Please scan warehouse qr code.": "請掃描倉庫 QR 碼。", | |||
| "receivedQty": "已來貨數量", | |||
| "dnQty": "本批來貨數量", | |||
| "dnQtyOrderUnitHint": "以訂單單位計算", | |||
| "stockQtyRef": "庫存", | |||
| "Accept submit": "接受來貨", | |||
| "qc processing": "處理來貨及品檢", | |||
| "putaway processing": "處理來貨及上架", | |||
| @@ -122,7 +122,11 @@ | |||
| "title": "庫存結餘報告", | |||
| "fields": { | |||
| "stockDate": "庫存日期", | |||
| "itemCode": "貨品編號" | |||
| "itemCode": "貨品編號", | |||
| "itemCodePaste": "貼上貨品編號" | |||
| }, | |||
| "fieldHints": { | |||
| "itemCodePaste": "可從 Excel 貼上多個貨品編號,以空格、逗號或換行分隔" | |||
| } | |||
| }, | |||
| "rep-014": { | |||