diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index dc105cd..d655829 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -81,11 +81,13 @@ export const getInventoryLotDetailsBySection = async ( stockTakeSection: string, stockTakeId?: number | null, pageNum?: number, - pageSize?: number + pageSize?: number, + stockTakeRoundId?: number | null ) => { console.log('🌐 [API] getInventoryLotDetailsBySection called with:', { stockTakeSection, stockTakeId, + stockTakeRoundId, pageNum, pageSize }); @@ -95,6 +97,9 @@ export const getInventoryLotDetailsBySection = async ( if (stockTakeId != null && stockTakeId > 0) { url += `&stockTakeId=${stockTakeId}`; } + if (stockTakeRoundId != null && stockTakeRoundId > 0) { + url += `&stockTakeRoundId=${stockTakeRoundId}`; + } console.log(' [API] Full URL:', url); @@ -476,7 +481,8 @@ export const getInventoryLotDetailsBySectionNotMatch = async ( stockTakeSection: string, stockTakeId?: number | null, pageNum: number = 0, - pageSize: number = 10 + pageSize: number = 10, + stockTakeRoundId?: number | null ) => { const encodedSection = encodeURIComponent(stockTakeSection); let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySectionNotMatch?stockTakeSection=${encodedSection}&pageNum=${pageNum}`; @@ -490,6 +496,9 @@ export const getInventoryLotDetailsBySectionNotMatch = async ( if (stockTakeId != null && stockTakeId > 0) { url += `&stockTakeId=${stockTakeId}`; } + if (stockTakeRoundId != null && stockTakeRoundId > 0) { + url += `&stockTakeRoundId=${stockTakeRoundId}`; + } const response = await serverFetchJson>( url, diff --git a/src/components/StockTakeManagement/ApproverStockTake.tsx b/src/components/StockTakeManagement/ApproverStockTake.tsx index 49d806e..f677f73 100644 --- a/src/components/StockTakeManagement/ApproverStockTake.tsx +++ b/src/components/StockTakeManagement/ApproverStockTake.tsx @@ -116,7 +116,10 @@ const ApproverStockTake: React.FC = ({ selectedSession.stockTakeSession, selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, pageNum, - actualSize + actualSize, + selectedSession.stockTakeRoundId != null && selectedSession.stockTakeRoundId > 0 + ? selectedSession.stockTakeRoundId + : null ); setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); setTotal(response.total || 0); diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index eebcfc8..2c6b57a 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -33,8 +33,6 @@ import { saveApproverStockTakeRecord, getApproverInventoryLotDetailsAllPending, getApproverInventoryLotDetailsAllApproved, - BatchSaveApproverStockTakeAllRequest, - batchSaveApproverStockTakeRecordsAll, updateStockTakeRecordStatusToNotMatch, type ApproverInventoryLotDetailsQuery, } from "@/app/api/stockTake/actions"; @@ -94,6 +92,79 @@ function hasAnyApproverSearchCriterion(f: ApproverSearchFilters): boolean { ); } +function isBlankApproverField(value: string | undefined): boolean { + return value == null || String(value).trim() === ""; +} + +/** 審核員「盤點數」與「不良數」皆未輸入時應跳過儲存,維持 pending */ +function isApproverInputsBothEmpty( + detailId: number, + approverQty: Record, + approverBadQty: Record +): boolean { + return isBlankApproverField(approverQty[detailId]) && isBlankApproverField(approverBadQty[detailId]); +} + +type ApproverSaveBuildResult = + | { + ok: true; + request: SaveApproverStockTakeRecordRequest; + finalQty: number; + finalBadQty: number; + goodQty: number; + selection: QtySelectionType; + } + | { ok: false; reason: "skip_approver_empty" } + | { ok: false; reason: "error"; message: string }; + +function buildApproverSaveRequest( + detail: InventoryLotDetailResponse, + qtySelection: Record, + approverQty: Record, + approverBadQty: Record, + currentUserId: number, + t: (key: string) => string +): ApproverSaveBuildResult { + const selection = qtySelection[detail.id] || "first"; + let finalQty: number; + let finalBadQty: number; + + if (selection === "first") { + if (detail.firstStockTakeQty == null) { + return { ok: false, reason: "error", message: t("First QTY is not available") }; + } + finalQty = detail.firstStockTakeQty; + finalBadQty = detail.firstBadQty || 0; + } else if (selection === "second") { + if (detail.secondStockTakeQty == null) { + return { ok: false, reason: "error", message: t("Second QTY is not available") }; + } + finalQty = detail.secondStockTakeQty; + finalBadQty = detail.secondBadQty || 0; + } else { + if (isApproverInputsBothEmpty(detail.id, approverQty, approverBadQty)) { + return { ok: false, reason: "skip_approver_empty" }; + } + const approverQtyValue = approverQty[detail.id] || "0"; + const approverBadQtyValue = approverBadQty[detail.id] || "0"; + finalQty = parseFloat(approverQtyValue) || 0; + finalBadQty = parseFloat(approverBadQtyValue) || 0; + } + + const request: SaveApproverStockTakeRecordRequest = { + stockTakeRecordId: detail.stockTakeRecordId || null, + qty: finalQty, + badQty: finalBadQty, + approverId: currentUserId, + approverQty: selection === "approver" ? finalQty : null, + approverBadQty: selection === "approver" ? finalBadQty : null, + lastSelect: selection === "first" ? 1 : selection === "second" ? 2 : 3, + }; + + const goodQty = finalQty - finalBadQty; + return { ok: true, request, finalQty, finalBadQty, goodQty, selection }; +} + function parseDateTimeMs( v: string | string[] | null | undefined ): number { @@ -528,52 +599,31 @@ const ApproverStockTakeAll: React.FC = ({ return; } - const selection = qtySelection[detail.id] || "first"; - let finalQty: number; - let finalBadQty: number; - - if (selection === "first") { - if (detail.firstStockTakeQty == null) { - onSnackbar(t("First QTY is not available"), "error"); - return; - } - finalQty = detail.firstStockTakeQty; - finalBadQty = detail.firstBadQty || 0; - } else if (selection === "second") { - if (detail.secondStockTakeQty == null) { - onSnackbar(t("Second QTY is not available"), "error"); + const built = buildApproverSaveRequest( + detail, + qtySelection, + approverQty, + approverBadQty, + currentUserId, + t + ); + if (!built.ok) { + if (built.reason === "skip_approver_empty") { + onSnackbar(t("Approver input empty; save skipped, row remains pending"), "warning"); return; } - - finalQty = detail.secondStockTakeQty; - finalBadQty = detail.secondBadQty || 0; - } else { - // 与 Picker 逻辑一致:Approver 输入为空时按 0 处理 - const approverQtyValue = approverQty[detail.id] || "0"; - const approverBadQtyValue = approverBadQty[detail.id] || "0"; - finalQty = parseFloat(approverQtyValue) || 0; - finalBadQty = parseFloat(approverBadQtyValue) || 0; + onSnackbar(built.message, "error"); + return; } + const { request, goodQty, finalQty, finalBadQty, selection } = built; + setSaving(true); try { - const request: SaveApproverStockTakeRecordRequest = { - stockTakeRecordId: detail.stockTakeRecordId || null, - qty: finalQty, - badQty: finalBadQty, - approverId: currentUserId, - approverQty: selection === "approver" ? finalQty : null, - approverBadQty: selection === "approver" ? finalBadQty : null, - // lastSelect: 1=First, 2=Second, 3=Approver Input - lastSelect: selection === "first" ? 1 : selection === "second" ? 2 : 3, - }; - await saveApproverStockTakeRecord(request, selectedSession.stockTakeId); onSnackbar(t("Approver stock take record saved successfully"), "success"); - const goodQty = finalQty - finalBadQty; - setInventoryLotDetails((prev) => prev.map((d) => d.id === detail.id @@ -666,53 +716,107 @@ const ApproverStockTakeAll: React.FC = ({ if (!selectedSession || !currentUserId) { return; } + if (inventoryLotDetails.length === 0) { + onSnackbar(t("No rows loaded; set search criteria and search first"), "warning"); + return; + } setBatchSaving(true); + let successCount = 0; + let skippedApproverEmpty = 0; + let errorCount = 0; + try { - const request: BatchSaveApproverStockTakeAllRequest = { - stockTakeId: selectedSession.stockTakeId, - approverId: currentUserId, - itemKeyword: appliedFilters?.itemKeyword || null, - warehouseKeyword: appliedFilters?.warehouseKeyword || null, - sectionDescription: - appliedFilters?.sectionDescription && appliedFilters.sectionDescription !== "All" - ? appliedFilters.sectionDescription - : null, - stockTakeSections: appliedFilters?.stockTakeSession || null, - }; + for (const detail of sortedDetails) { + if (detail.stockTakeRecordStatus === "completed") { + continue; + } - const result = await batchSaveApproverStockTakeRecordsAll(request); + const built = buildApproverSaveRequest( + detail, + qtySelection, + approverQty, + approverBadQty, + currentUserId, + t + ); + if (!built.ok) { + if (built.reason === "skip_approver_empty") { + skippedApproverEmpty += 1; + continue; + } + errorCount += 1; + continue; + } + + try { + await saveApproverStockTakeRecord(built.request, selectedSession.stockTakeId); + successCount += 1; + const { goodQty, finalQty, finalBadQty, selection } = built; + setInventoryLotDetails((prev) => + prev.map((d) => + d.id === detail.id + ? { + ...d, + finalQty: goodQty, + approverQty: selection === "approver" ? finalQty : d.approverQty, + approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty, + stockTakeRecordStatus: "completed", + } + : d + ) + ); + } catch (e: any) { + errorCount += 1; + let msg = e?.message || t("Failed to save approver stock take record"); + if (e?.response) { + try { + const errorData = await e.response.json(); + msg = errorData.message || errorData.error || msg; + } catch { + /* ignore */ + } + } + console.error("Batch save row failed", detail.id, msg); + } + } onSnackbar( - t("Batch approver save completed: {{success}} success, {{errors}} errors", { - success: result.successCount, - errors: result.errorCount, + t("Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors", { + success: successCount, + skipped: skippedApproverEmpty, + errors: errorCount, }), - result.errorCount > 0 ? "warning" : "success" + errorCount > 0 ? "warning" : "success" ); - if (appliedFilters) { + if (appliedFilters && successCount > 0) { await loadDetails(appliedFilters); } } catch (e: any) { console.error("handleBatchSubmitAll (all): Error:", e); let errorMessage = t("Failed to batch save approver stock take records"); - if (e?.message) { errorMessage = e.message; - } else if (e?.response) { - try { - const errorData = await e.response.json(); - errorMessage = errorData.message || errorData.error || errorMessage; - } catch { - } } - onSnackbar(errorMessage, "error"); } finally { setBatchSaving(false); } - }, [selectedSession, currentUserId, variancePercentTolerance, t, onSnackbar, loadDetails, page, pageSize, mode, appliedFilters]); + }, [ + selectedSession, + currentUserId, + t, + onSnackbar, + loadDetails, + mode, + appliedFilters, + inventoryLotDetails.length, + sortedDetails, + qtySelection, + approverQty, + approverBadQty, + ]); const formatNumber = (num: number | null | undefined): string => { if (num == null) return "0"; diff --git a/src/components/StockTakeManagement/PickerReStockTake.tsx b/src/components/StockTakeManagement/PickerReStockTake.tsx index 84645a7..239e364 100644 --- a/src/components/StockTakeManagement/PickerReStockTake.tsx +++ b/src/components/StockTakeManagement/PickerReStockTake.tsx @@ -111,7 +111,10 @@ const PickerReStockTake: React.FC = ({ selectedSession.stockTakeSession, selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, pageNum, - actualSize + actualSize, + selectedSession.stockTakeRoundId != null && selectedSession.stockTakeRoundId > 0 + ? selectedSession.stockTakeRoundId + : null ); setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); setTotal(response.total || 0); diff --git a/src/components/StockTakeManagement/PickerStockTake.tsx b/src/components/StockTakeManagement/PickerStockTake.tsx index 53bd8c0..3984bf2 100644 --- a/src/components/StockTakeManagement/PickerStockTake.tsx +++ b/src/components/StockTakeManagement/PickerStockTake.tsx @@ -125,7 +125,10 @@ const PickerStockTake: React.FC = ({ selectedSession.stockTakeSession, selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, pageNum, - actualSize + actualSize, + selectedSession.stockTakeRoundId != null && selectedSession.stockTakeRoundId > 0 + ? selectedSession.stockTakeRoundId + : null ); setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); setTotal(response.total || 0); diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index daaf142..3ad5e02 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -181,6 +181,9 @@ "UNAVAILABLE": "不可用", "No issues found": "未找到問題", "Approver stock take record saved successfully": "審核員盤點記錄保存成功", + "Approver input empty; save skipped, row remains pending": "審核員盤點數與不良數皆未輸入,已略過儲存,該列維持待審核", + "No rows loaded; set search criteria and search first": "尚未載入資料,請設定搜尋條件並按搜尋", + "Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors": "批次審核儲存完成:成功 {{success}} 筆,略過 {{skipped}} 筆,錯誤 {{errors}} 筆", "Approver Input": "審核員輸入", "Approve": "審核", "complete": "完成",