Просмотр исходного кода

Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1

# Conflicts:
#	src/app/api/bom/client.ts
#	src/app/api/bom/index.ts
#	src/components/Qc/QcStockInModal.tsx
reset-do-picking-order
CANCERYS\kw093 2 недель назад
Родитель
Сommit
6dc9687949
16 измененных файлов: 813 добавлений и 234 удалений
  1. +2
    -2
      src/app/(main)/settings/bomWeighting/page.tsx
  2. +9
    -0
      src/app/api/bom/client.ts
  3. +26
    -11
      src/app/api/bom/index.ts
  4. +16
    -0
      src/app/api/bom/recalculateClient.ts
  5. +82
    -0
      src/components/BomScoreTable/BomScoreTable.tsx
  6. +2
    -0
      src/components/BomScoreTable/index.ts
  7. +155
    -136
      src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx
  8. +132
    -0
      src/components/BomWeightingTabs/BomWeightingTabs.tsx
  9. +2
    -0
      src/components/BomWeightingTabs/index.ts
  10. +20
    -20
      src/components/NavigationContent/NavigationContent.tsx
  11. +17
    -9
      src/components/PoDetail/PoInputGrid.tsx
  12. +92
    -34
      src/components/PutAwayScan/PutAwayCamScan.tsx
  13. +24
    -9
      src/components/Qc/QcStockInModal.tsx
  14. +20
    -13
      src/i18n/zh/common.json
  15. +160
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomScoreRecalculateService.kt
  16. +54
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/BomScoreRecalcController.kt

+ 2
- 2
src/app/(main)/settings/bomWeighting/page.tsx Просмотреть файл

@@ -1,7 +1,7 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import PageTitleBar from "@/components/PageTitleBar";
import BomWeightingScoreTable from "@/components/BomWeightingScoreTable";
import BomWeightingTabs from "@/components/BomWeightingTabs";
import { fetchBomWeightingScores } from "@/app/api/settings/bomWeighting";

export const metadata: Metadata = {
@@ -16,7 +16,7 @@ const BomWeightingScorePage: React.FC = async () => {
<>
<PageTitleBar title={t("BOM Weighting Score List")} className="mb-4" />
<I18nProvider namespaces={["common"]}>
<BomWeightingScoreTable bomWeightingScores={bomWeightingScores} />
<BomWeightingTabs bomWeightingScores={bomWeightingScores} />
</I18nProvider>
</>
);


+ 9
- 0
src/app/api/bom/client.ts Просмотреть файл

@@ -51,3 +51,12 @@ export async function importBom(
);
return response.data as Blob;
}
import type { BomScoreResult } from "./index";

export const fetchBomScoresClient = async (): Promise<BomScoreResult[]> => {
const response = await axiosInstance.get<BomScoreResult[]>(
`${NEXT_PUBLIC_API_URL}/bom/scores`,
);
return response.data;
};


+ 26
- 11
src/app/api/bom/index.ts Просмотреть файл

@@ -3,12 +3,12 @@ import { BASE_API_URL } from "@/config/api";
import { cache } from "react";

export interface BomCombo {
id: number;
value: number;
label: string;
outputQty: number;
outputQtyUom: string;
description: string;
id: number;
value: number;
label: string;
outputQty: number;
outputQtyUom: string;
description: string;
}

export interface BomFormatFileGroup {
@@ -35,11 +35,26 @@ export interface ImportBomItemPayload {
export const preloadBomCombo = (() => {
fetchBomCombo()
})
export interface BomScoreResult {
id: number;
code: string;
name: string;
baseScore: number | string | { value?: number; [key: string]: any };
}

export const fetchBomCombo = cache(async () => {
return serverFetchJson<BomCombo[]>(`${BASE_API_URL}/bom/combo`, {
next: { tags: ["bomCombo"] },
})
})
export const preloadBomCombo = () => {
fetchBomCombo();
};

export const fetchBomCombo = cache(async () => {
return serverFetchJson<BomCombo[]>(`${BASE_API_URL}/bom/combo`, {
next: { tags: ["bomCombo"] },
});
});

export const fetchBomScores = cache(async () => {
return serverFetchJson<BomScoreResult[]>(`${BASE_API_URL}/bom/scores`, {
next: { tags: ["boms"] },
});
});


+ 16
- 0
src/app/api/bom/recalculateClient.ts Просмотреть файл

@@ -0,0 +1,16 @@
"use client";

import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

export interface BomScoreRecalcResponse {
updatedCount: number;
}

export const recalcBomScoresClient = async (): Promise<BomScoreRecalcResponse> => {
const response = await axiosInstance.post<BomScoreRecalcResponse>(
`${NEXT_PUBLIC_API_URL}/bom/scores/recalculate`,
);
return response.data;
};


+ 82
- 0
src/components/BomScoreTable/BomScoreTable.tsx Просмотреть файл

@@ -0,0 +1,82 @@
"use client";

import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { BomScoreResult } from "@/app/api/bom";
import { GridColDef, GridValueFormatterParams } from "@mui/x-data-grid";
import StyledDataGrid from "../StyledDataGrid";
import Paper from "@mui/material/Paper";

interface Props {
boms: BomScoreResult[];
}

const BomScoreTable: React.FC<Props> = ({ boms }) => {
const { t } = useTranslation("common");

const columns = useMemo<GridColDef<BomScoreResult>[]>(
() => [
{
field: "code",
headerName: t("Code"),
flex: 1,
minWidth: 150,
},
{
field: "name",
headerName: t("Name"),
flex: 1.5,
minWidth: 220,
},
{
field: "baseScore",
headerName: t("Base Score"),
flex: 1,
minWidth: 140,
sortable: false,
valueFormatter: (params: GridValueFormatterParams) => {
const v = params.value;
if (v == null) return "";

let num: number | null = null;
if (typeof v === "number") {
num = v;
} else if (typeof v === "string") {
num = parseFloat(v);
} else if (typeof v === "object" && v !== null) {
const obj = v as any;
if (typeof obj.value === "number") {
num = obj.value;
} else if (typeof obj.toString === "function") {
num = parseFloat(obj.toString());
}
}

if (num == null || Number.isNaN(num)) return "";
return num.toFixed(2);
},
},
],
[t],
);

return (
<Paper variant="outlined" sx={{ overflow: "hidden" }}>
<StyledDataGrid
rows={boms}
columns={columns}
getRowId={(row) => row.id}
autoHeight
disableRowSelectionOnClick
hideFooterPagination={true}
sx={{
"& .MuiDataGrid-columnHeaderTitle": { fontSize: 15 },
"& .MuiDataGrid-cell": { fontSize: 16 },
}}
/>
</Paper>
);
};

export default BomScoreTable;


+ 2
- 0
src/components/BomScoreTable/index.ts Просмотреть файл

@@ -0,0 +1,2 @@
export { default } from "./BomScoreTable";


+ 155
- 136
src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx Просмотреть файл

@@ -4,18 +4,15 @@ import React, { useMemo, useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting";
import { updateBomWeightingScoreClient } from "@/app/api/settings/bomWeighting/client";
import { GridColDef, GridValueGetterParams, GridValueFormatterParams, GridRenderCellParams } from "@mui/x-data-grid";
import { recalcBomScoresClient } from "@/app/api/bom/recalculateClient";
import { GridColDef, GridValueFormatterParams } from "@mui/x-data-grid";
import StyledDataGrid from "../StyledDataGrid";
import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
import EditNote from "@mui/icons-material/EditNote";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import { successDialog } from "../Swal/CustomAlerts";
import { successDialog, warningDialog } from "../Swal/CustomAlerts";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";

interface Props {
bomWeightingScores: BomWeightingScoreResult[];
@@ -24,98 +21,137 @@ interface Props {
const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores }) => {
const { t } = useTranslation("common");
const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<BomWeightingScoreResult | null>(null);
const [isEditMode, setIsEditMode] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({
name: "",
range: "",
weighting: "",
remarks: "",
});
const [draftWeightingById, setDraftWeightingById] = useState<Record<number, string>>({});

useEffect(() => {
setBomWeightingScores(initialBomWeightingScores);
}, [initialBomWeightingScores]);

const handleEditClick = useCallback((row: BomWeightingScoreResult) => {
let weightingValue = 0;
if (row.weighting != null) {
if (typeof row.weighting === "object" && row.weighting !== null) {
const obj = row.weighting as any;
weightingValue = typeof obj.value === "number" ? obj.value : parseFloat(String(row.weighting)) || 0;
} else {
weightingValue = typeof row.weighting === "number" ? row.weighting : parseFloat(String(row.weighting)) || 0;
}
const extractWeightingNumber = useCallback((row: BomWeightingScoreResult): number => {
const w = row.weighting;
if (w == null) return 0;
if (typeof w === "number") return w;
if (typeof w === "string") return parseFloat(w) || 0;
if (typeof w === "object" && w !== null) {
const obj = w as any;
if (typeof obj.value === "number") return obj.value;
if (typeof obj.toString === "function") return parseFloat(obj.toString()) || 0;
}

setEditingItem(row);
setFormData({
name: row.name || "",
range: String(row.range || ""),
weighting: String(weightingValue),
remarks: row.remarks || "",
});
setEditDialogOpen(true);
return parseFloat(String(w)) || 0;
}, []);

const handleCloseDialog = useCallback(() => {
setEditDialogOpen(false);
setEditingItem(null);
setFormData({ name: "", range: "", weighting: "", remarks: "" });
const enterEditMode = useCallback(() => {
const nextDraft: Record<number, string> = {};
for (const row of bomWeightingScores) {
nextDraft[row.id] = String(extractWeightingNumber(row));
}
setDraftWeightingById(nextDraft);
setIsEditMode(true);
}, [bomWeightingScores, extractWeightingNumber]);

const cancelEditMode = useCallback(() => {
setIsEditMode(false);
setDraftWeightingById({});
}, []);

const handleSave = useCallback(async (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
if (!editingItem || isSaving) return;
if (!isEditMode || isSaving) return;

setIsSaving(true);
try {
const updated = await updateBomWeightingScoreClient({
id: editingItem.id,
name: editingItem.name, // Keep original name
range: parseInt(formData.range, 10),
weighting: parseFloat(formData.weighting),
remarks: editingItem.remarks || undefined, // Keep original remarks
});

// Update local state immediately
setBomWeightingScores((prev) =>
prev.map((item) => (item.id === editingItem.id ? updated : item))
);

// Close dialog first, then show success message
handleCloseDialog();
await successDialog(t("Update Success"), t);
let sum = 0;
for (const row of bomWeightingScores) {
const raw = draftWeightingById[row.id] ?? String(extractWeightingNumber(row));
const n = parseFloat(raw);
if (Number.isNaN(n)) {
await warningDialog("權重必須為數字", t);
return;
}
sum += n;
}

const EPS = 1e-6;
if (Math.abs(sum - 1) > EPS) {
await warningDialog(`權重總和必須等於 1(目前總和: ${sum.toFixed(4)})`, t);
return;
}

setIsSaving(true);
const updates = bomWeightingScores
.map((row) => {
const nextWeightingStr = draftWeightingById[row.id];
if (nextWeightingStr == null) return null;

const prevWeighting = extractWeightingNumber(row);
const nextWeighting = parseFloat(nextWeightingStr);
if (Number.isNaN(nextWeighting)) return null;

// Only update changed values (weighting only; range locked)
if (nextWeighting === prevWeighting) return null;

return {
row,
nextWeighting,
};
})
.filter(Boolean) as Array<{ row: BomWeightingScoreResult; nextWeighting: number }>;

let updatedCount: number | null = null;

if (updates.length > 0) {
const updatedRows = await Promise.all(
updates.map(({ row, nextWeighting }) =>
updateBomWeightingScoreClient({
id: row.id,
name: row.name,
range: row.range,
weighting: nextWeighting,
remarks: row.remarks || undefined,
}),
),
);

const updatedById = new Map(updatedRows.map((r) => [r.id, r]));
setBomWeightingScores((prev) =>
prev.map((r) => updatedById.get(r.id) ?? r),
);

// After weighting changes, trigger BOM baseScore recalculation on the server
try {
const result = await recalcBomScoresClient();
updatedCount = result?.updatedCount ?? null;
} catch (recalcError) {
console.error("Failed to recalculate BOM base scores:", recalcError);
// We don't block the main save flow if recalculation fails
}
}

cancelEditMode();

// Show success message, with extra info about how many BOM base scores were recalculated (if available)
if (updatedCount != null) {
await successDialog(
`${t("Update Success")}(已重新計算 ${updatedCount} 筆 BOM 基礎分)`,
t,
);
} else {
await successDialog(t("Update Success"), t);
}
} catch (error: any) {
console.error("Error updating bom weighting score:", error);
// Show error message to user
const errorMessage = error?.response?.data?.message || error?.message || t("Update Failed") || "Update failed. Please try again.";
alert(errorMessage);
} finally {
setIsSaving(false);
}
}, [editingItem, formData, t, handleCloseDialog, isSaving]);
}, [isEditMode, isSaving, bomWeightingScores, draftWeightingById, extractWeightingNumber, cancelEditMode, t]);

const columns = useMemo<GridColDef<BomWeightingScoreResult>[]>(
() => [
{
field: "actions",
headerName: t("Edit"),
width: 100,
sortable: false,
renderCell: (params: GridRenderCellParams<BomWeightingScoreResult>) => (
<IconButton
size="small"
onClick={() => handleEditClick(params.row)}
color="primary"
>
<EditNote fontSize="small" />
</IconButton>
),
},
{
field: "name",
headerName: t("Name"),
@@ -132,38 +168,60 @@ const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomW
field: "weighting",
headerName: t("Weighting"),
flex: 1,
valueGetter: (params: GridValueGetterParams<BomWeightingScoreResult>) => {
const weighting = params.row.weighting;
if (weighting == null || weighting === undefined) return null;
if (typeof weighting === "object" && weighting !== null) {
const obj = weighting as any;
if (typeof obj.value === "number") {
return obj.value;
}
if (typeof obj.toString === "function") {
return parseFloat(obj.toString());
}
const numValue = parseFloat(String(weighting));
return isNaN(numValue) ? null : numValue;
}
const numValue = typeof weighting === "number" ? weighting : parseFloat(String(weighting));
return isNaN(numValue) ? null : numValue;
},
sortable: false,
valueFormatter: (params: GridValueFormatterParams) => {
const value = params.value;
if (value == null || value === undefined) return "";
return typeof value === "number" ? value.toFixed(2) : "";
},
renderCell: (params) => {
if (!isEditMode) {
const value = extractWeightingNumber(params.row);
return value.toFixed(2);
}

const current = draftWeightingById[params.row.id] ?? String(extractWeightingNumber(params.row));
return (
<TextField
size="small"
type="number"
inputProps={{ step: "0.01" }}
value={current}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const next = e.target.value;
setDraftWeightingById((prev) => ({ ...prev, [params.row.id]: next }));
}}
sx={{ width: "100%" }}
/>
);
},
},
],
[t, handleEditClick],
[t, isEditMode, draftWeightingById, extractWeightingNumber],
);

return (
<>
<Paper variant="outlined" sx={{ overflow: "hidden" }}>
<Box sx={{ p: 2, pb: 0 }}>
<Stack direction="row" justifyContent="flex-end" spacing={1}>
{isEditMode ? (
<>
<Button variant="outlined" onClick={cancelEditMode} disabled={isSaving}>
{t("Cancel")}
</Button>
<Button variant="contained" onClick={handleSave} disabled={isSaving}>
{isSaving ? t("Saving") || "Saving..." : t("Save")}
</Button>
</>
) : (
<Button variant="contained" onClick={enterEditMode}>
{t("Edit")}
</Button>
)}
</Stack>
</Box>
<StyledDataGrid
rows={bomWeightingScores}
columns={columns}
@@ -171,51 +229,12 @@ const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomW
autoHeight
disableRowSelectionOnClick
hideFooterPagination={true}
sx={{
"& .MuiDataGrid-columnHeaderTitle": { fontSize: 15 },
"& .MuiDataGrid-cell": { fontSize: 16 },
}}
/>
</Paper>

<Dialog open={editDialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>{t("Edit BOM Weighting Score")}</DialogTitle>
<DialogContent>
<TextField
fullWidth
label={t("Name")}
value={formData.name}
margin="normal"
disabled
/>
<TextField
fullWidth
label={t("Range")}
type="number"
value={formData.range}
onChange={(e) => setFormData({ ...formData, range: e.target.value })}
margin="normal"
required
/>
<TextField
fullWidth
label={t("Weighting")}
type="number"
inputProps={{ step: "0.01" }}
value={formData.weighting}
onChange={(e) => setFormData({ ...formData, weighting: e.target.value })}
margin="normal"
required
/>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog} disabled={isSaving}>{t("Cancel")}</Button>
<Button
onClick={handleSave}
variant="contained"
color="primary"
disabled={isSaving}
>
{isSaving ? t("Saving") || "Saving..." : t("Save")}
</Button>
</DialogActions>
</Dialog>
</>
);
};


+ 132
- 0
src/components/BomWeightingTabs/BomWeightingTabs.tsx Просмотреть файл

@@ -0,0 +1,132 @@
"use client";

import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting";
import type { BomScoreResult } from "@/app/api/bom";
import { fetchBomScoresClient } from "@/app/api/bom/client";
import BomWeightingScoreTable from "@/components/BomWeightingScoreTable";
import BomScoreTable from "@/components/BomScoreTable";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";

interface Props {
bomWeightingScores: BomWeightingScoreResult[];
}

const BomWeightingTabs: React.FC<Props> = ({ bomWeightingScores }) => {
const { t } = useTranslation("common");
const [tab, setTab] = useState(0);
const [bomScores, setBomScores] = useState<BomScoreResult[] | null>(null);
const [loadingScores, setLoadingScores] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);

useEffect(() => {
if (tab !== 1) return;

const load = async () => {
try {
setLoadingScores(true);
setLoadError(null);
console.log("Fetching BOM scores from /bom/scores...");
const data = await fetchBomScoresClient();
console.log("BOM scores received:", data);
setBomScores(data || []);
} catch (err: any) {
console.error("Failed to load BOM scores:", err);
const errorMsg =
err?.response?.data?.message || err?.message || t("Update Failed") || "Load failed";
setLoadError(errorMsg);
setBomScores([]);
} finally {
setLoadingScores(false);
}
};

void load();
}, [tab, t]);

return (
<>
<Paper
variant="outlined"
sx={{
mb: 2,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
}}
>
<Tabs
value={tab}
onChange={(_, v) => setTab(v)}
indicatorColor="primary"
textColor="primary"
sx={{
pl: 2,
minHeight: 44,
}}
>
<Tab
label={t("Material Weighting")}
value={0}
sx={{ textTransform: "none", fontSize: 16, px: 3, minHeight: 44 }}
/>
<Tab
label={t("Material Score")}
value={1}
sx={{ textTransform: "none", fontSize: 16, px: 3, minHeight: 44 }}
/>
</Tabs>
</Paper>

<Box>
{tab === 0 && (
<BomWeightingScoreTable bomWeightingScores={bomWeightingScores} />
)}
{tab === 1 && (
loadingScores ? (
<Paper
variant="outlined"
sx={{
p: 3,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
}}
>
<div className="text-slate-700">{t("Loading")}</div>
</Paper>
) : loadError ? (
<Paper
variant="outlined"
sx={{
p: 3,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
}}
>
<div className="text-slate-700 text-red-600">{loadError}</div>
</Paper>
) : bomScores && bomScores.length > 0 ? (
<BomScoreTable boms={bomScores} />
) : (
<Paper
variant="outlined"
sx={{
p: 3,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
}}
>
<div className="text-slate-700">{t("No data available")}</div>
</Paper>
)
)}
</Box>
</>
);
};

export default BomWeightingTabs;


+ 2
- 0
src/components/BomWeightingTabs/index.ts Просмотреть файл

@@ -0,0 +1,2 @@
export { default } from "./BomWeightingTabs";


+ 20
- 20
src/components/NavigationContent/NavigationContent.tsx Просмотреть файл

@@ -207,21 +207,6 @@ const NavigationContent: React.FC = () => {
label: "Items",
path: "/settings/items",
},
{
icon: <ViewModule />,
label: "BOM Weighting Score List",
path: "/settings/bomWeighting",
},
{
icon: <Storefront />,
label: "ShopAndTruck",
path: "/settings/shop",
},
{
icon: <TrendingUp />,
label: "Demand Forecast Setting",
path: "/settings/rss",
},
{
icon: <Build />,
label: "Equipment",
@@ -237,11 +222,6 @@ const NavigationContent: React.FC = () => {
label: "Printer",
path: "/settings/printer",
},
//{
// icon: <Person />,
// label: "Customer",
// path: "/settings/user",
//},
{
icon: <VerifiedUser />,
label: "QC Check Item",
@@ -257,6 +237,26 @@ const NavigationContent: React.FC = () => {
label: "QC Item All",
path: "/settings/qcItemAll",
},
{
icon: <Storefront />,
label: "ShopAndTruck",
path: "/settings/shop",
},
{
icon: <TrendingUp />,
label: "Demand Forecast Setting",
path: "/settings/rss",
},
//{
// icon: <Person />,
// label: "Customer",
// path: "/settings/user",
//},
{
icon: <ViewModule />,
label: "BOM Weighting Score List",
path: "/settings/bomWeighting",
},
{
icon: <QrCodeIcon />,
label: "QR Code Handle",


+ 17
- 9
src/components/PoDetail/PoInputGrid.tsx Просмотреть файл

@@ -285,15 +285,23 @@ function PoInputGrid({
const stockInLineId = searchParams.get("stockInLineId");
const poLineId = searchParams.get("poLineId");

const closeNewModal = useCallback(() => {
const newParams = new URLSearchParams(searchParams.toString());
newParams.delete("stockInLineId"); // Remove the parameter
router.replace(`${pathname}?${newParams.toString()}`);
fetchPoDetail(itemDetail.purchaseOrderId.toString());
setNewOpen(false); // Close the modal first
// setTimeout(() => {
// }, 300); // Add a delay to avoid immediate re-trigger of useEffect
}, [searchParams, pathname, router]);
const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => {
const newParams = new URLSearchParams(searchParams.toString());
newParams.delete("stockInLineId");
if (typeof window !== "undefined") {
window.history.replaceState({}, "", `${pathname}?${newParams.toString()}`);
}
setNewOpen(false);

if (updatedStockInLine?.id != null) {
setEntries((prev) =>
prev.map((e) => (e.id === updatedStockInLine.id ? { ...e, ...updatedStockInLine } : e))
);
setStockInLine((prev) =>
(prev || []).map((p) => (p.id === updatedStockInLine.id ? { ...p, ...updatedStockInLine } : p))
);
}
}, [pathname, searchParams]);

// Open modal
const openNewModal = useCallback(() => {


+ 92
- 34
src/components/PutAwayScan/PutAwayCamScan.tsx Просмотреть файл

@@ -1,7 +1,8 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Paper, Typography } from "@mui/material";
import type { Result } from "@zxing/library";
import ReactQrCodeScanner, {
ScannerConfig,
defaultScannerConfig,
@@ -14,6 +15,15 @@ import PutAwayReviewGrid from "./PutAwayReviewGrid";
import type { PutAwayRecord } from ".";
import type { QrCodeScanner as QrCodeScannerType } from "../QrCodeScannerProvider/QrCodeScannerProvider";

/** Find first number after a keyword in a string (e.g. "StockInLine" or "warehouseId"). */
function findIdByRoughMatch(inputString: string, keyword: string): number | null {
const idx = inputString.indexOf(keyword);
if (idx === -1) return null;
const after = inputString.slice(idx + keyword.length);
const match = after.match(/\d+/);
return match ? parseInt(match[0], 10) : null;
}

type Props = {
warehouse: WarehouseResult[];
};
@@ -51,51 +61,93 @@ const PutAwayCamScan: React.FC<Props> = ({ warehouse }) => {
}
}, [scannedWareHouseId]);

// Refs so the scanner (which only gets config on mount) always calls the latest handler and we throttle duplicates
const handleScanRef = useRef<(rawText: string) => void>(() => {});
const lastScannedRef = useRef({ text: "", at: 0 });
const THROTTLE_MS = 2000;

const handleScan = useCallback(
(rawText: string) => {
if (!rawText) return;
const trimmed = (rawText || "").trim();
if (!trimmed) return;
const now = Date.now();
if (
lastScannedRef.current.text === trimmed &&
now - lastScannedRef.current.at < THROTTLE_MS
) {
return;
}

setScanStatus("scanning");

const trySetNumeric = (value: unknown) => {
const num = Number(value);
const done = () => {
lastScannedRef.current = { text: trimmed, at: now };
};

const trySetSilId = (num: number): boolean => {
if (!Number.isFinite(num) || num <= 0) return false;
if (scannedSilId === 0) {
setScannedSilId(num);
} else if (scannedWareHouseId === 0) {
setScannedWareHouseId(num);
}
setScannedSilId(num);
done();
return true;
};
const trySetWarehouseId = (num: number): boolean => {
if (!Number.isFinite(num) || num <= 0) return false;
setScannedWareHouseId(num);
done();
return true;
};

const isFirstScan = scannedSilId === 0;
const isSecondScan = scannedSilId > 0 && scannedWareHouseId === 0;

// 1) Try JSON payload first
// 1) Try JSON
try {
const data = JSON.parse(rawText) as any;
if (data) {
if (scannedSilId === 0) {
if (data.stockInLineId && trySetNumeric(data.stockInLineId)) return;
if (data.value && trySetNumeric(data.value)) return;
} else {
if (data.warehouseId && trySetNumeric(data.warehouseId)) return;
if (data.value && trySetNumeric(data.value)) return;
const data = JSON.parse(trimmed) as Record<string, unknown>;
if (data && typeof data === "object") {
if (isFirstScan) {
if (data.stockInLineId != null && trySetSilId(Number(data.stockInLineId))) return;
if (data.value != null && trySetSilId(Number(data.value))) return;
}
if (isSecondScan) {
if (data.warehouseId != null && trySetWarehouseId(Number(data.warehouseId))) return;
if (data.value != null && trySetWarehouseId(Number(data.value))) return;
}
}
} catch {
// Not JSON – fall through to numeric parsing
// not JSON
}

// 2) Fallback: plain numeric content
if (trySetNumeric(rawText)) return;
// 2) Rough match: "StockInLine" or "warehouseId" + number (same as barcode scanner)
if (isFirstScan) {
const sil =
findIdByRoughMatch(trimmed, "StockInLine") ??
findIdByRoughMatch(trimmed, "stockInLineId");
if (sil != null && trySetSilId(sil)) return;
}
if (isSecondScan) {
const wh =
findIdByRoughMatch(trimmed, "warehouseId") ??
findIdByRoughMatch(trimmed, "WarehouseId");
if (wh != null && trySetWarehouseId(wh)) return;
}

// 3) Plain number
const num = Number(trimmed);
if (isFirstScan && trySetSilId(num)) return;
if (isSecondScan && trySetWarehouseId(num)) return;
},
[scannedSilId, scannedWareHouseId],
);

handleScanRef.current = handleScan;

// Open modal only after both stock-in-line and location (warehouse) are scanned
useEffect(() => {
if (scannedSilId > 0) {
if (scannedSilId > 0 && scannedWareHouseId > 0) {
setOpenPutAwayModal(true);
setScanStatus("pending");
}
}, [scannedSilId]);
}, [scannedSilId, scannedWareHouseId]);

const closeModal = () => {
setScannedSilId(0);
@@ -108,20 +160,26 @@ const PutAwayCamScan: React.FC<Props> = ({ warehouse }) => {
if (scanStatus === "scanning") {
return t("Scanning");
}
if (scannedSilId > 0) {
if (scannedSilId > 0 && scannedWareHouseId > 0) {
return t("Scanned, opening detail");
}
if (scannedSilId > 0) {
return t("Please scan warehouse qr code");
}
return t("Pending scan");
}, [scanStatus, scannedSilId, t]);

const scannerConfig: ScannerConfig = {
...defaultScannerConfig,
onUpdate: (_err, result) => {
if (result) {
handleScan(result.getText());
}
},
};
}, [scanStatus, scannedSilId, scannedWareHouseId, t]);

const scannerConfig: ScannerConfig = useMemo(
() => ({
...defaultScannerConfig,
onUpdate: (_err: unknown, result?: Result): void => {
if (result) {
handleScanRef.current(result.getText());
}
},
}),
[],
);

return (
<>


+ 24
- 9
src/components/Qc/QcStockInModal.tsx Просмотреть файл

@@ -54,12 +54,13 @@ const style = {
height: "min(900px, calc(100vh - 48px))",
maxHeight: "calc(100vh - 48px)",
};
interface CommonProps extends Omit<ModalProps, "children"> {
interface CommonProps extends Omit<ModalProps, "children" | "onClose"> {
// itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] } | undefined;
inputDetail: StockInLineInput | undefined;
session: SessionWithTokens | null;
warehouse?: any[];
printerCombo: PrinterCombo[];
onClose: () => void;
onClose: (updatedStockInLine?: StockInLine) => void;
skipQc?: Boolean;
printSource?: "stockIn" | "productionProcess";
uiMode?: "default" | "dashboard" | "productionProcess";
@@ -235,7 +236,16 @@ const QcStockInModal: React.FC<Props> = ({
...defaultNewValue,
},
});

const closeWithResult = useCallback(
(updatedStockInLine?: StockInLine) => {
setStockInLineInfo(undefined);
formProps.reset({});
onClose?.(updatedStockInLine);
},
[onClose],
);

const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
() => {
setStockInLineInfo(undefined);
@@ -418,6 +428,9 @@ const QcStockInModal: React.FC<Props> = ({
// const qcData = data;

console.log("QC Data for submission:", qcData);

let qcRes: StockInLine | undefined;
if (data.qcDecision == 3) { // Escalate
if (data.escalationLog?.handlerId == undefined) { alert("請選擇上報負責同事!"); return; }
else if (data.escalationLog?.handlerId < 1) { alert("上報負責同事資料有誤"); return; }
@@ -431,12 +444,14 @@ const QcStockInModal: React.FC<Props> = ({
}
console.log("Escalation Data for submission", escalationLog);

setIsSubmitting(true); //TODO improve
await postStockInLine({...qcData, escalationLog});
setIsSubmitting(true);
const resEscalate = await postStockInLine({...qcData, escalationLog});
qcRes = Array.isArray(resEscalate.entity) ? resEscalate.entity[0] : (resEscalate.entity as StockInLine);

} else {
setIsSubmitting(true); //TODO improve
await postStockInLine(qcData);
setIsSubmitting(true);
const resNormal = await postStockInLine(qcData);
qcRes = Array.isArray(resNormal.entity) ? resNormal.entity[0] : (resNormal.entity as StockInLine);
}

if (qcData.qcAccept) {
@@ -491,10 +506,10 @@ const QcStockInModal: React.FC<Props> = ({
}

}
closeHandler({}, "backdropClick");
closeWithResult(qcRes);
// setTabIndex(1); // Need to go Putaway tab?
} else {
closeHandler({}, "backdropClick");
closeWithResult(qcRes);
}
setIsSubmitting(false);
msg("已更新來貨狀態");


+ 20
- 13
src/i18n/zh/common.json Просмотреть файл

@@ -78,10 +78,17 @@
"user": "用戶",
"User Group": "用戶群組",
"Items": "物料",
"BOM Weighting Score List": "物料清單",
"BOM Weighting Score List": "物料清單權重得分",
"Material Weighting": "物料清單加權",
"Material Score": "物料清單得分",
"Coming soon": "即將推出",
"Base Score": "基礎得分",
"Column Name": "欄位名稱",
"Range": "範圍",
"Weighting": "權重",
"Total weighting must equal 1": "權重總和必須等於 1",
"Current total": "目前總和",
"Weighting must be a number": "權重必須為數字",
"Edit": "編輯",
"Edit BOM Weighting Score": "編輯物料清單",
"Save": "儲存",
@@ -132,12 +139,12 @@
"Day Before Yesterday": "前天",
"Select Date": "選擇日期",
"Production Date": "生產日期",
"QC Check Item": "QC品檢項目",
"QC Category": "QC品檢模板",
"QC Check Item": "QC 品檢項目",
"QC Category": "QC 品檢模板",
"QC Item All": "QC 綜合管理",
"qcItemAll": "QC 綜合管理",
"qcCategory": "品檢模板",
"QC Check Template": "QC檢查模板",
"QC Check Template": "QC 檢查模板",
"Mail": "郵件",
"Import Testing": "匯入測試",
"FG":"成品",
@@ -150,8 +157,8 @@
"qcItem":"品檢項目",
"Item":"物料",
"Production Date":"生產日期",
"QC Check Item":"QC品檢項目",
"QC Category":"QC品檢模板",
"QC Check Item":"QC 品檢項目",
"QC Category":"QC 品檢模板",
"QC Item All":"QC 綜合管理",
"qcCategory":"品檢模板",
"QC Check Template":"QC檢查模板",
@@ -498,11 +505,11 @@
"Handled By": "處理者",
"submit": "提交",
"Received Qty": "接收數量",
"bomWeighting": "物料清單",
"Now": "現時",
"Last updated": "最後更新",
"Auto-refresh every 5 minutes": "每5分鐘自動刷新",
"Auto-refresh every 10 minutes": "每10分鐘自動刷新",
"Auto-refresh every 15 minutes": "每15分鐘自動刷新",
"Auto-refresh every 1 minute": "每1分鐘自動刷新"
"bomWeighting": "物料清單權重得分",
"Now": "現時",
"Last updated": "最後更新",
"Auto-refresh every 5 minutes": "每5分鐘自動刷新",
"Auto-refresh every 10 minutes": "每10分鐘自動刷新",
"Auto-refresh every 15 minutes": "每15分鐘自動刷新",
"Auto-refresh every 1 minute": "每1分鐘自動刷新"
}

+ 160
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomScoreRecalculateService.kt Просмотреть файл

@@ -0,0 +1,160 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.BomRepository
import com.ffii.fpsms.modules.settings.entity.BomWeightingScoreRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode

@Service
open class BomScoreRecalculateService(
private val bomRepository: BomRepository,
private val bomWeightingScoreRepository: BomWeightingScoreRepository,
) {

/**
* Recalculate baseScore for all non-deleted BOMs and persist the result.
*/
@Transactional
open fun recalculateAllBaseScores(): Int {
val boms: List<Bom> = bomRepository.findAllByDeletedIsFalse()
if (boms.isEmpty()) return 0

boms.forEach { bom ->
val newScore = calculateBaseScoreForBom(bom)
bom.baseScore = newScore
}

bomRepository.saveAll(boms)
return boms.size
}

/**
* Same logic as BomService.calculateBaseScore (984–1023),
* duplicated to avoid modifying BomService.kt.
*/
private fun calculateBaseScoreForBom(bom: Bom): BigDecimal {
val scale = 2
val roundingMode = RoundingMode.HALF_UP
var sum = BigDecimal.ZERO.setScale(scale, roundingMode)

// Score columns: contribution = (extractedScore / range) * weighting * 100
val scoreColumns: List<Pair<String, BigDecimal>> = listOf(
"isDark" to (bom.isDark?.toBigDecimal() ?: BigDecimal.ZERO),
"isFloat" to (bom.isFloat?.toBigDecimal() ?: BigDecimal.ZERO),
"isDense" to (bom.isDense?.toBigDecimal() ?: BigDecimal.ZERO),
"allergicSubstances" to (bom.allergicSubstances?.toBigDecimal() ?: BigDecimal.ZERO),
"timeSequence" to (bom.timeSequence?.toBigDecimal() ?: BigDecimal.ZERO),
"complexity" to (bom.complexity?.toBigDecimal() ?: BigDecimal.ZERO),
)

for ((code, extractedScore) in scoreColumns) {
val row = bomWeightingScoreRepository.findByCodeAndDeletedFalse(code) ?: continue
val range = (row.range ?: 1).toBigDecimal()
val weighting = row.weighting ?: continue
if (range.compareTo(BigDecimal.ZERO) == 0) continue

val contribution = extractedScore
.divide(range, scale, roundingMode)
.multiply(weighting)
.multiply(BigDecimal(100))
.setScale(scale, roundingMode)
sum = sum.add(contribution)
}

// equipmentConflict: contribution = weighting * 100 only
val equipmentConflictRow =
bomWeightingScoreRepository.findByCodeAndDeletedFalse("equipmentConflict")
if (equipmentConflictRow?.weighting != null) {
val contribution = equipmentConflictRow.weighting!!
.multiply(BigDecimal(100))
.setScale(scale, roundingMode)
sum = sum.add(contribution)
}

return sum
}
}

package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.BomRepository
import com.ffii.fpsms.modules.settings.entity.BomWeightingScoreRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode

@Service
open class BomScoreRecalculateService(
private val bomRepository: BomRepository,
private val bomWeightingScoreRepository: BomWeightingScoreRepository,
) {

/**
* Recalculate baseScore for all non-deleted BOMs and persist the result.
*/
@Transactional
open fun recalculateAllBaseScores(): Int {
val boms: List<Bom> = bomRepository.findAllByDeletedIsFalse()
if (boms.isEmpty()) return 0

boms.forEach { bom ->
val newScore = calculateBaseScoreForBom(bom)
bom.baseScore = newScore
}

bomRepository.saveAll(boms)
return boms.size
}

/**
* Same logic as BomService.calculateBaseScore, but duplicated here
* to avoid modifying the existing BomService file.
*/
private fun calculateBaseScoreForBom(bom: Bom): BigDecimal {
val scale = 2
val roundingMode = RoundingMode.HALF_UP
var sum = BigDecimal.ZERO.setScale(scale, roundingMode)

// Score columns: contribution = (extractedScore / range) * weighting * 100
val scoreColumns: List<Pair<String, BigDecimal>> = listOf(
"isDark" to (bom.isDark?.toBigDecimal() ?: BigDecimal.ZERO),
"isFloat" to (bom.isFloat?.toBigDecimal() ?: BigDecimal.ZERO),
"isDense" to (bom.isDense?.toBigDecimal() ?: BigDecimal.ZERO),
"allergicSubstances" to (bom.allergicSubstances?.toBigDecimal() ?: BigDecimal.ZERO),
"timeSequence" to (bom.timeSequence?.toBigDecimal() ?: BigDecimal.ZERO),
"complexity" to (bom.complexity?.toBigDecimal() ?: BigDecimal.ZERO),
)

for ((code, extractedScore) in scoreColumns) {
val row = bomWeightingScoreRepository.findByCodeAndDeletedFalse(code) ?: continue
val range = (row.range ?: 1).toBigDecimal()
val weighting = row.weighting ?: continue
if (range.compareTo(BigDecimal.ZERO) == 0.toBigDecimal()) continue

val contribution = extractedScore
.divide(range, scale, roundingMode)
.multiply(weighting)
.multiply(BigDecimal(100))
.setScale(scale, roundingMode)
sum = sum.add(contribution)
}

// equipmentConflict: contribution = weighting * 100 only
val equipmentConflictRow =
bomWeightingScoreRepository.findByCodeAndDeletedFalse("equipmentConflict")
if (equipmentConflictRow?.weighting != null) {
val contribution = equipmentConflictRow.weighting!!
.multiply(BigDecimal(100))
.setScale(scale, roundingMode)
sum = sum.add(contribution)
}

return sum
}
}


+ 54
- 0
src/main/java/com/ffii/fpsms/modules/master/web/BomScoreRecalcController.kt Просмотреть файл

@@ -0,0 +1,54 @@
package com.ffii.fpsms.modules.master.web

import com.ffii.fpsms.modules.master.service.BomScoreRecalculateService
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

data class BomScoreRecalcResponse(
val updatedCount: Int,
)

@RestController
@RequestMapping("/bom/scores")
class BomScoreRecalcController(
private val bomScoreRecalculateService: BomScoreRecalculateService,
) {

/**
* Recalculate and persist baseScore for all BOMs using the current weighting configuration.
*/
@PostMapping("/recalculate")
fun recalculateAll(): BomScoreRecalcResponse {
val count = bomScoreRecalculateService.recalculateAllBaseScores()
return BomScoreRecalcResponse(updatedCount = count)
}
}

package com.ffii.fpsms.modules.master.web

import com.ffii.fpsms.modules.master.service.BomScoreRecalculateService
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

data class BomScoreRecalcResponse(
val updatedCount: Int,
)

@RestController
@RequestMapping("/bom/scores")
class BomScoreRecalcController(
private val bomScoreRecalculateService: BomScoreRecalculateService,
) {

/**
* Recalculate and persist baseScore for all BOMs using the current weighting configuration.
*/
@PostMapping("/recalculate")
fun recalculateAll(): BomScoreRecalcResponse {
val count = bomScoreRecalculateService.recalculateAllBaseScores()
return BomScoreRecalcResponse(updatedCount = count)
}
}


Загрузка…
Отмена
Сохранить