jskim-node #416
|
|
@ -113,6 +113,7 @@ import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
||||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||||
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
||||||
|
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
|
||||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||||
|
|
@ -310,6 +311,7 @@ app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
||||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||||
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||||
|
app.use("/api/production", productionRoutes); // 생산계획 관리
|
||||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
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,
|
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`, {
|
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 10000,
|
size: 10000,
|
||||||
autoFilter: true,
|
autoFilter: true,
|
||||||
|
...(Object.keys(searchParams).length > 0 ? { search: searchParams } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseData = response.data?.data?.data || response.data?.data || [];
|
const responseData = response.data?.data?.data || response.data?.data || [];
|
||||||
|
|
@ -195,7 +201,8 @@ export function useTimelineData(
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 () => {
|
const fetchResources = useCallback(async () => {
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,9 @@ export interface TimelineSchedulerConfig extends ComponentConfig {
|
||||||
/** 커스텀 테이블명 */
|
/** 커스텀 테이블명 */
|
||||||
customTableName?: string;
|
customTableName?: string;
|
||||||
|
|
||||||
|
/** 정적 필터 조건 (커스텀 테이블에서 특정 조건으로 필터링) */
|
||||||
|
staticFilters?: Record<string, string>;
|
||||||
|
|
||||||
/** 리소스 테이블명 (설비/작업자) */
|
/** 리소스 테이블명 (설비/작업자) */
|
||||||
resourceTable?: string;
|
resourceTable?: string;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ export type ButtonActionType =
|
||||||
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||||
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
||||||
| "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
| "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
||||||
| "approval"; // 결재 요청
|
| "approval" // 결재 요청
|
||||||
|
| "apiCall"; // 범용 API 호출 (생산계획 자동 스케줄 등)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 액션 설정
|
* 버튼 액션 설정
|
||||||
|
|
@ -286,6 +287,18 @@ export interface ButtonActionConfig {
|
||||||
eventName: string; // 발송할 이벤트 이름 (V2_EVENTS 키)
|
eventName: string; // 발송할 이벤트 이름 (V2_EVENTS 키)
|
||||||
eventPayload?: Record<string, any>; // 이벤트 페이로드 (requestId는 자동 생성)
|
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":
|
case "event":
|
||||||
return await this.handleEvent(config, context);
|
return await this.handleEvent(config, context);
|
||||||
|
|
||||||
|
case "apiCall":
|
||||||
|
return await this.handleApiCall(config, context);
|
||||||
|
|
||||||
case "approval":
|
case "approval":
|
||||||
return this.handleApproval(config, context);
|
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: {
|
approval: {
|
||||||
type: "approval",
|
type: "approval",
|
||||||
},
|
},
|
||||||
|
apiCall: {
|
||||||
|
type: "apiCall",
|
||||||
|
successMessage: "처리되었습니다.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue