# 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 { Metadata } from "next"; | ||||
| import { getServerI18n, I18nProvider } from "@/i18n"; | import { getServerI18n, I18nProvider } from "@/i18n"; | ||||
| import PageTitleBar from "@/components/PageTitleBar"; | import PageTitleBar from "@/components/PageTitleBar"; | ||||
| import BomWeightingScoreTable from "@/components/BomWeightingScoreTable"; | |||||
| import BomWeightingTabs from "@/components/BomWeightingTabs"; | |||||
| import { fetchBomWeightingScores } from "@/app/api/settings/bomWeighting"; | import { fetchBomWeightingScores } from "@/app/api/settings/bomWeighting"; | ||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| @@ -16,7 +16,7 @@ const BomWeightingScorePage: React.FC = async () => { | |||||
| <> | <> | ||||
| <PageTitleBar title={t("BOM Weighting Score List")} className="mb-4" /> | <PageTitleBar title={t("BOM Weighting Score List")} className="mb-4" /> | ||||
| <I18nProvider namespaces={["common"]}> | <I18nProvider namespaces={["common"]}> | ||||
| <BomWeightingScoreTable bomWeightingScores={bomWeightingScores} /> | |||||
| <BomWeightingTabs bomWeightingScores={bomWeightingScores} /> | |||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -51,3 +51,12 @@ export async function importBom( | |||||
| ); | ); | ||||
| return response.data as Blob; | 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"; | import { cache } from "react"; | ||||
| export interface BomCombo { | 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 { | export interface BomFormatFileGroup { | ||||
| @@ -35,11 +35,26 @@ export interface ImportBomItemPayload { | |||||
| export const preloadBomCombo = (() => { | export const preloadBomCombo = (() => { | ||||
| fetchBomCombo() | 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 { useTranslation } from "react-i18next"; | ||||
| import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting"; | import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting"; | ||||
| import { updateBomWeightingScoreClient } from "@/app/api/settings/bomWeighting/client"; | 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 StyledDataGrid from "../StyledDataGrid"; | ||||
| import Paper from "@mui/material/Paper"; | 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 TextField from "@mui/material/TextField"; | ||||
| import Button from "@mui/material/Button"; | 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 { | interface Props { | ||||
| bomWeightingScores: BomWeightingScoreResult[]; | bomWeightingScores: BomWeightingScoreResult[]; | ||||
| @@ -24,98 +21,137 @@ interface Props { | |||||
| const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores }) => { | const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores }) => { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores); | 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 [isSaving, setIsSaving] = useState(false); | ||||
| const [formData, setFormData] = useState({ | |||||
| name: "", | |||||
| range: "", | |||||
| weighting: "", | |||||
| remarks: "", | |||||
| }); | |||||
| const [draftWeightingById, setDraftWeightingById] = useState<Record<number, string>>({}); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| setBomWeightingScores(initialBomWeightingScores); | setBomWeightingScores(initialBomWeightingScores); | ||||
| }, [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) => { | const handleSave = useCallback(async (e?: React.MouseEvent) => { | ||||
| e?.preventDefault(); | e?.preventDefault(); | ||||
| e?.stopPropagation(); | e?.stopPropagation(); | ||||
| if (!editingItem || isSaving) return; | |||||
| if (!isEditMode || isSaving) return; | |||||
| setIsSaving(true); | |||||
| try { | 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) { | } catch (error: any) { | ||||
| console.error("Error updating bom weighting score:", error); | 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."; | const errorMessage = error?.response?.data?.message || error?.message || t("Update Failed") || "Update failed. Please try again."; | ||||
| alert(errorMessage); | alert(errorMessage); | ||||
| } finally { | } finally { | ||||
| setIsSaving(false); | setIsSaving(false); | ||||
| } | } | ||||
| }, [editingItem, formData, t, handleCloseDialog, isSaving]); | |||||
| }, [isEditMode, isSaving, bomWeightingScores, draftWeightingById, extractWeightingNumber, cancelEditMode, t]); | |||||
| const columns = useMemo<GridColDef<BomWeightingScoreResult>[]>( | 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", | field: "name", | ||||
| headerName: t("Name"), | headerName: t("Name"), | ||||
| @@ -132,38 +168,60 @@ const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomW | |||||
| field: "weighting", | field: "weighting", | ||||
| headerName: t("Weighting"), | headerName: t("Weighting"), | ||||
| flex: 1, | 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) => { | valueFormatter: (params: GridValueFormatterParams) => { | ||||
| const value = params.value; | const value = params.value; | ||||
| if (value == null || value === undefined) return ""; | if (value == null || value === undefined) return ""; | ||||
| return typeof value === "number" ? value.toFixed(2) : ""; | 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 ( | return ( | ||||
| <> | <> | ||||
| <Paper variant="outlined" sx={{ overflow: "hidden" }}> | <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 | <StyledDataGrid | ||||
| rows={bomWeightingScores} | rows={bomWeightingScores} | ||||
| columns={columns} | columns={columns} | ||||
| @@ -171,51 +229,12 @@ const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomW | |||||
| autoHeight | autoHeight | ||||
| disableRowSelectionOnClick | disableRowSelectionOnClick | ||||
| hideFooterPagination={true} | hideFooterPagination={true} | ||||
| sx={{ | |||||
| "& .MuiDataGrid-columnHeaderTitle": { fontSize: 15 }, | |||||
| "& .MuiDataGrid-cell": { fontSize: 16 }, | |||||
| }} | |||||
| /> | /> | ||||
| </Paper> | </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", | label: "Items", | ||||
| path: "/settings/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 />, | icon: <Build />, | ||||
| label: "Equipment", | label: "Equipment", | ||||
| @@ -237,11 +222,6 @@ const NavigationContent: React.FC = () => { | |||||
| label: "Printer", | label: "Printer", | ||||
| path: "/settings/printer", | path: "/settings/printer", | ||||
| }, | }, | ||||
| //{ | |||||
| // icon: <Person />, | |||||
| // label: "Customer", | |||||
| // path: "/settings/user", | |||||
| //}, | |||||
| { | { | ||||
| icon: <VerifiedUser />, | icon: <VerifiedUser />, | ||||
| label: "QC Check Item", | label: "QC Check Item", | ||||
| @@ -257,6 +237,26 @@ const NavigationContent: React.FC = () => { | |||||
| label: "QC Item All", | label: "QC Item All", | ||||
| path: "/settings/qcItemAll", | 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 />, | icon: <QrCodeIcon />, | ||||
| label: "QR Code Handle", | label: "QR Code Handle", | ||||
| @@ -285,15 +285,23 @@ function PoInputGrid({ | |||||
| const stockInLineId = searchParams.get("stockInLineId"); | const stockInLineId = searchParams.get("stockInLineId"); | ||||
| const poLineId = searchParams.get("poLineId"); | 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 | // Open modal | ||||
| const openNewModal = useCallback(() => { | const openNewModal = useCallback(() => { | ||||
| @@ -1,7 +1,8 @@ | |||||
| "use client"; | "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 { Box, Paper, Typography } from "@mui/material"; | ||||
| import type { Result } from "@zxing/library"; | |||||
| import ReactQrCodeScanner, { | import ReactQrCodeScanner, { | ||||
| ScannerConfig, | ScannerConfig, | ||||
| defaultScannerConfig, | defaultScannerConfig, | ||||
| @@ -14,6 +15,15 @@ import PutAwayReviewGrid from "./PutAwayReviewGrid"; | |||||
| import type { PutAwayRecord } from "."; | import type { PutAwayRecord } from "."; | ||||
| import type { QrCodeScanner as QrCodeScannerType } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | 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 = { | type Props = { | ||||
| warehouse: WarehouseResult[]; | warehouse: WarehouseResult[]; | ||||
| }; | }; | ||||
| @@ -51,51 +61,93 @@ const PutAwayCamScan: React.FC<Props> = ({ warehouse }) => { | |||||
| } | } | ||||
| }, [scannedWareHouseId]); | }, [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( | const handleScan = useCallback( | ||||
| (rawText: string) => { | (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"); | 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 (!Number.isFinite(num) || num <= 0) return false; | ||||
| if (scannedSilId === 0) { | |||||
| setScannedSilId(num); | |||||
| } else if (scannedWareHouseId === 0) { | |||||
| setScannedWareHouseId(num); | |||||
| } | |||||
| setScannedSilId(num); | |||||
| done(); | |||||
| return true; | 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 { | 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 { | } 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], | [scannedSilId, scannedWareHouseId], | ||||
| ); | ); | ||||
| handleScanRef.current = handleScan; | |||||
| // Open modal only after both stock-in-line and location (warehouse) are scanned | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (scannedSilId > 0) { | |||||
| if (scannedSilId > 0 && scannedWareHouseId > 0) { | |||||
| setOpenPutAwayModal(true); | setOpenPutAwayModal(true); | ||||
| setScanStatus("pending"); | setScanStatus("pending"); | ||||
| } | } | ||||
| }, [scannedSilId]); | |||||
| }, [scannedSilId, scannedWareHouseId]); | |||||
| const closeModal = () => { | const closeModal = () => { | ||||
| setScannedSilId(0); | setScannedSilId(0); | ||||
| @@ -108,20 +160,26 @@ const PutAwayCamScan: React.FC<Props> = ({ warehouse }) => { | |||||
| if (scanStatus === "scanning") { | if (scanStatus === "scanning") { | ||||
| return t("Scanning"); | return t("Scanning"); | ||||
| } | } | ||||
| if (scannedSilId > 0) { | |||||
| if (scannedSilId > 0 && scannedWareHouseId > 0) { | |||||
| return t("Scanned, opening detail"); | return t("Scanned, opening detail"); | ||||
| } | } | ||||
| if (scannedSilId > 0) { | |||||
| return t("Please scan warehouse qr code"); | |||||
| } | |||||
| return t("Pending scan"); | 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 ( | return ( | ||||
| <> | <> | ||||
| @@ -54,12 +54,13 @@ const style = { | |||||
| height: "min(900px, calc(100vh - 48px))", | height: "min(900px, calc(100vh - 48px))", | ||||
| maxHeight: "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; | inputDetail: StockInLineInput | undefined; | ||||
| session: SessionWithTokens | null; | session: SessionWithTokens | null; | ||||
| warehouse?: any[]; | warehouse?: any[]; | ||||
| printerCombo: PrinterCombo[]; | printerCombo: PrinterCombo[]; | ||||
| onClose: () => void; | |||||
| onClose: (updatedStockInLine?: StockInLine) => void; | |||||
| skipQc?: Boolean; | skipQc?: Boolean; | ||||
| printSource?: "stockIn" | "productionProcess"; | printSource?: "stockIn" | "productionProcess"; | ||||
| uiMode?: "default" | "dashboard" | "productionProcess"; | uiMode?: "default" | "dashboard" | "productionProcess"; | ||||
| @@ -235,7 +236,16 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| ...defaultNewValue, | ...defaultNewValue, | ||||
| }, | }, | ||||
| }); | }); | ||||
| const closeWithResult = useCallback( | |||||
| (updatedStockInLine?: StockInLine) => { | |||||
| setStockInLineInfo(undefined); | |||||
| formProps.reset({}); | |||||
| onClose?.(updatedStockInLine); | |||||
| }, | |||||
| [onClose], | |||||
| ); | |||||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | ||||
| () => { | () => { | ||||
| setStockInLineInfo(undefined); | setStockInLineInfo(undefined); | ||||
| @@ -418,6 +428,9 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| // const qcData = data; | // const qcData = data; | ||||
| console.log("QC Data for submission:", qcData); | console.log("QC Data for submission:", qcData); | ||||
| let qcRes: StockInLine | undefined; | |||||
| if (data.qcDecision == 3) { // Escalate | if (data.qcDecision == 3) { // Escalate | ||||
| if (data.escalationLog?.handlerId == undefined) { alert("請選擇上報負責同事!"); return; } | if (data.escalationLog?.handlerId == undefined) { alert("請選擇上報負責同事!"); return; } | ||||
| else if (data.escalationLog?.handlerId < 1) { 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); | 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 { | } 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) { | if (qcData.qcAccept) { | ||||
| @@ -491,10 +506,10 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| } | } | ||||
| } | } | ||||
| closeHandler({}, "backdropClick"); | |||||
| closeWithResult(qcRes); | |||||
| // setTabIndex(1); // Need to go Putaway tab? | // setTabIndex(1); // Need to go Putaway tab? | ||||
| } else { | } else { | ||||
| closeHandler({}, "backdropClick"); | |||||
| closeWithResult(qcRes); | |||||
| } | } | ||||
| setIsSubmitting(false); | setIsSubmitting(false); | ||||
| msg("已更新來貨狀態"); | msg("已更新來貨狀態"); | ||||
| @@ -78,10 +78,17 @@ | |||||
| "user": "用戶", | "user": "用戶", | ||||
| "User Group": "用戶群組", | "User Group": "用戶群組", | ||||
| "Items": "物料", | "Items": "物料", | ||||
| "BOM Weighting Score List": "物料清單", | |||||
| "BOM Weighting Score List": "物料清單權重得分", | |||||
| "Material Weighting": "物料清單加權", | |||||
| "Material Score": "物料清單得分", | |||||
| "Coming soon": "即將推出", | |||||
| "Base Score": "基礎得分", | |||||
| "Column Name": "欄位名稱", | "Column Name": "欄位名稱", | ||||
| "Range": "範圍", | "Range": "範圍", | ||||
| "Weighting": "權重", | "Weighting": "權重", | ||||
| "Total weighting must equal 1": "權重總和必須等於 1", | |||||
| "Current total": "目前總和", | |||||
| "Weighting must be a number": "權重必須為數字", | |||||
| "Edit": "編輯", | "Edit": "編輯", | ||||
| "Edit BOM Weighting Score": "編輯物料清單", | "Edit BOM Weighting Score": "編輯物料清單", | ||||
| "Save": "儲存", | "Save": "儲存", | ||||
| @@ -132,12 +139,12 @@ | |||||
| "Day Before Yesterday": "前天", | "Day Before Yesterday": "前天", | ||||
| "Select Date": "選擇日期", | "Select Date": "選擇日期", | ||||
| "Production Date": "生產日期", | "Production Date": "生產日期", | ||||
| "QC Check Item": "QC品檢項目", | |||||
| "QC Category": "QC品檢模板", | |||||
| "QC Check Item": "QC 品檢項目", | |||||
| "QC Category": "QC 品檢模板", | |||||
| "QC Item All": "QC 綜合管理", | "QC Item All": "QC 綜合管理", | ||||
| "qcItemAll": "QC 綜合管理", | "qcItemAll": "QC 綜合管理", | ||||
| "qcCategory": "品檢模板", | "qcCategory": "品檢模板", | ||||
| "QC Check Template": "QC檢查模板", | |||||
| "QC Check Template": "QC 檢查模板", | |||||
| "Mail": "郵件", | "Mail": "郵件", | ||||
| "Import Testing": "匯入測試", | "Import Testing": "匯入測試", | ||||
| "FG":"成品", | "FG":"成品", | ||||
| @@ -150,8 +157,8 @@ | |||||
| "qcItem":"品檢項目", | "qcItem":"品檢項目", | ||||
| "Item":"物料", | "Item":"物料", | ||||
| "Production Date":"生產日期", | "Production Date":"生產日期", | ||||
| "QC Check Item":"QC品檢項目", | |||||
| "QC Category":"QC品檢模板", | |||||
| "QC Check Item":"QC 品檢項目", | |||||
| "QC Category":"QC 品檢模板", | |||||
| "QC Item All":"QC 綜合管理", | "QC Item All":"QC 綜合管理", | ||||
| "qcCategory":"品檢模板", | "qcCategory":"品檢模板", | ||||
| "QC Check Template":"QC檢查模板", | "QC Check Template":"QC檢查模板", | ||||
| @@ -498,11 +505,11 @@ | |||||
| "Handled By": "處理者", | "Handled By": "處理者", | ||||
| "submit": "提交", | "submit": "提交", | ||||
| "Received Qty": "接收數量", | "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) | |||||
| } | |||||
| } | |||||