diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 41926dd0..9de5f66c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -143,6 +143,7 @@ import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import moldRoutes from "./routes/moldRoutes"; // 금형 관리 +import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -335,6 +336,7 @@ app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테 app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/mold", moldRoutes); // 금형 관리 +app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts new file mode 100644 index 00000000..99c85889 --- /dev/null +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -0,0 +1,458 @@ +/** + * 출하계획 컨트롤러 + * + * 수주 마스터(sales_order_mng, INTEGER id) 또는 + * 수주 디테일(sales_order_detail, UUID id) 양쪽에서 호출 가능. + * + * ID 포맷으로 소스 테이블 자동 감지 → JOIN으로 완전한 정보 조합 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// UUID 포맷 감지 (하이픈 포함 36자) +const isUUID = (val: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + val + ); + +type SourceTable = "master" | "detail"; + +interface NormalizedOrder { + sourceId: string; // 원본 ID (master: 정수, detail: UUID) + masterId: number | null; + detailId: string | null; + orderNo: string; + partCode: string; + partName: string; + partnerCode: string; + partnerName: string; + dueDate: string; + orderQty: number; + shipQty: number; + balanceQty: number; +} + +// ─── 소스 테이블 감지 ─── + +function detectSource(ids: string[]): SourceTable { + if (ids.length === 0) return "detail"; + return ids.every((id) => isUUID(id)) ? "detail" : "master"; +} + +// ─── 수주 정보 정규화 (마스터/디테일 양쪽 JOIN) ─── + +async function getNormalizedOrders( + companyCode: string, + ids: string[], + source: SourceTable +): Promise { + const pool = getPool(); + + if (source === "detail") { + // 디테일 기준 → 마스터 JOIN (order_no), 거래처 JOIN (customer_mng) + // item_info는 LATERAL로 1건만 매칭 (item_number 중복 대비) + const res = await pool.query( + `SELECT + d.id AS detail_id, + m.id AS master_id, + d.order_no, + d.part_code, + COALESCE(d.part_name, i.item_name, d.part_code) AS part_name, + COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code, + COALESCE(c.customer_name, d.delivery_partner_code, m.partner_id, '') AS partner_name, + COALESCE(d.due_date, m.due_date::text, '') AS due_date, + COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty, + COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS ship_qty, + COALESCE(NULLIF(d.balance_qty,'')::numeric, m.balance_qty, 0) AS balance_qty + FROM sales_order_detail d + LEFT JOIN sales_order_mng m + ON d.order_no = m.order_no AND d.company_code = m.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = d.part_code AND company_code = d.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code + AND d.company_code = c.company_code + WHERE d.company_code = $1 + AND d.id = ANY($2::text[])`, + [companyCode, ids] + ); + + return res.rows.map((r) => ({ + sourceId: r.detail_id, + masterId: r.master_id, + detailId: r.detail_id, + orderNo: r.order_no || "", + partCode: r.part_code || "", + partName: r.part_name || "", + partnerCode: r.partner_code || "", + partnerName: r.partner_name || "", + dueDate: r.due_date || "", + orderQty: Number(r.order_qty || 0), + shipQty: Number(r.ship_qty || 0), + balanceQty: Number(r.balance_qty || 0), + })); + } else { + // 마스터 기준 → 거래처 JOIN + const numericIds = ids.map(Number).filter((n) => !isNaN(n)); + // item_info는 LATERAL로 1건만 매칭 (item_number 중복 대비) + const res = await pool.query( + `SELECT + m.id AS master_id, + NULL AS detail_id, + m.order_no, + m.part_code, + COALESCE(m.part_name, i.item_name, m.part_code, '') AS part_name, + COALESCE(m.partner_id, '') AS partner_code, + COALESCE(c.customer_name, m.partner_id, '') AS partner_name, + COALESCE(m.due_date::text, '') AS due_date, + COALESCE(m.order_qty, 0) AS order_qty, + COALESCE(m.ship_qty, 0) AS ship_qty, + COALESCE(m.balance_qty, 0) AS balance_qty + FROM sales_order_mng m + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = m.part_code AND company_code = m.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON m.partner_id = c.customer_code AND m.company_code = c.company_code + WHERE m.company_code = $1 + AND m.id = ANY($2::int[])`, + [companyCode, numericIds] + ); + + return res.rows.map((r) => ({ + sourceId: String(r.master_id), + masterId: r.master_id, + detailId: null, + orderNo: r.order_no || "", + partCode: r.part_code || "", + partName: r.part_name || "", + partnerCode: r.partner_code || "", + partnerName: r.partner_name || "", + dueDate: r.due_date || "", + orderQty: Number(r.order_qty || 0), + shipQty: Number(r.ship_qty || 0), + balanceQty: Number(r.balance_qty || 0), + })); + } +} + +// ─── 품목별 집계 + 기존 출하계획 조회 ─── + +export async function getAggregate(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { ids } = req.query; + + if (!ids) { + return res + .status(400) + .json({ success: false, message: "ids 파라미터가 필요합니다" }); + } + + const idList = (ids as string).split(",").filter(Boolean); + if (idList.length === 0) { + return res + .status(400) + .json({ success: false, message: "유효한 ID가 필요합니다" }); + } + + const source = detectSource(idList); + logger.info("출하계획 집계 조회", { + companyCode, + source, + idCount: idList.length, + }); + + // 1) 정규화된 수주 정보 조회 (JOIN 포함) + const orders = await getNormalizedOrders(companyCode, idList, source); + + if (orders.length === 0) { + return res + .status(404) + .json({ success: false, message: "해당 수주를 찾을 수 없습니다" }); + } + + // 2) 품목별 그룹핑 + const partCodeMap = new Map(); + for (const order of orders) { + const key = order.partCode || "UNKNOWN"; + if (!partCodeMap.has(key)) partCodeMap.set(key, []); + partCodeMap.get(key)!.push(order); + } + + const pool = getPool(); + const result: Record = {}; + + for (const [partCode, partOrders] of partCodeMap) { + // 총수주잔량: 선택된 수주들의 balance_qty 합 + const totalBalance = partOrders.reduce( + (s, o) => s + (o.balanceQty > 0 ? o.balanceQty : o.orderQty - o.shipQty), + 0 + ); + + // 기존 출하계획 조회 (detail_id 또는 sales_order_id 기준) + let existingPlans: any[] = []; + if (source === "detail") { + const planDetailIds = partOrders + .map((o) => o.detailId) + .filter(Boolean); + if (planDetailIds.length > 0) { + const planRes = await pool.query( + `SELECT id, detail_id, sales_order_id, plan_qty, plan_date, + shipment_plan_no, status + FROM shipment_plan + WHERE company_code = $1 AND detail_id = ANY($2::text[]) + ORDER BY created_date DESC`, + [companyCode, planDetailIds] + ); + existingPlans = planRes.rows.map((r) => ({ + id: r.id, + sourceId: r.detail_id, + planQty: Number(r.plan_qty || 0), + planDate: r.plan_date, + shipmentPlanNo: r.shipment_plan_no, + status: r.status, + })); + } + } else { + const planMasterIds = partOrders + .map((o) => o.masterId) + .filter((id): id is number => id != null); + if (planMasterIds.length > 0) { + const planRes = await pool.query( + `SELECT id, sales_order_id, detail_id, plan_qty, plan_date, + shipment_plan_no, status + FROM shipment_plan + WHERE company_code = $1 AND sales_order_id = ANY($2::int[]) + ORDER BY created_date DESC`, + [companyCode, planMasterIds] + ); + existingPlans = planRes.rows.map((r) => ({ + id: r.id, + sourceId: String(r.sales_order_id), + planQty: Number(r.plan_qty || 0), + planDate: r.plan_date, + shipmentPlanNo: r.shipment_plan_no, + status: r.status, + })); + } + } + + const totalPlanQty = existingPlans.reduce((s, p) => s + p.planQty, 0); + + // 현재고 + const stockRes = await pool.query( + `SELECT COALESCE(SUM(current_qty::numeric), 0) AS current_stock + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2`, + [companyCode, partCode] + ); + const currentStock = Number(stockRes.rows[0]?.current_stock || 0); + + // 생산중수량 + const prodRes = await pool.query( + `SELECT COALESCE(SUM(plan_qty - COALESCE(completed_qty, 0)), 0) AS in_production + FROM production_plan_mng + WHERE company_code = $1 + AND item_code = $2 + AND status IN ('in_progress', 'planned')`, + [companyCode, partCode] + ); + const inProductionQty = Number(prodRes.rows[0]?.in_production || 0); + + result[partCode] = { + totalBalance, + totalPlanQty, + currentStock, + availableStock: currentStock - totalPlanQty, + inProductionQty, + existingPlans, + orders: partOrders.map((o) => ({ + sourceId: o.sourceId, + orderNo: o.orderNo, + partCode: o.partCode, + partName: o.partName, + partnerName: o.partnerName, + dueDate: o.dueDate, + orderQty: o.orderQty, + shipQty: o.shipQty, + balanceQty: o.balanceQty, + })), + }; + } + + logger.info("출하계획 집계 조회 완료", { + companyCode, + source, + partCodes: Array.from(partCodeMap.keys()), + orderCount: orders.length, + }); + + return res.json({ success: true, data: result, source }); + } catch (error: any) { + logger.error("출하계획 집계 조회 실패", { + error: error.message, + stack: error.stack, + }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하계획 일괄 저장 ─── + +export async function batchSave(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { plans, source } = req.body; + + if (!Array.isArray(plans) || plans.length === 0) { + return res.status(400).json({ + success: false, + message: "저장할 출하계획 데이터가 필요합니다", + }); + } + + // source 자동 감지 (프론트에서 전달, 또는 ID 포맷으로 추론) + const detectedSource: SourceTable = + source || detectSource(plans.map((p: any) => String(p.sourceId))); + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + const savedPlans = []; + + for (const plan of plans) { + const { sourceId, planQty } = plan; + if (!sourceId || !planQty || planQty <= 0) continue; + + if (detectedSource === "detail") { + // 디테일 소스: detail_id로 저장 + const detailCheck = await client.query( + `SELECT d.id, d.order_no, d.part_code, d.qty, d.ship_qty, d.balance_qty, + m.id AS master_id + FROM sales_order_detail d + LEFT JOIN sales_order_mng m + ON d.order_no = m.order_no AND d.company_code = m.company_code + WHERE d.id = $1 AND d.company_code = $2`, + [sourceId, companyCode] + ); + + if (detailCheck.rowCount === 0) { + throw new Error(`수주상세 ${sourceId}을 찾을 수 없습니다`); + } + + const detail = detailCheck.rows[0]; + const qty = Number(detail.qty || 0); + const shipQty = Number(detail.ship_qty || 0); + const balanceQty = detail.balance_qty + ? Number(detail.balance_qty) + : qty - shipQty; + + if (balanceQty > 0 && planQty > balanceQty) { + throw new Error( + `수주번호 ${detail.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다` + ); + } + + const insertRes = await client.query( + `INSERT INTO shipment_plan + (company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by) + VALUES ($1, $2, $3, $4, CURRENT_DATE, 'READY', $5) + RETURNING *`, + [companyCode, sourceId, detail.master_id, planQty, userId] + ); + savedPlans.push(insertRes.rows[0]); + + // detail ship_qty 업데이트 + await client.query( + `UPDATE sales_order_detail + SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text, + balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0) + - COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [planQty, sourceId, companyCode] + ); + } else { + // 마스터 소스: sales_order_id로 저장 + const masterId = Number(sourceId); + const masterCheck = await client.query( + `SELECT id, order_no, order_qty, ship_qty, balance_qty + FROM sales_order_mng + WHERE id = $1 AND company_code = $2`, + [masterId, companyCode] + ); + + if (masterCheck.rowCount === 0) { + throw new Error(`수주 ID ${masterId}을 찾을 수 없습니다`); + } + + const master = masterCheck.rows[0]; + const balanceQty = Number(master.balance_qty || 0); + + if (balanceQty > 0 && planQty > balanceQty) { + throw new Error( + `수주번호 ${master.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다` + ); + } + + const insertRes = await client.query( + `INSERT INTO shipment_plan + (company_code, sales_order_id, plan_qty, plan_date, status, created_by) + VALUES ($1, $2, $3, CURRENT_DATE, 'READY', $4) + RETURNING *`, + [companyCode, masterId, planQty, userId] + ); + savedPlans.push(insertRes.rows[0]); + + // 마스터 ship_qty 업데이트 + await client.query( + `UPDATE sales_order_mng + SET ship_qty = COALESCE(ship_qty, 0) + $1, + balance_qty = COALESCE(order_qty, 0) - COALESCE(ship_qty, 0) - $1, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [planQty, masterId, companyCode] + ); + } + } + + await client.query("COMMIT"); + + logger.info("출하계획 일괄 저장 완료", { + companyCode, + source: detectedSource, + savedCount: savedPlans.length, + userId, + }); + + return res.json({ + success: true, + message: `${savedPlans.length}건 저장 완료`, + data: savedPlans, + }); + } catch (txError) { + await client.query("ROLLBACK"); + throw txError; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("출하계획 일괄 저장 실패", { + error: error.message, + stack: error.stack, + }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/shippingPlanRoutes.ts b/backend-node/src/routes/shippingPlanRoutes.ts new file mode 100644 index 00000000..16ff0050 --- /dev/null +++ b/backend-node/src/routes/shippingPlanRoutes.ts @@ -0,0 +1,19 @@ +/** + * 출하계획 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as shippingPlanController from "../controllers/shippingPlanController"; + +const router = Router(); + +router.use(authenticateToken); + +// 품목별 집계 + 기존 출하계획 조회 +router.get("/aggregate", shippingPlanController.getAggregate); + +// 출하계획 일괄 저장 +router.post("/batch", shippingPlanController.batchSave); + +export default router; diff --git a/docs/shipping-plan-editor-plan.md b/docs/shipping-plan-editor-plan.md new file mode 100644 index 00000000..1f2f95d3 --- /dev/null +++ b/docs/shipping-plan-editor-plan.md @@ -0,0 +1,176 @@ +# 출하계획 동시 등록 컴포넌트 (v2-shipping-plan-editor) 설계서 + +## 개요 + +수주 목록에서 다건 선택 후 "출하계획" 버튼 클릭 시 모달로 열리는 출하계획 일괄 등록 화면. +기존 ScreenModal + modalScreenId 매커니즘을 활용하여, DB 기반 화면(screen_definitions)으로 구현한다. + +## 핵심 기능 + +1. 선택된 수주를 **품목(part_code) 기준으로 그룹핑** +2. 그룹별 **5칸 집계 카드**: 총수주잔량, 총 출하계획량, 현재고, 가용재고, 생산중수량 +3. 그룹별 상세 테이블: 기존 계획(기존) + 신규 입력(신규) 구분 표시 +4. 출하계획량만 입력 → 확인 시 shipment_plan에 일괄 INSERT + +## 테이블 관계 + +``` +sales_order_mng (수주) + ├─ id (PK) + ├─ part_code (품목코드) ← 그룹핑 기준 + ├─ part_name (품명) + ├─ order_qty (수주수량) + ├─ ship_qty (출하수량) + ├─ balance_qty (잔량) = order_qty - ship_qty + ├─ partner_id (거래처) + └─ due_date (납기일) + +shipment_plan (출하계획) + ├─ sales_order_id (FK → sales_order_mng.id) + ├─ plan_qty (출하계획수량) + ├─ plan_date (출하예정일) + ├─ shipment_plan_no (자동 채번) + └─ status (READY) + +inventory_stock (재고) + ├─ item_code (품목코드) + └─ current_qty (현재고) + +production_plan_mng (생산계획) + ├─ item_code (품목코드) + ├─ plan_qty (계획수량) + ├─ completed_qty (완료수량) + └─ status (진행중 = in_progress / planned) +``` + +## 집계 카드 데이터 소스 + +| 카드 | 계산 방법 | +|------|----------| +| 총수주잔량 | SUM(sales_order_mng.balance_qty) WHERE part_code = ? | +| 총 출하계획량 | SUM(shipment_plan.plan_qty) WHERE sales_order_id IN (해당 품목 수주들) | +| 현재고 | SUM(inventory_stock.current_qty) WHERE item_code = part_code | +| 가용재고 | 현재고 - 총 출하계획량 (기존 계획분) | +| 생산중수량 | SUM(production_plan_mng.plan_qty - completed_qty) WHERE item_code = part_code AND status IN ('in_progress', 'planned') | + +## 상세 테이블 컬럼 + +| 컬럼 | 소스 | 편집 | +|------|------|------| +| 구분 | "기존" or "신규" | 읽기 전용 (배지) | +| 수주번호 | sales_order_mng.order_no | 읽기 전용 | +| 거래처 | sales_order_mng.partner_id (엔티티 조인) | 읽기 전용 | +| 납기일 | sales_order_mng.due_date | 읽기 전용 | +| 미출하 | sales_order_mng.balance_qty | 읽기 전용 | +| 출하계획량 | 입력값 / shipment_plan.plan_qty | **입력 가능** | + +## 데이터 흐름 + +``` +1. 수주 목록에서 체크박스 선택 → "출하계획" 버튼 클릭 +2. openScreenModal 이벤트 발생 (selectedData = 선택된 수주 배열) +3. ScreenModal이 모달 화면 로드 (v2-shipping-plan-editor 컴포넌트) +4. 컴포넌트가 groupedData (= selectedData) 수신 +5. part_code 기준 그룹핑 +6. 백엔드 API 호출: GET /api/shipping-plan/aggregate + → 품목별 재고, 생산중수량, 기존 출하계획 조회 +7. UI 렌더링 (집계 카드 + 상세 테이블) +8. 사용자가 출하계획량 입력 +9. 확인 버튼 → POST /api/shipping-plan/batch + → shipment_plan INSERT + sales_order_mng.plan_ship_qty UPDATE +``` + +## 파일 구조 + +``` +frontend/lib/registry/components/v2-shipping-plan-editor/ + ├── index.ts # createComponentDefinition + ├── ShippingPlanEditorRenderer.tsx # AutoRegisteringComponentRenderer + ├── ShippingPlanEditorComponent.tsx # 메인 UI 컴포넌트 + └── types.ts # 타입 정의 + +frontend/lib/api/ + └── shipping.ts # API 클라이언트 함수 + +backend-node/src/ + ├── controllers/shippingPlanController.ts # API 핸들러 + └── routes/shippingPlanRoutes.ts # 라우터 +``` + +## 백엔드 API + +### GET /api/shipping-plan/aggregate +품목별 집계 + 기존 출하계획 조회 + +Request: `?partCodes=ITEM001,SEAL-100&orderIds=172,175,178` +Response: +```json +{ + "success": true, + "data": { + "ITEM001": { + "totalBalance": 1700, + "totalPlanQty": 500, + "currentStock": 1000, + "availableStock": 500, + "inProductionQty": 300, + "existingPlans": [ + { "id": 76, "salesOrderId": 172, "planQty": 500, "planDate": "2025-12-10", "shipmentPlanNo": "SPL-..." } + ] + } + } +} +``` + +### POST /api/shipping-plan/batch +출하계획 일괄 저장 + +Request: +```json +{ + "plans": [ + { "salesOrderId": 172, "planQty": 1000 }, + { "salesOrderId": 175, "planQty": 500 } + ] +} +``` + +## 구현 상태 + +### 완료 +- [x] types.ts (타입 정의) +- [x] index.ts (컴포넌트 정의) +- [x] ShippingPlanEditorRenderer.tsx (레지스트리 등록) +- [x] ShippingPlanEditorComponent.tsx (메인 UI) +- [x] frontend/lib/api/shipping.ts (API 클라이언트) +- [x] backend-node/src/controllers/shippingPlanController.ts (집계 + 일괄 저장) +- [x] backend-node/src/routes/shippingPlanRoutes.ts (라우터) +- [x] screen_definitions (screen_id: 4573, screen_code: *_SHIP_PLAN_EDITOR) +- [x] screen_layouts_v2 (layout_id: 11562) + +### 연동 정보 +| 항목 | 마스터(*) | 탑씰(COMPANY_7) | +|------|-----------|-----------------| +| screen_id | 4573 | 4574 | +| screen_code | *_SHIP_PLAN_EDITOR | TOPSEAL_SHIP_PLAN_EDITOR | +| layout_id | 11562 | 11563 | + +탑씰 수주관리 화면(screen_id: 156)의 "출하계획" 버튼(comp_33659)이 +targetScreenId: 4574로 연결되어, 체크박스 선택 → 버튼 클릭 → 모달 오픈. +선택된 수주 데이터는 `groupedData` prop으로 전달됨. + +## 테스트 계획 + +### 1단계: 기본 기능 +- [ ] 수주 선택 → 모달 열기 → groupedData 수신 확인 +- [ ] part_code 기준 그룹핑 확인 +- [ ] 집계 카드 데이터 표시 확인 + +### 2단계: CRUD +- [ ] 출하계획량 입력 → 집계 자동 재계산 +- [ ] 확인 버튼 → shipment_plan INSERT 확인 +- [ ] 기존 계획 "기존" 배지 표시 확인 + +### 3단계: 검증 +- [ ] 출하계획량 > 미출하 시 에러 처리 +- [ ] 멀티테넌시 (company_code) 필터링 확인 diff --git a/frontend/lib/api/shipping.ts b/frontend/lib/api/shipping.ts new file mode 100644 index 00000000..0aa9c8b3 --- /dev/null +++ b/frontend/lib/api/shipping.ts @@ -0,0 +1,59 @@ +import { apiClient } from "./client"; + +export interface EnrichedOrder { + sourceId: string; + orderNo: string; + partCode: string; + partName: string; + partnerName: string; + dueDate: string; + orderQty: number; + shipQty: number; + balanceQty: number; +} + +export interface ExistingPlan { + id: number; + sourceId: string; + planQty: number; + planDate: string; + shipmentPlanNo: string; + status: string; +} + +export interface AggregateResponse { + [partCode: string]: { + totalBalance: number; + totalPlanQty: number; + currentStock: number; + availableStock: number; + inProductionQty: number; + existingPlans: ExistingPlan[]; + orders: EnrichedOrder[]; + }; +} + +export interface BatchSavePlan { + sourceId: string; + planQty: number; +} + +// ID만 전달 → 백엔드에서 소스 테이블 자동 감지 + JOIN +export async function getShippingPlanAggregate(ids: string[]) { + const res = await apiClient.get("/shipping-plan/aggregate", { + params: { ids: ids.join(",") }, + }); + return res.data as { + success: boolean; + data: AggregateResponse; + source: "master" | "detail"; + }; +} + +export async function batchSaveShippingPlans( + plans: BatchSavePlan[], + source?: string +) { + const res = await apiClient.post("/shipping-plan/batch", { plans, source }); + return res.data as { success: boolean; message?: string; data?: any }; +} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 46e92af1..f3f4e552 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -119,6 +119,7 @@ import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화 import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드 import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅 +import "./v2-shipping-plan-editor/ShippingPlanEditorRenderer"; // 출하계획 동시등록 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 8fe0d7dd..403183b5 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -604,7 +604,7 @@ export const ButtonPrimaryComponent: React.FC = ({ toast.dismiss(); // UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음 - const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "approval"]; + const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "approval", "event"]; if (!silentActions.includes(actionConfig.type)) { currentLoadingToastRef.current = toast.loading( actionConfig.type === "save" @@ -631,7 +631,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // 실패한 경우 오류 처리 if (!success) { // UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시) - const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert"]; + const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert", "event"]; if (silentErrorActions.includes(actionConfig.type)) { return; } diff --git a/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx new file mode 100644 index 00000000..c96d9286 --- /dev/null +++ b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx @@ -0,0 +1,576 @@ +"use client"; + +import React, { useEffect, useState, useCallback, useMemo, useRef } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { + Loader2, + Package, + TrendingUp, + Warehouse, + CheckCircle, + Factory, + Truck, +} from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { + ShippingPlanEditorConfig, + ItemGroup, + PlanDetailRow, + ItemAggregation, +} from "./types"; +import { getShippingPlanAggregate, batchSaveShippingPlans } from "@/lib/api/shipping"; + +export interface ShippingPlanEditorComponentProps + extends ComponentRendererProps {} + +export const ShippingPlanEditorComponent: React.FC< + ShippingPlanEditorComponentProps +> = ({ component, isDesignMode = false, groupedData, formData, onFormDataChange, onClose, ...props }) => { + const config = (component?.componentConfig || + {}) as ShippingPlanEditorConfig; + const [itemGroups, setItemGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [source, setSource] = useState<"master" | "detail">("detail"); + const itemGroupsRef = useRef([]); + const sourceRef = useRef<"master" | "detail">("detail"); + + // groupedData에서 선택된 행 추출 (마스터든 디테일이든 그대로) + const selectedRows = useMemo(() => { + if (!groupedData) return []; + if (Array.isArray(groupedData)) return groupedData; + if (groupedData.selectedRows) return groupedData.selectedRows; + if (groupedData.data) return groupedData.data; + return []; + }, [groupedData]); + + // 선택된 행의 ID 목록 추출 (문자열) + const selectedIds = useMemo(() => { + return selectedRows + .map((row: any) => String(row.id)) + .filter((id: string) => id && id !== "undefined" && id !== "null"); + }, [selectedRows]); + + const loadData = useCallback(async () => { + if (selectedIds.length === 0 || isDesignMode) return; + + setLoading(true); + try { + // ID만 보내면 백엔드에서 소스 감지 + JOIN + 정규화 + const res = await getShippingPlanAggregate(selectedIds); + + if (!res.success) { + toast.error("집계 데이터 조회 실패"); + return; + } + + setSource(res.source); + const aggregateData = res.data || {}; + + const groups: ItemGroup[] = Object.entries(aggregateData).map( + ([partCode, data]) => { + const details: PlanDetailRow[] = []; + + // 수주별로 기존 계획 합산량 계산 + const existingPlansBySource = new Map(); + for (const plan of data.existingPlans || []) { + const prev = existingPlansBySource.get(plan.sourceId) || 0; + existingPlansBySource.set(plan.sourceId, prev + plan.planQty); + } + + // 신규 행 먼저: 모든 수주에 대해 항상 추가 (분할출하 대응) + for (const order of data.orders || []) { + const alreadyPlanned = existingPlansBySource.get(order.sourceId) || 0; + const remainingBalance = Math.max(0, order.balanceQty - alreadyPlanned); + details.push({ + type: "new", + sourceId: order.sourceId, + orderNo: order.orderNo, + partnerName: order.partnerName, + dueDate: order.dueDate, + balanceQty: remainingBalance, + planQty: 0, + }); + } + + // 기존 출하계획 아래에 표시 + for (const plan of data.existingPlans || []) { + const matchOrder = data.orders?.find( + (o) => o.sourceId === plan.sourceId + ); + details.push({ + type: "existing", + sourceId: plan.sourceId, + orderNo: matchOrder?.orderNo || "-", + partnerName: matchOrder?.partnerName || "-", + dueDate: matchOrder?.dueDate || "-", + balanceQty: matchOrder?.balanceQty || 0, + planQty: plan.planQty, + existingPlanId: plan.id, + }); + } + + // partName: orders에서 가져오기 + const partName = + data.orders?.[0]?.partName || partCode; + + return { + partCode, + partName, + aggregation: { + totalBalance: data.totalBalance, + totalPlanQty: data.totalPlanQty, + currentStock: data.currentStock, + availableStock: data.availableStock, + inProductionQty: data.inProductionQty, + }, + details, + }; + } + ); + + setItemGroups(groups); + } catch (err) { + console.error("[v2-shipping-plan-editor] 데이터 로드 실패:", err); + toast.error("데이터를 불러오는데 실패했습니다"); + } finally { + setLoading(false); + } + }, [selectedIds, isDesignMode]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // ref 동기화 (이벤트 핸들러에서 최신 state 접근용) + useEffect(() => { + itemGroupsRef.current = itemGroups; + }, [itemGroups]); + + useEffect(() => { + sourceRef.current = source; + }, [source]); + + // 저장 로직 (ref 기반으로 최신 state 접근, 재구독 방지) + const savingRef = useRef(false); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + const configRef = useRef(config); + configRef.current = config; + + const handleSave = useCallback(async () => { + if (savingRef.current) return; + + const currentGroups = itemGroupsRef.current; + const currentSource = sourceRef.current; + const currentConfig = configRef.current; + + const plans = currentGroups.flatMap((g) => + g.details + .filter((d) => d.type === "new" && d.planQty > 0) + .map((d) => ({ sourceId: d.sourceId, planQty: d.planQty, balanceQty: d.balanceQty })) + ); + + if (plans.length === 0) { + toast.warning("저장할 출하계획이 없습니다. 수량을 입력해주세요."); + return; + } + + // 잔량 초과 검증 (allowOverPlan = false일 때) + if (!currentConfig.allowOverPlan) { + const overPlan = plans.find((p) => p.balanceQty > 0 && p.planQty > p.balanceQty); + if (overPlan) { + toast.error("출하계획량이 미출하량을 초과합니다."); + return; + } + } + + // 저장 전 확인 (confirmBeforeSave = true일 때) + if (currentConfig.confirmBeforeSave) { + const msg = currentConfig.confirmMessage || "출하계획을 저장하시겠습니까?"; + if (!window.confirm(msg)) return; + } + + savingRef.current = true; + setSaving(true); + try { + const savePlans = plans.map((p) => ({ sourceId: p.sourceId, planQty: p.planQty })); + const res = await batchSaveShippingPlans(savePlans, currentSource); + if (res.success) { + toast.success(`${plans.length}건의 출하계획이 저장되었습니다.`); + if (currentConfig.autoCloseOnSave !== false && onCloseRef.current) { + onCloseRef.current(); + } + } else { + toast.error(res.error || "출하계획 저장에 실패했습니다."); + } + } catch (err) { + console.error("[v2-shipping-plan-editor] 저장 실패:", err); + toast.error("출하계획 저장 중 오류가 발생했습니다."); + } finally { + savingRef.current = false; + setSaving(false); + } + }, []); + + // V2 이벤트 버스 구독 (마운트 1회만, ref로 최신 핸들러 참조) + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + useEffect(() => { + let unsubscribe: (() => void) | null = null; + let mounted = true; + + (async () => { + const { v2EventBus, V2_EVENTS } = await import("@/lib/v2-core"); + if (!mounted) return; + unsubscribe = v2EventBus.subscribe(V2_EVENTS.SHIPPING_PLAN_SAVE, () => { + handleSaveRef.current(); + }); + })(); + + return () => { + mounted = false; + if (unsubscribe) unsubscribe(); + }; + }, []); + + const handlePlanQtyChange = useCallback( + (groupIdx: number, detailIdx: number, value: string) => { + setItemGroups((prev) => { + const next = [...prev]; + const group = { ...next[groupIdx] }; + const details = [...group.details]; + const detail = { ...details[detailIdx] }; + + detail.planQty = Number(value) || 0; + details[detailIdx] = detail; + group.details = details; + + const newPlanTotal = details + .filter((d) => d.type === "new") + .reduce((sum, d) => sum + d.planQty, 0); + const existingPlanTotal = details + .filter((d) => d.type === "existing") + .reduce((sum, d) => sum + d.planQty, 0); + + group.aggregation = { + ...group.aggregation, + totalPlanQty: existingPlanTotal + newPlanTotal, + availableStock: + group.aggregation.currentStock - + (existingPlanTotal + newPlanTotal), + }; + + next[groupIdx] = group; + return next; + }); + }, + [] + ); + + if (isDesignMode) { + return ( +
+
+ + + {config.title || "출하계획 등록"} + +
+
+ {[ + "총수주잔량", + "총출하계획량", + "현재고", + "가용재고", + "생산중수량", + ].map((label) => ( +
+ 0 + {label} +
+ ))} +
+
+ 상세 테이블 영역 +
+
+ ); + } + + if (loading || saving) { + return ( +
+
+ + + {saving ? "출하계획 저장 중..." : "데이터 로딩 중..."} + +
+
+ ); + } + + if (selectedIds.length === 0) { + return ( +
+ + 선택된 수주가 없습니다 + +
+ ); + } + + const showSummary = config.showSummaryCards !== false; + const showExisting = config.showExistingPlans !== false; + + return ( +
+ {itemGroups.map((group, groupIdx) => ( +
+
+ + + {group.partName} ({group.partCode}) + +
+ + {showSummary && ( + + )} + + +
+ ))} + +
+ ); +}; + +interface VisibleCards { + totalBalance?: boolean; + totalPlanQty?: boolean; + currentStock?: boolean; + availableStock?: boolean; + inProductionQty?: boolean; +} + +const SummaryCards: React.FC<{ + aggregation: ItemAggregation; + visibleCards?: VisibleCards; +}> = ({ aggregation, visibleCards }) => { + const allCards = [ + { + key: "totalBalance" as const, + label: "총수주잔량", + value: aggregation.totalBalance, + icon: TrendingUp, + color: { + bg: "bg-blue-50", + text: "text-blue-600", + border: "border-blue-200", + }, + }, + { + key: "totalPlanQty" as const, + label: "총출하계획량", + value: aggregation.totalPlanQty, + icon: Truck, + color: { + bg: "bg-indigo-50", + text: "text-indigo-600", + border: "border-indigo-200", + }, + }, + { + key: "currentStock" as const, + label: "현재고", + value: aggregation.currentStock, + icon: Warehouse, + color: { + bg: "bg-emerald-50", + text: "text-emerald-600", + border: "border-emerald-200", + }, + }, + { + key: "availableStock" as const, + label: "가용재고", + value: aggregation.availableStock, + icon: CheckCircle, + color: { + bg: aggregation.availableStock < 0 ? "bg-red-50" : "bg-amber-50", + text: + aggregation.availableStock < 0 + ? "text-red-600" + : "text-amber-600", + border: + aggregation.availableStock < 0 + ? "border-red-200" + : "border-amber-200", + }, + }, + { + key: "inProductionQty" as const, + label: "생산중수량", + value: aggregation.inProductionQty, + icon: Factory, + color: { + bg: "bg-purple-50", + text: "text-purple-600", + border: "border-purple-200", + }, + }, + ]; + + const cards = allCards.filter( + (c) => !visibleCards || visibleCards[c.key] !== false + ); + + return ( +
+ {cards.map((card) => { + const Icon = card.icon; + return ( +
+
+ + + {card.value.toLocaleString()} + +
+ + {card.label} + +
+ ); + })} +
+ ); +}; + +const DetailTable: React.FC<{ + details: PlanDetailRow[]; + groupIdx: number; + onPlanQtyChange: ( + groupIdx: number, + detailIdx: number, + value: string + ) => void; + showExisting?: boolean; +}> = ({ details, groupIdx, onPlanQtyChange, showExisting = true }) => { + const visibleDetails = details + .map((d, idx) => ({ ...d, _origIdx: idx })) + .filter((d) => showExisting || d.type === "new"); + return ( +
+ + + + + + + + + + + + + {visibleDetails.map((detail, detailIdx) => ( + + + + + + + + + ))} + {visibleDetails.length === 0 && ( + + + + )} + +
+ 구분 + + 수주번호 + + 거래처 + + 납기일 + + 미출하 + + 출하계획량 +
+ {detail.type === "existing" ? ( + + 기존 + + ) : ( + + 신규 + + )} + {detail.orderNo}{detail.partnerName} + {detail.dueDate || "-"} + + {detail.balanceQty.toLocaleString()} + + {detail.type === "existing" ? ( + + {detail.planQty.toLocaleString()} + + ) : ( + 0 ? detail.balanceQty : undefined + } + value={detail.planQty || ""} + onChange={(e) => + onPlanQtyChange(groupIdx, detail._origIdx, e.target.value) + } + className="ml-auto h-7 w-24 text-right text-xs" + placeholder="0" + /> + )} +
+ 데이터가 없습니다 +
+
+ ); +}; + +export const ShippingPlanEditorWrapper: React.FC< + ShippingPlanEditorComponentProps +> = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorConfigPanel.tsx b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorConfigPanel.tsx new file mode 100644 index 00000000..de21012a --- /dev/null +++ b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorConfigPanel.tsx @@ -0,0 +1,166 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; + +interface ShippingPlanEditorConfigPanelProps { + config: any; + onChange: (config: any) => void; +} + +export const ShippingPlanEditorConfigPanel: React.FC< + ShippingPlanEditorConfigPanelProps +> = ({ config, onChange }) => { + const handleChange = (key: string, value: any) => { + onChange({ ...config, [key]: value }); + }; + + const handleSummaryCardToggle = (cardKey: string, checked: boolean) => { + onChange({ + ...config, + visibleSummaryCards: { + ...(config.visibleSummaryCards || defaultSummaryCards), + [cardKey]: checked, + }, + }); + }; + + const defaultSummaryCards = { + totalBalance: true, + totalPlanQty: true, + currentStock: true, + availableStock: true, + inProductionQty: true, + }; + + const summaryCards = config.visibleSummaryCards || defaultSummaryCards; + + const summaryCardLabels: Record = { + totalBalance: "총수주잔량", + totalPlanQty: "총출하계획량", + currentStock: "현재고", + availableStock: "가용재고", + inProductionQty: "생산중수량", + }; + + return ( +
+ {/* 기본 설정 */} +
+ 기본 설정 +
+ +
+ + handleChange("title", e.target.value)} + placeholder="출하계획 등록" + className="h-8 text-xs" + /> +
+ + + + {/* 표시 설정 */} +
+ 표시 설정 +
+ +
+ + + handleChange("showSummaryCards", checked) + } + /> +
+ +
+ + + handleChange("showExistingPlans", checked) + } + /> +
+ + {config.showSummaryCards !== false && ( + <> + +
+ 집계 카드 항목 +
+
+ {Object.entries(summaryCardLabels).map(([key, label]) => ( +
+ + + handleSummaryCardToggle(key, checked) + } + /> +
+ ))} +
+ + )} + + + + {/* 저장 설정 */} +
+ 저장 설정 +
+ +
+ + + handleChange("allowOverPlan", checked) + } + /> +
+ +
+ + + handleChange("autoCloseOnSave", checked) + } + /> +
+ +
+ + + handleChange("confirmBeforeSave", checked) + } + /> +
+ + {config.confirmBeforeSave && ( +
+ +