Compare commits

...

8 Commits

Author SHA1 Message Date
kjs 7b5c875ac0 Merge pull request 'jskim-node' (#416) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/416
2026-03-16 09:29:41 +09:00
kjs af91cafc02 Merge branch 'main' into jskim-node 2026-03-16 09:29:33 +09:00
kjs ba39ebf341 Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-16 09:28:46 +09:00
kjs 59a70b83aa Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-16 09:28:24 +09:00
kjs 17a5d2ff9b feat: implement production plan management functionality
- Added production plan management routes and controller to handle various operations including order summary retrieval, stock shortage checks, and CRUD operations for production plans.
- Introduced service layer for production plan management, encapsulating business logic for handling production-related data.
- Created API client for production plan management, enabling frontend interaction with the new backend endpoints.
- Enhanced button actions to support API calls for production scheduling and management tasks.

These changes aim to improve the management of production plans, enhancing usability and functionality within the ERP system.

Made-with: Cursor
2026-03-16 09:28:22 +09:00
kmh 8c12caf936 chore: update .gitignore and remove unused images
- Added support for ignoring PNG files in .gitignore to streamline file management.
- Deleted unused image files from the .playwright-mcp directory to reduce clutter and improve project organization.
- Enhanced column visibility handling in TableListComponent to include width adjustments and localStorage synchronization for better user experience.

Made-with: Cursor
2026-03-16 09:26:04 +09:00
kjs 28b7f196e0 docs: add production plan management screen implementation guide
- Introduced a comprehensive implementation guide for the production plan management screen, detailing the overall structure, table mappings, and V2 component capabilities.
- Included specific information on the main tables used, their columns, and how they relate to the screen's functionality.
- Provided an analysis of existing V2 components that can be utilized, along with those that require further development or customization.
- This guide aims to facilitate the development process and ensure adherence to established standards for screen implementation.

Made-with: Cursor
2026-03-13 17:22:27 +09:00
kmh 5e8572954a chore: update .gitignore and remove unused images
- Added support for ignoring PNG files in .gitignore to streamline file management.
- Deleted unused image files from the .playwright-mcp directory to reduce clutter and improve project organization.
- Enhanced column visibility handling in TableListComponent to include width adjustments and localStorage synchronization for better user experience.

Made-with: Cursor
2026-03-13 14:57:07 +09:00
21 changed files with 2117 additions and 30 deletions

1
.gitignore vendored
View File

@ -153,6 +153,7 @@ backend-node/uploads/
uploads/
*.jpg
*.jpeg
*.png
*.gif
*.pdf
*.doc

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -113,6 +113,7 @@ import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
@ -310,6 +311,7 @@ app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리

View File

@ -0,0 +1,190 @@
/**
*
*/
import { Request, Response } from "express";
import * as productionService from "../services/productionPlanService";
import { logger } from "../utils/logger";
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
export async function getOrderSummary(req: Request, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { excludePlanned, itemCode, itemName } = req.query;
const data = await productionService.getOrderSummary(companyCode, {
excludePlanned: excludePlanned === "true",
itemCode: itemCode as string,
itemName: itemName as string,
});
return res.json({ success: true, data });
} catch (error: any) {
logger.error("수주 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 안전재고 부족분 조회 ───
export async function getStockShortage(req: Request, res: Response) {
try {
const companyCode = req.user!.companyCode;
const data = await productionService.getStockShortage(companyCode);
return res.json({ success: true, data });
} catch (error: any) {
logger.error("안전재고 부족분 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 생산계획 상세 조회 ───
export async function getPlanById(req: Request, res: Response) {
try {
const companyCode = req.user!.companyCode;
const planId = parseInt(req.params.id, 10);
const data = await productionService.getPlanById(companyCode, planId);
if (!data) {
return res.status(404).json({ success: false, message: "생산계획을 찾을 수 없습니다" });
}
return res.json({ success: true, data });
} catch (error: any) {
logger.error("생산계획 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 생산계획 수정 ───
export async function updatePlan(req: Request, res: Response) {
try {
const companyCode = req.user!.companyCode;
const planId = parseInt(req.params.id, 10);
const updatedBy = req.user!.userId;
const data = await productionService.updatePlan(companyCode, planId, req.body, updatedBy);
return res.json({ success: true, data });
} catch (error: any) {
logger.error("생산계획 수정 실패", { error: error.message });
return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({
success: false,
message: error.message,
});
}
}
// ─── 생산계획 삭제 ───
export async function deletePlan(req: Request, res: Response) {
try {
const companyCode = req.user!.companyCode;
const planId = parseInt(req.params.id, 10);
await productionService.deletePlan(companyCode, planId);
return res.json({ success: true, message: "삭제되었습니다" });
} catch (error: any) {
logger.error("생산계획 삭제 실패", { error: error.message });
return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({
success: false,
message: error.message,
});
}
}
// ─── 자동 스케줄 생성 ───
export async function generateSchedule(req: Request, res: Response) {
try {
const companyCode = req.user!.companyCode;
const createdBy = req.user!.userId;
const { items, options } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "품목 정보가 필요합니다" });
}
const data = await productionService.generateSchedule(companyCode, items, options || {}, createdBy);
return res.json({ success: true, data });
} catch (error: any) {
logger.error("자동 스케줄 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 스케줄 병합 ───
export async function mergeSchedules(req: Request, res: Response) {
try {
const companyCode = req.user!.companyCode;
const mergedBy = req.user!.userId;
const { schedule_ids, product_type } = req.body;
if (!schedule_ids || !Array.isArray(schedule_ids) || schedule_ids.length < 2) {
return res.status(400).json({ success: false, message: "2개 이상의 스케줄을 선택해주세요" });
}
const data = await productionService.mergeSchedules(
companyCode,
schedule_ids,
product_type || "완제품",
mergedBy
);
return res.json({ success: true, data });
} catch (error: any) {
logger.error("스케줄 병합 실패", { error: error.message });
const status = error.message.includes("동일 품목") || error.message.includes("찾을 수 없") ? 400 : 500;
return res.status(status).json({ success: false, message: error.message });
}
}
// ─── 반제품 계획 자동 생성 ───
export async function generateSemiSchedule(req: Request, res: Response) {
try {
const companyCode = req.user!.companyCode;
const createdBy = req.user!.userId;
const { plan_ids, options } = req.body;
if (!plan_ids || !Array.isArray(plan_ids) || plan_ids.length === 0) {
return res.status(400).json({ success: false, message: "완제품 계획을 선택해주세요" });
}
const data = await productionService.generateSemiSchedule(
companyCode,
plan_ids,
options || {},
createdBy
);
return res.json({ success: true, data });
} catch (error: any) {
logger.error("반제품 계획 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 스케줄 분할 ───
export async function splitSchedule(req: Request, res: Response) {
try {
const companyCode = req.user!.companyCode;
const splitBy = req.user!.userId;
const planId = parseInt(req.params.id, 10);
const { split_qty } = req.body;
if (!split_qty || split_qty <= 0) {
return res.status(400).json({ success: false, message: "분할 수량을 입력해주세요" });
}
const data = await productionService.splitSchedule(companyCode, planId, split_qty, splitBy);
return res.json({ success: true, data });
} catch (error: any) {
logger.error("스케줄 분할 실패", { error: error.message });
return res.status(error.message.includes("찾을 수 없") ? 404 : 400).json({
success: false,
message: error.message,
});
}
}

View File

@ -0,0 +1,36 @@
/**
*
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as productionController from "../controllers/productionController";
const router = Router();
router.use(authenticateToken);
// 수주 데이터 조회 (품목별 그룹핑)
router.get("/order-summary", productionController.getOrderSummary);
// 안전재고 부족분 조회
router.get("/stock-shortage", productionController.getStockShortage);
// 생산계획 CRUD
router.get("/plan/:id", productionController.getPlanById);
router.put("/plan/:id", productionController.updatePlan);
router.delete("/plan/:id", productionController.deletePlan);
// 자동 스케줄 생성
router.post("/generate-schedule", productionController.generateSchedule);
// 스케줄 병합
router.post("/merge-schedules", productionController.mergeSchedules);
// 반제품 계획 자동 생성
router.post("/generate-semi-schedule", productionController.generateSemiSchedule);
// 스케줄 분할
router.post("/plan/:id/split", productionController.splitSchedule);
export default router;

View File

@ -0,0 +1,668 @@
/**
*
* - ( )
* -
* -
* -
* -
* -
*/
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
export async function getOrderSummary(
companyCode: string,
options?: { excludePlanned?: boolean; itemCode?: string; itemName?: string }
) {
const pool = getPool();
const conditions: string[] = ["so.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (options?.itemCode) {
conditions.push(`so.part_code ILIKE $${paramIdx}`);
params.push(`%${options.itemCode}%`);
paramIdx++;
}
if (options?.itemName) {
conditions.push(`so.part_name ILIKE $${paramIdx}`);
params.push(`%${options.itemName}%`);
paramIdx++;
}
const whereClause = conditions.join(" AND ");
const query = `
WITH order_summary AS (
SELECT
so.part_code AS item_code,
COALESCE(so.part_name, so.part_code) AS item_name,
SUM(COALESCE(so.order_qty::numeric, 0)) AS total_order_qty,
SUM(COALESCE(so.ship_qty::numeric, 0)) AS total_ship_qty,
SUM(COALESCE(so.balance_qty::numeric, 0)) AS total_balance_qty,
COUNT(*) AS order_count,
MIN(so.due_date) AS earliest_due_date
FROM sales_order_mng so
WHERE ${whereClause}
GROUP BY so.part_code, so.part_name
),
stock_info AS (
SELECT
item_code,
SUM(COALESCE(current_qty::numeric, 0)) AS current_stock,
MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock
FROM inventory_stock
WHERE company_code = $1
GROUP BY item_code
),
plan_info AS (
SELECT
item_code,
SUM(CASE WHEN status = 'planned' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS existing_plan_qty,
SUM(CASE WHEN status = 'in_progress' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS in_progress_qty
FROM production_plan_mng
WHERE company_code = $1
AND COALESCE(product_type, '완제품') = '완제품'
AND status NOT IN ('completed', 'cancelled')
GROUP BY item_code
)
SELECT
os.item_code,
os.item_name,
os.total_order_qty,
os.total_ship_qty,
os.total_balance_qty,
os.order_count,
os.earliest_due_date,
COALESCE(si.current_stock, 0) AS current_stock,
COALESCE(si.safety_stock, 0) AS safety_stock,
COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty,
COALESCE(pi.in_progress_qty, 0) AS in_progress_qty,
GREATEST(
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
0
) AS required_plan_qty
FROM order_summary os
LEFT JOIN stock_info si ON os.item_code = si.item_code
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
ORDER BY os.item_code;
`;
const result = await pool.query(query, params);
// 그룹별 상세 수주 데이터도 함께 조회
const detailWhere = conditions.map(c => c.replace(/so\./g, "")).join(" AND ");
const detailQuery = `
SELECT
id, order_no, part_code, part_name,
COALESCE(order_qty::numeric, 0) AS order_qty,
COALESCE(ship_qty::numeric, 0) AS ship_qty,
COALESCE(balance_qty::numeric, 0) AS balance_qty,
due_date, status, partner_id, manager_name
FROM sales_order_mng
WHERE ${detailWhere}
ORDER BY part_code, due_date;
`;
const detailResult = await pool.query(detailQuery, params);
// 그룹별로 상세 데이터 매핑
const ordersByItem: Record<string, any[]> = {};
for (const row of detailResult.rows) {
const key = row.part_code || "__null__";
if (!ordersByItem[key]) ordersByItem[key] = [];
ordersByItem[key].push(row);
}
const data = result.rows.map((group: any) => ({
...group,
orders: ordersByItem[group.item_code || "__null__"] || [],
}));
logger.info("수주 데이터 조회", { companyCode, groupCount: data.length });
return data;
}
// ─── 안전재고 부족분 조회 ───
export async function getStockShortage(companyCode: string) {
const pool = getPool();
const query = `
SELECT
ist.item_code,
ii.item_name,
COALESCE(ist.current_qty::numeric, 0) AS current_qty,
COALESCE(ist.safety_qty::numeric, 0) AS safety_qty,
(COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty,
GREATEST(
COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0
) AS recommended_qty,
ist.last_in_date
FROM inventory_stock ist
LEFT JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code
WHERE ist.company_code = $1
AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0)
ORDER BY shortage_qty ASC;
`;
const result = await pool.query(query, [companyCode]);
logger.info("안전재고 부족분 조회", { companyCode, count: result.rowCount });
return result.rows;
}
// ─── 생산계획 CRUD ───
export async function getPlanById(companyCode: string, planId: number) {
const pool = getPool();
const result = await pool.query(
`SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`,
[planId, companyCode]
);
return result.rows[0] || null;
}
export async function updatePlan(
companyCode: string,
planId: number,
data: Record<string, any>,
updatedBy: string
) {
const pool = getPool();
const allowedFields = [
"plan_qty", "start_date", "end_date", "due_date",
"equipment_id", "equipment_code", "equipment_name",
"manager_name", "work_shift", "priority", "remarks", "status",
"item_code", "item_name", "product_type", "order_no",
];
const setClauses: string[] = [];
const params: any[] = [];
let paramIdx = 1;
for (const field of allowedFields) {
if (data[field] !== undefined) {
setClauses.push(`${field} = $${paramIdx}`);
params.push(data[field]);
paramIdx++;
}
}
if (setClauses.length === 0) {
throw new Error("수정할 필드가 없습니다");
}
setClauses.push(`updated_date = NOW()`);
setClauses.push(`updated_by = $${paramIdx}`);
params.push(updatedBy);
paramIdx++;
params.push(planId);
params.push(companyCode);
const query = `
UPDATE production_plan_mng
SET ${setClauses.join(", ")}
WHERE id = $${paramIdx - 1} AND company_code = $${paramIdx}
RETURNING *
`;
const result = await pool.query(query, params);
if (result.rowCount === 0) {
throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다");
}
logger.info("생산계획 수정", { companyCode, planId });
return result.rows[0];
}
export async function deletePlan(companyCode: string, planId: number) {
const pool = getPool();
const result = await pool.query(
`DELETE FROM production_plan_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
[planId, companyCode]
);
if (result.rowCount === 0) {
throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다");
}
logger.info("생산계획 삭제", { companyCode, planId });
return { id: planId };
}
// ─── 자동 스케줄 생성 ───
interface GenerateScheduleItem {
item_code: string;
item_name: string;
required_qty: number;
earliest_due_date: string;
hourly_capacity?: number;
daily_capacity?: number;
lead_time?: number;
}
interface GenerateScheduleOptions {
safety_lead_time?: number;
recalculate_unstarted?: boolean;
product_type?: string;
}
export async function generateSchedule(
companyCode: string,
items: GenerateScheduleItem[],
options: GenerateScheduleOptions,
createdBy: string
) {
const pool = getPool();
const client = await pool.connect();
const productType = options.product_type || "완제품";
const safetyLeadTime = options.safety_lead_time || 1;
try {
await client.query("BEGIN");
let deletedCount = 0;
let keptCount = 0;
const newSchedules: any[] = [];
for (const item of items) {
// 기존 미진행(planned) 스케줄 처리
if (options.recalculate_unstarted) {
const deleteResult = await client.query(
`DELETE FROM production_plan_mng
WHERE company_code = $1
AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'
RETURNING id`,
[companyCode, item.item_code, productType]
);
deletedCount += deleteResult.rowCount || 0;
const keptResult = await client.query(
`SELECT COUNT(*) AS cnt FROM production_plan_mng
WHERE company_code = $1
AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType]
);
keptCount += parseInt(keptResult.rows[0].cnt, 10);
}
// 생산일수 계산
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
// 시작일 = 납기일 - 생산일수 - 안전리드타임
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
// 시작일이 오늘보다 이전이면 오늘로 조정
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
}
// 계획번호 생성
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
);
const nextNo = planNoResult.rows[0].next_no || 1;
const planNo = `PP-${String(nextNo).padStart(6, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
company_code, plan_no, plan_date, item_code, item_name,
product_type, plan_qty, start_date, end_date, due_date,
status, priority, hourly_capacity, daily_capacity, lead_time,
created_by, created_date, updated_date
) VALUES (
$1, $2, CURRENT_DATE, $3, $4,
$5, $6, $7, $8, $9,
'planned', 'normal', $10, $11, $12,
$13, NOW(), NOW()
) RETURNING *`,
[
companyCode, planNo, item.item_code, item.item_name,
productType, requiredQty,
startDate.toISOString().split("T")[0],
endDate.toISOString().split("T")[0],
item.earliest_due_date,
item.hourly_capacity || 100,
dailyCapacity,
item.lead_time || 1,
createdBy,
]
);
newSchedules.push(insertResult.rows[0]);
}
await client.query("COMMIT");
const summary = {
total: newSchedules.length + keptCount,
new_count: newSchedules.length,
kept_count: keptCount,
deleted_count: deletedCount,
};
logger.info("자동 스케줄 생성 완료", { companyCode, summary });
return { summary, schedules: newSchedules };
} catch (error) {
await client.query("ROLLBACK");
logger.error("자동 스케줄 생성 실패", { companyCode, error });
throw error;
} finally {
client.release();
}
}
// ─── 스케줄 병합 ───
export async function mergeSchedules(
companyCode: string,
scheduleIds: number[],
productType: string,
mergedBy: string
) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 대상 스케줄 조회
const placeholders = scheduleIds.map((_, i) => `$${i + 2}`).join(", ");
const targetResult = await client.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND id IN (${placeholders})
ORDER BY start_date`,
[companyCode, ...scheduleIds]
);
if (targetResult.rowCount !== scheduleIds.length) {
throw new Error("일부 스케줄을 찾을 수 없습니다");
}
const rows = targetResult.rows;
// 동일 품목 검증
const itemCodes = [...new Set(rows.map((r: any) => r.item_code))];
if (itemCodes.length > 1) {
throw new Error("동일 품목의 스케줄만 병합할 수 있습니다");
}
// 병합 값 계산
const totalQty = rows.reduce((sum: number, r: any) => sum + (parseFloat(r.plan_qty) || 0), 0);
const earliestStart = rows.reduce(
(min: string, r: any) => (!min || r.start_date < min ? r.start_date : min),
""
);
const latestEnd = rows.reduce(
(max: string, r: any) => (!max || r.end_date > max ? r.end_date : max),
""
);
const earliestDue = rows.reduce(
(min: string, r: any) => (!min || (r.due_date && r.due_date < min) ? r.due_date : min),
""
);
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))].join(", ");
// 기존 삭제
await client.query(
`DELETE FROM production_plan_mng WHERE company_code = $1 AND id IN (${placeholders})`,
[companyCode, ...scheduleIds]
);
// 병합된 스케줄 생성
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
company_code, plan_no, plan_date, item_code, item_name,
product_type, plan_qty, start_date, end_date, due_date,
status, order_no, created_by, created_date, updated_date
) VALUES (
$1, $2, CURRENT_DATE, $3, $4,
$5, $6, $7, $8, $9,
'planned', $10, $11, NOW(), NOW()
) RETURNING *`,
[
companyCode, planNo, rows[0].item_code, rows[0].item_name,
productType, totalQty,
earliestStart, latestEnd, earliestDue || null,
orderNos || null, mergedBy,
]
);
await client.query("COMMIT");
logger.info("스케줄 병합 완료", {
companyCode,
mergedFrom: scheduleIds,
mergedTo: insertResult.rows[0].id,
});
return insertResult.rows[0];
} catch (error) {
await client.query("ROLLBACK");
logger.error("스케줄 병합 실패", { companyCode, error });
throw error;
} finally {
client.release();
}
}
// ─── 반제품 계획 자동 생성 ───
export async function generateSemiSchedule(
companyCode: string,
planIds: number[],
options: { considerStock?: boolean; excludeUsed?: boolean },
createdBy: string
) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 선택된 완제품 계획 조회
const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", ");
const plansResult = await client.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND id IN (${placeholders})`,
[companyCode, ...planIds]
);
const newSemiPlans: any[] = [];
for (const plan of plansResult.rows) {
// BOM에서 해당 품목의 반제품 소요량 조회
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_code AS child_item_code,
bd.quantity AS bom_qty,
bd.unit
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
WHERE b.company_code = $1
AND b.item_code = $2
AND COALESCE(b.status, 'active') = 'active'
`;
const bomResult = await client.query(bomQuery, [companyCode, plan.item_code]);
for (const bomItem of bomResult.rows) {
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
// 재고 고려
if (options.considerStock) {
const stockResult = await client.query(
`SELECT COALESCE(SUM(current_qty::numeric), 0) AS stock
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2`,
[companyCode, bomItem.child_item_code || bomItem.child_item_id]
);
const stock = parseFloat(stockResult.rows[0].stock) || 0;
requiredQty = Math.max(requiredQty - stock, 0);
}
if (requiredQty <= 0) continue;
// 반제품 납기일 = 완제품 시작일
const semiDueDate = plan.start_date;
const semiEndDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (plan.lead_time || 1));
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
company_code, plan_no, plan_date, item_code, item_name,
product_type, plan_qty, start_date, end_date, due_date,
status, parent_plan_id, created_by, created_date, updated_date
) VALUES (
$1, $2, CURRENT_DATE, $3, $4,
'반제품', $5, $6, $7, $8,
'planned', $9, $10, NOW(), NOW()
) RETURNING *`,
[
companyCode, planNo,
bomItem.child_item_code || bomItem.child_item_id,
bomItem.child_item_name || bomItem.child_item_id,
requiredQty,
semiStartDate.toISOString().split("T")[0],
typeof semiEndDate === "string" ? semiEndDate : semiEndDate.toISOString().split("T")[0],
typeof semiDueDate === "string" ? semiDueDate : semiDueDate.toISOString().split("T")[0],
plan.id,
createdBy,
]
);
newSemiPlans.push(insertResult.rows[0]);
}
}
await client.query("COMMIT");
logger.info("반제품 계획 생성 완료", {
companyCode,
parentPlanIds: planIds,
semiPlanCount: newSemiPlans.length,
});
return { count: newSemiPlans.length, schedules: newSemiPlans };
} catch (error) {
await client.query("ROLLBACK");
logger.error("반제품 계획 생성 실패", { companyCode, error });
throw error;
} finally {
client.release();
}
}
// ─── 스케줄 분할 ───
export async function splitSchedule(
companyCode: string,
planId: number,
splitQty: number,
splitBy: string
) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const planResult = await client.query(
`SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`,
[planId, companyCode]
);
if (planResult.rowCount === 0) {
throw new Error("생산계획을 찾을 수 없습니다");
}
const plan = planResult.rows[0];
const originalQty = parseFloat(plan.plan_qty) || 0;
if (splitQty >= originalQty || splitQty <= 0) {
throw new Error("분할 수량은 0보다 크고 원래 수량보다 작아야 합니다");
}
// 원본 수량 감소
await client.query(
`UPDATE production_plan_mng SET plan_qty = $1, updated_date = NOW(), updated_by = $2
WHERE id = $3 AND company_code = $4`,
[originalQty - splitQty, splitBy, planId, companyCode]
);
// 분할된 새 계획 생성
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
company_code, plan_no, plan_date, item_code, item_name,
product_type, plan_qty, start_date, end_date, due_date,
status, priority, equipment_id, equipment_code, equipment_name,
order_no, parent_plan_id, created_by, created_date, updated_date
) VALUES (
$1, $2, CURRENT_DATE, $3, $4,
$5, $6, $7, $8, $9,
$10, $11, $12, $13, $14,
$15, $16, $17, NOW(), NOW()
) RETURNING *`,
[
companyCode, planNo, plan.item_code, plan.item_name,
plan.product_type, splitQty,
plan.start_date, plan.end_date, plan.due_date,
plan.status, plan.priority, plan.equipment_id, plan.equipment_code, plan.equipment_name,
plan.order_no, plan.parent_plan_id,
splitBy,
]
);
await client.query("COMMIT");
logger.info("스케줄 분할 완료", { companyCode, planId, splitQty });
return {
original: { id: planId, plan_qty: originalQty - splitQty },
split: insertResult.rows[0],
};
} catch (error) {
await client.query("ROLLBACK");
logger.error("스케줄 분할 실패", { companyCode, error });
throw error;
} finally {
client.release();
}
}

View File

@ -0,0 +1,856 @@
# 생산계획관리 화면 구현 설계서
> **Screen Code**: `TOPSEAL_PP_MAIN` (screen_id: 3985)
> **메뉴 경로**: 생산관리 > 생산계획관리
> **HTML 예시**: `00_화면개발_html/Cursor 폴더/화면개발/PC브라우저/생산/생산계획관리.html`
> **작성일**: 2026-03-13
---
## 1. 화면 전체 구조
```
+---------------------------------------------------------------------+
| 검색 섹션 (상단) |
| [품목코드] [품명] [계획기간(daterange)] [상태] |
| [사용자옵션] [엑셀업로드] [엑셀다운로드] |
+----------------------------------+--+-------------------------------+
| 좌측 패널 (50%, 리사이즈) | | 우측 패널 (50%) |
| +------------------------------+ |리| +---------------------------+ |
| | [수주데이터] [안전재고 부족분] | |사| | [완제품] [반제품] | |
| +------------------------------+ |이| +---------------------------+ |
| | 수주 목록 헤더 | |즈| | 완제품 생산 타임라인 헤더 | |
| | [계획에없는품목만] [불러오기] | |핸| | [새로고침] [자동스케줄] | |
| | +---------------------------+| |들| | [병합] [반제품계획] [저장] | |
| | | 품목 그룹 테이블 || | | | +------------------------+| |
| | | - 품목별 그룹 행 (13컬럼) || | | | | 옵션 패널 || |
| | | -> 수주 상세 행 (7컬럼) || | | | | [리드타임] [기간] [재계산]|| |
| | | - 접기/펼치기 토글 || | | | +------------------------+| |
| | | - 체크박스 (그룹/개별) || | | | | 범례 || |
| | +---------------------------+| | | | +------------------------+| |
| +------------------------------+ | | | | 타임라인 스케줄러 || |
| | | | | (간트차트 형태) || |
| -- 안전재고 부족분 탭 -- | | | +------------------------+| |
| | 부족 품목 테이블 (8컬럼) | | | +---------------------------+ |
| | - 체크박스, 품목코드, 품명 | | | |
| | - 현재고, 안전재고, 부족수량 | | | -- 반제품 탭 -- |
| | - 권장생산량, 최종입고일 | | | | 옵션 + 안내 패널 | |
| +------------------------------+ | | | 반제품 타임라인 스케줄러 | |
+----------------------------------+--+-------------------------------+
```
---
## 2. 사용 테이블 및 컬럼 매핑
### 2.1 메인 테이블
| 테이블명 | 용도 | PK |
|----------|------|-----|
| `production_plan_mng` | 생산계획 마스터 | `id` (serial) |
| `sales_order_mng` | 수주 데이터 (좌측 패널 조회용) | `id` (serial) |
| `item_info` | 품목 마스터 (참조) | `id` (uuid text) |
| `inventory_stock` | 재고 현황 (안전재고 부족분 탭) | `id` (uuid text) |
| `equipment_info` | 설비 정보 (타임라인 리소스) | `id` (serial) |
| `bom` / `bom_detail` | BOM 정보 (반제품 계획 생성) | `id` (uuid text) |
| `work_instruction` | 작업지시 (타임라인 연동) | 별도 확인 필요 |
### 2.2 핵심 컬럼 매핑 - production_plan_mng
| 컬럼명 | 타입 | 용도 | HTML 매핑 |
|--------|------|------|-----------|
| `id` | serial PK | 고유 ID | `schedule.id` |
| `company_code` | varchar | 멀티테넌시 | - |
| `plan_no` | varchar NOT NULL | 계획번호 | `SCH-{timestamp}` |
| `plan_date` | date | 계획 등록일 | 자동 |
| `item_code` | varchar NOT NULL | 품목코드 | `schedule.itemCode` |
| `item_name` | varchar | 품목명 | `schedule.itemName` |
| `product_type` | varchar | 완제품/반제품 | `'완제품'` or `'반제품'` |
| `plan_qty` | numeric NOT NULL | 계획 수량 | `schedule.quantity` |
| `completed_qty` | numeric | 완료 수량 | `schedule.completedQty` |
| `progress_rate` | numeric | 진행률(%) | `schedule.progressRate` |
| `start_date` | date NOT NULL | 시작일 | `schedule.startDate` |
| `end_date` | date NOT NULL | 종료일 | `schedule.endDate` |
| `due_date` | date | 납기일 | `schedule.dueDate` |
| `equipment_id` | integer | 설비 ID | `schedule.equipmentId` |
| `equipment_code` | varchar | 설비 코드 | - |
| `equipment_name` | varchar | 설비명 | `schedule.productionLine` |
| `status` | varchar | 상태 | `planned/in_progress/completed/work-order` |
| `priority` | varchar | 우선순위 | `normal/high/urgent` |
| `hourly_capacity` | numeric | 시간당 생산능력 | `schedule.hourlyCapacity` |
| `daily_capacity` | numeric | 일일 생산능력 | `schedule.dailyCapacity` |
| `lead_time` | integer | 리드타임(일) | `schedule.leadTime` |
| `work_shift` | varchar | 작업조 | `DAY/NIGHT/BOTH` |
| `work_order_no` | varchar | 작업지시번호 | `schedule.workOrderNo` |
| `manager_name` | varchar | 담당자 | `schedule.manager` |
| `order_no` | varchar | 연관 수주번호 | `schedule.orderInfo[].orderNo` |
| `parent_plan_id` | integer | 모 계획 ID (반제품용) | `schedule.parentPlanId` |
| `remarks` | text | 비고 | `schedule.remarks` |
### 2.3 수주 데이터 조회용 - sales_order_mng
| 컬럼명 | 용도 | 좌측 테이블 컬럼 매핑 |
|--------|------|----------------------|
| `order_no` | 수주번호 | 수주 상세 행 - 수주번호 |
| `part_code` | 품목코드 | 그룹 행 - 품목코드 (그룹 기준) |
| `part_name` | 품명 | 그룹 행 - 품목명 |
| `order_qty` | 수주량 | 총수주량 (SUM) |
| `ship_qty` | 출고량 | 출고량 (SUM) |
| `balance_qty` | 잔량 | 잔량 (SUM) |
| `due_date` | 납기일 | 수주 상세 행 - 납기일 |
| `partner_id` | 거래처 | 수주 상세 행 - 거래처 |
| `status` | 상태 | 상태 배지 (일반/긴급) |
### 2.4 안전재고 부족분 조회용 - inventory_stock + item_info
| 컬럼명 | 출처 | 좌측 테이블 컬럼 매핑 |
|--------|------|----------------------|
| `item_code` | inventory_stock | 품목코드 |
| `item_name` | item_info (JOIN) | 품목명 |
| `current_qty` | inventory_stock | 현재고 |
| `safety_qty` | inventory_stock | 안전재고 |
| `부족수량` | 계산값 (`safety_qty - current_qty`) | 부족수량 (음수면 부족) |
| `권장생산량` | 계산값 (`safety_qty * 2 - current_qty`) | 권장생산량 |
| `last_in_date` | inventory_stock | 최종입고일 |
---
## 3. V2 컴포넌트 구현 가능/불가능 분석
### 3.1 구현 가능 (기존 V2 컴포넌트)
| 기능 | V2 컴포넌트 | 현재 상태 |
|------|-------------|-----------|
| 좌우 분할 레이아웃 | `v2-split-panel-layout` (`displayMode: "custom"`) | layout_data에 이미 존재 |
| 검색 필터 | `v2-table-search-widget` | layout_data에 이미 존재 |
| 좌측/우측 탭 전환 | `v2-tabs-widget` | layout_data에 이미 존재 |
| 체크박스 선택 | `v2-table-grouped` (`showCheckbox: true`) | layout_data에 이미 존재 |
| 단순 그룹핑 테이블 | `v2-table-grouped` (`groupByColumn`) | layout_data에 이미 존재 |
| 타임라인 스케줄러 | `v2-timeline-scheduler` | layout_data에 이미 존재 |
| 버튼 액션 | `v2-button-primary` | layout_data에 이미 존재 |
| 안전재고 부족분 테이블 | `v2-table-list` 또는 `v2-table-grouped` | 미구성 (탭2에 컴포넌트 없음) |
### 3.2 부분 구현 가능 (개선/확장 필요)
| 기능 | 문제점 | 필요 작업 |
|------|--------|-----------|
| 수주 그룹 테이블 (2레벨) | `v2-table-grouped`는 **동일 컬럼 기준 그룹핑**만 지원. HTML은 그룹 행(13컬럼)과 상세 행(7컬럼)이 완전히 다른 구조 | 컴포넌트 확장 or 백엔드에서 집계 데이터를 별도 API로 제공 |
| 스케줄러 옵션 패널 | HTML의 안전리드타임/표시기간/재계산 옵션을 위한 전용 UI 없음 | `v2-input` + `v2-select` 조합으로 구성 가능 |
| 범례 UI | `v2-timeline-scheduler`에 statusColors 설정은 있지만 범례 UI 자체는 없음 | `v2-text-display` 또는 커스텀 구성 |
| 부족수량 빨간색 강조 | 조건부 서식(conditional formatting) 미지원 | 컴포넌트 확장 필요 |
| "계획에 없는 품목만" 필터 | 단순 테이블 필터가 아닌 교차 테이블 비교 필터 | 백엔드 API 필요 |
### 3.3 신규 개발 필요 (현재 V2 컴포넌트로 불가능)
| 기능 | 설명 | 구현 방안 |
|------|------|-----------|
| **자동 스케줄 생성 API** | 선택 품목의 필요생산계획량, 납기일, 설비 생산능력 기반으로 타임라인 자동 배치 | 백엔드 전용 API |
| **선택 계획 병합 API** | 동일 품목 복수 스케줄을 하나로 합산 | 백엔드 전용 API |
| **반제품 계획 자동 생성 API** | BOM 기반으로 완제품 계획에서 필요 반제품 소요량 계산 | 백엔드 전용 API (BOM + 재고 연계) |
| **수주 잔량/현재고 연산 조회 API** | 여러 테이블 JOIN + 집계 연산으로 좌측 패널 데이터 제공 | 백엔드 전용 API |
| **스케줄 상세 모달** | 기본정보, 근거정보, 생산정보, 계획기간, 계획분할, 설비할당 | 모달 화면 (`TOPSEAL_PP_MODAL` screen_id: 3986) 보강 |
| **설비 선택 모달** | 설비별 수량 할당 및 일정 등록 | 신규 모달 화면 필요 |
| **변경사항 확인 모달** | 자동 스케줄 생성 전후 비교 (신규/유지/삭제 건수 요약) | 신규 모달 또는 확인 다이얼로그 |
---
## 4. 백엔드 API 설계
### 4.1 수주 데이터 조회 API (좌측 패널 - 수주데이터 탭)
```
GET /api/production/order-summary
```
**목적**: 수주 데이터를 **품목별로 그룹핑**하여 반환. 그룹 헤더에 집계값(총수주량, 출고량, 잔량, 현재고, 안전재고, 기생산계획량 등) 포함.
**응답 구조**:
```json
{
"success": true,
"data": [
{
"item_code": "ITEM-001",
"item_name": "탑씰 Type A",
"hourly_capacity": 100,
"daily_capacity": 800,
"lead_time": 1,
"total_order_qty": 1000,
"total_ship_qty": 300,
"total_balance_qty": 700,
"current_stock": 100,
"safety_stock": 150,
"plan_ship_qty": 0,
"existing_plan_qty": 0,
"in_progress_qty": 0,
"required_plan_qty": 750,
"orders": [
{
"order_no": "SO-2025-101",
"partner_name": "ABC 상사",
"order_qty": 500,
"ship_qty": 200,
"balance_qty": 300,
"due_date": "2025-11-05",
"is_urgent": false
},
{
"order_no": "SO-2025-102",
"partner_name": "XYZ 무역",
"order_qty": 500,
"ship_qty": 100,
"balance_qty": 400,
"due_date": "2025-11-10",
"is_urgent": false
}
]
}
]
}
```
**SQL 로직 (핵심)**:
```sql
WITH order_summary AS (
SELECT
so.part_code AS item_code,
so.part_name AS item_name,
SUM(COALESCE(so.order_qty, 0)) AS total_order_qty,
SUM(COALESCE(so.ship_qty, 0)) AS total_ship_qty,
SUM(COALESCE(so.balance_qty, 0)) AS total_balance_qty
FROM sales_order_mng so
WHERE so.company_code = $1
AND so.status NOT IN ('cancelled', 'completed')
AND so.balance_qty > 0
GROUP BY so.part_code, so.part_name
),
stock_info AS (
SELECT
item_code,
SUM(COALESCE(current_qty::numeric, 0)) AS current_stock,
MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock
FROM inventory_stock
WHERE company_code = $1
GROUP BY item_code
),
plan_info AS (
SELECT
item_code,
SUM(CASE WHEN status = 'planned' THEN plan_qty ELSE 0 END) AS existing_plan_qty,
SUM(CASE WHEN status = 'in_progress' THEN plan_qty ELSE 0 END) AS in_progress_qty
FROM production_plan_mng
WHERE company_code = $1
AND product_type = '완제품'
AND status NOT IN ('completed', 'cancelled')
GROUP BY item_code
)
SELECT
os.*,
COALESCE(si.current_stock, 0) AS current_stock,
COALESCE(si.safety_stock, 0) AS safety_stock,
COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty,
COALESCE(pi.in_progress_qty, 0) AS in_progress_qty,
GREATEST(
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
0
) AS required_plan_qty
FROM order_summary os
LEFT JOIN stock_info si ON os.item_code = si.item_code
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
ORDER BY os.item_code;
```
**파라미터**:
- `company_code`: req.user.companyCode (자동)
- `exclude_planned` (optional): `true`이면 기존 계획이 있는 품목 제외
---
### 4.2 안전재고 부족분 조회 API (좌측 패널 - 안전재고 탭)
```
GET /api/production/stock-shortage
```
**응답 구조**:
```json
{
"success": true,
"data": [
{
"item_code": "ITEM-001",
"item_name": "탑씰 Type A",
"current_qty": 50,
"safety_qty": 200,
"shortage_qty": -150,
"recommended_qty": 300,
"last_in_date": "2025-10-15"
}
]
}
```
**SQL 로직**:
```sql
SELECT
ist.item_code,
ii.item_name,
COALESCE(ist.current_qty::numeric, 0) AS current_qty,
COALESCE(ist.safety_qty::numeric, 0) AS safety_qty,
(COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty,
GREATEST(COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0) AS recommended_qty,
ist.last_in_date
FROM inventory_stock ist
JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code
WHERE ist.company_code = $1
AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0)
ORDER BY shortage_qty ASC;
```
---
### 4.3 자동 스케줄 생성 API
```
POST /api/production/generate-schedule
```
**요청 body**:
```json
{
"items": [
{
"item_code": "ITEM-001",
"item_name": "탑씰 Type A",
"required_qty": 750,
"earliest_due_date": "2025-11-05",
"hourly_capacity": 100,
"daily_capacity": 800,
"lead_time": 1,
"orders": [
{ "order_no": "SO-2025-101", "balance_qty": 300, "due_date": "2025-11-05" },
{ "order_no": "SO-2025-102", "balance_qty": 400, "due_date": "2025-11-10" }
]
}
],
"options": {
"safety_lead_time": 1,
"recalculate_unstarted": true,
"product_type": "완제품"
}
}
```
**비즈니스 로직**:
1. 각 품목의 필요생산계획량, 납기일, 일일생산능력을 기반으로 생산일수 계산
2. `생산일수 = ceil(필요생산계획량 / 일일생산능력)`
3. `시작일 = 납기일 - 생산일수 - 안전리드타임`
4. 시작일이 오늘 이전이면 오늘로 조정
5. `recalculate_unstarted = true`면 기존 진행중/작업지시/완료 스케줄은 유지, 미진행(planned)만 제거 후 재계산
6. 결과를 `production_plan_mng`에 INSERT
7. 변경사항 요약(신규/유지/삭제 건수) 반환
**응답 구조**:
```json
{
"success": true,
"data": {
"summary": {
"total": 3,
"new_count": 2,
"kept_count": 1,
"deleted_count": 1
},
"schedules": [
{
"id": 101,
"plan_no": "PP-2025-0001",
"item_code": "ITEM-001",
"item_name": "탑씰 Type A",
"plan_qty": 750,
"start_date": "2025-10-30",
"end_date": "2025-11-03",
"due_date": "2025-11-05",
"status": "planned"
}
]
}
}
```
---
### 4.4 스케줄 병합 API
```
POST /api/production/merge-schedules
```
**요청 body**:
```json
{
"schedule_ids": [101, 102, 103],
"product_type": "완제품"
}
```
**비즈니스 로직**:
1. 선택된 스케줄이 모두 동일 품목인지 검증
2. 완제품/반제품이 섞여있지 않은지 검증
3. 수량 합산, 가장 빠른 시작일/납기일, 가장 늦은 종료일 적용
4. 원본 스케줄 DELETE, 병합된 스케줄 INSERT
5. 수주 정보(order_no)는 병합 (중복 제거)
---
### 4.5 반제품 계획 자동 생성 API
```
POST /api/production/generate-semi-schedule
```
**요청 body**:
```json
{
"plan_ids": [101, 102],
"options": {
"consider_stock": true,
"keep_in_progress": false,
"exclude_used": true
}
}
```
**비즈니스 로직**:
1. 선택된 완제품 계획의 품목코드로 BOM 조회
2. `bom` 테이블에서 해당 품목의 `item_id``bom_detail`에서 하위 반제품(`child_item_id`) 조회
3. 각 반제품의 필요 수량 = `완제품 계획수량 x BOM 소요량(quantity)`
4. `consider_stock = true`면 현재고/안전재고 감안하여 순 필요량 계산
5. `exclude_used = true`면 이미 투입된 반제품 수량 차감
6. 모품목 생산 시작일 고려하여 반제품 납기일 설정 (시작일 - 반제품 리드타임)
7. `production_plan_mng``product_type = '반제품'`, `parent_plan_id` 설정하여 INSERT
---
### 4.6 스케줄 상세 저장/수정 API
```
PUT /api/production/plan/:id
```
**요청 body**:
```json
{
"plan_qty": 750,
"start_date": "2025-10-30",
"end_date": "2025-11-03",
"equipment_id": 1,
"equipment_code": "LINE-01",
"equipment_name": "1호기",
"manager_name": "홍길동",
"work_shift": "DAY",
"priority": "high",
"remarks": "긴급 생산"
}
```
---
### 4.7 스케줄 분할 API
```
POST /api/production/split-schedule
```
**요청 body**:
```json
{
"plan_id": 101,
"splits": [
{ "qty": 500, "start_date": "2025-10-30", "end_date": "2025-11-01" },
{ "qty": 250, "start_date": "2025-11-02", "end_date": "2025-11-03" }
]
}
```
**비즈니스 로직**:
1. 분할 수량 합산이 원본 수량과 일치하는지 검증
2. 원본 스케줄 DELETE
3. 분할된 각 조각을 신규 INSERT (동일 `order_no`, `item_code` 유지)
---
## 5. 모달 화면 설계
### 5.1 스케줄 상세 모달 (screen_id: 3986 보강)
**섹션 구성**:
| 섹션 | 필드 | 타입 | 비고 |
|------|------|------|------|
| **기본 정보** | 품목코드, 품목명 | text (readonly) | 자동 채움 |
| **근거 정보** | 수주번호/거래처/납기일 목록 | text (readonly) | 연관 수주 정보 표시 |
| **생산 정보** | 총 생산수량 | number | 수정 가능 |
| | 납기일 (수주 기준) | date (readonly) | 가장 빠른 납기일 |
| **계획 기간** | 계획 시작일, 종료일 | date | 수정 가능 |
| | 생산 기간 | text (readonly) | 자동 계산 표시 |
| **계획 분할** | 분할 개수, 분할 수량 입력 | select, number | 분할하기 기능 |
| **설비 할당** | 설비 선택 버튼 | button → 모달 | 설비 선택 모달 오픈 |
| **생산 상태** | 상태 | select (disabled) | `planned/work-order/in_progress/completed` |
| **추가 정보** | 담당자, 작업지시번호, 비고 | text | 수정 가능 |
| **하단 버튼** | 삭제, 취소, 저장 | buttons | - |
### 5.2 수주 불러오기 모달
**구성**:
- 선택된 품목 목록 표시
- 주의사항 안내
- 라디오 버튼: "기존 계획에 추가" / "별도 계획으로 생성"
- 취소/불러오기 버튼
### 5.3 안전재고 불러오기 모달
**구성**: 수주 불러오기 모달과 동일한 패턴
### 5.4 설비 선택 모달
**구성**:
- 총 수량 / 할당 수량 / 미할당 수량 요약
- 설비 카드 그리드 (설비명, 생산능력, 할당 수량 입력, 시작일/종료일)
- 취소/저장 버튼
### 5.5 변경사항 확인 모달
**구성**:
- 경고 메시지
- 변경사항 요약 카드 (총 계획, 신규 생성, 유지됨, 삭제됨)
- 변경사항 상세 목록 (품목별 변경 전/후 비교)
- 취소/확인 및 적용 버튼
---
## 6. 현재 layout_data 수정 필요 사항
### 6.1 현재 layout_data 구조 (screen_id: 3985, layout_id: 9192)
```
comp_search (v2-table-search-widget) - 검색 필터
comp_split_panel (v2-split-panel-layout)
├── leftPanel (custom mode)
│ ├── left_tabs (v2-tabs-widget) - [수주데이터, 안전재고 부족분]
│ ├── order_table (v2-table-grouped) - 수주 테이블
│ └── btn_import (v2-button-primary) - 선택 품목 불러오기
├── rightPanel (custom mode)
│ ├── right_tabs (v2-tabs-widget) - [완제품, 반제품]
│ │ └── finished_tab.components
│ │ ├── v2-timeline-scheduler - 타임라인
│ │ └── v2-button-primary - 스케줄 생성
│ ├── btn_save (v2-button-primary) - 자동 스케줄 생성
│ └── btn_clear (v2-button-primary) - 초기화
comp_q0iqzkpx (v2-button-primary) - 하단 저장 버튼 (무의미)
```
### 6.2 수정 필요 사항
| 항목 | 현재 상태 | 필요 상태 |
|------|-----------|-----------|
| **좌측 - 안전재고 탭** | 컴포넌트 없음 (`"컴포넌트가 없습니다"` 표시) | `v2-table-list` 또는 별도 조회 API 연결된 테이블 추가 |
| **좌측 - order_table** | `selectedTable: "sales_order_mng"` (범용 API) | 전용 API (`/api/production/order-summary`)로 변경 필요 |
| **좌측 - 체크박스 필터** | 없음 | "계획에 없는 품목만" 체크박스 UI 추가 |
| **우측 - 반제품 탭** | 컴포넌트 없음 | 반제품 타임라인 + 옵션 패널 추가 |
| **우측 - 타임라인** | `selectedTable: "work_instruction"` | `selectedTable: "production_plan_mng"` + 필터 `product_type='완제품'` |
| **우측 - 옵션 패널** | 없음 | 안전리드타임, 표시기간, 재계산 체크박스 → `v2-input` 조합 |
| **우측 - 범례** | 없음 | `v2-text-display` 또는 커스텀 범례 컴포넌트 |
| **우측 - 버튼들** | 일부만 존재 | 병합, 반제품계획, 저장, 초기화 추가 |
| **하단 저장 버튼** | 존재 (무의미) | 제거 |
| **우측 패널 렌더링 버그** | 타임라인 미렌더링 | SplitPanelLayout custom 모드 디버깅 필요 |
---
## 7. 구현 단계별 계획
### Phase 1: 기존 버그 수정 + 기본 구조 안정화
**목표**: 현재 layout_data로 화면이 최소한 정상 렌더링되게 만들기
| 작업 | 상세 | 예상 난이도 |
|------|------|-------------|
| 1-1. 좌측 z-index 겹침 수정 | SplitPanelLayout의 custom 모드에서 내부 컴포넌트가 비대화형 div에 가려지는 이슈 | 중 |
| 1-2. 우측 타임라인 렌더링 수정 | tabs-widget 내부 timeline-scheduler가 렌더링되지 않는 이슈 | 중 |
| 1-3. 하단 저장 버튼 제거 | layout_data에서 `comp_q0iqzkpx` 제거 | 하 |
| 1-4. 타임라인 데이터 소스 수정 | `work_instruction``production_plan_mng`으로 변경 | 하 |
### Phase 2: 백엔드 API 개발
**목표**: 화면에 필요한 데이터를 제공하는 전용 API 구축
| 작업 | 상세 | 예상 난이도 |
|------|------|-------------|
| 2-1. 수주 데이터 조회 API | `GET /api/production/order-summary` (4.1 참조) | 중 |
| 2-2. 안전재고 부족분 API | `GET /api/production/stock-shortage` (4.2 참조) | 하 |
| 2-3. 자동 스케줄 생성 API | `POST /api/production/generate-schedule` (4.3 참조) | 상 |
| 2-4. 스케줄 CRUD API | `PUT/DELETE /api/production/plan/:id` (4.6 참조) | 중 |
| 2-5. 스케줄 병합 API | `POST /api/production/merge-schedules` (4.4 참조) | 중 |
| 2-6. 반제품 계획 자동 생성 API | `POST /api/production/generate-semi-schedule` (4.5 참조) | 상 |
| 2-7. 스케줄 분할 API | `POST /api/production/split-schedule` (4.7 참조) | 중 |
### Phase 3: layout_data 보강 + 모달 화면
**목표**: 안전재고 탭, 반제품 탭, 모달들 구성
| 작업 | 상세 | 예상 난이도 |
|------|------|-------------|
| 3-1. 안전재고 부족분 탭 구성 | `stock_tab`에 테이블 컴포넌트 + "선택 품목 불러오기" 버튼 추가 | 중 |
| 3-2. 반제품 탭 구성 | `semi_tab`에 타임라인 + 옵션 + 버튼 추가 | 중 |
| 3-3. 옵션 패널 구성 | v2-input 조합으로 안전리드타임, 표시기간, 체크박스 | 중 |
| 3-4. 버튼 액션 연결 | 자동 스케줄, 병합, 반제품계획, 저장, 초기화 → API 연결 | 중 |
| 3-5. 스케줄 상세 모달 보강 | screen_id: 3986 layout_data 수정 | 중 |
| 3-6. 수주/안전재고 불러오기 모달 | 신규 모달 screen 생성 | 중 |
| 3-7. 설비 선택 모달 | 신규 모달 screen 생성 | 중 |
### Phase 4: v2-table-grouped 확장 (2레벨 트리 지원)
**목표**: HTML 예시의 "품목 그룹 → 수주 상세" 2레벨 트리 테이블 구현
| 작업 | 상세 | 예상 난이도 |
|------|------|-------------|
| 4-1. 컴포넌트 확장 설계 | 그룹 행과 상세 행이 다른 컬럼 구조를 가질 수 있도록 설계 | 상 |
| 4-2. expandedRowRenderer 구현 | 그룹 행 펼침 시 별도 컬럼/데이터로 하위 행 렌더링 | 상 |
| 4-3. 그룹 행 집계 컬럼 설정 | 그룹 헤더에 SUM, 계산 필드 표시 (현재고, 안전재고, 필요생산계획 등) | 중 |
| 4-4. 조건부 서식 지원 | 부족수량 빨간색, 양수 초록색 등 | 중 |
**대안**: Phase 4가 너무 복잡하면, 좌측 수주데이터를 2개 연동 테이블로 분리 (상단: 품목별 집계 테이블, 하단: 선택 품목의 수주 상세 테이블) 하는 방식도 검토 가능
---
## 8. 파일 생성/수정 목록
### 8.1 백엔드
| 파일 | 작업 | 비고 |
|------|------|------|
| `backend-node/src/routes/productionRoutes.ts` | 라우터 등록 | 신규 or 기존 확장 |
| `backend-node/src/controllers/productionController.ts` | API 핸들러 | 신규 or 기존 확장 |
| `backend-node/src/services/productionPlanService.ts` | 비즈니스 로직 서비스 | 신규 |
### 8.2 DB (layout_data 수정)
| 대상 | 작업 |
|------|------|
| `screen_layouts_v2` (screen_id: 3985) | layout_data JSON 수정 |
| `screen_layouts_v2` (screen_id: 3986) | 모달 layout_data 보강 |
| `screen_definitions` + `screen_layouts_v2` | 설비 선택 모달 신규 등록 |
| `screen_definitions` + `screen_layouts_v2` | 불러오기 모달 신규 등록 |
### 8.3 프론트엔드 (API 클라이언트)
| 파일 | 작업 |
|------|------|
| `frontend/lib/api/production.ts` | 생산계획 전용 API 클라이언트 함수 추가 |
### 8.4 프론트엔드 (V2 컴포넌트 확장, Phase 4)
| 파일 | 작업 |
|------|------|
| `frontend/lib/registry/components/v2-table-grouped/` | 2레벨 트리 지원 확장 |
| `frontend/lib/registry/components/v2-timeline-scheduler/` | 옵션 패널/범례 확장 (필요시) |
---
## 9. 이벤트 흐름 (주요 시나리오)
### 9.1 자동 스케줄 생성 흐름
```
1. 사용자가 좌측 수주데이터에서 품목 체크박스 선택
2. 우측 "자동 스케줄 생성" 버튼 클릭
3. (옵션 확인) 안전리드타임, 재계산 모드 체크
4. POST /api/production/generate-schedule 호출
5. (응답) 변경사항 확인 모달 표시 (신규/유지/삭제 건수)
6. 사용자 "확인 및 적용" 클릭
7. 타임라인 스케줄러 새로고침
8. 좌측 수주 목록의 "기생산계획량" 컬럼 갱신
```
### 9.2 수주 불러오기 흐름
```
1. 사용자가 좌측 수주데이터에서 품목 체크박스 선택
2. "선택 품목 불러오기" 버튼 클릭
3. 불러오기 모달 표시 (선택 품목 목록 + 추가방식 선택)
4. "기존 계획에 추가" or "별도 계획으로 생성" 선택
5. "불러오기" 버튼 클릭
6. POST /api/production/generate-schedule 호출 (단건)
7. 타임라인 새로고침
```
### 9.3 타임라인 스케줄 클릭 → 상세 모달
```
1. 사용자가 타임라인의 스케줄 바 클릭
2. 스케줄 상세 모달 오픈 (TOPSEAL_PP_MODAL)
3. 기본정보(readonly), 근거정보(readonly), 생산정보(수정가능) 표시
4. 계획기간 수정, 설비할당, 분할 등 작업
5. "저장" → PUT /api/production/plan/:id
6. "삭제" → DELETE /api/production/plan/:id
7. 모달 닫기 → 타임라인 새로고침
```
### 9.4 반제품 계획 생성 흐름
```
1. 우측 완제품 탭에서 스케줄 체크박스 선택
2. "선택 품목 → 반제품 계획" 버튼 클릭
3. POST /api/production/generate-semi-schedule 호출
- BOM 조회 → 필요 반제품 목록 + 소요량 계산
- 재고 감안 → 순 필요량 계산
- 반제품 계획 INSERT (product_type='반제품', parent_plan_id 설정)
4. 반제품 탭으로 자동 전환
5. 반제품 타임라인 새로고침
```
---
## 10. 검색 필드 설정
| 필드명 | 타입 | 라벨 | 대상 컬럼 |
|--------|------|------|-----------|
| `item_code` | text | 품목코드 | `part_code` (수주) / `item_code` (계획) |
| `item_name` | text | 품명 | `part_name` / `item_name` |
| `plan_date` | daterange | 계획기간 | `start_date` ~ `end_date` |
| `status` | select | 상태 | 전체 / 계획 / 진행 / 완료 |
---
## 11. 권한 및 멀티테넌시
### 11.1 모든 API에 적용
```typescript
const companyCode = req.user!.companyCode;
if (companyCode === '*') {
// 최고관리자: 모든 회사 데이터 조회 가능
} else {
// 일반 회사: WHERE company_code = $1 필수
}
```
### 11.2 데이터 격리
- `production_plan_mng.company_code` 필터 필수
- `sales_order_mng.company_code` 필터 필수
- `inventory_stock.company_code` 필터 필수
- JOIN 시 양쪽 테이블 모두 `company_code` 조건 포함
---
## 12. 우선순위 정리
| 우선순위 | 작업 | 이유 |
|----------|------|------|
| **1 (긴급)** | Phase 1: 기존 렌더링 버그 수정 | 현재 화면 자체가 정상 동작하지 않음 |
| **2 (높음)** | Phase 2-1, 2-2: 수주/재고 조회 API | 좌측 패널의 핵심 데이터 |
| **3 (높음)** | Phase 2-3: 자동 스케줄 생성 API | 우측 패널의 핵심 기능 |
| **4 (중간)** | Phase 3: layout_data 보강 | 안전재고 탭, 반제품 탭, 모달 |
| **5 (중간)** | Phase 2-4~2-7: 나머지 API | 병합, 분할, 반제품 계획 |
| **6 (낮음)** | Phase 4: 2레벨 트리 테이블 확장 | 현재 단순 그룹핑으로도 기본 동작 |
---
## 부록 A: HTML 예시의 모달 목록
| 모달명 | HTML ID | 용도 |
|--------|---------|------|
| 스케줄 상세 모달 | `scheduleModal` | 스케줄 기본정보/근거정보/생산정보/계획기간/분할/설비할당/상태/추가정보 |
| 수주 불러오기 모달 | `orderImportModal` | 선택 품목 목록 + 추가방식 선택 (기존추가/별도생성) |
| 안전재고 불러오기 모달 | `stockImportModal` | 부족 품목 목록 + 추가방식 선택 |
| 설비 선택 모달 | `equipmentSelectModal` | 설비 카드 + 수량할당 + 일정등록 |
| 변경사항 확인 모달 | `changeConfirmModal` | 자동스케줄 생성 결과 요약 + 상세 비교 |
## 부록 B: HTML 예시의 JS 핵심 함수 목록
| 함수명 | 기능 | 매핑 API |
|--------|------|----------|
| `generateSchedule()` | 자동 스케줄 생성 (품목별 합산) | POST /api/production/generate-schedule |
| `saveSchedule()` | 스케줄 저장 (localStorage → DB) | POST /api/production/plan (bulk) |
| `mergeSelectedSchedules()` | 선택 계획 병합 | POST /api/production/merge-schedules |
| `generateSemiFromSelected()` | 반제품 계획 자동 생성 | POST /api/production/generate-semi-schedule |
| `saveScheduleFromModal()` | 모달에서 스케줄 저장 | PUT /api/production/plan/:id |
| `deleteScheduleFromModal()` | 모달에서 스케줄 삭제 | DELETE /api/production/plan/:id |
| `openOrderImportModal()` | 수주 불러오기 모달 열기 | - (프론트엔드 UI) |
| `importOrderItems()` | 수주 품목 불러오기 실행 | POST /api/production/generate-schedule |
| `openStockImportModal()` | 안전재고 불러오기 모달 열기 | - (프론트엔드 UI) |
| `importStockItems()` | 안전재고 품목 불러오기 실행 | POST /api/production/generate-schedule |
| `refreshOrderList()` | 수주 목록 새로고침 | GET /api/production/order-summary |
| `refreshStockList()` | 재고 부족 목록 새로고침 | GET /api/production/stock-shortage |
| `switchTab(tabName)` | 좌측 탭 전환 | - (프론트엔드 UI) |
| `switchTimelineTab(tabName)` | 우측 탭 전환 | - (프론트엔드 UI) |
| `toggleOrderDetails(itemGroup)` | 품목 그룹 펼치기/접기 | - (프론트엔드 UI) |
| `renderTimeline()` | 완제품 타임라인 렌더링 | - (프론트엔드 UI) |
| `renderSemiTimeline()` | 반제품 타임라인 렌더링 | - (프론트엔드 UI) |
| `executeSplit()` | 계획 분할 실행 | POST /api/production/split-schedule |
| `openEquipmentSelectModal()` | 설비 선택 모달 열기 | GET /api/equipment (기존) |
| `saveEquipmentSelection()` | 설비 할당 저장 | PUT /api/production/plan/:id |
| `applyScheduleChanges()` | 변경사항 확인 후 적용 | - (프론트엔드 상태 관리) |
## 부록 C: 수주 데이터 테이블 컬럼 상세
### 그룹 행 (품목별 집계)
| # | 컬럼 | 데이터 소스 | 정렬 |
|---|------|-------------|------|
| 1 | 체크박스 | - | center |
| 2 | 토글 (펼치기/접기) | - | center |
| 3 | 품목코드 | `sales_order_mng.part_code` (GROUP BY) | left |
| 4 | 품목명 | `sales_order_mng.part_name` | left |
| 5 | 총수주량 | `SUM(order_qty)` | right |
| 6 | 출고량 | `SUM(ship_qty)` | right |
| 7 | 잔량 | `SUM(balance_qty)` | right |
| 8 | 현재고 | `inventory_stock.current_qty` (JOIN) | right |
| 9 | 안전재고 | `inventory_stock.safety_qty` (JOIN) | right |
| 10 | 출하계획량 | `SUM(plan_ship_qty)` | right |
| 11 | 기생산계획량 | `production_plan_mng` 조회 (JOIN) | right |
| 12 | 생산진행 | `production_plan_mng` (status='in_progress') 조회 | right |
| 13 | 필요생산계획 | 계산값 (잔량+안전재고-현재고-기생산계획량-생산진행) | right, 빨간색 강조 |
### 상세 행 (개별 수주)
| # | 컬럼 | 데이터 소스 |
|---|------|-------------|
| 1 | (빈 칸) | - |
| 2 | (빈 칸) | - |
| 3-4 | 수주번호, 거래처, 상태배지 | `order_no`, `partner_id` → partner_name, `status` |
| 5 | 수주량 | `order_qty` |
| 6 | 출고량 | `ship_qty` |
| 7 | 잔량 | `balance_qty` |
| 8-13 | 납기일 (colspan) | `due_date` |
## 부록 D: 타임라인 스케줄러 필드 매핑
### 완제품 타임라인
| 타임라인 필드 | production_plan_mng 컬럼 | 비고 |
|--------------|--------------------------|------|
| `id` | `id` | PK |
| `resourceId` | `item_code` | 품목 기준 리소스 (설비 기준이 아님) |
| `title` | `item_name` + `plan_qty` | 표시 텍스트 |
| `startDate` | `start_date` | 시작일 |
| `endDate` | `end_date` | 종료일 |
| `status` | `status` | planned/in_progress/completed/work-order |
| `progress` | `progress_rate` | 진행률(%) |
### 반제품 타임라인
동일 구조, 단 `product_type = '반제품'` 필터 적용
### statusColors 매핑
| 상태 | 색상 | 의미 |
|------|------|------|
| `planned` | `#3b82f6` (파란색) | 계획됨 |
| `work-order` | `#f59e0b` (노란색) | 작업지시 |
| `in_progress` | `#10b981` (초록색) | 진행중 |
| `completed` | `#6b7280` (회색, 반투명) | 완료 |
| `delayed` | `#ef4444` (빨간색) | 지연 |

View File

@ -402,18 +402,9 @@ select {
/* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */
/* 예: Calendar, Table 등의 미세 조정 */
/* 모바일에서 테이블 레이아웃 고정 (화면 밖으로 넘어가지 않도록) */
@media (max-width: 639px) {
.table-mobile-fixed {
/* 테이블 레이아웃 고정 (셀 내용이 영역을 벗어나지 않도록) */
.table-mobile-fixed {
table-layout: fixed;
}
}
/* 데스크톱에서 테이블 레이아웃 자동 (기본값이지만 명시적으로 설정) */
@media (min-width: 640px) {
.table-mobile-fixed {
table-layout: auto;
}
}
/* 그리드선 숨기기 */

View File

@ -583,7 +583,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
const needsStripBorder = isV2HorizLabel || isButtonComponent;
const safeComponentStyle = needsStripBorder
? (() => {
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
const { borderWidth, borderColor, borderStyle, border, ...rest } = componentStyle as any;
return rest;
})()
: componentStyle;

View File

@ -0,0 +1,178 @@
/**
* API
*/
import apiClient from "./client";
// ─── 타입 정의 ───
export interface OrderSummaryItem {
item_code: string;
item_name: string;
total_order_qty: number;
total_ship_qty: number;
total_balance_qty: number;
order_count: number;
earliest_due_date: string | null;
current_stock: number;
safety_stock: number;
existing_plan_qty: number;
in_progress_qty: number;
required_plan_qty: number;
orders: OrderDetail[];
}
export interface OrderDetail {
id: string;
order_no: string;
part_code: string;
part_name: string;
order_qty: number;
ship_qty: number;
balance_qty: number;
due_date: string | null;
status: string;
customer_name: string | null;
}
export interface StockShortageItem {
item_code: string;
item_name: string;
current_qty: number;
safety_qty: number;
shortage_qty: number;
recommended_qty: number;
last_in_date: string | null;
}
export interface ProductionPlan {
id: number;
company_code: string;
plan_no: string;
plan_date: string;
item_code: string;
item_name: string;
product_type: string;
plan_qty: number;
completed_qty: number;
progress_rate: number;
start_date: string;
end_date: string;
due_date: string | null;
equipment_id: number | null;
equipment_code: string | null;
equipment_name: string | null;
status: string;
priority: string | null;
order_no: string | null;
parent_plan_id: number | null;
remarks: string | null;
}
export interface GenerateScheduleRequest {
items: {
item_code: string;
item_name: string;
required_qty: number;
earliest_due_date: string;
hourly_capacity?: number;
daily_capacity?: number;
lead_time?: number;
}[];
options?: {
safety_lead_time?: number;
recalculate_unstarted?: boolean;
product_type?: string;
};
}
export interface GenerateScheduleResponse {
summary: {
total: number;
new_count: number;
kept_count: number;
deleted_count: number;
};
schedules: ProductionPlan[];
}
// ─── API 함수 ───
/** 수주 데이터 조회 (품목별 그룹핑) */
export async function getOrderSummary(params?: {
excludePlanned?: boolean;
itemCode?: string;
itemName?: string;
}) {
const queryParams = new URLSearchParams();
if (params?.excludePlanned) queryParams.set("excludePlanned", "true");
if (params?.itemCode) queryParams.set("itemCode", params.itemCode);
if (params?.itemName) queryParams.set("itemName", params.itemName);
const qs = queryParams.toString();
const url = `/api/production/order-summary${qs ? `?${qs}` : ""}`;
const response = await apiClient.get(url);
return response.data as { success: boolean; data: OrderSummaryItem[] };
}
/** 안전재고 부족분 조회 */
export async function getStockShortage() {
const response = await apiClient.get("/api/production/stock-shortage");
return response.data as { success: boolean; data: StockShortageItem[] };
}
/** 생산계획 상세 조회 */
export async function getPlanById(planId: number) {
const response = await apiClient.get(`/api/production/plan/${planId}`);
return response.data as { success: boolean; data: ProductionPlan };
}
/** 생산계획 수정 */
export async function updatePlan(planId: number, data: Partial<ProductionPlan>) {
const response = await apiClient.put(`/api/production/plan/${planId}`, data);
return response.data as { success: boolean; data: ProductionPlan };
}
/** 생산계획 삭제 */
export async function deletePlan(planId: number) {
const response = await apiClient.delete(`/api/production/plan/${planId}`);
return response.data as { success: boolean; message: string };
}
/** 자동 스케줄 생성 */
export async function generateSchedule(request: GenerateScheduleRequest) {
const response = await apiClient.post("/api/production/generate-schedule", request);
return response.data as { success: boolean; data: GenerateScheduleResponse };
}
/** 스케줄 병합 */
export async function mergeSchedules(scheduleIds: number[], productType?: string) {
const response = await apiClient.post("/api/production/merge-schedules", {
schedule_ids: scheduleIds,
product_type: productType || "완제품",
});
return response.data as { success: boolean; data: ProductionPlan };
}
/** 반제품 계획 자동 생성 */
export async function generateSemiSchedule(
planIds: number[],
options?: { considerStock?: boolean; excludeUsed?: boolean }
) {
const response = await apiClient.post("/api/production/generate-semi-schedule", {
plan_ids: planIds,
options: options || {},
});
return response.data as { success: boolean; data: { count: number; schedules: ProductionPlan[] } };
}
/** 스케줄 분할 */
export async function splitSchedule(planId: number, splitQty: number) {
const response = await apiClient.post(`/api/production/plan/${planId}/split`, {
split_qty: splitQty,
});
return response.data as {
success: boolean;
data: { original: { id: number; plan_qty: number }; split: ProductionPlan };
};
}

View File

@ -2670,7 +2670,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<td
key={colIdx}
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
style={{ textAlign: col.align || "left" }}
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis" }}
>
{formatCellValue(
col.name,
@ -2732,7 +2732,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<td
key={colIdx}
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
style={{ textAlign: col.align || "left" }}
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis" }}
>
{formatCellValue(
col.name,
@ -3415,7 +3415,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<td
key={colIdx}
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
style={{ textAlign: col.align || "left" }}
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis" }}
>
{formatCellValue(
col.name,

View File

@ -379,12 +379,33 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}, [tableConfig.selectedTable, currentUserId]);
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
// columnVisibility 변경 시 컬럼 순서, 가시성, 너비 적용
useEffect(() => {
if (columnVisibility.length > 0) {
const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외
setColumnOrder(newOrder);
// 너비 적용
const newWidths: Record<string, number> = {};
columnVisibility.forEach((cv) => {
if (cv.width) {
newWidths[cv.columnName] = cv.width;
}
});
if (Object.keys(newWidths).length > 0) {
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
// table_column_widths_* localStorage도 동기화 (초기 너비 로드 시 올바른 값 사용)
if (tableConfig.selectedTable && userId) {
const widthsKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`;
try {
const existing = localStorage.getItem(widthsKey);
const merged = existing ? { ...JSON.parse(existing), ...newWidths } : newWidths;
localStorage.setItem(widthsKey, JSON.stringify(merged));
} catch { /* ignore */ }
}
}
// localStorage에 저장 (사용자별)
if (tableConfig.selectedTable && currentUserId) {
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;

View File

@ -570,6 +570,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...restComponentStyle,
width: "100%",
height: "100%",
borderRadius: _br || "0.5rem",
overflow: "hidden",
};
// 디자인 모드 스타일

View File

@ -3607,7 +3607,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<td
key={colIdx}
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
style={{ textAlign: col.align || "left" }}
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis" }}
>
{formatCellValue(
col.name,
@ -3704,7 +3704,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<td
key={colIdx}
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
style={{ textAlign: col.align || "left" }}
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis" }}
>
{formatCellValue(
col.name,
@ -4201,7 +4201,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
>
{tabSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs">
<td key={col.name} className="px-3 py-2 text-xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
@ -4317,7 +4317,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
>
{listSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs">
<td key={col.name} className="px-3 py-2 text-xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
@ -4384,9 +4384,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
})()
) : componentConfig.rightPanel?.displayMode === "custom" ? (
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
// 실행 모드에서 좌측 미선택 시 안내 메시지 표시
!isDesignMode && !selectedLeftItem ? (
// 커스텀 모드: alwaysShow가 아닌 경우에만 좌측 선택 필요
!isDesignMode && !selectedLeftItem && !componentConfig.rightPanel?.alwaysShow ? (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> </p>
@ -4710,8 +4709,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="px-3 py-2 text-xs whitespace-nowrap"
style={{ textAlign: col.align || "left" }}
className="px-3 py-2 text-xs"
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
@ -4857,7 +4856,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
onClick={() => toggleRightItemExpansion(itemId)}
>
{columnsToDisplay.map((col) => (
<td key={col.name} className="px-3 py-2 text-xs">
<td key={col.name} className="px-3 py-2 text-xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),

View File

@ -237,6 +237,7 @@ export interface SplitPanelLayoutConfig {
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
dataSource?: string;
displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀
alwaysShow?: boolean; // true면 좌측 선택 없이도 우측 패널 항상 표시
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조)
components?: PanelInlineComponent[];
showSearch?: boolean;

View File

@ -521,12 +521,33 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}, [tableConfig.selectedTable, currentUserId]);
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
// columnVisibility 변경 시 컬럼 순서, 가시성, 너비 적용
useEffect(() => {
if (columnVisibility.length > 0) {
const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외
setColumnOrder(newOrder);
// 너비 적용
const newWidths: Record<string, number> = {};
columnVisibility.forEach((cv) => {
if (cv.width) {
newWidths[cv.columnName] = cv.width;
}
});
if (Object.keys(newWidths).length > 0) {
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
// table_column_widths_* localStorage도 동기화 (초기 너비 로드 시 올바른 값 사용)
if (tableConfig.selectedTable && userId) {
const widthsKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`;
try {
const existing = localStorage.getItem(widthsKey);
const merged = existing ? { ...JSON.parse(existing), ...newWidths } : newWidths;
localStorage.setItem(widthsKey, JSON.stringify(merged));
} catch { /* ignore */ }
}
}
// localStorage에 저장 (사용자별)
if (tableConfig.selectedTable && currentUserId) {
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;

View File

@ -124,10 +124,16 @@ export function useTimelineData(
sourceKeys: currentSourceKeys,
});
const searchParams: Record<string, any> = {};
if (!isScheduleMng && config.staticFilters) {
Object.assign(searchParams, config.staticFilters);
}
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
page: 1,
size: 10000,
autoFilter: true,
...(Object.keys(searchParams).length > 0 ? { search: searchParams } : {}),
});
const responseData = response.data?.data?.data || response.data?.data || [];
@ -195,7 +201,8 @@ export function useTimelineData(
setIsLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, externalSchedules, fieldMappingKey, config.scheduleType]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, externalSchedules, fieldMappingKey, config.scheduleType, JSON.stringify(config.staticFilters)]);
// 리소스 데이터 로드
const fetchResources = useCallback(async () => {

View File

@ -144,6 +144,9 @@ export interface TimelineSchedulerConfig extends ComponentConfig {
/** 커스텀 테이블명 */
customTableName?: string;
/** 정적 필터 조건 (커스텀 테이블에서 특정 조건으로 필터링) */
staticFilters?: Record<string, string>;
/** 리소스 테이블명 (설비/작업자) */
resourceTable?: string;

View File

@ -59,7 +59,8 @@ export type ButtonActionType =
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
| "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
| "approval"; // 결재 요청
| "approval" // 결재 요청
| "apiCall"; // 범용 API 호출 (생산계획 자동 스케줄 등)
/**
*
@ -286,6 +287,18 @@ export interface ButtonActionConfig {
eventName: string; // 발송할 이벤트 이름 (V2_EVENTS 키)
eventPayload?: Record<string, any>; // 이벤트 페이로드 (requestId는 자동 생성)
};
// 범용 API 호출 관련 (apiCall 액션용)
apiCallConfig?: {
method: "GET" | "POST" | "PUT" | "DELETE";
endpoint: string; // 예: "/api/production/generate-schedule"
payloadMapping?: Record<string, string>; // formData 필드 → API body 필드 매핑
staticPayload?: Record<string, any>; // 고정 페이로드 값
useSelectedRows?: boolean; // true면 선택된 행 데이터를 body에 포함
selectedRowsKey?: string; // 선택된 행 데이터의 key (기본: "items")
refreshAfterSuccess?: boolean; // 성공 후 테이블 새로고침 (기본: true)
confirmMessage?: string; // 실행 전 확인 메시지
};
}
/**
@ -457,6 +470,9 @@ export class ButtonActionExecutor {
case "event":
return await this.handleEvent(config, context);
case "apiCall":
return await this.handleApiCall(config, context);
case "approval":
return this.handleApproval(config, context);
@ -7681,6 +7697,97 @@ export class ButtonActionExecutor {
}
}
/**
* API ( )
*/
private static async handleApiCall(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
const { apiCallConfig } = config;
if (!apiCallConfig?.endpoint) {
toast.error("API 엔드포인트가 설정되지 않았습니다.");
return false;
}
// 확인 메시지
if (apiCallConfig.confirmMessage) {
const confirmed = window.confirm(apiCallConfig.confirmMessage);
if (!confirmed) return false;
}
// 페이로드 구성
let payload: Record<string, any> = { ...(apiCallConfig.staticPayload || {}) };
// formData에서 매핑
if (apiCallConfig.payloadMapping && context.formData) {
for (const [formField, apiField] of Object.entries(apiCallConfig.payloadMapping)) {
if (context.formData[formField] !== undefined) {
payload[apiField] = context.formData[formField];
}
}
}
// 선택된 행 데이터 포함
if (apiCallConfig.useSelectedRows && context.selectedRowsData) {
const key = apiCallConfig.selectedRowsKey || "items";
payload[key] = context.selectedRowsData;
}
console.log("[handleApiCall] API 호출:", {
method: apiCallConfig.method,
endpoint: apiCallConfig.endpoint,
payload,
});
// API 호출
const { apiClient } = await import("@/lib/api/client");
let response: any;
switch (apiCallConfig.method) {
case "GET":
response = await apiClient.get(apiCallConfig.endpoint, { params: payload });
break;
case "POST":
response = await apiClient.post(apiCallConfig.endpoint, payload);
break;
case "PUT":
response = await apiClient.put(apiCallConfig.endpoint, payload);
break;
case "DELETE":
response = await apiClient.delete(apiCallConfig.endpoint, { data: payload });
break;
}
const result = response?.data;
if (result?.success === false) {
toast.error(result.message || "API 호출에 실패했습니다.");
return false;
}
// 성공 메시지
if (config.successMessage) {
toast.success(config.successMessage);
}
// 테이블 새로고침
if (apiCallConfig.refreshAfterSuccess !== false) {
const { v2EventBus, V2_EVENTS } = await import("@/lib/v2-core");
v2EventBus.emitSync(V2_EVENTS.TABLE_REFRESH, {
tableName: context.tableName,
target: "all",
});
}
return true;
} catch (error: any) {
console.error("[handleApiCall] API 호출 오류:", error);
const msg = error?.response?.data?.message || error?.message || "API 호출 중 오류가 발생했습니다.";
toast.error(msg);
return false;
}
}
/**
*
*/
@ -7843,4 +7950,8 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
approval: {
type: "approval",
},
apiCall: {
type: "apiCall",
successMessage: "처리되었습니다.",
},
};