|
|
|
@@ -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<number, string>, |
|
|
|
approverBadQty: Record<number, string> |
|
|
|
): 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<number, QtySelectionType>, |
|
|
|
approverQty: Record<number, string>, |
|
|
|
approverBadQty: Record<number, string>, |
|
|
|
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<ApproverStockTakeAllProps> = ({ |
|
|
|
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<ApproverStockTakeAllProps> = ({ |
|
|
|
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"; |
|
|
|
|