feat: implement shipping plan management features

- Added shipping plan routes and controller to handle aggregate and batch save operations.
- Introduced a new shipping plan editor component for bulk registration of shipping plans based on selected orders.
- Enhanced API client functions for fetching aggregated shipping plan data and saving plans in bulk.
- Updated the registry to include the new shipping plan editor component, improving the overall shipping management workflow.

These changes aim to streamline the shipping plan process, allowing for efficient management and registration of shipping plans in the application.
This commit is contained in:
kjs 2026-03-18 14:42:47 +09:00
parent 579461a6cb
commit 9decf13068
13 changed files with 1583 additions and 2 deletions

View File

@ -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); // 차량 운행 이력 관리

View File

@ -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<NormalizedOrder[]> {
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<string, NormalizedOrder[]>();
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<string, any> = {};
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 });
}
}

View File

@ -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;

View File

@ -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) 필터링 확인

View File

@ -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 };
}

View File

@ -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"; // 출하계획 동시등록
/**
*

View File

@ -604,7 +604,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
// 실패한 경우 오류 처리
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;
}

View File

@ -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<ItemGroup[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [source, setSource] = useState<"master" | "detail">("detail");
const itemGroupsRef = useRef<ItemGroup[]>([]);
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<string, number>();
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 (
<div className="flex h-full w-full flex-col gap-3 rounded-lg border border-dashed border-gray-300 p-4">
<div className="flex items-center gap-2">
<Truck className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
{config.title || "출하계획 등록"}
</span>
</div>
<div className="flex gap-2">
{[
"총수주잔량",
"총출하계획량",
"현재고",
"가용재고",
"생산중수량",
].map((label) => (
<div
key={label}
className="flex flex-1 flex-col items-center rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
<span className="text-lg font-bold text-gray-400">0</span>
<span className="text-[10px] text-gray-400">{label}</span>
</div>
))}
</div>
<div className="flex-1 rounded-lg border border-gray-200 bg-gray-50 p-3">
<span className="text-xs text-gray-400"> </span>
</div>
</div>
);
}
if (loading || saving) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">
{saving ? "출하계획 저장 중..." : "데이터 로딩 중..."}
</span>
</div>
</div>
);
}
if (selectedIds.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center">
<span className="text-sm text-muted-foreground">
</span>
</div>
);
}
const showSummary = config.showSummaryCards !== false;
const showExisting = config.showExistingPlans !== false;
return (
<div className="flex h-full w-full flex-col gap-4 overflow-auto p-4">
{itemGroups.map((group, groupIdx) => (
<div key={group.partCode} className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold">
{group.partName} ({group.partCode})
</span>
</div>
{showSummary && (
<SummaryCards
aggregation={group.aggregation}
visibleCards={config.visibleSummaryCards}
/>
)}
<DetailTable
details={group.details}
groupIdx={groupIdx}
onPlanQtyChange={handlePlanQtyChange}
showExisting={showExisting}
/>
</div>
))}
</div>
);
};
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 (
<div className="flex gap-2">
{cards.map((card) => {
const Icon = card.icon;
return (
<div
key={card.label}
className={`flex flex-1 flex-col items-center rounded-lg border ${card.color.border} ${card.color.bg} px-3 py-2 transition-shadow hover:shadow-sm`}
>
<div className="flex items-center gap-1">
<Icon className={`h-3.5 w-3.5 ${card.color.text}`} />
<span className={`text-xl font-bold ${card.color.text}`}>
{card.value.toLocaleString()}
</span>
</div>
<span className={`mt-0.5 text-[10px] ${card.color.text}`}>
{card.label}
</span>
</div>
);
})}
</div>
);
};
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 (
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
</th>
</tr>
</thead>
<tbody>
{visibleDetails.map((detail, detailIdx) => (
<tr
key={`${detail.type}-${detail.sourceId}-${detail.existingPlanId || detailIdx}`}
className="border-b last:border-b-0 hover:bg-muted/30"
>
<td className="px-3 py-2">
{detail.type === "existing" ? (
<Badge variant="secondary" className="text-[10px]">
</Badge>
) : (
<Badge className="bg-primary text-[10px] text-primary-foreground">
</Badge>
)}
</td>
<td className="px-3 py-2 text-xs">{detail.orderNo}</td>
<td className="px-3 py-2 text-xs">{detail.partnerName}</td>
<td className="px-3 py-2 text-xs">
{detail.dueDate || "-"}
</td>
<td className="px-3 py-2 text-right text-xs font-medium">
{detail.balanceQty.toLocaleString()}
</td>
<td className="px-3 py-2 text-right">
{detail.type === "existing" ? (
<span className="text-xs text-muted-foreground">
{detail.planQty.toLocaleString()}
</span>
) : (
<Input
type="number"
min={0}
max={
detail.balanceQty > 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"
/>
)}
</td>
</tr>
))}
{visibleDetails.length === 0 && (
<tr>
<td
colSpan={6}
className="px-3 py-6 text-center text-xs text-muted-foreground"
>
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
export const ShippingPlanEditorWrapper: React.FC<
ShippingPlanEditorComponentProps
> = (props) => {
return <ShippingPlanEditorComponent {...props} />;
};

View File

@ -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<string, string> = {
totalBalance: "총수주잔량",
totalPlanQty: "총출하계획량",
currentStock: "현재고",
availableStock: "가용재고",
inProductionQty: "생산중수량",
};
return (
<div className="space-y-4 p-4">
{/* 기본 설정 */}
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={config.title || "출하계획 등록"}
onChange={(e) => handleChange("title", e.target.value)}
placeholder="출하계획 등록"
className="h-8 text-xs"
/>
</div>
<Separator />
{/* 표시 설정 */}
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showSummaryCards !== false}
onCheckedChange={(checked) =>
handleChange("showSummaryCards", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showExistingPlans !== false}
onCheckedChange={(checked) =>
handleChange("showExistingPlans", checked)
}
/>
</div>
{config.showSummaryCards !== false && (
<>
<Separator />
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="space-y-2">
{Object.entries(summaryCardLabels).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">
<Label className="text-xs">{label}</Label>
<Switch
checked={summaryCards[key] !== false}
onCheckedChange={(checked) =>
handleSummaryCardToggle(key, checked)
}
/>
</div>
))}
</div>
</>
)}
<Separator />
{/* 저장 설정 */}
<div className="text-sm font-semibold text-muted-foreground">
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.allowOverPlan === true}
onCheckedChange={(checked) =>
handleChange("allowOverPlan", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.autoCloseOnSave !== false}
onCheckedChange={(checked) =>
handleChange("autoCloseOnSave", checked)
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.confirmBeforeSave === true}
onCheckedChange={(checked) =>
handleChange("confirmBeforeSave", checked)
}
/>
</div>
{config.confirmBeforeSave && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Textarea
value={config.confirmMessage || "출하계획을 저장하시겠습니까?"}
onChange={(e) => handleChange("confirmMessage", e.target.value)}
placeholder="출하계획을 저장하시겠습니까?"
className="min-h-[60px] text-xs"
/>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,16 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2ShippingPlanEditorDefinition } from "./index";
import { ShippingPlanEditorComponent } from "./ShippingPlanEditorComponent";
export class ShippingPlanEditorRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2ShippingPlanEditorDefinition;
render(): React.ReactElement {
return <ShippingPlanEditorComponent {...this.props} />;
}
}
ShippingPlanEditorRenderer.registerSelf();

View File

@ -0,0 +1,27 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ShippingPlanEditorWrapper } from "./ShippingPlanEditorComponent";
import { ShippingPlanEditorConfigPanel } from "./ShippingPlanEditorConfigPanel";
export const V2ShippingPlanEditorDefinition = createComponentDefinition({
id: "v2-shipping-plan-editor",
name: "출하계획 동시등록",
nameEng: "Shipping Plan Editor",
description: "수주 선택 후 품목별 그룹핑하여 출하계획을 일괄 등록하는 컴포넌트",
category: ComponentCategory.DISPLAY,
webType: "text",
component: ShippingPlanEditorWrapper,
configPanel: ShippingPlanEditorConfigPanel,
defaultConfig: {
title: "출하계획 등록",
},
defaultSize: { width: 900, height: 600 },
icon: "Truck",
tags: ["출하", "계획", "수주", "일괄등록", "v2"],
version: "1.0.0",
author: "개발팀",
});
export type { ShippingPlanEditorConfig, ItemGroup, PlanDetailRow } from "./types";

View File

@ -0,0 +1,67 @@
import { ComponentConfig } from "@/types/component";
export interface ShippingPlanEditorConfig extends ComponentConfig {
title?: string;
showSummaryCards?: boolean;
showExistingPlans?: boolean;
allowOverPlan?: boolean;
autoCloseOnSave?: boolean;
confirmBeforeSave?: boolean;
confirmMessage?: string;
visibleSummaryCards?: {
totalBalance?: boolean;
totalPlanQty?: boolean;
currentStock?: boolean;
availableStock?: boolean;
inProductionQty?: boolean;
};
}
// 백엔드에서 정규화해서 내려주는 수주 정보
export interface EnrichedOrder {
sourceId: string;
orderNo: string;
partCode: string;
partName: string;
partnerName: string;
dueDate: string;
orderQty: number;
shipQty: number;
balanceQty: number;
}
export interface ItemAggregation {
totalBalance: number;
totalPlanQty: number;
currentStock: number;
availableStock: number;
inProductionQty: number;
}
export interface ExistingPlan {
id: number;
sourceId: string;
planQty: number;
planDate: string;
shipmentPlanNo: string;
status: string;
}
// 상세 테이블 행 (기존 출하계획 + 신규 입력)
export interface PlanDetailRow {
type: "existing" | "new";
sourceId: string;
orderNo: string;
partnerName: string;
dueDate: string;
balanceQty: number;
planQty: number;
existingPlanId?: number;
}
export interface ItemGroup {
partCode: string;
partName: string;
aggregation: ItemAggregation;
details: PlanDetailRow[];
}

View File

@ -54,6 +54,9 @@ export const V2_EVENTS = {
RELATED_BUTTON_UNREGISTER: "v2:related-button:unregister",
RELATED_BUTTON_SELECT: "v2:related-button:select",
// 출하계획 저장
SHIPPING_PLAN_SAVE: "v2:shipping-plan:save",
// 스케줄 자동 생성
SCHEDULE_GENERATE_REQUEST: "v2:schedule:generate:request",
SCHEDULE_GENERATE_PREVIEW: "v2:schedule:generate:preview",
@ -237,6 +240,15 @@ export interface V2RelatedButtonSelectEvent {
selectedData: any[];
}
// ============================================================================
// 출하계획 저장 이벤트
// ============================================================================
/** 출하계획 저장 요청 이벤트 */
export interface V2ShippingPlanSaveEvent {
requestId: string;
}
// ============================================================================
// 스케줄 자동 생성 이벤트
// ============================================================================
@ -334,6 +346,8 @@ export interface V2EventPayloadMap {
[V2_EVENTS.RELATED_BUTTON_UNREGISTER]: V2RelatedButtonUnregisterEvent;
[V2_EVENTS.RELATED_BUTTON_SELECT]: V2RelatedButtonSelectEvent;
[V2_EVENTS.SHIPPING_PLAN_SAVE]: V2ShippingPlanSaveEvent;
[V2_EVENTS.SCHEDULE_GENERATE_REQUEST]: V2ScheduleGenerateRequestEvent;
[V2_EVENTS.SCHEDULE_GENERATE_PREVIEW]: V2ScheduleGeneratePreviewEvent;
[V2_EVENTS.SCHEDULE_GENERATE_APPLY]: V2ScheduleGenerateApplyEvent;