# Conflicts: # src/app/api/bom/client.ts # src/app/api/bom/index.ts # src/components/Qc/QcStockInModal.tsxreset-do-picking-order
| @@ -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> | |||
| </> | |||
| ); | |||
| @@ -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; | |||
| }; | |||
| @@ -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"] }, | |||
| }); | |||
| }); | |||
| @@ -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; | |||
| }; | |||
| @@ -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; | |||
| @@ -0,0 +1,2 @@ | |||
| export { default } from "./BomScoreTable"; | |||
| @@ -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> | |||
| </> | |||
| ); | |||
| }; | |||
| @@ -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; | |||
| @@ -0,0 +1,2 @@ | |||
| export { default } from "./BomWeightingTabs"; | |||
| @@ -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", | |||
| @@ -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(() => { | |||
| @@ -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 ( | |||
| <> | |||
| @@ -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("已更新來貨狀態"); | |||
| @@ -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分鐘自動刷新" | |||
| } | |||
| @@ -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 | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||