| @@ -54,6 +54,7 @@ export interface StockUomForPoLine { | |||
| id: number; | |||
| stockUomCode: string; | |||
| stockUomDesc: string; | |||
| stockUomShortDesc?: string | null; | |||
| stockQty: number; | |||
| stockRatioN: number; | |||
| stockRatioD: number; | |||
| @@ -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"]); | |||
| @@ -186,6 +195,27 @@ 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, | |||
| ); | |||
| const convertedStockQtyWithUnit = formatQtyWithStockUom( | |||
| convertedStockQty, | |||
| row.stockUom, | |||
| ); | |||
| return ( | |||
| <TableRow | |||
| @@ -243,7 +273,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 +310,95 @@ 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: 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> | |||
| ) : 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; | |||
| } | |||
| @@ -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", | |||
| @@ -126,6 +126,8 @@ | |||
| "Please scan warehouse qr code.": "請掃描倉庫 QR 碼。", | |||
| "receivedQty": "已來貨數量", | |||
| "dnQty": "本批來貨數量", | |||
| "dnQtyOrderUnitHint": "以訂單單位計算", | |||
| "stockQtyRef": "庫存", | |||
| "Accept submit": "接受來貨", | |||
| "qc processing": "處理來貨及品檢", | |||
| "putaway processing": "處理來貨及上架", | |||