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 }, + }} /> - - - {t("Edit BOM Weighting Score")} - - - setFormData({ ...formData, range: e.target.value })} - margin="normal" - required - /> - setFormData({ ...formData, weighting: e.target.value })} - margin="normal" - required - /> - - - - - - ); }; 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) + } +} +