From 7e3a503adc107506ee7acf36de1af1a05c37e3a4 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 24 Apr 2026 17:58:11 +0900 Subject: [PATCH] feat: Add sales order bulk upload functionality - Introduced a new controller for handling bulk uploads of sales orders via Excel, allowing users to efficiently manage large volumes of sales data. - Implemented validation to ensure required fields are present and provided appropriate error messages for missing data. - Enhanced the service layer to support item creation and master data management during the upload process. - Updated routes to include a new endpoint for the bulk upload feature, ensuring secure access through token authentication. These changes aim to streamline the sales order management process and improve user experience when handling bulk data uploads. --- backend-node/src/app.ts | 2 + .../src/controllers/moldController.ts | 96 ++++ .../src/controllers/processInfoController.ts | 3 +- .../controllers/salesOrderBulkController.ts | 45 ++ .../controllers/tableManagementController.ts | 9 +- backend-node/src/routes/moldRoutes.ts | 4 + .../src/routes/salesOrderBulkRoutes.ts | 10 + backend-node/src/services/bomService.ts | 4 +- .../src/services/salesOrderBulkService.ts | 377 +++++++++++++ .../(main)/COMPANY_10/equipment/info/page.tsx | 92 ++- .../(main)/COMPANY_10/logistics/info/page.tsx | 38 +- .../app/(main)/COMPANY_10/mold/info/page.tsx | 159 ++++-- .../(main)/COMPANY_10/production/bom/page.tsx | 9 +- .../(main)/COMPANY_10/purchase/order/page.tsx | 130 ++--- .../(main)/COMPANY_16/equipment/info/page.tsx | 61 +- .../(main)/COMPANY_16/logistics/info/page.tsx | 38 +- .../app/(main)/COMPANY_16/mold/info/page.tsx | 159 ++++-- .../(main)/COMPANY_16/production/bom/page.tsx | 9 +- .../(main)/COMPANY_16/purchase/order/page.tsx | 130 ++--- .../COMPANY_16/quality/inspection/page.tsx | 533 +++++++----------- .../(main)/COMPANY_29/equipment/info/page.tsx | 92 ++- .../(main)/COMPANY_29/logistics/info/page.tsx | 38 +- .../app/(main)/COMPANY_29/mold/info/page.tsx | 159 ++++-- .../(main)/COMPANY_29/production/bom/page.tsx | 9 +- .../(main)/COMPANY_29/purchase/order/page.tsx | 130 ++--- .../(main)/COMPANY_30/equipment/info/page.tsx | 132 ++++- .../(main)/COMPANY_30/logistics/info/page.tsx | 38 +- .../app/(main)/COMPANY_30/mold/info/page.tsx | 159 ++++-- .../(main)/COMPANY_30/production/bom/page.tsx | 9 +- .../(main)/COMPANY_30/purchase/order/page.tsx | 130 ++--- .../(main)/COMPANY_7/equipment/info/page.tsx | 92 ++- .../(main)/COMPANY_7/logistics/info/page.tsx | 40 +- .../app/(main)/COMPANY_7/mold/info/page.tsx | 159 ++++-- .../(main)/COMPANY_7/production/bom/page.tsx | 9 +- .../(main)/COMPANY_7/purchase/order/page.tsx | 130 ++--- .../(main)/COMPANY_8/equipment/info/page.tsx | 92 ++- .../(main)/COMPANY_8/logistics/info/page.tsx | 38 +- .../app/(main)/COMPANY_8/mold/info/page.tsx | 159 ++++-- .../(main)/COMPANY_8/production/bom/page.tsx | 9 +- .../(main)/COMPANY_8/purchase/order/page.tsx | 130 ++--- .../(main)/COMPANY_9/equipment/info/page.tsx | 102 +++- .../(main)/COMPANY_9/logistics/info/page.tsx | 38 +- .../app/(main)/COMPANY_9/mold/info/page.tsx | 159 ++++-- .../(main)/COMPANY_9/production/bom/page.tsx | 9 +- .../(main)/COMPANY_9/purchase/order/page.tsx | 130 ++--- .../sales/order/SalesOrderExcelModal.tsx | 371 ++++++++++++ .../app/(main)/COMPANY_9/sales/order/page.tsx | 69 +-- 47 files changed, 3054 insertions(+), 1486 deletions(-) create mode 100644 backend-node/src/controllers/salesOrderBulkController.ts create mode 100644 backend-node/src/routes/salesOrderBulkRoutes.ts create mode 100644 backend-node/src/services/salesOrderBulkService.ts create mode 100644 frontend/app/(main)/COMPANY_9/sales/order/SalesOrderExcelModal.tsx diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 52e8190b..389f8b11 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -156,6 +156,7 @@ import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리 import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리 import cuttingPlanRoutes from "./routes/cuttingPlanRoutes"; // 절단계획 관리 +import salesOrderBulkRoutes from "./routes/salesOrderBulkRoutes"; // 수주 엑셀 일괄업로드 (제일그라스) import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별) import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀) @@ -383,6 +384,7 @@ app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리 app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리 app.use("/api/cutting-plan", cuttingPlanRoutes); // 절단계획 관리 +app.use("/api/sales-order", salesOrderBulkRoutes); // 수주 엑셀 일괄업로드 app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장) app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값 diff --git a/backend-node/src/controllers/moldController.ts b/backend-node/src/controllers/moldController.ts index 19ef1eb2..dff3f765 100644 --- a/backend-node/src/controllers/moldController.ts +++ b/backend-node/src/controllers/moldController.ts @@ -403,6 +403,56 @@ export async function createMoldInspection(req: AuthenticatedRequest, res: Respo } } +export async function updateMoldInspection(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { + inspection_item, inspection_cycle, inspection_method, + inspection_content, lower_limit, upper_limit, unit, + is_active, checklist, remarks, + } = req.body; + + if (!inspection_item) { + res.status(400).json({ success: false, message: "점검항목명은 필수입니다." }); + return; + } + + const sql = ` + UPDATE mold_inspection_item SET + inspection_item = $1, + inspection_cycle = $2, + inspection_method = $3, + inspection_content = $4, + lower_limit = $5, + upper_limit = $6, + unit = $7, + is_active = COALESCE($8, is_active), + checklist = $9, + remarks = $10 + WHERE id = $11 AND company_code = $12 + RETURNING * + `; + const params = [ + inspection_item, inspection_cycle || null, inspection_method || null, + inspection_content || null, lower_limit || null, upper_limit || null, + unit || null, is_active, checklist || null, remarks || null, + id, companyCode, + ]; + const result = await query(sql, params); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "점검항목을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, data: result[0], message: "점검항목이 수정되었습니다." }); + } catch (error: any) { + logger.error("점검항목 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + export async function deleteMoldInspection(req: AuthenticatedRequest, res: Response): Promise { try { const companyCode = req.user!.companyCode; @@ -481,6 +531,52 @@ export async function createMoldPart(req: AuthenticatedRequest, res: Response): } } +export async function updateMoldPart(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { + part_name, replacement_cycle, unit, specification, + manufacturer, manufacturer_code, image_path, remarks, + } = req.body; + + if (!part_name) { + res.status(400).json({ success: false, message: "부품명은 필수입니다." }); + return; + } + + const sql = ` + UPDATE mold_part SET + part_name = $1, + replacement_cycle = $2, + unit = $3, + specification = $4, + manufacturer = $5, + manufacturer_code = $6, + image_path = $7, + remarks = $8 + WHERE id = $9 AND company_code = $10 + RETURNING * + `; + const params = [ + part_name, replacement_cycle || null, unit || null, + specification || null, manufacturer || null, manufacturer_code || null, + image_path || null, remarks || null, id, companyCode, + ]; + const result = await query(sql, params); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "부품을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, data: result[0], message: "부품이 수정되었습니다." }); + } catch (error: any) { + logger.error("부품 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + export async function deleteMoldPart(req: AuthenticatedRequest, res: Response): Promise { try { const companyCode = req.user!.companyCode; diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index bb83fd2c..22dfe51d 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -477,8 +477,9 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons for (let i = 0; i < supplierIds.length; i++) { await client.query( + // 본서버 id 컬럼이 uuid 타입, 개발서버는 varchar — ::text 캐스팅하면 본서버에서 타입 불일치 오류 발생 `INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4)`, + VALUES (gen_random_uuid(), $1, $2, $3, $4)`, [companyCode, newDetailId, supplierIds[i], i] ); } diff --git a/backend-node/src/controllers/salesOrderBulkController.ts b/backend-node/src/controllers/salesOrderBulkController.ts new file mode 100644 index 00000000..84deae33 --- /dev/null +++ b/backend-node/src/controllers/salesOrderBulkController.ts @@ -0,0 +1,45 @@ +/** + * 제일그라스 수주 엑셀 일괄 업로드 컨트롤러 + */ +import { Request, Response } from "express"; +import logger from "../utils/logger"; +import { excelBulkUpload } from "../services/salesOrderBulkService"; + +export async function bulkUploadHandler(req: Request, res: Response): Promise { + try { + const user = (req as any).user || {}; + const companyCode = user.companyCode; + const userId = user.userId || user.username || "system"; + + if (!companyCode) { + res.status(401).json({ success: false, message: "로그인 정보 없음" }); + return; + } + + const { rows, autoCreateItems, defaultItemDivision } = req.body || {}; + if (!Array.isArray(rows) || rows.length === 0) { + res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다." }); + return; + } + + const result = await excelBulkUpload({ + companyCode, + userId, + rows, + autoCreateItems, + defaultItemDivision, + }); + + res.status(200).json({ + success: true, + message: "업로드 완료", + data: result, + }); + } catch (err: any) { + logger.error("수주 일괄 업로드 실패:", err); + res.status(500).json({ + success: false, + message: err?.message || "업로드 실패", + }); + } +} diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index ae795dc1..12cb7de7 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1185,12 +1185,15 @@ export async function editTableData( if (companyCode !== "*") { const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); if (hasCompanyCodeColumn) { - // 1. 원본 데이터의 company_code 확인 - if (originalData?.id) { + // 1. 원본 데이터의 company_code 확인 (id exact match 필수) + if (originalData?.id !== undefined && originalData?.id !== null && originalData?.id !== "") { try { + // ⚠️ 기존 search: { id: String(id) } 는 내부에서 ILIKE '%id%' 부분일치로 변환되어 + // 다른 회사의 id (예: 116 검색 시 1116, 1160 등)가 매칭되어 cross-company 오탐 발생. + // 반드시 operator:equals 를 명시하여 정확 일치 조회할 것. const existing = await tableManagementService.getTableData(tableName, { page: 1, size: 1, - search: { id: String(originalData.id) }, + search: { id: { value: String(originalData.id), operator: "equals" } }, }); const existingRow = existing.data?.[0]; if (existingRow && existingRow.company_code && existingRow.company_code !== companyCode && existingRow.company_code !== "*") { diff --git a/backend-node/src/routes/moldRoutes.ts b/backend-node/src/routes/moldRoutes.ts index 5067ef79..3c5a72d6 100644 --- a/backend-node/src/routes/moldRoutes.ts +++ b/backend-node/src/routes/moldRoutes.ts @@ -12,9 +12,11 @@ import { deleteMoldSerial, getMoldInspections, createMoldInspection, + updateMoldInspection, deleteMoldInspection, getMoldParts, createMoldPart, + updateMoldPart, deleteMoldPart, getMoldSerialSummary, } from "../controllers/moldController"; @@ -41,11 +43,13 @@ router.get("/:moldCode/serial-summary", getMoldSerialSummary); // 점검항목 router.get("/:moldCode/inspections", getMoldInspections); router.post("/:moldCode/inspections", createMoldInspection); +router.put("/inspections/:id", updateMoldInspection); router.delete("/inspections/:id", deleteMoldInspection); // 부품 router.get("/:moldCode/parts", getMoldParts); router.post("/:moldCode/parts", createMoldPart); +router.put("/parts/:id", updateMoldPart); router.delete("/parts/:id", deleteMoldPart); export default router; diff --git a/backend-node/src/routes/salesOrderBulkRoutes.ts b/backend-node/src/routes/salesOrderBulkRoutes.ts new file mode 100644 index 00000000..4df281c4 --- /dev/null +++ b/backend-node/src/routes/salesOrderBulkRoutes.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { bulkUploadHandler } from "../controllers/salesOrderBulkController"; + +const router = Router(); + +// POST /api/sales-order/excel-bulk-upload +router.post("/excel-bulk-upload", authenticateToken, bulkUploadHandler); + +export default router; diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index b9c349ce..691c24d4 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -57,9 +57,11 @@ export async function addBomHistory( export async function getBomHeader(bomId: string, tableName?: string) { const table = safeTableName(tableName || "", "bom"); + // b.* 에 포함된 item_type(BOM 유형)을 i.division으로 덮어쓰지 않도록 alias 제거. + // 품목 분류가 필요하면 프론트에서 i.division 컬럼을 직접 사용. const sql = ` SELECT b.*, - i.item_name, i.item_number, i.division as item_type, + i.item_name, i.item_number, COALESCE(NULLIF(b.unit, ''), NULLIF(i.unit, ''), NULLIF(i.inventory_unit, '')) as unit, i.unit as item_unit, i.inventory_unit as item_inventory_unit, diff --git a/backend-node/src/services/salesOrderBulkService.ts b/backend-node/src/services/salesOrderBulkService.ts new file mode 100644 index 00000000..f83828e0 --- /dev/null +++ b/backend-node/src/services/salesOrderBulkService.ts @@ -0,0 +1,377 @@ +/** + * 제일그라스(COMPANY_9) 수주 엑셀 일괄 업로드 서비스 + * - 품목 자동 등록 (item_info에 없는 품명/규격 조합은 신규 생성) + * - 마스터 UPSERT (sales_order_mng) + * - 디테일 INSERT (sales_order_detail) + * - 트랜잭션 보장 + */ + +import { getPool } from "../database/db"; +import { numberingRuleService } from "./numberingRuleService"; +import logger from "../utils/logger"; + +// 업로드 요청 페이로드 — 프론트 매핑 결과 +export interface BulkRow { + // 마스터 후보 필드 + order_no?: string; + partner_code?: string; + partner_name?: string; + order_date?: string; + due_date?: string; + status?: string; + + // 디테일 필드 + part_code?: string; + part_name?: string; + spec?: string; + width?: number | string; + height?: number | string; + thickness?: number | string; + area?: number | string; + unit?: string; + qty?: number | string; + unit_price?: number | string; + amount?: number | string; + memo?: string; + + // 품목 자동등록 옵션 필드 + division?: string; +} + +export interface BulkUploadPayload { + companyCode: string; + userId: string; + rows: BulkRow[]; + autoCreateItems?: boolean; + defaultItemDivision?: string; // e.g. 'CAT_DIV_RAW_MAT' +} + +export interface BulkUploadResult { + itemsCreated: number; + mastersCreated: number; + detailsCreated: number; + warnings: string[]; + errors: string[]; +} + +const ITEM_NUMBER_PREFIX = "R"; // 자동 생성 품번 접두어 + +function toNum(v: any): number { + if (v === null || v === undefined || v === "") return 0; + const n = Number(String(v).replace(/,/g, "")); + return isNaN(n) ? 0 : n; +} + +function normStr(v: any): string { + if (v === null || v === undefined) return ""; + return String(v).trim(); +} + +/** + * 엑셀 한 줄 → item_info에서 기존 품목 조회, 없으면 INSERT + * 반환: { itemNumber: string, created: boolean } + */ +async function resolveOrCreateItem( + client: any, + companyCode: string, + row: BulkRow, + defaultDivision: string, + userId: string +): Promise<{ itemNumber: string; created: boolean } | null> { + const partName = normStr(row.part_name); + const partCode = normStr(row.part_code); + if (!partName && !partCode) return null; + + // 1) part_code 직접 매칭 우선 + if (partCode) { + const r = await client.query( + `SELECT item_number FROM item_info + WHERE company_code = $1 AND item_number = $2 + LIMIT 1`, + [companyCode, partCode] + ); + if (r.rows.length > 0) return { itemNumber: r.rows[0].item_number, created: false }; + } + + // 2) part_name + 규격 매칭 + if (partName) { + const w = toNum(row.width); + const h = toNum(row.height); + const t = toNum(row.thickness); + const r2 = await client.query( + `SELECT item_number FROM item_info + WHERE company_code = $1 + AND item_name = $2 + AND COALESCE(width::numeric,0) = $3 + AND COALESCE(height::numeric,0) = $4 + AND COALESCE(thickness::numeric,0) = $5 + LIMIT 1`, + [companyCode, partName, w, h, t] + ); + if (r2.rows.length > 0) return { itemNumber: r2.rows[0].item_number, created: false }; + } + + // 3) 자동 생성 + if (!partName) return null; // 품명 없으면 생성 불가 + + // 간단 채번: R_YYYYMMDD_NNNN (company 내 해당 prefix 최대값+1) + const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const prefix = `${ITEM_NUMBER_PREFIX}_${today}_`; + const seqR = await client.query( + `SELECT COUNT(*)::int AS c FROM item_info + WHERE company_code = $1 AND item_number LIKE $2`, + [companyCode, `${prefix}%`] + ); + const seq = (Number(seqR.rows[0]?.c) || 0) + 1; + const itemNumber = `${prefix}${String(seq).padStart(4, "0")}`; + + const division = normStr(row.division) || defaultDivision; + const unit = normStr(row.unit) || "EA"; + + await client.query( + `INSERT INTO item_info ( + id, company_code, item_number, item_name, + size, width, height, thickness, + unit, division, status, writer, created_date + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, + $4, $5, $6, $7, + $8, $9, 'active', $10, NOW() + )`, + [ + companyCode, + itemNumber, + partName, + normStr(row.spec), + toNum(row.width) || null, + toNum(row.height) || null, + toNum(row.thickness) || null, + unit, + division, + userId, + ] + ); + + return { itemNumber, created: true }; +} + +/** + * 수주번호 채번 — numbering_rules에 설정 있으면 사용, 없으면 ORD-YYYYMMDD-NNNN 폴백 + */ +async function allocateOrderNo(companyCode: string, client: any): Promise { + // 기존 규칙 조회 + const ruleRes = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE company_code = $1 AND table_name = 'sales_order_mng' AND column_name = 'order_no' + LIMIT 1`, + [companyCode] + ); + if (ruleRes.rows.length > 0) { + try { + const code = await numberingRuleService.allocateCode( + ruleRes.rows[0].rule_id, + companyCode + ); + if (code) return code; + } catch (e: any) { + logger.warn(`allocateCode 실패 → 폴백 사용: ${e?.message}`); + } + } + + // 폴백 + const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const prefix = `ORD-${today}-`; + const r = await client.query( + `SELECT COUNT(*)::int AS c FROM sales_order_mng + WHERE company_code = $1 AND order_no LIKE $2`, + [companyCode, `${prefix}%`] + ); + const seq = (Number(r.rows[0]?.c) || 0) + 1; + return `${prefix}${String(seq).padStart(4, "0")}`; +} + +export async function excelBulkUpload( + payload: BulkUploadPayload +): Promise { + const pool = getPool(); + const client = await pool.connect(); + const result: BulkUploadResult = { + itemsCreated: 0, + mastersCreated: 0, + detailsCreated: 0, + warnings: [], + errors: [], + }; + + const autoCreate = payload.autoCreateItems !== false; // 기본 true + const defaultDivision = payload.defaultItemDivision || "CAT_DIV_RAW_MAT"; + + try { + await client.query("BEGIN"); + + // 1) 각 행의 품목 확정 (part_code를 확정값으로 채움) + const resolved: Array<{ row: BulkRow; partCode: string }> = []; + for (let i = 0; i < payload.rows.length; i++) { + const row = payload.rows[i]; + if (!autoCreate && !normStr(row.part_code)) { + // 자동생성 비활성 + part_code 비어있음: 매칭만 시도 + const pr = await resolveOrCreateItem( + client, payload.companyCode, row, defaultDivision, payload.userId + ); + if (!pr) { + result.warnings.push(`행 ${i + 1}: 품목 매칭 실패 (품명=${row.part_name})`); + continue; + } + resolved.push({ row, partCode: pr.itemNumber }); + } else { + const pr = await resolveOrCreateItem( + client, payload.companyCode, row, defaultDivision, payload.userId + ); + if (!pr) { + result.warnings.push(`행 ${i + 1}: 품목 정보 부족 (품명/품번 모두 없음)`); + continue; + } + if (pr.created) result.itemsCreated++; + resolved.push({ row, partCode: pr.itemNumber }); + } + } + + if (resolved.length === 0) { + await client.query("ROLLBACK"); + result.errors.push("유효한 행이 없습니다."); + return result; + } + + // 2) order_no 기준 그룹핑 + // - 엑셀에 order_no 있는 행은 동일 번호끼리 묶음 + // - 비어있는 행들은 하나의 그룹 "(auto)" 으로 묶어 자동 채번 1건 + const groups = new Map>(); + for (const item of resolved) { + const key = normStr(item.row.order_no) || "__AUTO__"; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(item); + } + + // 3) 그룹별 마스터 UPSERT + 디테일 INSERT + let autoOrderNo: string | null = null; + + for (const [key, items] of groups.entries()) { + let orderNo = key; + if (key === "__AUTO__") { + if (!autoOrderNo) autoOrderNo = await allocateOrderNo(payload.companyCode, client); + orderNo = autoOrderNo; + } + + // 마스터 존재 확인 + const mRes = await client.query( + `SELECT id FROM sales_order_mng + WHERE company_code = $1 AND order_no = $2 LIMIT 1`, + [payload.companyCode, orderNo] + ); + + // 대표값 (첫 행 기준) + const first = items[0].row; + + // partner_id 조회 (name으로) — 없어도 무방 + let partnerId = normStr(first.partner_code); + if (!partnerId && normStr(first.partner_name)) { + const pRes = await client.query( + `SELECT id FROM customer_mng + WHERE company_code = $1 AND customer_name = $2 LIMIT 1`, + [payload.companyCode, normStr(first.partner_name)] + ); + if (pRes.rows.length > 0) partnerId = pRes.rows[0].id; + } + + if (mRes.rows.length === 0) { + // INSERT + await client.query( + `INSERT INTO sales_order_mng ( + company_code, order_no, order_date, due_date, partner_id, + status, manager_id, created_date, created_by + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, NOW(), $8 + )`, + [ + payload.companyCode, + orderNo, + normStr(first.order_date) || new Date().toISOString().slice(0, 10), + normStr(first.due_date) || null, + partnerId || null, + normStr(first.status) || "수주", + payload.userId, + payload.userId, + ] + ); + result.mastersCreated++; + } + + // 디테일 INSERT + // 기존 디테일의 최대 seq_no 조회 + const sRes = await client.query( + `SELECT COALESCE(MAX(NULLIF(seq_no,'')::int), 0)::int AS max_seq + FROM sales_order_detail + WHERE company_code = $1 AND order_no = $2`, + [payload.companyCode, orderNo] + ); + let seqStart = (Number(sRes.rows[0]?.max_seq) || 0) + 1; + + for (const it of items) { + const r = it.row; + const w = toNum(r.width); + const h = toNum(r.height); + const qty = toNum(r.qty); + const price = toNum(r.unit_price); + const area = normStr(r.area) || (w > 0 && h > 0 ? (w * h / 91808).toFixed(4) : ""); + const amount = normStr(r.amount) || (qty && price ? String(qty * price) : ""); + + await client.query( + `INSERT INTO sales_order_detail ( + id, company_code, order_no, seq_no, + part_code, part_name, spec, + width, height, thickness, area, + unit, qty, unit_price, amount, + delivery_partner_code, due_date, memo, + writer, created_date + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, + $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, + $15, $16, $17, + $18, NOW() + )`, + [ + payload.companyCode, + orderNo, + String(seqStart++), + it.partCode, + normStr(r.part_name), + normStr(r.spec), + w || null, h || null, toNum(r.thickness) || null, area || null, + normStr(r.unit) || null, + qty || null, + price || null, + amount || null, + normStr(r.partner_code) || null, + normStr(r.due_date) || null, + normStr(r.memo) || null, + payload.userId, + ] + ); + result.detailsCreated++; + } + } + + await client.query("COMMIT"); + return result; + } catch (err: any) { + await client.query("ROLLBACK"); + logger.error(`excelBulkUpload 실패: ${err?.message}`, err); + result.errors.push(err?.message || "알 수 없는 오류"); + return result; + } finally { + client.release(); + } +} diff --git a/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx index 64a8a2a1..9b6d2fd9 100644 --- a/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx @@ -59,6 +59,7 @@ export default function EquipmentInfoPage() { const [equipCount, setEquipCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); const [selectedEquipId, setSelectedEquipId] = useState(null); + const [checkedEquipIds, setCheckedEquipIds] = useState([]); // 우측 탭 const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info"); @@ -258,7 +259,15 @@ export default function EquipmentInfoPage() { } } catch { /* 채번 규칙 없으면 수동 입력 */ } }; - const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); }; + const openEquipEdit = () => { + if (checkedEquipIds.length > 1) { toast.error("수정은 한 건만 선택해주세요."); return; } + const targetId = checkedEquipIds[0] || selectedEquipId; + const target = equipments.find((e) => e.id === targetId); + if (!target) { toast.error("수정할 설비를 선택해주세요."); return; } + setEquipForm({ ...target }); + setEquipEditMode(true); + setEquipModalOpen(true); + }; const handleEquipSave = async () => { if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; } @@ -287,12 +296,16 @@ export default function EquipmentInfoPage() { }; const handleEquipDelete = async () => { - if (!selectedEquipId) return; - const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" }); + const targetIds = checkedEquipIds.length > 0 ? checkedEquipIds : (selectedEquipId ? [selectedEquipId] : []); + if (targetIds.length === 0) { toast.error("삭제할 설비를 선택해주세요."); return; } + const ok = await confirm(`선택한 ${targetIds.length}건의 설비를 삭제하시겠습니까?`, { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" }); if (!ok) return; try { - await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] }); - toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments(); + await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: targetIds.map((id) => ({ id })) }); + toast.success("삭제되었습니다."); + setCheckedEquipIds([]); + if (selectedEquipId && targetIds.includes(selectedEquipId)) setSelectedEquipId(null); + fetchEquipments(); } catch { toast.error("삭제 실패"); } }; @@ -398,7 +411,12 @@ export default function EquipmentInfoPage() { const allItems = new Map(); for (const res of results) { const rows = res.data?.data?.data || res.data?.data?.rows || []; - for (const row of rows) allItems.set(row.id, row); + for (const row of rows) { + // item_info 이미지 컬럼 이중화 대응: `image`(품목정보 UI가 저장하는 기본 컬럼) 우선, + // `image_path`는 과거 데이터 레거시 fallback. 옵션 객체는 image_path 필드로 정규화해 하류 호환 유지. + const normalized = { ...row, image_path: row.image || row.image_path || "" }; + allItems.set(row.id, normalized); + } } setConsumableItemOptions(Array.from(allItems.values())); } catch { setConsumableItemOptions([]); } @@ -530,9 +548,9 @@ export default function EquipmentInfoPage() {
- +
- +
-
- setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" />
-
- setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" />
+
+ + setConsumableForm((p) => ({ ...p, unit: e.target.value }))} + placeholder="단위" + className={cn("h-9", consumableItemOptions.length > 0 && "bg-muted cursor-not-allowed")} + readOnly={consumableItemOptions.length > 0} + /> +
+
+ + setConsumableForm((p) => ({ ...p, specification: e.target.value }))} + placeholder="규격" + className={cn("h-9", consumableItemOptions.length > 0 && "bg-muted cursor-not-allowed")} + readOnly={consumableItemOptions.length > 0} + /> +
setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" />
-
- setConsumableForm((p) => ({ ...p, image_path: v }))} - tableName={CONSUMABLE_TABLE} columnName="image_path" />
+
+ + {/* value 변경 시 확실한 리마운트를 위해 key에 image_path 바인딩 (이전 미리보기 잔류 방지) */} + setConsumableForm((p) => ({ ...p, image_path: v }))} + tableName={CONSUMABLE_TABLE} + columnName="image_path" + disabled={consumableItemOptions.length > 0} + /> +
- {/* 데이터 테이블 — 플랫 리스트 */} -
- {loading ? ( -
- ) : orders.length === 0 ? ( -
- -

등록된 발주가 없어요

-
- ) : ( - (() => { - const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]); - const renderCell = (row: any, key: string) => { - const val = row[key]; - if (key === "status") return val ? {val} : "-"; - if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-"; - if (numCols.has(key)) return {val ? Number(val).toLocaleString() : ""}; - return val || ""; - }; - return ( - - - - { - const allIds = orders.map((r) => r.id); - const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id)); - setCheckedIds(allChecked ? [] : allIds); - }} - > - 0 && orders.every((r) => checkedIds.includes(r.id))} - onCheckedChange={() => {}} - /> - - {ts.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {orders.map((row) => { - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - onDoubleClick={() => openEditModal(row.purchase_no)} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - {ts.visibleColumns.map((col) => ( - - {renderCell(row, col.key)} - - ))} - - ); - })} - -
- ); - })() - )} -
- 전체 {orders.length}건 -
+ {/* 데이터 테이블 — 플랫 리스트 (EDataTable: 컬럼별 Popover 체크박스 필터 + 정렬 내장) */} +
+ openEditModal(row.purchase_no)} + showPagination + draggableColumns={false} + columnOrderKey="c10-purchase-order-main" + />
{/* 발주 등록/수정 모달 */} diff --git a/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx index 0b297655..9b6d2fd9 100644 --- a/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx @@ -411,7 +411,12 @@ export default function EquipmentInfoPage() { const allItems = new Map(); for (const res of results) { const rows = res.data?.data?.data || res.data?.data?.rows || []; - for (const row of rows) allItems.set(row.id, row); + for (const row of rows) { + // item_info 이미지 컬럼 이중화 대응: `image`(품목정보 UI가 저장하는 기본 컬럼) 우선, + // `image_path`는 과거 데이터 레거시 fallback. 옵션 객체는 image_path 필드로 정규화해 하류 호환 유지. + const normalized = { ...row, image_path: row.image || row.image_path || "" }; + allItems.set(row.id, normalized); + } } setConsumableItemOptions(Array.from(allItems.values())); } catch { setConsumableItemOptions([]); } @@ -946,11 +951,16 @@ export default function EquipmentInfoPage() { {consumableItemOptions.length > 0 ? ( setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" />
-
- setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" />
-
- setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" />
+
+ + setConsumableForm((p) => ({ ...p, unit: e.target.value }))} + placeholder="단위" + className={cn("h-9", consumableItemOptions.length > 0 && "bg-muted cursor-not-allowed")} + readOnly={consumableItemOptions.length > 0} + /> +
+
+ + setConsumableForm((p) => ({ ...p, specification: e.target.value }))} + placeholder="규격" + className={cn("h-9", consumableItemOptions.length > 0 && "bg-muted cursor-not-allowed")} + readOnly={consumableItemOptions.length > 0} + /> +
setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" />
-
- setConsumableForm((p) => ({ ...p, image_path: v }))} - tableName={CONSUMABLE_TABLE} columnName="image_path" />
+
+ + {/* value 변경 시 확실한 리마운트를 위해 key에 image_path 바인딩 (이전 미리보기 잔류 방지) */} + setConsumableForm((p) => ({ ...p, image_path: v }))} + tableName={CONSUMABLE_TABLE} + columnName="image_path" + disabled={consumableItemOptions.length > 0} + /> +