Browse Source

Bom Supporting Function

reset-do-picking-order
B.E.N.S.O.N 2 weeks ago
parent
commit
51e4f705c3
12 changed files with 650 additions and 151 deletions
  1. +2
    -2
      src/app/(main)/settings/bomWeighting/page.tsx
  2. +13
    -0
      src/app/api/bom/client.ts
  3. +25
    -13
      src/app/api/bom/index.ts
  4. +16
    -0
      src/app/api/bom/recalculateClient.ts
  5. +82
    -0
      src/components/BomScoreTable/BomScoreTable.tsx
  6. +2
    -0
      src/components/BomScoreTable/index.ts
  7. +155
    -136
      src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx
  8. +132
    -0
      src/components/BomWeightingTabs/BomWeightingTabs.tsx
  9. +2
    -0
      src/components/BomWeightingTabs/index.ts
  10. +7
    -0
      src/i18n/zh/common.json
  11. +160
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomScoreRecalculateService.kt
  12. +54
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/BomScoreRecalcController.kt

+ 2
- 2
src/app/(main)/settings/bomWeighting/page.tsx View File

@@ -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>
</>
);


+ 13
- 0
src/app/api/bom/client.ts View File

@@ -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<BomScoreResult[]> => {
const response = await axiosInstance.get<BomScoreResult[]>(
`${NEXT_PUBLIC_API_URL}/bom/scores`,
);
return response.data;
};


+ 25
- 13
src/app/api/bom/index.ts View File

@@ -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<BomCombo[]>(`${BASE_API_URL}/bom/combo`, {
next: { tags: ["bomCombo"] },
})
})
return serverFetchJson<BomCombo[]>(`${BASE_API_URL}/bom/combo`, {
next: { tags: ["bomCombo"] },
});
});

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


+ 16
- 0
src/app/api/bom/recalculateClient.ts View File

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

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

export interface BomScoreRecalcResponse {
updatedCount: number;
}

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


+ 82
- 0
src/components/BomScoreTable/BomScoreTable.tsx View File

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

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

interface Props {
boms: BomScoreResult[];
}

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

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

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

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

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

export default BomScoreTable;


+ 2
- 0
src/components/BomScoreTable/index.ts View File

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


+ 155
- 136
src/components/BomWeightingScoreTable/BomWeightingScoreTable.tsx View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

let updatedCount: number | null = null;

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

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

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

cancelEditMode();

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

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

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

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

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


+ 132
- 0
src/components/BomWeightingTabs/BomWeightingTabs.tsx View File

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

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

interface Props {
bomWeightingScores: BomWeightingScoreResult[];
}

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

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

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

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

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

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

export default BomWeightingTabs;


+ 2
- 0
src/components/BomWeightingTabs/index.ts View File

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


+ 7
- 0
src/i18n/zh/common.json View File

@@ -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": "儲存",


+ 160
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomScoreRecalculateService.kt View File

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

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

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

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

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

bomRepository.saveAll(boms)
return boms.size
}

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

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

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

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

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

return sum
}
}

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

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

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

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

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

bomRepository.saveAll(boms)
return boms.size
}

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

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

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

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

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

return sum
}
}


+ 54
- 0
src/main/java/com/ffii/fpsms/modules/master/web/BomScoreRecalcController.kt View File

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


Loading…
Cancel
Save