From e25ca7beca349579eff58fb98f0bfdc6da1cb49e Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 3 Apr 2026 17:38:14 +0900 Subject: [PATCH] feat: Enhance outbound and receiving functionalities - Updated inventory history insertion logic in both outbound and receiving controllers to use consistent field names and types. - Added a new endpoint for retrieving warehouse locations, improving the ability to manage inventory locations. - Enhanced the outbound page to include location selection based on the selected warehouse, improving user experience and data accuracy. - Implemented validation for warehouse code duplication during new warehouse registration in the warehouse management page. These changes aim to streamline inventory management processes and enhance the overall functionality of the logistics module. --- .../src/controllers/outboundController.ts | 32 +++- .../src/controllers/receivingController.ts | 16 +- backend-node/src/routes/outboundRoutes.ts | 3 + .../(main)/COMPANY_16/equipment/info/page.tsx | 155 ++++++++++++++---- .../COMPANY_16/logistics/inventory/page.tsx | 101 ++++++------ .../COMPANY_16/logistics/outbound/page.tsx | 107 +++++++++--- .../COMPANY_16/logistics/warehouse/page.tsx | 67 +++++--- frontend/lib/api/outbound.ts | 13 ++ 8 files changed, 354 insertions(+), 140 deletions(-) diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 86156ca5..b4b942a0 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -221,10 +221,10 @@ export async function create(req: AuthenticatedRequest, res: Response) { const afterQty = afterStockRes.rows[0]?.current_qty || '0'; await client.query( `INSERT INTO inventory_history ( - id, company_code, item_number, warehouse_code, location_code, - history_type, history_date, change_qty, after_qty, reason, - created_by, created_date - ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '출고', NOW()::date, $5, $6, $7, $8, NOW())`, + id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, remark, + writer, created_date + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '출고', NOW(), $5, $6, $7, $8, NOW())`, [companyCode, itemCode, whCode, locCode, String(-outQty), afterQty, item.outbound_type || '출고', userId] ); } @@ -515,7 +515,7 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) { const result = await pool.query( `SELECT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info - WHERE company_code = $1 AND status != '삭제' + WHERE company_code = $1 AND COALESCE(status, '') != '삭제' ORDER BY warehouse_name`, [companyCode] ); @@ -526,3 +526,25 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: error.message }); } } + +// 창고별 위치 목록 조회 +export async function getLocations(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const warehouseCode = req.query.warehouse_code as string; + const pool = getPool(); + + const result = await pool.query( + `SELECT location_code, location_name, warehouse_code + FROM warehouse_location + WHERE company_code = $1 ${warehouseCode ? "AND warehouse_code = $2" : ""} + ORDER BY location_code`, + warehouseCode ? [companyCode, warehouseCode] : [companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("위치 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index ff892318..fbf40176 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -266,10 +266,10 @@ export async function create(req: AuthenticatedRequest, res: Response) { const afterQty = afterStockRes.rows[0]?.current_qty || String(inQty); await client.query( `INSERT INTO inventory_history ( - id, company_code, item_number, warehouse_code, location_code, - history_type, history_date, change_qty, after_qty, reason, - created_by, created_date - ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW()::date, $5, $6, $7, $8, NOW())`, + id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, remark, + writer, created_date + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, NOW())`, [companyCode, itemCode, whCode, locCode, String(inQty), afterQty, item.inbound_type || '입고', userId] ); } @@ -548,10 +548,10 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response) const afterQty = afterStockRes.rows[0]?.current_qty || '0'; await client.query( `INSERT INTO inventory_history ( - id, company_code, item_number, warehouse_code, location_code, - history_type, history_date, change_qty, after_qty, reason, - created_by, created_date - ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW()::date, $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`, + id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, remark, + writer, created_date + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW(), $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`, [companyCode, itemCode, whCode, locCode, String(-inQty), afterQty, userId] ); } diff --git a/backend-node/src/routes/outboundRoutes.ts b/backend-node/src/routes/outboundRoutes.ts index f81f722b..1d9f24cf 100644 --- a/backend-node/src/routes/outboundRoutes.ts +++ b/backend-node/src/routes/outboundRoutes.ts @@ -19,6 +19,9 @@ router.get("/generate-number", outboundController.generateNumber); // 창고 목록 조회 router.get("/warehouses", outboundController.getWarehouses); +// 위치 목록 조회 +router.get("/locations", outboundController.getLocations); + // 소스 데이터: 출하지시 (판매출고) router.get("/source/shipment-instructions", outboundController.getShipmentInstructions); diff --git a/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx index 314db047..7ec2b68c 100644 --- a/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx @@ -78,15 +78,17 @@ export default function EquipmentInfoPage() { const [infoForm, setInfoForm] = useState>({}); const [infoSaving, setInfoSaving] = useState(false); - // 점검항목 추가 모달 + // 점검항목 추가/수정 모달 const [inspectionModalOpen, setInspectionModalOpen] = useState(false); const [inspectionForm, setInspectionForm] = useState>({}); const [inspectionContinuous, setInspectionContinuous] = useState(false); + const [inspectionEditMode, setInspectionEditMode] = useState(false); - // 소모품 추가 모달 + // 소모품 추가/수정 모달 const [consumableModalOpen, setConsumableModalOpen] = useState(false); const [consumableForm, setConsumableForm] = useState>({}); const [consumableContinuous, setConsumableContinuous] = useState(false); + const [consumableEditMode, setConsumableEditMode] = useState(false); const [consumableItemOptions, setConsumableItemOptions] = useState([]); // 점검항목 복사 @@ -255,16 +257,44 @@ export default function EquipmentInfoPage() { // 점검항목 추가 const handleInspectionSave = async () => { if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; } + if (!inspectionForm.inspection_cycle) { toast.error("점검주기는 필수입니다."); return; } + if (!inspectionForm.inspection_method) { toast.error("점검방법은 필수입니다."); return; } + const methodLabel = resolve("inspection_method", inspectionForm.inspection_method); + const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자"; + if (isNumeric && !inspectionForm.unit) { toast.error("숫자 점검방법은 측정단위가 필수입니다."); return; } + // 기준값/오차범위 → 하한치/상한치 자동 계산 + const saveData = { ...inspectionForm }; + if (isNumeric && saveData.standard_value) { + const std = Number(saveData.standard_value) || 0; + const tol = Number(saveData.tolerance) || 0; + saveData.lower_limit = String(std - tol); + saveData.upper_limit = String(std + tol); + } + if (!isNumeric) { + saveData.unit = ""; + saveData.standard_value = ""; + saveData.tolerance = ""; + saveData.lower_limit = ""; + saveData.upper_limit = ""; + } setSaving(true); try { - await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { - ...inspectionForm, equipment_code: selectedEquip?.equipment_code, - }); - toast.success("추가되었습니다."); - if (inspectionContinuous) { - setInspectionForm({}); - } else { + if (inspectionEditMode) { + await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, { + originalData: { id: saveData.id }, updatedData: { ...saveData, equipment_code: selectedEquip?.equipment_code }, + }); + toast.success("수정되었습니다."); setInspectionModalOpen(false); + } else { + await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { + id: crypto.randomUUID(), ...saveData, equipment_code: selectedEquip?.equipment_code, + }); + toast.success("추가되었습니다."); + if (inspectionContinuous) { + setInspectionForm({}); + } else { + setInspectionModalOpen(false); + } } refreshRight(); } catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); } @@ -308,14 +338,22 @@ export default function EquipmentInfoPage() { if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; } setSaving(true); try { - await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, { - ...consumableForm, equipment_code: selectedEquip?.equipment_code, - }); - toast.success("추가되었습니다."); - if (consumableContinuous) { - setConsumableForm({}); - } else { + if (consumableEditMode) { + await apiClient.put(`/table-management/tables/${CONSUMABLE_TABLE}/edit`, { + originalData: { id: consumableForm.id }, updatedData: { ...consumableForm, equipment_code: selectedEquip?.equipment_code }, + }); + toast.success("수정되었습니다."); setConsumableModalOpen(false); + } else { + await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, { + id: crypto.randomUUID(), ...consumableForm, equipment_code: selectedEquip?.equipment_code, + }); + toast.success("추가되었습니다."); + if (consumableContinuous) { + setConsumableForm({}); + } else { + setConsumableModalOpen(false); + } } refreshRight(); } catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); } @@ -497,7 +535,7 @@ export default function EquipmentInfoPage() {
{rightTab === "inspection" && ( <> - )} @@ -598,7 +636,13 @@ export default function EquipmentInfoPage() { {inspections.map((item) => ( - + { + const std = item.standard_value || ""; + const tol = item.tolerance || ""; + setInspectionForm({ ...item, standard_value: std, tolerance: tol }); + setInspectionEditMode(true); + setInspectionModalOpen(true); + }}> {item.inspection_item || "-"} {resolve("inspection_cycle", item.inspection_cycle)} {resolve("inspection_method", item.inspection_method)} @@ -636,7 +680,12 @@ export default function EquipmentInfoPage() { {consumables.map((item) => ( - + { + setConsumableForm({ ...item }); + setConsumableEditMode(true); + loadConsumableItems(); + setConsumableModalOpen(true); + }}> {item.consumable_name || "-"} {item.replacement_cycle || "-"} {item.unit || "-"} @@ -696,24 +745,62 @@ export default function EquipmentInfoPage() { {/* 점검항목 추가 모달 */} - 점검항목 추가{selectedEquip?.equipment_name}에 점검항목을 추가합니다. + {inspectionEditMode ? "점검항목 수정" : "점검항목 추가"}{selectedEquip?.equipment_name}에 점검항목을 {inspectionEditMode ? "수정" : "추가"}합니다.
-
- setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" />
-
+
+ setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="예: 온도점검, 진동점검" className="h-9" />
+
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}
-
- {catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}
-
- setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" />
-
- setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" />
-
- setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" />
+
+
+ {catSelect("inspection_method", inspectionForm.inspection_method, (v) => { + const label = resolve("inspection_method", v); + const isNum = label === "숫자" || v === "숫자"; + if (!isNum) { + setInspectionForm((p) => ({ ...p, inspection_method: v, unit: "", standard_value: "", tolerance: "", lower_limit: "", upper_limit: "" })); + } else { + setInspectionForm((p) => ({ ...p, inspection_method: v })); + } + }, "점검방법")}
+ {(() => { + const methodLabel = resolve("inspection_method", inspectionForm.inspection_method); + const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자"; + if (!isNumeric) return null; + return ( +
+ setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, mm, V" className="h-9" />
+ ); + })()} +
+ {(() => { + const methodLabel = resolve("inspection_method", inspectionForm.inspection_method); + const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자"; + if (!isNumeric) return null; + return ( +
+
+ setInspectionForm((p) => ({ ...p, standard_value: e.target.value }))} placeholder="기준값 입력" className="h-9" type="number" />
+
+ setInspectionForm((p) => ({ ...p, tolerance: e.target.value }))} placeholder="허용 오차범위" className="h-9" type="number" />
+
+ ); + })()}
- setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" />
+