| @@ -26,6 +26,7 @@ import { | |||||
| import DownloadIcon from '@mui/icons-material/Download'; | import DownloadIcon from '@mui/icons-material/Download'; | ||||
| import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | ||||
| import { REPORTS } from '@/config/reportConfig'; | import { REPORTS } from '@/config/reportConfig'; | ||||
| import { mergePastedItemCodes, buildStockBalanceRequestBody } from './parseItemCodeTokens'; | |||||
| import { NEXT_PUBLIC_API_URL } from '@/config/api'; | import { NEXT_PUBLIC_API_URL } from '@/config/api'; | ||||
| import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | ||||
| import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; | 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 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ | ||||
| export default function ReportPage() { | export default function ReportPage() { | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | 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 isZh = (i18n.language || 'zh').startsWith('zh'); | ||||
| const dateDisplayFormat = isZh ? 'DD/MM/YYYY' : 'DD/MM/YYYY'; | const dateDisplayFormat = isZh ? 'DD/MM/YYYY' : 'DD/MM/YYYY'; | ||||
| const includeGrnFinancialColumns = | const includeGrnFinancialColumns = | ||||
| @@ -358,6 +359,22 @@ export default function ReportPage() { | |||||
| return p.toString(); | 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 () => { | const handlePrint = async () => { | ||||
| if (!currentReport) return; | if (!currentReport) return; | ||||
| if (!validateRequiredFields()) return; | if (!validateRequiredFields()) return; | ||||
| @@ -399,28 +416,24 @@ export default function ReportPage() { | |||||
| await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t); | await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t); | ||||
| } else { | } else { | ||||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | // 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 === 401 || response.status === 403) return; | ||||
| if (response.status === 204) { | if (response.status === 204) { | ||||
| @@ -472,18 +485,24 @@ export default function ReportPage() { | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | 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.status === 401 || response.status === 403) return; | ||||
| if (!response.ok) { | if (!response.ok) { | ||||
| @@ -574,7 +593,10 @@ export default function ReportPage() { | |||||
| : currentValue; | : currentValue; | ||||
| // Use larger grid size for 成品/半成品生產分析報告 | // 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) => { | const disabledByCheckedCheckbox = currentReport.fields.some((f) => { | ||||
| if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false; | if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false; | ||||
| @@ -762,9 +784,11 @@ export default function ReportPage() { | |||||
| fullWidth | fullWidth | ||||
| required={field.required} | required={field.required} | ||||
| error={Boolean(fieldErrors[field.name])} | error={Boolean(fieldErrors[field.name])} | ||||
| helperText={fieldErrors[field.name] || undefined} | |||||
| helperText={fieldErrors[field.name] || hintText || undefined} | |||||
| label={translatedLabel} | label={translatedLabel} | ||||
| type={field.type} | |||||
| type={field.multiline ? 'text' : field.type} | |||||
| multiline={field.multiline} | |||||
| minRows={field.multiline ? (field.minRows ?? 4) : undefined} | |||||
| placeholder={field.placeholder} | placeholder={field.placeholder} | ||||
| disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} | disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} | ||||
| sx={{ | 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, | 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 = ( | const optionLabel = ( | ||||
| reportId: string, | reportId: string, | ||||
| fieldName: string, | fieldName: string, | ||||
| @@ -35,7 +41,7 @@ export function useReportLabels() { | |||||
| const categoryTitle = (id: string, fallback: string) => | const categoryTitle = (id: string, fallback: string) => | ||||
| t(`categories.${id}`, { defaultValue: fallback }); | t(`categories.${id}`, { defaultValue: fallback }); | ||||
| return { t, i18n, reportTitle, fieldLabel, optionLabel, categoryTitle }; | |||||
| return { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel, categoryTitle }; | |||||
| } | } | ||||
| export function reportExcelT( | export function reportExcelT( | ||||
| @@ -54,11 +54,15 @@ export interface StockUomForPoLine { | |||||
| id: number; | id: number; | ||||
| stockUomCode: string; | stockUomCode: string; | ||||
| stockUomDesc: string; | stockUomDesc: string; | ||||
| stockUomShortDesc?: string | null; | |||||
| stockQty: number; | stockQty: number; | ||||
| stockRatioN: number; | stockRatioN: number; | ||||
| stockRatioD: number; | stockRatioD: number; | ||||
| purchaseRatioN: number; | purchaseRatioN: number; | ||||
| purchaseRatioD: number; | purchaseRatioD: number; | ||||
| sourceRatioN?: number | null; | |||||
| sourceRatioD?: number | null; | |||||
| stockQtyInteger?: boolean | null; | |||||
| } | } | ||||
| // DEPRECIATED | // DEPRECIATED | ||||
| @@ -9,6 +9,8 @@ export interface Uom { | |||||
| id: number; | id: number; | ||||
| code: string; | code: string; | ||||
| udfudesc: string; | udfudesc: string; | ||||
| /** Short UoM label from M18 (`udfShortDesc`), e.g. 箱. */ | |||||
| udfShortDesc?: string | null; | |||||
| unit1: string; | unit1: string; | ||||
| unit1Qty: number; | unit1Qty: number; | ||||
| unit2?: string; | unit2?: string; | ||||
| @@ -875,7 +875,13 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| <TableCell align="left" sx={{ width: '75px' }}>{t("status")}</TableCell> | <TableCell align="left" sx={{ width: '75px' }}>{t("status")}</TableCell> | ||||
| {/* {renderFieldCondition(FIRST_IN_FIELD) ? <TableCell align="right">{t("receivedQty")}</TableCell> : undefined} */} | {/* {renderFieldCondition(FIRST_IN_FIELD) ? <TableCell align="right">{t("receivedQty")}</TableCell> : undefined} */} | ||||
| <TableCell align="center" sx={{ width: '150px' }}>{t("productLotNo")}</TableCell> | <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> | <TableCell align="center" sx={{ width: '100px' }}></TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| @@ -4,10 +4,13 @@ import { PurchaseOrderLine } from "@/app/api/po"; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| InputAdornment, | |||||
| Radio, | Radio, | ||||
| Stack, | |||||
| TableCell, | TableCell, | ||||
| TableRow, | TableRow, | ||||
| TextField, | TextField, | ||||
| Typography, | |||||
| alpha, | alpha, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { memo, useCallback, useEffect, useRef, useState } from "react"; | 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 { submitDialogWithWarning } from "../Swal/CustomAlerts"; | ||||
| import { createStockInLine } from "@/app/api/stockIn/actions"; | import { createStockInLine } from "@/app/api/stockIn/actions"; | ||||
| import { previewPoBatchStockQty } from "./stockQtyRound"; | import { previewPoBatchStockQty } from "./stockQtyRound"; | ||||
| import { | |||||
| formatQtyWithPurchaseUom, | |||||
| formatQtyWithStockUom, | |||||
| poLineNeedsStockQtyConversion, | |||||
| purchaseUomShortDesc, | |||||
| } from "./poPurchaseUom"; | |||||
| const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); | const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); | ||||
| @@ -108,6 +117,7 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| orderQty, | orderQty, | ||||
| Number(row.stockUom?.stockQty ?? 0), | Number(row.stockUom?.stockQty ?? 0), | ||||
| acceptedQty, | acceptedQty, | ||||
| row.stockUom, | |||||
| ); | ); | ||||
| const doSubmit = () => { | const doSubmit = () => { | ||||
| @@ -186,6 +196,28 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; | Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; | ||||
| const needsStockInAttention = | const needsStockInAttention = | ||||
| canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); | 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 ( | return ( | ||||
| <TableRow | <TableRow | ||||
| @@ -243,7 +275,19 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| > | > | ||||
| {row.itemName} | {row.itemName} | ||||
| </TableCell> | </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="right">{integerFormatter.format(row.processed)}</TableCell> | ||||
| <TableCell align="left">{row.uom?.udfudesc}</TableCell> | <TableCell align="left">{row.uom?.udfudesc}</TableCell> | ||||
| <TableCell sx={{ color: highlightColor }} align="right"> | <TableCell sx={{ color: highlightColor }} align="right"> | ||||
| @@ -268,25 +312,107 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| {showDnQty ? ( | {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()} | 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> | </TableCell> | ||||
| ) : null} | ) : null} | ||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| @@ -61,7 +61,7 @@ import DoDisturbIcon from "@mui/icons-material/DoDisturb"; | |||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| // import { SessionWithTokens } from "src/config/authConfig"; | // import { SessionWithTokens } from "src/config/authConfig"; | ||||
| import QcStockInModal from "../Qc/QcStockInModal"; | 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 { PrinterCombo } from "@/app/api/settings/printer"; | ||||
| import { EscalationResult } from "@/app/api/escalation"; | import { EscalationResult } from "@/app/api/escalation"; | ||||
| import { fetchEscalationLogsByStockInLines } from "@/app/api/escalation/actions"; | import { fetchEscalationLogsByStockInLines } from "@/app/api/escalation/actions"; | ||||
| @@ -70,10 +70,11 @@ import { EscalationCombo } from "@/app/api/user"; | |||||
| import { deleteDialog } from "../Swal/CustomAlerts"; | import { deleteDialog } from "../Swal/CustomAlerts"; | ||||
| import StockInLineRowActions from "./StockInLineRowActions"; | import StockInLineRowActions from "./StockInLineRowActions"; | ||||
| import { StockQtyRoundMode, needsPoQcStockQtyRound } from "./stockQtyRound"; | import { StockQtyRoundMode, needsPoQcStockQtyRound } from "./stockQtyRound"; | ||||
| import { formatQtyWithPurchaseUom } from "./poPurchaseUom"; | |||||
| // 3 buttons after QC (view + print QR + delete): 176*3 + gap + cell padding | // 3 buttons after QC (view + print QR + delete): 176*3 + gap + cell padding | ||||
| const ACTIONS_COLUMN_WIDTH = 580; | const ACTIONS_COLUMN_WIDTH = 580; | ||||
| const PURCHASE_QTY_COLUMN_WIDTH = 72; | |||||
| const PURCHASE_QTY_COLUMN_WIDTH = 96; | |||||
| const UOM_COLUMN_WIDTH = 124; | const UOM_COLUMN_WIDTH = 124; | ||||
| const STOCK_QTY_COLUMN_WIDTH = 110; | const STOCK_QTY_COLUMN_WIDTH = 110; | ||||
| const STOCK_IN_ROW_HEIGHT = 58; | const STOCK_IN_ROW_HEIGHT = 58; | ||||
| @@ -719,7 +720,7 @@ function PoInputGrid({ | |||||
| type: "number", | type: "number", | ||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| const qty = params.row.purchaseAcceptedQty ?? 0; | 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; | 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( | export function previewPoBatchStockQty( | ||||
| orderM18Qty: number, | orderM18Qty: number, | ||||
| orderStockQty: number, | orderStockQty: number, | ||||
| batchM18Qty: number, | batchM18Qty: number, | ||||
| hint?: PoStockQtyConvertHint, | |||||
| ): number { | ): number { | ||||
| const fromRatios = convertPoBatchToStockQty(batchM18Qty, hint); | |||||
| if (fromRatios != null) return fromRatios; | |||||
| if (!Number.isFinite(orderM18Qty) || orderM18Qty === 0) { | 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 { | export function isNotIntegerQty(qty: number): boolean { | ||||
| @@ -25,6 +25,10 @@ export interface ReportField { | |||||
| minDate?: 'today'; | minDate?: 'today'; | ||||
| /** Disable the input (e.g. date locked to today) */ | /** Disable the input (e.g. date locked to today) */ | ||||
| disabled?: boolean; | 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'; | export type ReportResponseType = 'pdf' | 'excel'; | ||||
| @@ -340,6 +344,14 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| fields: [ | fields: [ | ||||
| { label: "庫存日期: Stock Date", name: "stockDate", type: "date", required: true }, | { label: "庫存日期: Stock Date", name: "stockDate", type: "date", required: true }, | ||||
| asyncItemCodeField(), | 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.", | "Please scan warehouse qr code.": "Please scan warehouse QR code.", | ||||
| "receivedQty": "Received Qty", | "receivedQty": "Received Qty", | ||||
| "dnQty": "Delivery Qty (This Batch)", | "dnQty": "Delivery Qty (This Batch)", | ||||
| "dnQtyOrderUnitHint": "in order unit", | |||||
| "stockQtyRef": "Stock", | |||||
| "Accept submit": "Accept Delivery", | "Accept submit": "Accept Delivery", | ||||
| "qc processing": "Delivery & QC Processing", | "qc processing": "Delivery & QC Processing", | ||||
| "putaway processing": "Delivery & Put Away Processing", | "putaway processing": "Delivery & Put Away Processing", | ||||
| @@ -122,7 +122,11 @@ | |||||
| "title": "Stock Balance Report", | "title": "Stock Balance Report", | ||||
| "fields": { | "fields": { | ||||
| "stockDate": "Stock Date", | "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": { | "rep-014": { | ||||
| @@ -126,6 +126,8 @@ | |||||
| "Please scan warehouse qr code.": "請掃描倉庫 QR 碼。", | "Please scan warehouse qr code.": "請掃描倉庫 QR 碼。", | ||||
| "receivedQty": "已來貨數量", | "receivedQty": "已來貨數量", | ||||
| "dnQty": "本批來貨數量", | "dnQty": "本批來貨數量", | ||||
| "dnQtyOrderUnitHint": "以訂單單位計算", | |||||
| "stockQtyRef": "庫存", | |||||
| "Accept submit": "接受來貨", | "Accept submit": "接受來貨", | ||||
| "qc processing": "處理來貨及品檢", | "qc processing": "處理來貨及品檢", | ||||
| "putaway processing": "處理來貨及上架", | "putaway processing": "處理來貨及上架", | ||||
| @@ -122,7 +122,11 @@ | |||||
| "title": "庫存結餘報告", | "title": "庫存結餘報告", | ||||
| "fields": { | "fields": { | ||||
| "stockDate": "庫存日期", | "stockDate": "庫存日期", | ||||
| "itemCode": "貨品編號" | |||||
| "itemCode": "貨品編號", | |||||
| "itemCodePaste": "貼上貨品編號" | |||||
| }, | |||||
| "fieldHints": { | |||||
| "itemCodePaste": "可從 Excel 貼上多個貨品編號,以空格、逗號或換行分隔" | |||||
| } | } | ||||
| }, | }, | ||||
| "rep-014": { | "rep-014": { | ||||