diff --git a/src/app/(main)/settings/bomWeighting/page.tsx b/src/app/(main)/settings/bomWeighting/page.tsx
index 4456c5f..551dcd2 100644
--- a/src/app/(main)/settings/bomWeighting/page.tsx
+++ b/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 () => {
<>
-
+
>
);
diff --git a/src/app/api/bom/client.ts b/src/app/api/bom/client.ts
new file mode 100644
index 0000000..445d1aa
--- /dev/null
+++ b/src/app/api/bom/client.ts
@@ -0,0 +1,13 @@
+"use client";
+
+import axiosInstance from "@/app/(main)/axios/axiosInstance";
+import { NEXT_PUBLIC_API_URL } from "@/config/api";
+import type { BomScoreResult } from "./index";
+
+export const fetchBomScoresClient = async (): Promise => {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/bom/scores`,
+ );
+ return response.data;
+};
+
diff --git a/src/app/api/bom/index.ts b/src/app/api/bom/index.ts
index c96c52b..fca06f5 100644
--- a/src/app/api/bom/index.ts
+++ b/src/app/api/bom/index.ts
@@ -3,22 +3,34 @@ 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 const preloadBomCombo = (() => {
- fetchBomCombo()
-})
+export interface BomScoreResult {
+ id: number;
+ code: string;
+ name: string;
+ baseScore: number | string | { value?: number; [key: string]: any };
+}
+
+export const preloadBomCombo = () => {
+ fetchBomCombo();
+};
export const fetchBomCombo = cache(async () => {
- return serverFetchJson(`${BASE_API_URL}/bom/combo`, {
- next: { tags: ["bomCombo"] },
- })
-})
+ return serverFetchJson(`${BASE_API_URL}/bom/combo`, {
+ next: { tags: ["bomCombo"] },
+ });
+});
+export const fetchBomScores = cache(async () => {
+ return serverFetchJson(`${BASE_API_URL}/bom/scores`, {
+ next: { tags: ["boms"] },
+ });
+});
diff --git a/src/app/api/bom/recalculateClient.ts b/src/app/api/bom/recalculateClient.ts
new file mode 100644
index 0000000..59c308b
--- /dev/null
+++ b/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 => {
+ const response = await axiosInstance.post(
+ `${NEXT_PUBLIC_API_URL}/bom/scores/recalculate`,
+ );
+ return response.data;
+};
+
diff --git a/src/components/BomScoreTable/BomScoreTable.tsx b/src/components/BomScoreTable/BomScoreTable.tsx
new file mode 100644
index 0000000..a0e2ae1
--- /dev/null
+++ b/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 = ({ boms }) => {
+ const { t } = useTranslation("common");
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ 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 (
+
+ row.id}
+ autoHeight
+ disableRowSelectionOnClick
+ hideFooterPagination={true}
+ sx={{
+ "& .MuiDataGrid-columnHeaderTitle": { fontSize: 15 },
+ "& .MuiDataGrid-cell": { fontSize: 16 },
+ }}
+ />
+
+ );
+};
+
+export default BomScoreTable;
+
diff --git a/src/components/BomScoreTable/index.ts b/src/components/BomScoreTable/index.ts
new file mode 100644
index 0000000..64d37e7
--- /dev/null
+++ b/src/components/BomScoreTable/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./BomScoreTable";
+
diff --git a/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx b/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx
index d6d9365..200357d 100644
--- a/src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx
+++ b/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 & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores }) => {
const { t } = useTranslation("common");
const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores);
- const [editDialogOpen, setEditDialogOpen] = useState(false);
- const [editingItem, setEditingItem] = useState(null);
+ const [isEditMode, setIsEditMode] = useState(false);
const [isSaving, setIsSaving] = useState(false);
- const [formData, setFormData] = useState({
- name: "",
- range: "",
- weighting: "",
- remarks: "",
- });
+ const [draftWeightingById, setDraftWeightingById] = useState>({});
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 = {};
+ 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[]>(
() => [
- {
- field: "actions",
- headerName: t("Edit"),
- width: 100,
- sortable: false,
- renderCell: (params: GridRenderCellParams) => (
- handleEditClick(params.row)}
- color="primary"
- >
-
-
- ),
- },
{
field: "name",
headerName: t("Name"),
@@ -132,38 +168,60 @@ const BomWeightingScoreTable: React.FC & { Loading?: React.FC } = ({ bomW
field: "weighting",
headerName: t("Weighting"),
flex: 1,
- valueGetter: (params: GridValueGetterParams) => {
- 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 (
+ 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 (
<>
+
+
+ {isEditMode ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
& { Loading?: React.FC } = ({ bomW
autoHeight
disableRowSelectionOnClick
hideFooterPagination={true}
+ sx={{
+ "& .MuiDataGrid-columnHeaderTitle": { fontSize: 15 },
+ "& .MuiDataGrid-cell": { fontSize: 16 },
+ }}
/>
-
-
>
);
};
diff --git a/src/components/BomWeightingTabs/BomWeightingTabs.tsx b/src/components/BomWeightingTabs/BomWeightingTabs.tsx
new file mode 100644
index 0000000..fe7320e
--- /dev/null
+++ b/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 = ({ bomWeightingScores }) => {
+ const { t } = useTranslation("common");
+ const [tab, setTab] = useState(0);
+ const [bomScores, setBomScores] = useState(null);
+ const [loadingScores, setLoadingScores] = useState(false);
+ const [loadError, setLoadError] = useState(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 (
+ <>
+
+ setTab(v)}
+ indicatorColor="primary"
+ textColor="primary"
+ sx={{
+ pl: 2,
+ minHeight: 44,
+ }}
+ >
+
+
+
+
+
+
+ {tab === 0 && (
+
+ )}
+ {tab === 1 && (
+ loadingScores ? (
+
+ {t("Loading")}
+
+ ) : loadError ? (
+
+ {loadError}
+
+ ) : bomScores && bomScores.length > 0 ? (
+
+ ) : (
+
+ {t("No data available")}
+
+ )
+ )}
+
+ >
+ );
+};
+
+export default BomWeightingTabs;
+
diff --git a/src/components/BomWeightingTabs/index.ts b/src/components/BomWeightingTabs/index.ts
new file mode 100644
index 0000000..08a06c3
--- /dev/null
+++ b/src/components/BomWeightingTabs/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./BomWeightingTabs";
+
diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json
index ea5476d..1b21899 100644
--- a/src/i18n/zh/common.json
+++ b/src/i18n/zh/common.json
@@ -78,9 +78,16 @@
"User Group": "用戶群組",
"Items": "物料",
"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": "儲存",
diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomScoreRecalculateService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomScoreRecalculateService.kt
new file mode 100644
index 0000000..a0982cf
--- /dev/null
+++ b/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 = 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> = 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 = 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> = 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
+ }
+}
+
diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/BomScoreRecalcController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/BomScoreRecalcController.kt
new file mode 100644
index 0000000..1049735
--- /dev/null
+++ b/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)
+ }
+}
+