Merge pull request 'jskim-node' (#416) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/416
This commit is contained in:
commit
7b5c875ac0
|
|
@ -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 |
|
|
@ -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); // 카테고리 값 관리
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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` (빨간색) | 지연 |
|
||||
|
|
@ -402,19 +402,10 @@ select {
|
|||
/* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */
|
||||
/* 예: Calendar, Table 등의 미세 조정 */
|
||||
|
||||
/* 모바일에서 테이블 레이아웃 고정 (화면 밖으로 넘어가지 않도록) */
|
||||
@media (max-width: 639px) {
|
||||
/* 테이블 레이아웃 고정 (셀 내용이 영역을 벗어나지 않도록) */
|
||||
.table-mobile-fixed {
|
||||
table-layout: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 데스크톱에서 테이블 레이아웃 자동 (기본값이지만 명시적으로 설정) */
|
||||
@media (min-width: 640px) {
|
||||
.table-mobile-fixed {
|
||||
table-layout: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 그리드선 숨기기 */
|
||||
.hide-grid td,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -570,6 +570,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
...restComponentStyle,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: _br || "0.5rem",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ export interface SplitPanelLayoutConfig {
|
|||
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
|
||||
dataSource?: string;
|
||||
displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀
|
||||
alwaysShow?: boolean; // true면 좌측 선택 없이도 우측 패널 항상 표시
|
||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조)
|
||||
components?: PanelInlineComponent[];
|
||||
showSearch?: boolean;
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -144,6 +144,9 @@ export interface TimelineSchedulerConfig extends ComponentConfig {
|
|||
/** 커스텀 테이블명 */
|
||||
customTableName?: string;
|
||||
|
||||
/** 정적 필터 조건 (커스텀 테이블에서 특정 조건으로 필터링) */
|
||||
staticFilters?: Record<string, string>;
|
||||
|
||||
/** 리소스 테이블명 (설비/작업자) */
|
||||
resourceTable?: string;
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "처리되었습니다.",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue