| @@ -54,6 +54,7 @@ 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; | ||||
| @@ -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"]); | ||||
| @@ -186,6 +195,27 @@ 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, | |||||
| ); | |||||
| const convertedStockQtyWithUnit = formatQtyWithStockUom( | |||||
| convertedStockQty, | |||||
| row.stockUom, | |||||
| ); | |||||
| return ( | return ( | ||||
| <TableRow | <TableRow | ||||
| @@ -243,7 +273,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 +310,95 @@ 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: 132, width: 148, flexShrink: 0 }} | |||||
| InputProps={{ | |||||
| endAdornment: uomShort ? ( | |||||
| <InputAdornment position="end"> | |||||
| <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; | |||||
| } | |||||
| @@ -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", | ||||
| @@ -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": "處理來貨及上架", | ||||