jskim-node #416
|
|
@ -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,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 };
|
||||
};
|
||||
}
|
||||
|
|
@ -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