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 index 8d7fa05..0af97fb 100644 --- a/src/app/api/bom/client.ts +++ b/src/app/api/bom/client.ts @@ -51,3 +51,12 @@ export async function importBom( ); return response.data as Blob; } +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 f64361f..56d8a64 100644 --- a/src/app/api/bom/index.ts +++ b/src/app/api/bom/index.ts @@ -3,12 +3,12 @@ import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; export interface BomCombo { - id: number; - value: number; - label: string; - outputQty: number; - outputQtyUom: string; - description: string; + id: number; + value: number; + label: string; + outputQty: number; + outputQtyUom: string; + description: string; } export interface BomFormatFileGroup { @@ -35,11 +35,26 @@ export interface ImportBomItemPayload { export const preloadBomCombo = (() => { fetchBomCombo() }) +export interface BomScoreResult { + id: number; + code: string; + name: string; + baseScore: number | string | { value?: number; [key: string]: any }; +} -export const fetchBomCombo = cache(async () => { - return serverFetchJson(`${BASE_API_URL}/bom/combo`, { - next: { tags: ["bomCombo"] }, - }) -}) +export const preloadBomCombo = () => { + fetchBomCombo(); +}; +export const fetchBomCombo = cache(async () => { + 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/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 8a24de1..ae1c632 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -207,21 +207,6 @@ const NavigationContent: React.FC = () => { label: "Items", path: "/settings/items", }, - { - icon: , - label: "BOM Weighting Score List", - path: "/settings/bomWeighting", - }, - { - icon: , - label: "ShopAndTruck", - path: "/settings/shop", - }, - { - icon: , - label: "Demand Forecast Setting", - path: "/settings/rss", - }, { icon: , label: "Equipment", @@ -237,11 +222,6 @@ const NavigationContent: React.FC = () => { label: "Printer", path: "/settings/printer", }, - //{ - // icon: , - // label: "Customer", - // path: "/settings/user", - //}, { icon: , label: "QC Check Item", @@ -257,6 +237,26 @@ const NavigationContent: React.FC = () => { label: "QC Item All", path: "/settings/qcItemAll", }, + { + icon: , + label: "ShopAndTruck", + path: "/settings/shop", + }, + { + icon: , + label: "Demand Forecast Setting", + path: "/settings/rss", + }, + //{ + // icon: , + // label: "Customer", + // path: "/settings/user", + //}, + { + icon: , + label: "BOM Weighting Score List", + path: "/settings/bomWeighting", + }, { icon: , label: "QR Code Handle", diff --git a/src/components/PoDetail/PoInputGrid.tsx b/src/components/PoDetail/PoInputGrid.tsx index b9cb8d9..fb66411 100644 --- a/src/components/PoDetail/PoInputGrid.tsx +++ b/src/components/PoDetail/PoInputGrid.tsx @@ -285,15 +285,23 @@ function PoInputGrid({ const stockInLineId = searchParams.get("stockInLineId"); const poLineId = searchParams.get("poLineId"); -const closeNewModal = useCallback(() => { - const newParams = new URLSearchParams(searchParams.toString()); - newParams.delete("stockInLineId"); // Remove the parameter - router.replace(`${pathname}?${newParams.toString()}`); - fetchPoDetail(itemDetail.purchaseOrderId.toString()); - setNewOpen(false); // Close the modal first - // setTimeout(() => { - // }, 300); // Add a delay to avoid immediate re-trigger of useEffect - }, [searchParams, pathname, router]); + const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => { + const newParams = new URLSearchParams(searchParams.toString()); + newParams.delete("stockInLineId"); + if (typeof window !== "undefined") { + window.history.replaceState({}, "", `${pathname}?${newParams.toString()}`); + } + setNewOpen(false); + + if (updatedStockInLine?.id != null) { + setEntries((prev) => + prev.map((e) => (e.id === updatedStockInLine.id ? { ...e, ...updatedStockInLine } : e)) + ); + setStockInLine((prev) => + (prev || []).map((p) => (p.id === updatedStockInLine.id ? { ...p, ...updatedStockInLine } : p)) + ); + } + }, [pathname, searchParams]); // Open modal const openNewModal = useCallback(() => { diff --git a/src/components/PutAwayScan/PutAwayCamScan.tsx b/src/components/PutAwayScan/PutAwayCamScan.tsx index 85a0b9a..20ae585 100644 --- a/src/components/PutAwayScan/PutAwayCamScan.tsx +++ b/src/components/PutAwayScan/PutAwayCamScan.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Paper, Typography } from "@mui/material"; +import type { Result } from "@zxing/library"; import ReactQrCodeScanner, { ScannerConfig, defaultScannerConfig, @@ -14,6 +15,15 @@ import PutAwayReviewGrid from "./PutAwayReviewGrid"; import type { PutAwayRecord } from "."; import type { QrCodeScanner as QrCodeScannerType } from "../QrCodeScannerProvider/QrCodeScannerProvider"; +/** Find first number after a keyword in a string (e.g. "StockInLine" or "warehouseId"). */ +function findIdByRoughMatch(inputString: string, keyword: string): number | null { + const idx = inputString.indexOf(keyword); + if (idx === -1) return null; + const after = inputString.slice(idx + keyword.length); + const match = after.match(/\d+/); + return match ? parseInt(match[0], 10) : null; +} + type Props = { warehouse: WarehouseResult[]; }; @@ -51,51 +61,93 @@ const PutAwayCamScan: React.FC = ({ warehouse }) => { } }, [scannedWareHouseId]); + // Refs so the scanner (which only gets config on mount) always calls the latest handler and we throttle duplicates + const handleScanRef = useRef<(rawText: string) => void>(() => {}); + const lastScannedRef = useRef({ text: "", at: 0 }); + const THROTTLE_MS = 2000; + const handleScan = useCallback( (rawText: string) => { - if (!rawText) return; + const trimmed = (rawText || "").trim(); + if (!trimmed) return; + const now = Date.now(); + if ( + lastScannedRef.current.text === trimmed && + now - lastScannedRef.current.at < THROTTLE_MS + ) { + return; + } setScanStatus("scanning"); - const trySetNumeric = (value: unknown) => { - const num = Number(value); + const done = () => { + lastScannedRef.current = { text: trimmed, at: now }; + }; + + const trySetSilId = (num: number): boolean => { if (!Number.isFinite(num) || num <= 0) return false; - if (scannedSilId === 0) { - setScannedSilId(num); - } else if (scannedWareHouseId === 0) { - setScannedWareHouseId(num); - } + setScannedSilId(num); + done(); return true; }; + const trySetWarehouseId = (num: number): boolean => { + if (!Number.isFinite(num) || num <= 0) return false; + setScannedWareHouseId(num); + done(); + return true; + }; + + const isFirstScan = scannedSilId === 0; + const isSecondScan = scannedSilId > 0 && scannedWareHouseId === 0; - // 1) Try JSON payload first + // 1) Try JSON try { - const data = JSON.parse(rawText) as any; - if (data) { - if (scannedSilId === 0) { - if (data.stockInLineId && trySetNumeric(data.stockInLineId)) return; - if (data.value && trySetNumeric(data.value)) return; - } else { - if (data.warehouseId && trySetNumeric(data.warehouseId)) return; - if (data.value && trySetNumeric(data.value)) return; + const data = JSON.parse(trimmed) as Record; + if (data && typeof data === "object") { + if (isFirstScan) { + if (data.stockInLineId != null && trySetSilId(Number(data.stockInLineId))) return; + if (data.value != null && trySetSilId(Number(data.value))) return; + } + if (isSecondScan) { + if (data.warehouseId != null && trySetWarehouseId(Number(data.warehouseId))) return; + if (data.value != null && trySetWarehouseId(Number(data.value))) return; } } } catch { - // Not JSON – fall through to numeric parsing + // not JSON } - // 2) Fallback: plain numeric content - if (trySetNumeric(rawText)) return; + // 2) Rough match: "StockInLine" or "warehouseId" + number (same as barcode scanner) + if (isFirstScan) { + const sil = + findIdByRoughMatch(trimmed, "StockInLine") ?? + findIdByRoughMatch(trimmed, "stockInLineId"); + if (sil != null && trySetSilId(sil)) return; + } + if (isSecondScan) { + const wh = + findIdByRoughMatch(trimmed, "warehouseId") ?? + findIdByRoughMatch(trimmed, "WarehouseId"); + if (wh != null && trySetWarehouseId(wh)) return; + } + + // 3) Plain number + const num = Number(trimmed); + if (isFirstScan && trySetSilId(num)) return; + if (isSecondScan && trySetWarehouseId(num)) return; }, [scannedSilId, scannedWareHouseId], ); + handleScanRef.current = handleScan; + + // Open modal only after both stock-in-line and location (warehouse) are scanned useEffect(() => { - if (scannedSilId > 0) { + if (scannedSilId > 0 && scannedWareHouseId > 0) { setOpenPutAwayModal(true); setScanStatus("pending"); } - }, [scannedSilId]); + }, [scannedSilId, scannedWareHouseId]); const closeModal = () => { setScannedSilId(0); @@ -108,20 +160,26 @@ const PutAwayCamScan: React.FC = ({ warehouse }) => { if (scanStatus === "scanning") { return t("Scanning"); } - if (scannedSilId > 0) { + if (scannedSilId > 0 && scannedWareHouseId > 0) { return t("Scanned, opening detail"); } + if (scannedSilId > 0) { + return t("Please scan warehouse qr code"); + } return t("Pending scan"); - }, [scanStatus, scannedSilId, t]); - - const scannerConfig: ScannerConfig = { - ...defaultScannerConfig, - onUpdate: (_err, result) => { - if (result) { - handleScan(result.getText()); - } - }, - }; + }, [scanStatus, scannedSilId, scannedWareHouseId, t]); + + const scannerConfig: ScannerConfig = useMemo( + () => ({ + ...defaultScannerConfig, + onUpdate: (_err: unknown, result?: Result): void => { + if (result) { + handleScanRef.current(result.getText()); + } + }, + }), + [], + ); return ( <> diff --git a/src/components/Qc/QcStockInModal.tsx b/src/components/Qc/QcStockInModal.tsx index dc6d4d3..c2fcafb 100644 --- a/src/components/Qc/QcStockInModal.tsx +++ b/src/components/Qc/QcStockInModal.tsx @@ -54,12 +54,13 @@ const style = { height: "min(900px, calc(100vh - 48px))", maxHeight: "calc(100vh - 48px)", }; -interface CommonProps extends Omit { +interface CommonProps extends Omit { + // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] } | undefined; inputDetail: StockInLineInput | undefined; session: SessionWithTokens | null; warehouse?: any[]; printerCombo: PrinterCombo[]; - onClose: () => void; + onClose: (updatedStockInLine?: StockInLine) => void; skipQc?: Boolean; printSource?: "stockIn" | "productionProcess"; uiMode?: "default" | "dashboard" | "productionProcess"; @@ -235,7 +236,16 @@ const QcStockInModal: React.FC = ({ ...defaultNewValue, }, }); - + + const closeWithResult = useCallback( + (updatedStockInLine?: StockInLine) => { + setStockInLineInfo(undefined); + formProps.reset({}); + onClose?.(updatedStockInLine); + }, + [onClose], + ); + const closeHandler = useCallback>( () => { setStockInLineInfo(undefined); @@ -418,6 +428,9 @@ const QcStockInModal: React.FC = ({ // const qcData = data; console.log("QC Data for submission:", qcData); + + let qcRes: StockInLine | undefined; + if (data.qcDecision == 3) { // Escalate if (data.escalationLog?.handlerId == undefined) { alert("請選擇上報負責同事!"); return; } else if (data.escalationLog?.handlerId < 1) { alert("上報負責同事資料有誤"); return; } @@ -431,12 +444,14 @@ const QcStockInModal: React.FC = ({ } console.log("Escalation Data for submission", escalationLog); - setIsSubmitting(true); //TODO improve - await postStockInLine({...qcData, escalationLog}); + setIsSubmitting(true); + const resEscalate = await postStockInLine({...qcData, escalationLog}); + qcRes = Array.isArray(resEscalate.entity) ? resEscalate.entity[0] : (resEscalate.entity as StockInLine); } else { - setIsSubmitting(true); //TODO improve - await postStockInLine(qcData); + setIsSubmitting(true); + const resNormal = await postStockInLine(qcData); + qcRes = Array.isArray(resNormal.entity) ? resNormal.entity[0] : (resNormal.entity as StockInLine); } if (qcData.qcAccept) { @@ -491,10 +506,10 @@ const QcStockInModal: React.FC = ({ } } - closeHandler({}, "backdropClick"); + closeWithResult(qcRes); // setTabIndex(1); // Need to go Putaway tab? } else { - closeHandler({}, "backdropClick"); + closeWithResult(qcRes); } setIsSubmitting(false); msg("已更新來貨狀態"); diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 60f3be9..2bab585 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -78,10 +78,17 @@ "user": "用戶", "User Group": "用戶群組", "Items": "物料", - "BOM Weighting Score List": "物料清單", + "BOM Weighting Score List": "物料清單權重得分", + "Material Weighting": "物料清單加權", + "Material Score": "物料清單得分", + "Coming soon": "即將推出", + "Base Score": "基礎得分", "Column Name": "欄位名稱", "Range": "範圍", "Weighting": "權重", + "Total weighting must equal 1": "權重總和必須等於 1", + "Current total": "目前總和", + "Weighting must be a number": "權重必須為數字", "Edit": "編輯", "Edit BOM Weighting Score": "編輯物料清單", "Save": "儲存", @@ -132,12 +139,12 @@ "Day Before Yesterday": "前天", "Select Date": "選擇日期", "Production Date": "生產日期", - "QC Check Item": "QC品檢項目", - "QC Category": "QC品檢模板", + "QC Check Item": "QC 品檢項目", + "QC Category": "QC 品檢模板", "QC Item All": "QC 綜合管理", "qcItemAll": "QC 綜合管理", "qcCategory": "品檢模板", - "QC Check Template": "QC檢查模板", + "QC Check Template": "QC 檢查模板", "Mail": "郵件", "Import Testing": "匯入測試", "FG":"成品", @@ -150,8 +157,8 @@ "qcItem":"品檢項目", "Item":"物料", "Production Date":"生產日期", - "QC Check Item":"QC品檢項目", - "QC Category":"QC品檢模板", + "QC Check Item":"QC 品檢項目", + "QC Category":"QC 品檢模板", "QC Item All":"QC 綜合管理", "qcCategory":"品檢模板", "QC Check Template":"QC檢查模板", @@ -498,11 +505,11 @@ "Handled By": "處理者", "submit": "提交", "Received Qty": "接收數量", - "bomWeighting": "物料清單", - "Now": "現時", - "Last updated": "最後更新", - "Auto-refresh every 5 minutes": "每5分鐘自動刷新", - "Auto-refresh every 10 minutes": "每10分鐘自動刷新", - "Auto-refresh every 15 minutes": "每15分鐘自動刷新", - "Auto-refresh every 1 minute": "每1分鐘自動刷新" + "bomWeighting": "物料清單權重得分", + "Now": "現時", + "Last updated": "最後更新", + "Auto-refresh every 5 minutes": "每5分鐘自動刷新", + "Auto-refresh every 10 minutes": "每10分鐘自動刷新", + "Auto-refresh every 15 minutes": "每15分鐘自動刷新", + "Auto-refresh every 1 minute": "每1分鐘自動刷新" } \ No newline at end of file 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) + } +} +