Переглянути джерело

Merge branch 'production' into fix負數倉

do_workbench_fix
Harry Groves 4 дні тому
джерело
коміт
f429f9c288
15 змінених файлів з 482 додано та 67 видалено
  1. +62
    -38
      src/app/(main)/report/page.tsx
  2. +47
    -0
      src/app/(main)/report/parseItemCodeTokens.ts
  3. +7
    -1
      src/app/(main)/report/reportI18n.ts
  4. +4
    -0
      src/app/api/po/index.ts
  5. +2
    -0
      src/app/api/settings/uom/index.ts
  6. +7
    -1
      src/components/PoDetail/PoDetail.tsx
  7. +145
    -19
      src/components/PoDetail/PoDetailRow.tsx
  8. +4
    -3
      src/components/PoDetail/PoInputGrid.tsx
  9. +96
    -0
      src/components/PoDetail/poPurchaseUom.ts
  10. +82
    -3
      src/components/PoDetail/stockQtyRound.ts
  11. +12
    -0
      src/config/reportConfig.ts
  12. +2
    -0
      src/i18n/en/purchaseOrder.json
  13. +5
    -1
      src/i18n/en/report.json
  14. +2
    -0
      src/i18n/zh/purchaseOrder.json
  15. +5
    -1
      src/i18n/zh/report.json

+ 62
- 38
src/app/(main)/report/page.tsx Переглянути файл

@@ -26,6 +26,7 @@ import {
import DownloadIcon from '@mui/icons-material/Download';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { REPORTS } from '@/config/reportConfig';
import { mergePastedItemCodes, buildStockBalanceRequestBody } from './parseItemCodeTokens';
import { NEXT_PUBLIC_API_URL } from '@/config/api';
import { clientAuthFetch } from '@/app/utils/clientAuthFetch';
import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport';
@@ -80,7 +81,7 @@ const FIELD_ERROR_SX = {
/** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */
export default function ReportPage() {
const { data: session } = useSession() as { data: SessionWithTokens | null };
const { t, i18n, reportTitle, fieldLabel, optionLabel } = useReportLabels();
const { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel } = useReportLabels();
const isZh = (i18n.language || 'zh').startsWith('zh');
const dateDisplayFormat = isZh ? 'DD/MM/YYYY' : 'DD/MM/YYYY';
const includeGrnFinancialColumns =
@@ -358,6 +359,22 @@ export default function ReportPage() {
return p.toString();
};

const buildCriteriaQueryString = (): string => {
if (!currentReport) return '';
if (currentReport.id === 'rep-012') return buildRep012QueryString();
if (currentReport.id === 'rep-010') return buildRep010QueryString();
const merged = mergePastedItemCodes(criteria);
const p = new URLSearchParams(merged);
if (currentReport.id === 'rep-016') {
const day = (merged.dateStart || '').trim();
if (day) {
p.set('dateStart', day);
p.set('dateEnd', day);
}
}
return p.toString();
};

const handlePrint = async () => {
if (!currentReport) return;
if (!validateRequiredFields()) return;
@@ -399,28 +416,24 @@ export default function ReportPage() {
await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t);
} else {
// Backend returns actual .xlsx bytes for this Excel endpoint.
let queryParams =
currentReport.id === 'rep-012'
? buildRep012QueryString()
: currentReport.id === 'rep-010'
? buildRep010QueryString()
: new URLSearchParams(criteria).toString();
// rep-016: single-day UI — mirror dateStart to dateEnd for backend API.
if (currentReport.id === 'rep-016') {
const p = new URLSearchParams(criteria);
const day = (criteria.dateStart || '').trim();
if (day) {
p.set('dateStart', day);
p.set('dateEnd', day);
}
queryParams = p.toString();
}
const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`;

const response = await clientAuthFetch(excelUrl, {
method: 'GET',
headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
});
const isStockBalance = currentReport.id === 'rep-007';
const excelUrl = isStockBalance
? `${currentReport.apiEndpoint}-excel`
: `${currentReport.apiEndpoint}-excel?${buildCriteriaQueryString()}`;

const response = await clientAuthFetch(excelUrl, isStockBalance
? {
method: 'POST',
headers: {
Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Type': 'application/json',
},
body: JSON.stringify(buildStockBalanceRequestBody(criteria)),
}
: {
method: 'GET',
headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
});

if (response.status === 401 || response.status === 403) return;
if (response.status === 204) {
@@ -472,18 +485,24 @@ export default function ReportPage() {

setLoading(true);
try {
let queryParams =
currentReport.id === 'rep-012'
? buildRep012QueryString()
: currentReport.id === 'rep-010'
? buildRep010QueryString()
: new URLSearchParams(criteria).toString();
const url = `${currentReport.apiEndpoint}?${queryParams}`;

const response = await clientAuthFetch(url, {
method: 'GET',
headers: { 'Accept': 'application/pdf' },
});
const isStockBalance = currentReport.id === 'rep-007';
const url = isStockBalance
? currentReport.apiEndpoint
: `${currentReport.apiEndpoint}?${buildCriteriaQueryString()}`;

const response = await clientAuthFetch(url, isStockBalance
? {
method: 'POST',
headers: {
Accept: 'application/pdf',
'Content-Type': 'application/json',
},
body: JSON.stringify(buildStockBalanceRequestBody(criteria)),
}
: {
method: 'GET',
headers: { Accept: 'application/pdf' },
});

if (response.status === 401 || response.status === 403) return;
if (!response.ok) {
@@ -574,7 +593,10 @@ export default function ReportPage() {
: currentValue;

// Use larger grid size for 成品/半成品生產分析報告
const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 };
const gridSize = field.multiline
? { xs: 12 }
: currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 };
const hintText = fieldHint(currentReport.id, field.name);

const disabledByCheckedCheckbox = currentReport.fields.some((f) => {
if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false;
@@ -762,9 +784,11 @@ export default function ReportPage() {
fullWidth
required={field.required}
error={Boolean(fieldErrors[field.name])}
helperText={fieldErrors[field.name] || undefined}
helperText={fieldErrors[field.name] || hintText || undefined}
label={translatedLabel}
type={field.type}
type={field.multiline ? 'text' : field.type}
multiline={field.multiline}
minRows={field.multiline ? (field.minRows ?? 4) : undefined}
placeholder={field.placeholder}
disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled}
sx={{


+ 47
- 0
src/app/(main)/report/parseItemCodeTokens.ts Переглянути файл

@@ -0,0 +1,47 @@
/** Split pasted / typed item codes from Excel or a text field. */
const ITEM_CODE_SEPARATORS = /[\s,;,、\u00A0\u3000]+/;

export function parseItemCodeTokens(raw: string | undefined | null): string[] {
if (!raw) return [];
const seen = new Set<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;
}

+ 7
- 1
src/app/(main)/report/reportI18n.ts Переглянути файл

@@ -19,6 +19,12 @@ export function useReportLabels() {
defaultValue: field.label,
});

const fieldHint = (reportId: string, fieldName: string, fallback = "") => {
const key = `reports.${reportId}.fieldHints.${fieldName}`;
if (!i18n.exists(key, { ns: "report" })) return fallback;
return String(t(key, { defaultValue: fallback }));
};

const optionLabel = (
reportId: string,
fieldName: string,
@@ -35,7 +41,7 @@ export function useReportLabels() {
const categoryTitle = (id: string, fallback: string) =>
t(`categories.${id}`, { defaultValue: fallback });

return { t, i18n, reportTitle, fieldLabel, optionLabel, categoryTitle };
return { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel, categoryTitle };
}

export function reportExcelT(


+ 4
- 0
src/app/api/po/index.ts Переглянути файл

@@ -54,11 +54,15 @@ export interface StockUomForPoLine {
id: number;
stockUomCode: string;
stockUomDesc: string;
stockUomShortDesc?: string | null;
stockQty: number;
stockRatioN: number;
stockRatioD: number;
purchaseRatioN: number;
purchaseRatioD: number;
sourceRatioN?: number | null;
sourceRatioD?: number | null;
stockQtyInteger?: boolean | null;
}

// DEPRECIATED


+ 2
- 0
src/app/api/settings/uom/index.ts Переглянути файл

@@ -9,6 +9,8 @@ export interface Uom {
id: number;
code: string;
udfudesc: string;
/** Short UoM label from M18 (`udfShortDesc`), e.g. 箱. */
udfShortDesc?: string | null;
unit1: string;
unit1Qty: number;
unit2?: string;


+ 7
- 1
src/components/PoDetail/PoDetail.tsx Переглянути файл

@@ -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>


+ 145
- 19
src/components/PoDetail/PoDetailRow.tsx Переглянути файл

@@ -4,10 +4,13 @@ import { PurchaseOrderLine } from "@/app/api/po";
import {
Box,
Button,
InputAdornment,
Radio,
Stack,
TableCell,
TableRow,
TextField,
Typography,
alpha,
} from "@mui/material";
import { memo, useCallback, useEffect, useRef, useState } from "react";
@@ -16,6 +19,12 @@ import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil";
import { submitDialogWithWarning } from "../Swal/CustomAlerts";
import { createStockInLine } from "@/app/api/stockIn/actions";
import { previewPoBatchStockQty } from "./stockQtyRound";
import {
formatQtyWithPurchaseUom,
formatQtyWithStockUom,
poLineNeedsStockQtyConversion,
purchaseUomShortDesc,
} from "./poPurchaseUom";

const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]);

@@ -108,6 +117,7 @@ export const PoDetailRow = memo(function PoDetailRow({
orderQty,
Number(row.stockUom?.stockQty ?? 0),
acceptedQty,
row.stockUom,
);

const doSubmit = () => {
@@ -186,6 +196,28 @@ export const PoDetailRow = memo(function PoDetailRow({
Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit";
const needsStockInAttention =
canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row);
const uomShort = purchaseUomShortDesc(row.uom);
const orderQtyWithUnit = formatQtyWithPurchaseUom(row.qty, row.uom);
const enteredBatchQty = Number(dnQtyInput.trim());
const hasEnteredBatchQty = Number.isInteger(enteredBatchQty) && enteredBatchQty > 0;
const showStockConversion =
hasEnteredBatchQty &&
poLineNeedsStockQtyConversion(
Number(row.qty ?? 0),
Number(row.stockUom?.stockQty ?? 0),
row.uom,
row.stockUom,
);
const convertedStockQty = previewPoBatchStockQty(
Number(row.qty ?? 0),
Number(row.stockUom?.stockQty ?? 0),
enteredBatchQty,
row.stockUom,
);
const convertedStockQtyWithUnit = formatQtyWithStockUom(
convertedStockQty,
row.stockUom,
);

return (
<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">


+ 4
- 3
src/components/PoDetail/PoInputGrid.tsx Переглянути файл

@@ -61,7 +61,7 @@ import DoDisturbIcon from "@mui/icons-material/DoDisturb";
import { useSession } from "next-auth/react";
// import { SessionWithTokens } from "src/config/authConfig";
import QcStockInModal from "../Qc/QcStockInModal";
import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil";
import { decimalFormatter } from "@/app/utils/formatUtil";
import { PrinterCombo } from "@/app/api/settings/printer";
import { EscalationResult } from "@/app/api/escalation";
import { fetchEscalationLogsByStockInLines } from "@/app/api/escalation/actions";
@@ -70,10 +70,11 @@ import { EscalationCombo } from "@/app/api/user";
import { deleteDialog } from "../Swal/CustomAlerts";
import StockInLineRowActions from "./StockInLineRowActions";
import { StockQtyRoundMode, needsPoQcStockQtyRound } from "./stockQtyRound";
import { formatQtyWithPurchaseUom } from "./poPurchaseUom";

// 3 buttons after QC (view + print QR + delete): 176*3 + gap + cell padding
const ACTIONS_COLUMN_WIDTH = 580;
const PURCHASE_QTY_COLUMN_WIDTH = 72;
const PURCHASE_QTY_COLUMN_WIDTH = 96;
const UOM_COLUMN_WIDTH = 124;
const STOCK_QTY_COLUMN_WIDTH = 110;
const STOCK_IN_ROW_HEIGHT = 58;
@@ -719,7 +720,7 @@ function PoInputGrid({
type: "number",
renderCell: (params) => {
const qty = params.row.purchaseAcceptedQty ?? 0;
return integerFormatter.format(qty);
return formatQtyWithPurchaseUom(qty, itemDetail.uom);
},
},
{


+ 96
- 0
src/components/PoDetail/poPurchaseUom.ts Переглянути файл

@@ -0,0 +1,96 @@
import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil";

type PurchaseUomLike = {
code?: string | null;
udfudesc?: string | null;
udfShortDesc?: string | null;
} | null | undefined;

type StockUomLike = {
stockUomCode?: string | null;
stockUomDesc?: string | null;
stockUomShortDesc?: string | null;
} | null | undefined;

function isCompactUnitLabel(value: string): boolean {
return value.length > 0 && value.length <= 6 && !/[x×]/i.test(value);
}

function compactUomLabel(
shortDesc?: string | null,
fullDesc?: string | null,
code?: string | null,
): string {
const short = shortDesc?.trim();
if (short) return short;
const full = fullDesc?.trim() ?? "";
if (isCompactUnitLabel(full)) return full;
const unitCode = code?.trim() ?? "";
if (isCompactUnitLabel(unitCode)) return unitCode;
return "";
}

function normUnitKey(...parts: Array<string | 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;
}

+ 82
- 3
src/components/PoDetail/stockQtyRound.ts Переглянути файл

@@ -7,16 +7,95 @@ export type StockQtyRoundChoice = {
after: number;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */
/** Keep in sync with ItemUomService.isIntegerCountStockUom */
const INTEGER_COUNT_UNIT_TOKENS = new Set([
"包",
"箱",
"件",
"個",
"PCS",
"PC",
"CTN",
"EA",
"PK",
"PKT",
"BOX",
]);

export type PoStockQtyConvertHint = {
sourceRatioN?: number | null;
sourceRatioD?: number | null;
stockRatioN?: number | null;
stockRatioD?: number | null;
purchaseRatioN?: number | null;
purchaseRatioD?: number | null;
stockUomCode?: string | null;
stockUomShortDesc?: string | null;
stockQtyInteger?: boolean | null;
};

function toRatio(value: number | null | undefined): number | null {
const n = Number(value);
if (!Number.isFinite(n) || n === 0) return null;
return n;
}

function stockQtyMustBeInteger(hint?: PoStockQtyConvertHint): boolean {
if (hint?.stockQtyInteger === true) return true;
if (hint?.stockQtyInteger === false) return false;
const code = hint?.stockUomCode?.trim() ?? "";
if (code.toUpperCase().startsWith("PCS/CTN")) return true;
const short = hint?.stockUomShortDesc?.trim() ?? "";
return INTEGER_COUNT_UNIT_TOKENS.has(short) || INTEGER_COUNT_UNIT_TOKENS.has(code.toUpperCase());
}

/** Java BigDecimal HALF_UP for non-negative qty. */
function roundHalfUp(value: number, scale: number): number {
const factor = 10 ** scale;
return Math.round(value * factor) / factor;
}

function finalizePoStockQty(raw: number, hint?: PoStockQtyConvertHint): number {
if (!Number.isFinite(raw)) return 0;
return stockQtyMustBeInteger(hint) ? roundHalfUp(raw, 0) : roundHalfUp(raw, 2);
}

/**
* Same ratio path as backend convertQtyToStockQtyPrecise:
* base = qty * sourceRatioN / sourceRatioD
* stock = base * stockRatioD / stockRatioN
*/
function convertPoBatchToStockQty(
batchM18Qty: number,
hint?: PoStockQtyConvertHint,
): number | null {
if (!hint || !Number.isFinite(batchM18Qty)) return null;
const sourceN = toRatio(hint.sourceRatioN) ?? toRatio(hint.purchaseRatioN);
const sourceD = toRatio(hint.sourceRatioD) ?? toRatio(hint.purchaseRatioD);
const stockN = toRatio(hint.stockRatioN);
const stockD = toRatio(hint.stockRatioD);
if (sourceN == null || sourceD == null || stockN == null || stockD == null) return null;
const baseQty = (batchM18Qty * sourceN) / sourceD;
const stockQty = (baseQty * stockD) / stockN;
return finalizePoStockQty(stockQty, hint);
}

/**
* Preview converted stock qty for a 來貨數 batch.
* Matches create-stock-in: ratio convert, then integer HALF_UP for 包/箱/PCS, else 2 decimals.
*/
export function previewPoBatchStockQty(
orderM18Qty: number,
orderStockQty: number,
batchM18Qty: number,
hint?: PoStockQtyConvertHint,
): number {
const fromRatios = convertPoBatchToStockQty(batchM18Qty, hint);
if (fromRatios != null) return fromRatios;
if (!Number.isFinite(orderM18Qty) || orderM18Qty === 0) {
return Number(batchM18Qty.toFixed(2));
return finalizePoStockQty(batchM18Qty, hint);
}
return Number(((batchM18Qty * orderStockQty) / orderM18Qty).toFixed(2));
return finalizePoStockQty((batchM18Qty * orderStockQty) / orderM18Qty, hint);
}

export function isNotIntegerQty(qty: number): boolean {


+ 12
- 0
src/config/reportConfig.ts Переглянути файл

@@ -25,6 +25,10 @@ export interface ReportField {
minDate?: 'today';
/** Disable the input (e.g. date locked to today) */
disabled?: boolean;
/** Render a multiline text area (for pasting many values) */
multiline?: boolean;
/** Rows for multiline text areas. Default 4. */
minRows?: number;
}

export type ReportResponseType = 'pdf' | 'excel';
@@ -340,6 +344,14 @@ export const REPORTS: ReportDefinition[] = [
fields: [
{ label: "庫存日期: Stock Date", name: "stockDate", type: "date", required: true },
asyncItemCodeField(),
{
label: "貼上貨品編號 Paste Item Codes",
name: "itemCodePaste",
type: "text",
required: false,
multiline: true,
minRows: 4,
},
]
},


+ 2
- 0
src/i18n/en/purchaseOrder.json Переглянути файл

@@ -126,6 +126,8 @@
"Please scan warehouse qr code.": "Please scan warehouse QR code.",
"receivedQty": "Received Qty",
"dnQty": "Delivery Qty (This Batch)",
"dnQtyOrderUnitHint": "in order unit",
"stockQtyRef": "Stock",
"Accept submit": "Accept Delivery",
"qc processing": "Delivery & QC Processing",
"putaway processing": "Delivery & Put Away Processing",


+ 5
- 1
src/i18n/en/report.json Переглянути файл

@@ -122,7 +122,11 @@
"title": "Stock Balance Report",
"fields": {
"stockDate": "Stock Date",
"itemCode": "Item Code"
"itemCode": "Item Code",
"itemCodePaste": "Paste Item Codes"
},
"fieldHints": {
"itemCodePaste": "Paste item codes from Excel. Separate with spaces, commas, or new lines"
}
},
"rep-014": {


+ 2
- 0
src/i18n/zh/purchaseOrder.json Переглянути файл

@@ -126,6 +126,8 @@
"Please scan warehouse qr code.": "請掃描倉庫 QR 碼。",
"receivedQty": "已來貨數量",
"dnQty": "本批來貨數量",
"dnQtyOrderUnitHint": "以訂單單位計算",
"stockQtyRef": "庫存",
"Accept submit": "接受來貨",
"qc processing": "處理來貨及品檢",
"putaway processing": "處理來貨及上架",


+ 5
- 1
src/i18n/zh/report.json Переглянути файл

@@ -122,7 +122,11 @@
"title": "庫存結餘報告",
"fields": {
"stockDate": "庫存日期",
"itemCode": "貨品編號"
"itemCode": "貨品編號",
"itemCodePaste": "貼上貨品編號"
},
"fieldHints": {
"itemCodePaste": "可從 Excel 貼上多個貨品編號,以空格、逗號或換行分隔"
}
},
"rep-014": {


Завантаження…
Відмінити
Зберегти