From 6731ca418311c6f7f5af3480e77d0e19cc3522f6 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 19 May 2026 11:57:05 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EC=A7=84=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=9D=BC=EB=B6=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/controllers/deliveryNoteController.ts | 157 +++++ .../controllers/popProductionController.ts | 16 +- backend-node/src/routes/deliveryNoteRoutes.ts | 24 + .../src/services/deliveryNoteService.ts | 277 +++++++++ .../(main)/COMPANY_8/equipment/info/page.tsx | 16 +- .../logistics/inbound-outbound/page.tsx | 19 +- .../COMPANY_8/logistics/outbound/page.tsx | 571 +++++++++++++++++- .../COMPANY_8/logistics/receiving/page.tsx | 94 ++- .../COMPANY_8/master-data/department/page.tsx | 5 +- .../COMPANY_8/master-data/item-info/page.tsx | 12 +- .../(main)/COMPANY_8/purchase/order/page.tsx | 10 +- .../COMPANY_8/quality/inspection/page.tsx | 22 +- .../quality/item-inspection/page.tsx | 332 +++++++--- .../app/(main)/COMPANY_8/sales/order/page.tsx | 156 ++++- .../app/(main)/COMPANY_8/sales/quote/page.tsx | 2 +- frontend/lib/api/outbound.ts | 90 +++ 17 files changed, 1673 insertions(+), 132 deletions(-) create mode 100644 backend-node/src/controllers/deliveryNoteController.ts create mode 100644 backend-node/src/routes/deliveryNoteRoutes.ts create mode 100644 backend-node/src/services/deliveryNoteService.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 4b2c5d60..cd887060 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -176,6 +176,7 @@ import outsourcePurchaseRoutes from "./routes/outsourcePurchaseRoutes"; // 외 import subcontractorStockRoutes from "./routes/subcontractorStockRoutes"; // 외주사재고관리 (TASK:ERP-026) import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import quoteRoutes from "./routes/quoteRoutes"; // 견적관리 +import deliveryNoteRoutes from "./routes/deliveryNoteRoutes"; // 거래명세서 관리 (TASK:ERP-070) import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -409,6 +410,7 @@ app.use("/api/outsourcing-outbound", outsourcingOutboundRoutes); // 외주출고 app.use("/api/outsource-purchase", outsourcePurchaseRoutes); // 외주발주관리 (TASK:ERP-019) app.use("/api/subcontractor-stock", subcontractorStockRoutes); // 외주사재고관리 (TASK:ERP-026) app.use("/api/quotes", quoteRoutes); // 견적관리 +app.use("/api/delivery-note", deliveryNoteRoutes); // 거래명세서 관리 (TASK:ERP-070) app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/deliveryNoteController.ts b/backend-node/src/controllers/deliveryNoteController.ts new file mode 100644 index 00000000..8717323e --- /dev/null +++ b/backend-node/src/controllers/deliveryNoteController.ts @@ -0,0 +1,157 @@ +/** + * 거래명세서 컨트롤러 + * Task: ERP-070 (COMPANY_8 대진산업) + */ + +import { Request, Response } from "express"; +import { logger } from "../utils/logger"; +import { + createDeliveryNote, + listDeliveryNotes, + getDeliveryNoteById, +} from "../services/deliveryNoteService"; + +interface AuthenticatedRequest extends Request { + user?: { + userId: string; + companyCode: string; + userName?: string; + }; +} + +/** + * POST /api/delivery-note + * 거래명세서 생성 + */ +export async function create(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { outbound_ids, issue_date, remark } = req.body as { + outbound_ids: string[]; + issue_date: string; + remark?: string; + }; + + if (!outbound_ids || outbound_ids.length === 0) { + res.status(400).json({ success: false, message: "출고 건을 선택해주세요." }); + return; + } + if (!issue_date) { + res.status(400).json({ success: false, message: "발행일을 입력해주세요." }); + return; + } + + const result = await createDeliveryNote({ + companyCode, + issuedBy: userId, + issuedByName: (req.user as any)?.userName, + issueDate: issue_date, + outboundIds: outbound_ids, + remark, + }); + + res.status(201).json({ + success: true, + message: "거래명세서가 생성되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("거래명세서 생성 실패:", error); + const statusCode = + error.message?.includes("동일 거래처") || + error.message?.includes("출고완료") || + error.message?.includes("찾을 수 없") + ? 400 + : 500; + res.status(statusCode).json({ + success: false, + message: error.message || "거래명세서 생성 중 오류가 발생했습니다.", + }); + } +} + +/** + * GET /api/delivery-note + * 거래명세서 목록 조회 + */ +export async function getList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { + customer_code, + date_from, + date_to, + search, + page = "1", + page_size = "20", + } = req.query as Record; + + const result = await listDeliveryNotes({ + companyCode, + customerCode: customer_code, + dateFrom: date_from, + dateTo: date_to, + search, + page: parseInt(page, 10), + pageSize: parseInt(page_size, 10), + }); + + res.json({ + success: true, + data: result.data, + pagination: { + page: result.page, + pageSize: result.pageSize, + total: result.total, + totalPages: Math.ceil(result.total / result.pageSize), + }, + }); + } catch (error: any) { + logger.error("거래명세서 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "거래명세서 목록 조회 중 오류가 발생했습니다.", + }); + } +} + +/** + * GET /api/delivery-note/:id + * 거래명세서 단건 상세 조회 + */ +export async function getById(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + return; + } + + const { id } = req.params; + const data = await getDeliveryNoteById(id, companyCode); + + if (!data) { + res.status(404).json({ success: false, message: "거래명세서를 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, data }); + } catch (error: any) { + logger.error("거래명세서 상세 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "거래명세서 조회 중 오류가 발생했습니다.", + }); + } +} diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index f8c4140d..476e0075 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -1669,6 +1669,20 @@ const checkAndCompleteWorkInstruction = async ( if (itemResult.rowCount === 0) return; const itemCode = itemResult.rows[0].item_number; + // 담당자 한글명 조회 — inventory_history manager_name 기록용 + // (CLAUDE.md "사용자 식별 표시 필수": DB에는 user_id 저장, 표시는 user_name) + let autoUserName = userId; + try { + const mgrRes = await pool.query( + `SELECT COALESCE(NULLIF(user_name, ''), user_id) AS user_name + FROM user_info WHERE user_id = $1 AND company_code = $2 LIMIT 1`, + [userId, companyCode], + ); + if (mgrRes.rows[0]?.user_name) autoUserName = mgrRes.rows[0].user_name; + } catch { + /* user_info 조회 실패 시 userId fallback 유지 */ + } + const warehouseResult = await pool.query( `SELECT target_warehouse_id, target_location_code FROM work_order_process @@ -1722,7 +1736,7 @@ const checkAndCompleteWorkInstruction = async ( completedQty, userId, { - userName: userId, + userName: autoUserName, source: "auto_cascade", woId, }, diff --git a/backend-node/src/routes/deliveryNoteRoutes.ts b/backend-node/src/routes/deliveryNoteRoutes.ts new file mode 100644 index 00000000..97536144 --- /dev/null +++ b/backend-node/src/routes/deliveryNoteRoutes.ts @@ -0,0 +1,24 @@ +/** + * 거래명세서 라우터 + * /api/delivery-note 경로 처리 + * Task: ERP-070 (COMPANY_8 대진산업) + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as deliveryNoteController from "../controllers/deliveryNoteController"; + +const router = Router(); + +router.use(authenticateToken); + +// 거래명세서 목록 조회 +router.get("/", deliveryNoteController.getList); + +// 거래명세서 상세 조회 (목록보다 먼저 정의) +router.get("/:id", deliveryNoteController.getById); + +// 거래명세서 생성 +router.post("/", deliveryNoteController.create); + +export default router; diff --git a/backend-node/src/services/deliveryNoteService.ts b/backend-node/src/services/deliveryNoteService.ts new file mode 100644 index 00000000..f22b061e --- /dev/null +++ b/backend-node/src/services/deliveryNoteService.ts @@ -0,0 +1,277 @@ +/** + * 거래명세서 서비스 + * Task: ERP-070 (COMPANY_8 대진산업) + * + * 백엔드는 공통 — company_code 스코프 필수 + */ + +import { getPool } from "../database/db"; +import { v4 as uuidv4 } from "uuid"; +import { logger } from "../utils/logger"; + +/** 거래명세서 생성 파라미터 */ +export interface CreateDeliveryNoteParams { + companyCode: string; + issuedBy: string; // 발행자 user_id + issuedByName?: string; // 발행자 user_name + issueDate: string; // 발행일 (YYYY-MM-DD) + outboundIds: string[]; // 선택된 outbound_mng.id 목록 + remark?: string; +} + +/** 거래명세서 목록 조회 파라미터 */ +export interface ListDeliveryNoteParams { + companyCode: string; + customerCode?: string; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + pageSize?: number; +} + +/** + * 발행번호 자동채번: DN-YYYYMMDD-XXXX + */ +async function generateNoteNumber(companyCode: string, issueDate: string): Promise { + const pool = getPool(); + const datePart = issueDate.replace(/-/g, "").slice(0, 8); + const prefix = `DN-${datePart}-`; + + const res = await pool.query( + `SELECT note_number FROM delivery_note_mng + WHERE company_code = $1 AND note_number LIKE $2 + ORDER BY note_number DESC LIMIT 1`, + [companyCode, `${prefix}%`], + ); + + let seq = 1; + if (res.rows.length > 0) { + const last = res.rows[0].note_number as string; + const seqStr = last.split("-").pop() || "0000"; + seq = parseInt(seqStr, 10) + 1; + } + + return `${prefix}${String(seq).padStart(4, "0")}`; +} + +/** + * 거래명세서 생성 (선택 출고건 → 집계 → 저장) + */ +export async function createDeliveryNote(params: CreateDeliveryNoteParams) { + const pool = getPool(); + const { + companyCode, + issuedBy, + issuedByName, + issueDate, + outboundIds, + remark, + } = params; + + if (!outboundIds || outboundIds.length === 0) { + throw new Error("출고 건을 최소 1개 이상 선택해야 합니다."); + } + + // 1) 선택된 출고 건 조회 (company_code 스코프) + const placeholders = outboundIds.map((_, i) => `$${i + 2}`).join(", "); + const outboundRes = await pool.query( + `SELECT id, outbound_number, outbound_date, customer_code, customer_name, + item_code, item_name, specification, unit, + outbound_qty, unit_price, total_amount, outbound_status + FROM outbound_mng + WHERE company_code = $1 AND id IN (${placeholders})`, + [companyCode, ...outboundIds], + ); + + if (outboundRes.rows.length === 0) { + throw new Error("선택한 출고 건을 찾을 수 없습니다."); + } + + // 2) 검증: 동일 거래처 확인 + const customerCodes = new Set(outboundRes.rows.map((r: any) => r.customer_code)); + if (customerCodes.size > 1) { + throw new Error("동일 거래처의 출고 건만 선택할 수 있습니다."); + } + + // 3) 검증: 출고완료 상태 확인 + const nonCompleted = outboundRes.rows.filter((r: any) => r.outbound_status !== "출고완료"); + if (nonCompleted.length > 0) { + throw new Error( + `출고완료 상태가 아닌 건이 포함되어 있습니다: ${nonCompleted.map((r: any) => r.outbound_number).join(", ")}`, + ); + } + + const rows = outboundRes.rows as any[]; + const firstRow = rows[0]; + const customerCode = firstRow.customer_code || ""; + const customerName = firstRow.customer_name || ""; + + // 4) 금액 집계 (공급가액 × 10% = 부가세) + const totalSupply = rows.reduce((sum: number, r: any) => sum + (Number(r.total_amount) || 0), 0); + const totalTax = Math.round(totalSupply * 0.1); + const totalAmount = totalSupply + totalTax; + + // 5) 채번 + const noteNumber = await generateNoteNumber(companyCode, issueDate); + const noteId = uuidv4(); + + // 6) 트랜잭션으로 저장 + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 헤더 + await client.query( + `INSERT INTO delivery_note_mng + (id, company_code, note_number, issue_date, customer_code, customer_name, + supply_amount, tax_amount, total_amount, issued_by, issued_by_name, + remark, created_date, updated_date) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),NOW())`, + [ + noteId, companyCode, noteNumber, issueDate, customerCode, customerName, + totalSupply, totalTax, totalAmount, + issuedBy, issuedByName || issuedBy, + remark || null, + ], + ); + + // 출고건 매핑 + for (const row of rows) { + await client.query( + `INSERT INTO delivery_note_outbound + (id, note_id, outbound_id, outbound_number, outbound_date, created_date) + VALUES ($1,$2,$3,$4,$5,NOW())`, + [uuidv4(), noteId, row.id, row.outbound_number, row.outbound_date], + ); + } + + // 품목 상세 (출고건별 1행) + let sortOrder = 1; + for (const row of rows) { + const supplyAmt = Number(row.total_amount) || 0; + const taxAmt = Math.round(supplyAmt * 0.1); + await client.query( + `INSERT INTO delivery_note_detail + (id, note_id, outbound_id, item_code, item_name, specification, unit, + outbound_qty, unit_price, supply_amount, tax_amount, total_amount, + sort_order, created_date) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())`, + [ + uuidv4(), noteId, row.id, + row.item_code, row.item_name, row.specification || "", row.unit || "EA", + Number(row.outbound_qty) || 0, + Number(row.unit_price) || 0, + supplyAmt, + taxAmt, + supplyAmt + taxAmt, + sortOrder++, + ], + ); + } + + await client.query("COMMIT"); + + return { + id: noteId, + note_number: noteNumber, + customer_name: customerName, + supply_amount: totalSupply, + tax_amount: totalTax, + total_amount: totalAmount, + }; + } catch (err) { + await client.query("ROLLBACK"); + logger.error("거래명세서 생성 실패:", err); + throw err; + } finally { + client.release(); + } +} + +/** + * 거래명세서 목록 조회 + */ +export async function listDeliveryNotes(params: ListDeliveryNoteParams) { + const pool = getPool(); + const { companyCode, customerCode, dateFrom, dateTo, search, page = 1, pageSize = 20 } = params; + + const conditions: string[] = []; + const queryParams: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`n.company_code = $${idx++}`); + queryParams.push(companyCode); + } + if (customerCode) { + conditions.push(`n.customer_code = $${idx++}`); + queryParams.push(customerCode); + } + if (dateFrom) { + conditions.push(`n.issue_date >= $${idx++}`); + queryParams.push(dateFrom); + } + if (dateTo) { + conditions.push(`n.issue_date <= $${idx++}`); + queryParams.push(dateTo); + } + if (search) { + conditions.push( + `(n.note_number ILIKE $${idx} OR n.customer_name ILIKE $${idx})`, + ); + queryParams.push(`%${search}%`); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const countRes = await pool.query( + `SELECT COUNT(*) FROM delivery_note_mng n ${where}`, + queryParams, + ); + const total = parseInt(countRes.rows[0].count, 10); + + const offset = (page - 1) * pageSize; + const listRes = await pool.query( + `SELECT n.* + FROM delivery_note_mng n + ${where} + ORDER BY n.created_date DESC + LIMIT $${idx} OFFSET $${idx + 1}`, + [...queryParams, pageSize, offset], + ); + + return { data: listRes.rows, total, page, pageSize }; +} + +/** + * 거래명세서 단건 상세 조회 (헤더 + 매핑 출고건 + 품목상세) + */ +export async function getDeliveryNoteById(noteId: string, companyCode: string) { + const pool = getPool(); + + const headerRes = await pool.query( + `SELECT * FROM delivery_note_mng WHERE id = $1 AND company_code = $2`, + [noteId, companyCode], + ); + if (headerRes.rows.length === 0) { + return null; + } + + const detailRes = await pool.query( + `SELECT * FROM delivery_note_detail WHERE note_id = $1 ORDER BY sort_order`, + [noteId], + ); + + const outboundRes = await pool.query( + `SELECT * FROM delivery_note_outbound WHERE note_id = $1`, + [noteId], + ); + + return { + ...headerRes.rows[0], + details: detailRes.rows, + outbounds: outboundRes.rows, + }; +} diff --git a/frontend/app/(main)/COMPANY_8/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_8/equipment/info/page.tsx index 9b6d2fd9..88d554cf 100644 --- a/frontend/app/(main)/COMPANY_8/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_8/equipment/info/page.tsx @@ -136,6 +136,11 @@ export default function EquipmentInfoPage() { if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } + // 소모품 단위 코드→라벨 변환용: item_info.inventory_unit 카테고리 로드 + try { + const res = await apiClient.get(`/table-categories/item_info/inventory_unit/values`); + if (res.data?.success) optMap["item_inventory_unit"] = flatten(res.data.data || []); + } catch { /* skip */ } setCatOptions(optMap); }; load(); @@ -951,15 +956,22 @@ export default function EquipmentInfoPage() { {consumableItemOptions.length > 0 ? ( setDnIssueDate(e.target.value)} + className="h-8 text-sm" + /> + + + {/* 비고 */} +
+ + setDnRemark(e.target.value)} + placeholder="비고 (선택)" + className="h-8 text-sm" + /> +
+ + + + + + + + + + {/* 거래명세서 이력 모달 */} + + + + 거래명세서 이력 + 발행된 거래명세서 목록입니다. + +
+ {dnListLoading ? ( +
+ +
+ ) : dnList.length === 0 ? ( +
+ + 발행된 거래명세서가 없습니다. +
+ ) : ( + + + + + + + + + + + + + + + {dnList.map((note) => ( + + + + + + + + + + + ))} + +
발행번호거래처발행일공급가액부가세합계발행자출력
{note.note_number}{note.customer_name}{note.issue_date} + {Number(note.supply_amount).toLocaleString()} + + {Number(note.tax_amount).toLocaleString()} + + {Number(note.total_amount).toLocaleString()} + {note.issued_by_name || note.issued_by} + +
+ )} +
+
+ +
+
+
+ + {/* 거래명세서 PDF 미리보기 → 즉시 출력 */} + {dnPreview && ( + { if (!open) setDnPreview(null); }} + > + + + 거래명세서 발행 완료 + + {dnPreview.note_number} — {dnPreview.customer_name} + + +
+
+ 공급가액 + {Number(dnPreview.supply_amount).toLocaleString()} 원 +
+
+ 부가세 + {Number(dnPreview.tax_amount).toLocaleString()} 원 +
+
+ 합계 + + {Number(dnPreview.total_amount).toLocaleString()} 원 + +
+
+ + + + +
+
+ )} ); } @@ -1696,8 +2255,8 @@ function SourceItemTable({ {item.spec || "-"} - {resolveCat("material", item.material) || "-"} - {resolveCat("inventory_unit", item.unit) || "-"} + {resolveCat("material", item.material || "") || "-"} + {resolveCat("inventory_unit", item.unit || "") || "-"} {Number(item.standard_price).toLocaleString()} diff --git a/frontend/app/(main)/COMPANY_8/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_8/logistics/receiving/page.tsx index c86bcc90..8439466a 100644 --- a/frontend/app/(main)/COMPANY_8/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_8/logistics/receiving/page.tsx @@ -77,6 +77,7 @@ import { type ItemSource, type WarehouseOption, } from "@/lib/api/receiving"; +import { SmartSelect, type SmartSelectOption } from "@/components/common/SmartSelect"; const GRID_COLUMNS = [ { key: "inbound_number", label: "입고번호" }, @@ -290,6 +291,11 @@ export default function ReceivingPage() { const [editMode, setEditMode] = useState(false); const [editItemIds, setEditItemIds] = useState([]); + // 공급처 (supplier_mng) 모달 state + const [modalSupplierCode, setModalSupplierCode] = useState(""); + const [modalSupplierName, setModalSupplierName] = useState(""); + const [supplierOptions, setSupplierOptions] = useState([]); + // 소스 데이터 const [sourceKeyword, setSourceKeyword] = useState(""); const [sourceLoading, setSourceLoading] = useState(false); @@ -386,6 +392,26 @@ export default function ReceivingPage() { })(); }, []); + // 공급처(supplier_mng) 목록 로드 — size:0 전체 반환 (CLAUDE.md 규칙 준수) + useEffect(() => { + (async () => { + try { + const res = await apiClient.post("/table-management/tables/supplier_mng/data", { + page: 1, size: 0, autoFilter: true, + }); + const supps = res.data?.data?.data || res.data?.data?.rows || []; + setSupplierOptions( + supps.map((s: any) => ({ + code: s.supplier_code, + label: s.supplier_name ? `${s.supplier_name}` : s.supplier_code, + })).filter((o: SmartSelectOption) => o.code) + ); + } catch { + // ignore + } + })(); + }, []); + // 플랫 행 생성 (입고유형 코드→라벨 변환, source_table→한글 등) const flatRows = useMemo(() => { return data.map((row) => ({ @@ -557,6 +583,8 @@ export default function ReceivingPage() { setModalInspector(""); setModalManager(""); setModalMemo(""); + setModalSupplierCode(""); + setModalSupplierName(""); setSelectedItems([]); setSourceKeyword(""); setPurchaseOrders([]); @@ -594,6 +622,8 @@ export default function ReceivingPage() { setModalInspector((first as any).inspector || ""); setModalManager((first as any).manager || ""); setModalMemo(first.memo || ""); + setModalSupplierCode((first as any).supplier_code || ""); + setModalSupplierName(first.supplier_name || ""); setSelectedItems( grouped.map((g, idx) => ({ key: (g as any).detail_id || `${g.id}__${idx}`, @@ -631,7 +661,7 @@ export default function ReceivingPage() { await loadSourceData(modalInboundType, sourceKeyword || undefined, 1); }, [modalInboundType, sourceKeyword, loadSourceData]); - // 입고유형 변경 시 소스 데이터 자동 리로드 + // 입고유형 변경 시 소스 데이터 자동 리로드 + 공급처 초기화 const handleInboundTypeChange = useCallback( (type: string) => { setModalInboundType(type); @@ -642,6 +672,11 @@ export default function ReceivingPage() { setSelectedItems([]); setSourcePage(1); setSourceTotalCount(0); + // 구매입고로 전환 시 공급처 초기화 (발주 기반 자동 채움으로 동작) + if (type === "구매입고") { + setModalSupplierCode(""); + setModalSupplierName(""); + } loadSourceData(type, undefined, 1); }, [loadSourceData] @@ -699,7 +734,7 @@ export default function ReceivingPage() { ]); }; - // 품목 추가 + // 품목 추가 (외주입고/사급자재입고/기타입고 — 폼의 공급처 선택값 반영) const addItem = (item: ItemSource) => { const key = `item-${item.id}`; if (selectedItems.some((s) => s.key === key)) return; @@ -707,10 +742,10 @@ export default function ReceivingPage() { ...prev, { key, - inbound_type: "기타입고", + inbound_type: modalInboundType || "기타입고", reference_number: item.item_number, - supplier_code: "", - supplier_name: "", + supplier_code: modalSupplierCode, + supplier_name: modalSupplierName, item_number: item.item_number, item_name: item.item_name, spec: item.spec || "", @@ -773,14 +808,25 @@ export default function ReceivingPage() { toast.error("창고를 선택해주세요."); return; } + + // 구매입고가 아닌 경우(외주입고/사급자재입고/기타입고) — 폼의 공급처 선택값을 selectedItems에 반영 + const isNonPurchase = modalInboundType !== "구매입고"; + const itemsWithSupplier = isNonPurchase + ? selectedItems.map((item) => ({ + ...item, + supplier_code: modalSupplierCode, + supplier_name: modalSupplierName, + })) + : selectedItems; + setSaving(true); try { if (editMode) { // 기존 item 수정 + 삭제된 item 삭제 + 새 item 추가 - const currentKeys = new Set(selectedItems.map((i) => i.key)); + const currentKeys = new Set(itemsWithSupplier.map((i) => i.key)); const toDelete = editItemIds.filter((id) => !currentKeys.has(id)); - const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key)); - const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key)); + const toUpdate = itemsWithSupplier.filter((i) => editItemIds.includes(i.key)); + const toCreate = itemsWithSupplier.filter((i) => !editItemIds.includes(i.key)); await Promise.all([ ...toDelete.map((id) => deleteReceiving(id)), @@ -838,7 +884,7 @@ export default function ReceivingPage() { inspector: modalInspector || undefined, manager: modalManager || undefined, memo: modalMemo || undefined, - items: selectedItems.map((item) => ({ + items: itemsWithSupplier.map((item) => ({ inbound_type: item.inbound_type, reference_number: item.reference_number, supplier_code: item.supplier_code, @@ -1373,6 +1419,36 @@ export default function ReceivingPage() { className="h-9 text-xs" /> + {/* 구매입고가 아닌 경우(외주입고/사급자재입고/기타입고)에만 공급처 선택 표시 */} + {modalInboundType !== "구매입고" && ( +
+
+ + {modalSupplierCode && ( + + )} +
+ { + setModalSupplierCode(v); + const found = supplierOptions.find((o) => o.code === v); + setModalSupplierName(found?.label || ""); + }} + options={supplierOptions} + placeholder={supplierOptions.length === 0 ? "등록된 공급처가 없습니다" : "공급처 선택"} + /> +
+ )}
d.dept_code === userForm.dept_code)?.dept_name || undefined, status: userForm.status || "active", end_date: userForm.end_date || null, + hire_date: userForm.hire_date || undefined, }, mainDept: userForm.dept_code ? { dept_code: userForm.dept_code, @@ -771,8 +772,8 @@ export default function DepartmentPage() { 입사일 setUserForm((p) => ({ ...p, regdate: e.target.value }))} + value={userForm.hire_date ? userForm.hire_date.substring(0, 10) : ""} + onChange={(e) => setUserForm((p) => ({ ...p, hire_date: e.target.value }))} className="h-9" />
diff --git a/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx index 8d371f0a..e465b4b7 100644 --- a/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx @@ -33,7 +33,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { exportToExcel } from "@/lib/utils/excelExport"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - Pencil, Copy, Settings2, Check, ChevronsUpDown, + Pencil, Copy, Settings2, Check, ChevronsUpDown, FileText, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { ImageUpload } from "@/components/common/ImageUpload"; @@ -721,7 +721,15 @@ export default function ItemInfoPage() { val ? ( { (e.target as HTMLImageElement).style.display = "none"; }} /> ) :
- ) : undefined, + ) : (col as any).key === "drawing_path" ? (val: any) => { + if (!val) return -; + const url = String(val).startsWith("http") || String(val).startsWith("/") ? val : `/api/files/preview/${val}`; + return ( + + + + ); + } : undefined, }))} data={ts.groupData(items)} loading={loading} diff --git a/frontend/app/(main)/COMPANY_8/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_8/purchase/order/page.tsx index da03b162..7961a058 100644 --- a/frontend/app/(main)/COMPANY_8/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_8/purchase/order/page.tsx @@ -32,6 +32,7 @@ import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { SmartSelect } from "@/components/common/SmartSelect"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; const MASTER_TABLE = "purchase_order_mng"; const DETAIL_TABLE = "purchase_detail"; @@ -919,12 +920,11 @@ export default function PurchaseOrderPage() {
- setMasterForm((p) => ({ ...p, order_date: e.target.value }))} - className="h-9" + onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} disabled={isReadOnly} + placeholder="발주일 선택" />
@@ -1123,7 +1123,7 @@ export default function PurchaseOrderPage() { {isReadOnly ? ( {row.due_date} ) : ( - updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" /> + updateDetailRow(idx, "due_date", v)} placeholder="납기일 선택" /> )} ); diff --git a/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx index dadb4a2f..58cba04e 100644 --- a/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx @@ -150,10 +150,16 @@ export default function InspectionManagementPage() { { table: EQUIPMENT_TABLE, col: "equipment_type" }, { table: EQUIPMENT_TABLE, col: "equipment_status" }, ]; + // fallback 테이블: inspection_standard에 COMPANY_8 카테고리가 없는 컬럼은 + // 실제 등록된 테이블에서 추가 조회하여 병합 + const fallbackCatList = [ + { table: "item_inspection_info", col: "inspection_method", targetKey: `${INSPECTION_TABLE}.inspection_method` }, + { table: "item_info", col: "unit", targetKey: `${INSPECTION_TABLE}.unit` }, + ]; await Promise.all( catList.map(async ({ table, col }) => { try { - const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`); + const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_8`); if (res.data?.data?.length > 0) { optMap[`${table}.${col}`] = flattenCategories(res.data.data); } @@ -162,6 +168,20 @@ export default function InspectionManagementPage() { } }), ); + // fallback: inspection_standard에 옵션이 없는 컬럼은 실제 카테고리 테이블에서 보충 + await Promise.all( + fallbackCatList.map(async ({ table, col, targetKey }) => { + if (optMap[targetKey]?.length > 0) return; // 이미 로드됐으면 skip + try { + const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_8`); + if (res.data?.data?.length > 0) { + optMap[targetKey] = flattenCategories(res.data.data); + } + } catch { + /* skip */ + } + }), + ); setCatOptions(optMap); // 사용자 목록 로드 try { diff --git a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx index d61d2649..411aeb0c 100644 --- a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx @@ -18,6 +18,7 @@ import { import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -46,18 +47,49 @@ const GRID_COLUMNS = [ type InspectionRow = { id: string; + inspection_item: string; // 검사항목 (inspection_standard.inspection_item) — 양방향 필터 그룹핑 키 inspection_standard_id: string; inspection_detail: string; inspection_method: string; apply_process: string; classification: string; acceptance_criteria: string; + upper_limit?: string; // 합격기준 상한치 (판단기준 수치형일 때 사용) + lower_limit?: string; // 합격기준 하한치 (판단기준 수치형일 때 사용) is_required: boolean; judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) selection_options?: string; // 선택형일 때 옵션 (콤마 구분) unit?: string; // 검사 단위 }; +// 판단기준 라벨이 수치형(상·하한 분리 입력 대상)인지 판별 +const isNumericJudgment = (jcLabel?: string) => + jcLabel === "수치(범위)" || jcLabel === "수치" || jcLabel === "범위"; + +// 조회 시 상·하한치 resolve +// - 신규 컬럼 upper_limit/lower_limit 우선 +// - 없으면 레거시 pass_criteria('기준값|오차') 파싱 fallback (하위호환) +const numericLimits = (r: any, jcLabel?: string): { upper: string; lower: string } => { + if (!isNumericJudgment(jcLabel)) return { upper: "", lower: "" }; + const u = r.upper_limit; + const l = r.lower_limit; + if ((u !== null && u !== undefined && u !== "") || (l !== null && l !== undefined && l !== "")) { + return { upper: u == null ? "" : String(u), lower: l == null ? "" : String(l) }; + } + // 레거시 pass_criteria = '기준값|오차' + const pc = String(r.pass_criteria || ""); + if (pc.includes("|")) { + const [stdRaw, tolRaw] = pc.split("|"); + const std = parseFloat(stdRaw); + const tol = tolRaw === "" || tolRaw === undefined ? 0 : parseFloat(tolRaw); + if (!isNaN(std)) { + const t = isNaN(tol) ? 0 : tol; + return { upper: String(std + t), lower: String(std - t) }; + } + } + return { upper: "", lower: "" }; +}; + function SortableInspectionTableRow({ id, children }: { id: string; children: (dragHandle: React.ReactNode) => React.ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style: React.CSSProperties = { @@ -113,7 +145,7 @@ export default function ItemInspectionInfoPage() { // FK 옵션 const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; size: string; unit: string }[]>([]); - const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); + const [inspOptions, setInspOptions] = useState<{ code: string; label: string; inspection_item: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); // 검사유형 목록 (검사기준 카테고리 기반) @@ -179,7 +211,8 @@ export default function ItemInspectionInfoPage() { setInspOptions(insps.map((r: any) => ({ code: r.id, label: r.inspection_criteria || r.inspection_standard || r.id, - detail: r.inspection_item || r.inspection_criteria || "", + inspection_item: r.inspection_item || "", + detail: r.criteria_detail || r.inspection_criteria || "", method: r.inspection_method || "", judgment_criteria: r.judgment_criteria || "", selection_options: r.selection_options || "", @@ -211,11 +244,23 @@ export default function ItemInspectionInfoPage() { } catch { /* skip */ } // 검사방법 카테고리 + // COMPANY_8 검사방법 라벨은 inspection_standard 가 아닌 item_inspection_info.inspection_method 에 존재(ERP-072). + // 1차 inspection_standard 조회 → 비면 item_inspection_info 로 fallback. 둘 다 있으면 병합(코드 중복은 1차 우선). try { - const methodRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_method/values`); const flatMethods: { code: string; label: string }[] = []; - const flattenM = (arr: any[]) => { for (const v of arr) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenM(v.children); } }; - if (methodRes.data?.data?.length) flattenM(methodRes.data.data); + const seenMethod = new Set(); + const flattenM = (arr: any[]) => { + for (const v of arr) { + if (!seenMethod.has(v.valueCode)) { flatMethods.push({ code: v.valueCode, label: v.valueLabel }); seenMethod.add(v.valueCode); } + if (v.children?.length) flattenM(v.children); + } + }; + for (const tbl of [INSPECTION_TABLE, "item_inspection_info"]) { + try { + const methodRes = await apiClient.get(`/table-categories/${tbl}/inspection_method/values`); + if (methodRes.data?.data?.length) flattenM(methodRes.data.data); + } catch { /* skip */ } + } setInspMethodCatOptions(flatMethods); } catch { /* skip */ } @@ -229,11 +274,23 @@ export default function ItemInspectionInfoPage() { } catch { /* skip */ } // 검사 단위 카테고리 + // COMPANY_8 단위 라벨은 inspection_standard 가 아닌 item_info.unit 에 존재(검사방법과 동일 패턴). + // 1차 inspection_standard 조회 → 비면 item_info 로 fallback. 둘 다 있으면 병합(코드 중복은 1차 우선). try { - const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`); const flatUnit: { code: string; label: string }[] = []; - const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } }; - if (unitRes.data?.data?.length) flattenU(unitRes.data.data); + const seenUnit = new Set(); + const flattenU = (arr: any[]) => { + for (const v of arr) { + if (!seenUnit.has(v.valueCode)) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); seenUnit.add(v.valueCode); } + if (v.children?.length) flattenU(v.children); + } + }; + for (const tbl of [INSPECTION_TABLE, "item_info"]) { + try { + const unitRes = await apiClient.get(`/table-categories/${tbl}/unit/values`); + if (unitRes.data?.data?.length) flattenU(unitRes.data.data); + } catch { /* skip */ } + } setInspUnitCatOptions(flatUnit); } catch { /* skip */ } @@ -391,12 +448,15 @@ export default function ItemInspectionInfoPage() { const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; rowMap[typeKey].push({ id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리) + inspection_item: inspOpt?.inspection_item || "", inspection_standard_id: r.inspection_standard_id || "", - inspection_detail: r.inspection_item_name || r.inspection_standard || "", + inspection_detail: r.criteria_detail || r.inspection_standard || "", inspection_method: mLabel, apply_process: r.apply_process || "", classification: r.classification || "", - acceptance_criteria: r.pass_criteria || "", + acceptance_criteria: isNumericJudgment(jcLabel) ? "" : (r.pass_criteria || ""), + upper_limit: numericLimits(r, jcLabel).upper, + lower_limit: numericLimits(r, jcLabel).lower, is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, selection_options: inspOpt?.selection_options || "", @@ -648,12 +708,15 @@ export default function ItemInspectionInfoPage() { const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; rowMap[typeKey].push({ id: r.id, + inspection_item: inspOpt?.inspection_item || "", inspection_standard_id: r.inspection_standard_id || "", - inspection_detail: r.inspection_item_name || r.inspection_standard || "", + inspection_detail: r.criteria_detail || r.inspection_standard || "", inspection_method: mLabel, apply_process: r.apply_process || "", classification: r.classification || "", - acceptance_criteria: r.pass_criteria || "", + acceptance_criteria: isNumericJudgment(jcLabel) ? "" : (r.pass_criteria || ""), + upper_limit: numericLimits(r, jcLabel).upper, + lower_limit: numericLimits(r, jcLabel).lower, is_required: r.is_required === "true" || r.is_required === true, judgment_criteria: jcLabel, selection_options: inspOpt?.selection_options || "", @@ -670,7 +733,7 @@ export default function ItemInspectionInfoPage() { const addInspRow = (typeKey: string) => { setInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_item: "", inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", upper_limit: "", lower_limit: "", is_required: false }], })); }; const removeInspRow = (typeKey: string, rowId: string) => { @@ -690,6 +753,25 @@ export default function ItemInspectionInfoPage() { ...prev, [typeKey]: (prev[typeKey] || []).map(r => { if (r.id !== rowId) return r; + if (field === "inspection_item") { + // 검사항목 변경 → 현 검사기준이 그 검사항목과 불일치하면 검사기준/관련값 리셋(양방향 필터) + const curOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const mismatch = !!r.inspection_standard_id && (curOpt?.inspection_item || "").trim() !== String(value || "").trim(); + if (!mismatch) return { ...r, inspection_item: value }; + return { + ...r, + inspection_item: value, + inspection_standard_id: "", + inspection_detail: "", + inspection_method: "", + judgment_criteria: "", + selection_options: "", + unit: "", + acceptance_criteria: "", + upper_limit: "", + lower_limit: "", + }; + } if (field === "inspection_standard_id") { const opt = inspOptions.find(o => o.code === value); const methodCode = opt?.method || ""; @@ -703,32 +785,59 @@ export default function ItemInspectionInfoPage() { return { ...r, inspection_standard_id: value, + // 검사기준 선택 → 검사항목 자동 동기화(양방향 필터: 검사기준 먼저 선택 시 검사항목 Fix) + inspection_item: opt?.inspection_item || r.inspection_item || "", inspection_detail: opt?.detail || "", inspection_method: methodLabel, judgment_criteria: jcLabel, selection_options: opt?.selection_options || "", unit: unitLabel, acceptance_criteria: "", // 판단기준 변경 시 초기화 + upper_limit: "", + lower_limit: "", }; } return { ...r, [field]: value }; }), })); }; - const getFilteredInspOptions = (typeKey: string) => { + // 검사유형 탭 기준으로 1차 필터된 검사기준 옵션 + const getTypeFilteredInspOptions = (typeKey: string) => { const typeDef = INSPECTION_TYPES.find(t => t.key === typeKey); if (!typeDef) return inspOptions; const matchCodes = inspTypeCatOptions.filter(cat => typeDef.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code); if (matchCodes.length === 0) return inspOptions; return inspOptions.filter(opt => opt.types.some(t => matchCodes.includes(t))); }; + + // 검사항목 셀렉트 옵션: 해당 검사유형 탭에 속한 검사기준의 distinct inspection_item + const getInspectionItemOptions = (typeKey: string) => { + const seen = new Set(); + const out: { code: string; label: string }[] = []; + for (const o of getTypeFilteredInspOptions(typeKey)) { + const it = (o.inspection_item || "").trim(); + if (!it || seen.has(it)) continue; + seen.add(it); + out.push({ code: it, label: it }); + } + return out.sort((a, b) => a.label.localeCompare(b.label)); + }; + + // 검사기준 셀렉트 옵션 (양방향 필터): + // 검사항목이 선택돼 있으면 그 검사항목에 해당하는 검사기준만 노출 + const getFilteredInspOptions = (typeKey: string, inspectionItem?: string) => { + const base = getTypeFilteredInspOptions(typeKey); + const it = (inspectionItem || "").trim(); + if (!it) return base; + return base.filter(opt => (opt.inspection_item || "").trim() === it); + }; const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); }; /* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */ const addCopyInspRow = (typeKey: string) => { setCopyInspectionRows(prev => ({ ...prev, - [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }], + [typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_item: "", inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", upper_limit: "", lower_limit: "", is_required: false }], })); }; const removeCopyInspRow = (typeKey: string, rowId: string) => { @@ -739,6 +848,16 @@ export default function ItemInspectionInfoPage() { ...prev, [typeKey]: (prev[typeKey] || []).map(r => { if (r.id !== rowId) return r; + if (field === "inspection_item") { + const curOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const mismatch = !!r.inspection_standard_id && (curOpt?.inspection_item || "").trim() !== String(value || "").trim(); + if (!mismatch) return { ...r, inspection_item: value }; + return { + ...r, inspection_item: value, inspection_standard_id: "", inspection_detail: "", + inspection_method: "", judgment_criteria: "", selection_options: "", unit: "", + acceptance_criteria: "", upper_limit: "", lower_limit: "", + }; + } if (field === "inspection_standard_id") { const opt = inspOptions.find(o => o.code === value); const methodCode = opt?.method || ""; @@ -750,12 +869,15 @@ export default function ItemInspectionInfoPage() { return { ...r, inspection_standard_id: value, + inspection_item: opt?.inspection_item || r.inspection_item || "", inspection_detail: opt?.detail || "", inspection_method: methodLabel, judgment_criteria: jcLabel, selection_options: opt?.selection_options || "", unit: unitLabel, acceptance_criteria: "", + upper_limit: "", + lower_limit: "", }; } return { ...r, [field]: value }; @@ -766,6 +888,32 @@ export default function ItemInspectionInfoPage() { const handleSave = async () => { if (!form.item_code) { toast.error("품목코드는 필수예요"); return; } + // 검증: 수치형이면 상·하한 둘 다 필수 & 상한 > 하한 + { + const enabled = INSPECTION_TYPES.filter(t => !!form[t.key]); + for (const t of enabled) { + for (const r of (inspectionRows[t.key] || [])) { + if (isNumericJudgment(r.judgment_criteria)) { + const lo = String(r.lower_limit ?? "").trim(); + const up = String(r.upper_limit ?? "").trim(); + if (lo === "" || up === "") { + toast.error(`[${t.label}] 수치형 합격기준은 상한치·하한치를 모두 입력해주세요`); + return; + } + const loN = parseFloat(lo); + const upN = parseFloat(up); + if (isNaN(loN) || isNaN(upN)) { + toast.error(`[${t.label}] 상한치·하한치는 숫자여야 합니다`); + return; + } + if (upN <= loN) { + toast.error(`[${t.label}] 상한치는 하한치보다 커야 합니다`); + return; + } + } + } + } + } setSaving(true); try { if (editMode) { @@ -793,7 +941,11 @@ export default function ItemInspectionInfoPage() { rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "", - inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "", + inspection_method: r.inspection_method || "", + // 수치형: 상·하한 컬럼 저장, pass_criteria 비움 / 그 외: pass_criteria 유지 + pass_criteria: isNumericJudgment(r.judgment_criteria) ? "" : (r.acceptance_criteria || ""), + upper_limit: isNumericJudgment(r.judgment_criteria) ? String(r.upper_limit ?? "").trim() : "", + lower_limit: isNumericJudgment(r.judgment_criteria) ? String(r.lower_limit ?? "").trim() : "", apply_process: r.apply_process || "", classification: r.classification || "", is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", @@ -1266,7 +1418,13 @@ export default function ItemInspectionInfoPage() { ) : selectedTabRows.map((row: any) => ( - {row.inspection_item_name || "-"} + {(() => { + // 검사항목명 우선, 없으면 검사기준 마스터(inspection_standard.inspection_item) fallback (ERP-057 분류키) + const nm = String(row.inspection_item_name || "").trim(); + if (nm) return nm; + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + return (insp?.inspection_item || "").trim() || "-"; + })()} {resolveInspLabel(row.inspection_standard_id)} {resolveMethodLabel(row.inspection_method)} {(() => { @@ -1293,6 +1451,20 @@ export default function ItemInspectionInfoPage() { })()} {(() => { + // 판단기준이 수치형이면 합격기준은 pass_criteria가 아닌 upper_limit/lower_limit에 저장됨 (ERP-057) + // → 모달과 동일한 isNumericJudgment/numericLimits 규칙으로 하한~상한 표시 + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const jcCode = insp?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + if (isNumericJudgment(jcLabel)) { + const { upper, lower } = numericLimits(row, jcLabel); + const up = String(upper ?? "").trim(); + const lo = String(lower ?? "").trim(); + if (up && lo) return `${lo} ~ ${up}`; + if (lo) return `${lo} 이상`; + if (up) return `${up} 이하`; + // 상·하한 모두 없으면 레거시 pass_criteria fallback + } const pc = row.pass_criteria; if (!pc) return "-"; if (pc.includes("|")) { @@ -1470,26 +1642,27 @@ export default function ItemInspectionInfoPage() { 항목추가
-
- +
+
- - 검사기준 선택 - 검사기준 상세 - 검사방법 - 적용공정 - 구분 - 판단기준 - 합격기준 (판단기준별) - 필수 - 단위 + 검사항목 + 검사기준 선택 + 검사기준 상세 + 검사방법 + 적용공정 + 구분 + 판단기준 + 합격기준 (판단기준별) + 필수 + 단위 + {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : ( (<> {dragHandle} - + updateInspRow(key, row.id, "inspection_item", v)} + options={getInspectionItemOptions(key)} + placeholder="검사항목 선택" + className="h-8 text-xs" + /> + + + updateInspRow(key, row.id, "inspection_standard_id", v)} + options={getFilteredInspOptions(key, row.inspection_item).map(o => ({ code: o.code, label: o.label }))} + placeholder={getFilteredInspOptions(key, row.inspection_item).length === 0 ? "해당 검사기준 없음" : "검사기준 선택"} + className="h-8 text-xs" + /> @@ -1559,19 +1744,11 @@ export default function ItemInspectionInfoPage() { X (불합격) - ) : row.judgment_criteria === "수치(범위)" ? ( + ) : isNumericJudgment(row.judgment_criteria) ? (
- { - const parts = (row.acceptance_criteria || "||").split("|"); - parts[0] = e.target.value; - updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); - }} placeholder="기준값" disabled={!row.inspection_standard_id} /> - ± - { - const parts = (row.acceptance_criteria || "||").split("|"); - parts[1] = e.target.value; - updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); - }} placeholder="오차" disabled={!row.inspection_standard_id} /> + updateInspRow(key, row.id, "lower_limit", e.target.value)} placeholder="하한치" disabled={!row.inspection_standard_id} /> + ~ + updateInspRow(key, row.id, "upper_limit", e.target.value)} placeholder="상한치" disabled={!row.inspection_standard_id} /> {row.unit && {row.unit}}
) : ( @@ -1766,32 +1943,45 @@ export default function ItemInspectionInfoPage() { 항목추가 -
-
+
+
- 검사기준 선택 - 검사기준 상세 - 검사방법 - 적용공정 - 구분 - 판단기준 - 합격기준 - 필수 - 단위 - + 검사항목 + 검사기준 선택 + 검사기준 상세 + 검사방법 + 적용공정 + 구분 + 판단기준 + 합격기준 + 필수 + 단위 + {(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : copyInspectionRows[key].map((row) => ( - + updateCopyInspRow(key, row.id, "inspection_item", v)} + options={getInspectionItemOptions(key)} + placeholder="검사항목" + className="h-7 text-[10px]" + /> + + + updateCopyInspRow(key, row.id, "inspection_standard_id", v)} + options={getFilteredInspOptions(key, row.inspection_item).map(o => ({ code: o.code, label: o.label }))} + placeholder={getFilteredInspOptions(key, row.inspection_item).length === 0 ? "해당 없음" : "검사기준"} + className="h-7 text-[10px]" + /> @@ -1833,19 +2023,11 @@ export default function ItemInspectionInfoPage() { X (불합격) - ) : row.judgment_criteria === "수치(범위)" ? ( + ) : isNumericJudgment(row.judgment_criteria) ? (
- { - const parts = (row.acceptance_criteria || "||").split("|"); - parts[0] = e.target.value; - updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); - }} placeholder="기준" disabled={!row.inspection_standard_id} /> - ± - { - const parts = (row.acceptance_criteria || "||").split("|"); - parts[1] = e.target.value; - updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|")); - }} placeholder="±" disabled={!row.inspection_standard_id} /> + updateCopyInspRow(key, row.id, "lower_limit", e.target.value)} placeholder="하한" disabled={!row.inspection_standard_id} /> + ~ + updateCopyInspRow(key, row.id, "upper_limit", e.target.value)} placeholder="상한" disabled={!row.inspection_standard_id} />
) : ( updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> diff --git a/frontend/app/(main)/COMPANY_8/sales/order/page.tsx b/frontend/app/(main)/COMPANY_8/sales/order/page.tsx index 10caa59e..13f87cf1 100644 --- a/frontend/app/(main)/COMPANY_8/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_8/sales/order/page.tsx @@ -247,6 +247,11 @@ export default function SalesOrderPage() { // 납품처 목록 const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]); + // 거래처 담당자 목록 (customer_contact). 미선택 거래처 시 빈 배열 + const [contactOptions, setContactOptions] = useState<{ code: string; label: string }[]>([]); + // 거래처 담당자 로딩 여부 (안내문 표시용) + const [contactsLoaded, setContactsLoaded] = useState(false); + // 테이블 설정 const ts = useTableSettings("c16-sales-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); @@ -580,22 +585,79 @@ export default function SalesOrderPage() { return found?.label || code; }; - const loadDeliveryOptions = async (customerCode: string) => { + // 납품처 옵션 로드 + 메인납품처(is_default='Y') 자동선택 + // autoSelect=false (수정모달)면 옵션만 로드하고 기존 납품처 값 보존 + const loadDeliveryOptions = async (customerCode: string, autoSelect = true) => { if (!customerCode) { setDeliveryOptions([]); return; } try { const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, { - page: 1, size: 100, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] }, autoFilter: true, }); const rows = res.data?.data?.data || res.data?.data?.rows || []; - setDeliveryOptions(rows.map((r: any) => ({ + const opts = rows.map((r: any) => ({ code: r.destination_code || r.id, label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`, - }))); + })); + setDeliveryOptions(opts); + + if (!autoSelect) return; + + // 메인납품처 자동선택 (is_default: 'Y'/true). 복수면 첫 번째 1건 + const def = rows.find((r: any) => r.is_default === "Y" || r.is_default === true || r.is_default === "true"); + if (def) { + const code = def.destination_code || def.id; + const addr = def.address || ""; + setMasterForm((p) => ({ + ...p, + delivery_partner_id: code, + ...(addr ? { delivery_address: addr } : {}), + })); + } } catch { setDeliveryOptions([]); } }; + // 거래처 담당자(customer_contact) 로드 + 메인담당자(is_main) 자동선택 + // autoSelect=false (수정모달)면 옵션만 로드하고 기존 담당자 값 보존 + const loadCustomerContacts = async (customerCode: string, autoSelect = true) => { + if (!customerCode) { + setContactOptions([]); + setContactsLoaded(false); + return; + } + try { + const res = await apiClient.get(`/customer-contacts/${encodeURIComponent(customerCode)}`); + const contacts = (res.data?.data || []) as Array<{ + id: string; contact_name: string; department: string | null; + contact_phone: string | null; is_main: boolean; + }>; + const opts = contacts.map((c) => ({ + code: c.id, + label: `${c.contact_name || "(이름없음)"}${c.department ? ` (${c.department})` : ""}`, + })); + setContactOptions(opts); + setContactsLoaded(true); + + if (!autoSelect) return; + + // 메인담당자 자동선택. 없으면 미선택 유지(에러 아님) + const main = contacts.find((c) => c.is_main); + const validIds = new Set(opts.map((o) => o.code)); + setMasterForm((p) => { + if (main) return { ...p, manager_id: main.id }; + // 거래처 변경으로 기존 담당자가 새 목록에 없으면 초기화 + if (p.manager_id && !validIds.has(p.manager_id)) { + return { ...p, manager_id: "" }; + } + return p; + }); + } catch { + setContactOptions([]); + setContactsLoaded(true); + } + }; + // 등록 모달 열기 const openRegisterModal = async () => { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; @@ -608,6 +670,8 @@ export default function SalesOrderPage() { }); setDetailRows([]); setDeliveryOptions([]); + setContactOptions([]); + setContactsLoaded(false); setIsEditMode(false); setOrderNoRuleId(null); setOrderNoPreview(null); @@ -646,8 +710,27 @@ export default function SalesOrderPage() { const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; setMasterForm(masterData || {}); + // 수정모달: 거래처 담당자/납품처 옵션은 로드하되 저장값 보존(autoSelect=false) + const editPartner = masterData?.partner_id || ""; + if (editPartner) { + void loadDeliveryOptions(editPartner, false); + void loadCustomerContacts(editPartner, false); + } else { + setDeliveryOptions([]); + setContactOptions([]); + setContactsLoaded(false); + } + // 재고단위 정규화: 과거 라벨로 저장된 데이터도 코드로 변환해 셀렉트가 매칭되도록 + const unitOpts = categoryOptions["item_inventory_unit"] || []; + const normalizeUnit = (u: string) => { + if (!u) return ""; + if (unitOpts.some((o) => o.code === u)) return u; // 이미 코드 + const byLabel = unitOpts.find((o) => o.label === u); + return byLabel ? byLabel.code : u; + }; const initialRows = detailData.map((d: any, i: number) => ({ ...d, + unit: normalizeUnit(d.unit || ""), _id: d.id || `row_${i}`, pkg_options: [] as any[], })); @@ -969,7 +1052,8 @@ export default function SalesOrderPage() { pkg_code: "", pkg_qty_per_unit: "0", pkg_options: [] as Array<{ pkg_code: string; pkg_name: string; pkg_type: string; pkg_qty_per_unit: number }>, - unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "", + // unit은 코드로 저장해야 셀렉트 매칭 정상 동작. 라벨로 저장하면 placeholder만 표시됨 + unit: item.inventory_unit || "", qty: "1", pack_count: "0", unit_price: unitPrice, @@ -1124,17 +1208,15 @@ export default function SalesOrderPage() { const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출"); const handleExcelDownload = async () => { - if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; } - const cols = ["order_no","part_code","part_name","spec","unit","qty","ship_qty","balance_qty","unit_price","amount","currency_code","due_date","memo"]; - const labels: Record = { - order_no:"수주번호",part_code:"품번",part_name:"품명",spec:"규격",unit:"단위", - qty:"수량",ship_qty:"출하수량",balance_qty:"잔량",unit_price:"단가", - amount:"금액",currency_code:"통화",due_date:"납기일",memo:"메모", - }; - const data = orders.map((o) => { - const row: Record = {}; - for (const col of cols) row[labels[col]] = o[col] || ""; - return row; + // 데이터 소스: flatRows (라벨 변환값 포함). 0건 가드 + if (flatRows.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; } + // 엑셀 컬럼: 화면 FLAT_COLUMNS 16개와 동일 (currency_code 제외 — 사용자 확정 2026-05-18) + const data = flatRows.map((row) => { + const excelRow: Record = {}; + for (const col of FLAT_COLUMNS) { + excelRow[col.label] = row[col.key] ?? ""; + } + return excelRow; }); await exportToExcel(data, "수주관리.xlsx", "수주목록"); toast.success("다운로드 완료"); @@ -1573,9 +1655,12 @@ export default function SalesOrderPage() { delete next.partner_id; delete next.delivery_partner_id; delete next.delivery_address; + delete next.manager_id; return next; }); setDeliveryOptions([]); + setContactOptions([]); + setContactsLoaded(false); }}> @@ -1640,20 +1725,39 @@ export default function SalesOrderPage() { { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }} + onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "", manager_id: "" })); loadDeliveryOptions(v); loadCustomerContacts(v); recalcPrices(masterForm.price_mode || "", v); }} placeholder="거래처 선택" />
- - +
+ + {masterForm.manager_id && ( + + )} +
+ {contactOptions.length > 0 ? ( + setMasterForm((p) => ({ ...p, manager_id: v }))} + placeholder="담당자 선택" + /> + ) : ( +
+ {!masterForm.partner_id + ? "거래처를 먼저 선택해주세요" + : contactsLoaded + ? "등록된 담당자 없음" + : "담당자 불러오는 중..."} +
+ )}
diff --git a/frontend/app/(main)/COMPANY_8/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_8/sales/quote/page.tsx index 73c6fc40..c8209678 100644 --- a/frontend/app/(main)/COMPANY_8/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_8/sales/quote/page.tsx @@ -591,7 +591,7 @@ export default function QuoteManagementPage() { const handleRowClick = (row: any) => setSelectedRow(row); const contextParams = selectedRow - ? { "$1": selectedRow.objid, "$2": user?.companyCode || "COMPANY_7", objid: selectedRow.objid, quote_no: selectedRow.quote_no, customer_name: selectedRow.customer_name, quote_date: selectedRow.quote_date } + ? { "$1": selectedRow.objid, "$2": user?.companyCode || "COMPANY_8", objid: selectedRow.objid, quote_no: selectedRow.quote_no, customer_name: selectedRow.customer_name, quote_date: selectedRow.quote_date } : undefined; // ── 편집 모달 제목/타입 판별 ── diff --git a/frontend/lib/api/outbound.ts b/frontend/lib/api/outbound.ts index e8fe3213..c55b00f5 100644 --- a/frontend/lib/api/outbound.ts +++ b/frontend/lib/api/outbound.ts @@ -200,3 +200,93 @@ export async function getItemSources(keyword?: string) { }); return res.data as { success: boolean; data: ItemSource[] }; } + +// --- 거래명세서 API (TASK:ERP-070) --- + +export interface DeliveryNote { + id: string; + company_code: string; + note_number: string; + issue_date: string; + customer_code: string; + customer_name: string; + supply_amount: number; + tax_amount: number; + total_amount: number; + issued_by: string; + issued_by_name: string; + remark: string | null; + created_date: string; + updated_date: string; +} + +export interface DeliveryNoteDetail { + id: string; + note_id: string; + outbound_id: string; + item_code: string; + item_name: string; + specification: string; + unit: string; + outbound_qty: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + total_amount: number; + sort_order: number; +} + +export interface DeliveryNoteWithDetails extends DeliveryNote { + details: DeliveryNoteDetail[]; + outbounds: Array<{ + id: string; + note_id: string; + outbound_id: string; + outbound_number: string; + outbound_date: string; + }>; +} + +/** 거래명세서 생성 */ +export async function createDeliveryNote(payload: { + outbound_ids: string[]; + issue_date: string; + remark?: string; +}) { + const res = await apiClient.post("/delivery-note", payload); + return res.data as { + success: boolean; + message?: string; + data: { + id: string; + note_number: string; + customer_name: string; + supply_amount: number; + tax_amount: number; + total_amount: number; + }; + }; +} + +/** 거래명세서 목록 조회 */ +export async function listDeliveryNotes(params?: { + customer_code?: string; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + page_size?: number; +}) { + const res = await apiClient.get("/delivery-note", { params }); + return res.data as { + success: boolean; + data: DeliveryNote[]; + pagination: { page: number; pageSize: number; total: number; totalPages: number }; + }; +} + +/** 거래명세서 단건 상세 조회 */ +export async function getDeliveryNoteById(id: string) { + const res = await apiClient.get(`/delivery-note/${id}`); + return res.data as { success: boolean; data: DeliveryNoteWithDetails }; +}