Merge remote-tracking branch 'origin/main' into kwshin-node

This commit is contained in:
shin
2026-03-17 15:45:43 +09:00
256 changed files with 51673 additions and 16103 deletions

View File

@@ -113,6 +113,7 @@ import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
@@ -124,6 +125,7 @@ import entitySearchRoutes, {
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
@@ -259,6 +261,7 @@ app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/pop", popActionRoutes); // POP 액션 실행
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes);
@@ -310,6 +313,7 @@ app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리

View File

@@ -314,13 +314,14 @@ router.post(
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
const { formData, manualInputValue } = req.body;
try {
const previewCode = await numberingRuleService.previewCode(
ruleId,
companyCode,
formData
formData,
manualInputValue
);
return res.json({ success: true, data: { generatedCode: previewCode } });
} catch (error: any) {

View File

@@ -0,0 +1,291 @@
import { Response } from "express";
import { getPool } from "../database/db";
import logger from "../utils/logger";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
/**
* D-BE1: 작업지시 공정 일괄 생성
* PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성.
*/
export const createWorkProcesses = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_instruction_id, item_code, routing_version_id, plan_qty } =
req.body;
if (!work_instruction_id || !routing_version_id) {
return res.status(400).json({
success: false,
message:
"work_instruction_id와 routing_version_id는 필수입니다.",
});
}
logger.info("[pop/production] create-work-processes 요청", {
companyCode,
userId,
work_instruction_id,
item_code,
routing_version_id,
plan_qty,
});
await client.query("BEGIN");
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[work_instruction_id, companyCode]
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
await client.query("ROLLBACK");
return res.status(409).json({
success: false,
message: "이미 공정이 생성된 작업지시입니다.",
});
}
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
const routingDetails = await client.query(
`SELECT rd.id, rd.seq_no, rd.process_code,
COALESCE(pm.process_name, rd.process_code) as process_name,
rd.is_required, rd.is_fixed_order, rd.standard_time
FROM item_routing_detail rd
LEFT JOIN process_mng pm ON pm.process_code = rd.process_code
AND pm.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY CAST(rd.seq_no AS int) NULLS LAST`,
[routing_version_id, companyCode]
);
if (routingDetails.rows.length === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "라우팅 버전에 등록된 공정이 없습니다.",
});
}
const processes: Array<{
id: string;
seq_no: string;
process_name: string;
checklist_count: number;
}> = [];
let totalChecklists = 0;
for (const rd of routingDetails.rows) {
// 2. work_order_process INSERT
const wopResult = await client.query(
`INSERT INTO work_order_process (
company_code, wo_id, seq_no, process_code, process_name,
is_required, is_fixed_order, standard_time, plan_qty,
status, routing_detail_id, writer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[
companyCode,
work_instruction_id,
rd.seq_no,
rd.process_code,
rd.process_name,
rd.is_required,
rd.is_fixed_order,
rd.standard_time,
plan_qty || null,
"waiting",
rd.id,
userId,
]
);
const wopId = wopResult.rows[0].id;
// 3. process_work_result INSERT (스냅샷 복사)
// process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사
const snapshotResult = await client.query(
`INSERT INTO process_work_result (
company_code, work_order_process_id,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
input_type, lookup_target, display_fields, duration_minutes,
status, writer
)
SELECT
pwi.company_code, $1,
pwi.id, pwd.id,
pwi.work_phase, pwi.title, pwi.sort_order::text,
pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required,
pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit,
pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text,
'pending', $2
FROM process_work_item pwi
JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id
AND pwd.company_code = pwi.company_code
WHERE pwi.routing_detail_id = $3
AND pwi.company_code = $4
ORDER BY pwi.sort_order, pwd.sort_order`,
[wopId, userId, rd.id, companyCode]
);
const checklistCount = snapshotResult.rowCount ?? 0;
totalChecklists += checklistCount;
processes.push({
id: wopId,
seq_no: rd.seq_no,
process_name: rd.process_name,
checklist_count: checklistCount,
});
logger.info("[pop/production] 공정 생성 완료", {
wopId,
processName: rd.process_name,
checklistCount,
});
}
await client.query("COMMIT");
logger.info("[pop/production] create-work-processes 완료", {
companyCode,
work_instruction_id,
total_processes: processes.length,
total_checklists: totalChecklists,
});
return res.json({
success: true,
data: {
processes,
total_processes: processes.length,
total_checklists: totalChecklists,
},
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("[pop/production] create-work-processes 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "공정 생성 중 오류가 발생했습니다.",
});
} finally {
client.release();
}
};
/**
* D-BE2: 타이머 API (시작/일시정지/재시작)
*/
export const controlTimer = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, action } = req.body;
if (!work_order_process_id || !action) {
return res.status(400).json({
success: false,
message: "work_order_process_id와 action은 필수입니다.",
});
}
if (!["start", "pause", "resume"].includes(action)) {
return res.status(400).json({
success: false,
message: "action은 start, pause, resume 중 하나여야 합니다.",
});
}
logger.info("[pop/production] timer 요청", {
companyCode,
userId,
work_order_process_id,
action,
});
let result;
switch (action) {
case "start":
// 최초 1회만 설정, 이미 있으면 무시
result = await pool.query(
`UPDATE work_order_process
SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END,
status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING id, started_at, status`,
[work_order_process_id, companyCode]
);
break;
case "pause":
result = await pool.query(
`UPDATE work_order_process
SET paused_at = NOW()::text,
updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND paused_at IS NULL
RETURNING id, paused_at`,
[work_order_process_id, companyCode]
);
break;
case "resume":
// 일시정지 시간 누적 후 paused_at 초기화
result = await pool.query(
`UPDATE work_order_process
SET total_paused_time = (
COALESCE(total_paused_time::int, 0)
+ EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int
)::text,
paused_at = NULL,
updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL
RETURNING id, total_paused_time`,
[work_order_process_id, companyCode]
);
break;
}
if (!result || result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.",
});
}
logger.info("[pop/production] timer 완료", {
action,
work_order_process_id,
result: result.rows[0],
});
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("[pop/production] timer 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "타이머 처리 중 오류가 발생했습니다.",
});
}
};

View File

@@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
routingTable = "item_routing_version",
routingFkColumn = "item_code",
search = "",
extraColumns = "",
filterConditions = "",
} = req.query as Record<string, string>;
const searchCondition = search
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
: "";
const params: any[] = [companyCode];
if (search) params.push(`%${search}%`);
let paramIndex = 2;
// 검색 조건
let searchCondition = "";
if (search) {
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
// 추가 컬럼 SELECT
const extraColumnNames: string[] = extraColumns
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
: [];
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
// 사전 필터 조건
let filterWhere = "";
if (filterConditions) {
try {
const filters = JSON.parse(filterConditions) as Array<{
column: string;
operator: string;
value: string;
}>;
for (const f of filters) {
if (!f.column || !f.value) continue;
if (f.operator === "equals") {
filterWhere += ` AND i.${f.column} = $${paramIndex}`;
params.push(f.value);
} else if (f.operator === "contains") {
filterWhere += ` AND i.${f.column} ILIKE $${paramIndex}`;
params.push(`%${f.value}%`);
} else if (f.operator === "not_equals") {
filterWhere += ` AND i.${f.column} != $${paramIndex}`;
params.push(f.value);
}
paramIndex++;
}
} catch { /* 파싱 실패 시 무시 */ }
}
const query = `
SELECT
i.id,
i.${nameColumn} AS item_name,
i.${codeColumn} AS item_code,
i.${codeColumn} AS item_code
${extraSelect ? ", " + extraSelect : ""},
COUNT(rv.id) AS routing_count
FROM ${tableName} i
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
AND rv.company_code = i.company_code
WHERE i.company_code = $1
${searchCondition}
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
${filterWhere}
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}, i.created_date
ORDER BY i.created_date DESC NULLS LAST
`;
@@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
client.release();
}
}
// ============================================================
// 등록 품목 관리 (item_routing_registered)
// ============================================================
/**
* 화면별 등록된 품목 목록 조회
*/
export async function getRegisteredItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { screenCode } = req.params;
const {
tableName = "item_info",
nameColumn = "item_name",
codeColumn = "item_number",
routingTable = "item_routing_version",
routingFkColumn = "item_code",
search = "",
extraColumns = "",
} = req.query as Record<string, string>;
const params: any[] = [companyCode, screenCode];
let paramIndex = 3;
let searchCondition = "";
if (search) {
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
const extraColumnNames: string[] = extraColumns
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
: [];
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
const query = `
SELECT
irr.id AS registered_id,
irr.sort_order,
i.id,
i.${nameColumn} AS item_name,
i.${codeColumn} AS item_code
${extraSelect ? ", " + extraSelect : ""},
COUNT(rv.id) AS routing_count
FROM item_routing_registered irr
JOIN ${tableName} i ON irr.item_id = i.id
AND i.company_code = irr.company_code
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
AND rv.company_code = i.company_code
WHERE irr.company_code = $1
AND irr.screen_code = $2
${searchCondition}
GROUP BY irr.id, irr.sort_order, i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}
ORDER BY CAST(irr.sort_order AS int) ASC, irr.created_date ASC
`;
const result = await getPool().query(query, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("등록 품목 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 품목 등록 (화면에 품목 추가)
*/
export async function registerItem(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { screenCode, itemId, itemCode } = req.body;
if (!screenCode || !itemId) {
return res.status(400).json({ success: false, message: "screenCode, itemId 필수" });
}
const query = `
INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
RETURNING *
`;
const result = await getPool().query(query, [
screenCode, itemId, itemCode || null, companyCode, req.user?.userId || null,
]);
if (result.rowCount === 0) {
return res.json({ success: true, message: "이미 등록된 품목입니다", data: null });
}
logger.info("품목 등록", { companyCode, screenCode, itemId });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("품목 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 여러 품목 일괄 등록
*/
export async function registerItemsBatch(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { screenCode, items } = req.body;
if (!screenCode || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "screenCode, items[] 필수" });
}
const client = await getPool().connect();
try {
await client.query("BEGIN");
const inserted: any[] = [];
for (const item of items) {
const result = await client.query(
`INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
RETURNING *`,
[screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null]
);
if (result.rows[0]) inserted.push(result.rows[0]);
}
await client.query("COMMIT");
logger.info("품목 일괄 등록", { companyCode, screenCode, count: inserted.length });
return res.json({ success: true, data: inserted });
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
} catch (error: any) {
logger.error("품목 일괄 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 등록 품목 제거
*/
export async function unregisterItem(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const result = await getPool().query(
`DELETE FROM item_routing_registered WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다" });
}
logger.info("등록 품목 제거", { companyCode, id });
return res.json({ success: true });
} catch (error: any) {
logger.error("등록 품목 제거 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -0,0 +1,233 @@
/**
* 생산계획 컨트롤러
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as productionService from "../services/productionPlanService";
import { logger } from "../utils/logger";
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
export async function getOrderSummary(req: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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,
});
}
}
// ─── 자동 스케줄 미리보기 (실제 INSERT 없이 예상 결과 반환) ───
export async function previewSchedule(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
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.previewSchedule(companyCode, items, options || {});
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 generateSchedule(req: AuthenticatedRequest, 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: AuthenticatedRequest, 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 previewSemiSchedule(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
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.previewSemiSchedule(
companyCode,
plan_ids,
options || {}
);
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 generateSemiSchedule(req: AuthenticatedRequest, 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: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const splitBy = req.user!.userId;
const planId = parseInt(req.params.id, 10);
const { split_qty } = req.body;
if (!split_qty || split_qty <= 0) {
return res.status(400).json({ success: false, message: "분할 수량을 입력해주세요" });
}
const data = await productionService.splitSchedule(companyCode, planId, split_qty, splitBy);
return res.json({ success: true, data });
} catch (error: any) {
logger.error("스케줄 분할 실패", { error: error.message });
return res.status(error.message.includes("찾을 수 없") ? 404 : 400).json({
success: false,
message: error.message,
});
}
}

View File

@@ -2058,6 +2058,119 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
});
});
// 6. v2-repeater 컴포넌트에서 selectedTable/foreignKey 추출
const v2RepeaterQuery = `
SELECT DISTINCT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
comp->'overrides'->>'type' as component_type,
comp->'overrides'->>'selectedTable' as sub_table,
comp->'overrides'->>'foreignKey' as foreign_key,
comp->'overrides'->>'parentTable' as parent_table
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp
WHERE sd.screen_id = ANY($1)
AND comp->'overrides'->>'type' = 'v2-repeater'
AND comp->'overrides'->>'selectedTable' IS NOT NULL
`;
const v2RepeaterResult = await pool.query(v2RepeaterQuery, [screenIds]);
v2RepeaterResult.rows.forEach((row: any) => {
const screenId = row.screen_id;
const mainTable = row.main_table;
const subTable = row.sub_table;
const foreignKey = row.foreign_key;
if (!subTable || subTable === mainTable) return;
if (!screenSubTables[screenId]) {
screenSubTables[screenId] = {
screenId,
screenName: row.screen_name,
mainTable: mainTable || '',
subTables: [],
};
}
const exists = screenSubTables[screenId].subTables.some(
(st) => st.tableName === subTable
);
if (!exists) {
screenSubTables[screenId].subTables.push({
tableName: subTable,
componentType: 'v2-repeater',
relationType: 'rightPanelRelation',
fieldMappings: foreignKey ? [{
sourceField: 'id',
targetField: foreignKey,
sourceDisplayName: 'ID',
targetDisplayName: foreignKey,
}] : undefined,
});
}
});
logger.info("v2-repeater 서브 테이블 추출 완료", {
screenIds,
v2RepeaterCount: v2RepeaterResult.rows.length,
});
// 7. rightPanel.components 내부의 componentConfig.detailTable 추출 (v2-bom-tree 등)
const v2DetailTableQuery = `
SELECT DISTINCT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
inner_comp->>'type' as component_type,
inner_comp->'componentConfig'->>'detailTable' as sub_table,
inner_comp->'componentConfig'->>'foreignKey' as foreign_key
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp,
jsonb_array_elements(
COALESCE(
comp->'overrides'->'rightPanel'->'components',
comp->'overrides'->'leftPanel'->'components',
'[]'::jsonb
)
) as inner_comp
WHERE sd.screen_id = ANY($1)
AND inner_comp->'componentConfig'->>'detailTable' IS NOT NULL
`;
const v2DetailTableResult = await pool.query(v2DetailTableQuery, [screenIds]);
v2DetailTableResult.rows.forEach((row: any) => {
const screenId = row.screen_id;
const mainTable = row.main_table;
const subTable = row.sub_table;
const foreignKey = row.foreign_key;
if (!subTable || subTable === mainTable) return;
if (!screenSubTables[screenId]) {
screenSubTables[screenId] = {
screenId,
screenName: row.screen_name,
mainTable: mainTable || '',
subTables: [],
};
}
const exists = screenSubTables[screenId].subTables.some(
(st) => st.tableName === subTable
);
if (!exists) {
screenSubTables[screenId].subTables.push({
tableName: subTable,
componentType: row.component_type || 'v2-bom-tree',
relationType: 'rightPanelRelation',
fieldMappings: foreignKey ? [{
sourceField: 'id',
targetField: foreignKey,
sourceDisplayName: 'ID',
targetDisplayName: foreignKey,
}] : undefined,
});
}
});
logger.info("v2-bom-tree/detailTable 서브 테이블 추출 완료", {
screenIds,
v2DetailTableCount: v2DetailTableResult.rows.length,
});
// ============================================================
// 저장 테이블 정보 추출
// ============================================================

View File

@@ -104,6 +104,11 @@ interface TaskBody {
manualItemField?: string;
manualPkColumn?: string;
cartScreenId?: string;
preCondition?: {
column: string;
expectedValue: string;
failMessage?: string;
};
}
function resolveStatusValue(
@@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
const item = items[i] ?? {};
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolved, companyCode, lookupValues[i]],
let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
const condParams: unknown[] = [resolved, companyCode, lookupValues[i]];
if (task.preCondition?.column && task.preCondition?.expectedValue) {
if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
condWhere += ` AND "${task.preCondition.column}" = $4`;
condParams.push(task.preCondition.expectedValue);
}
const condResult = await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`,
condParams,
);
if (task.preCondition && condResult.rowCount === 0) {
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
(err as any).isPreConditionFail = true;
throw err;
}
processedCount++;
}
} else if (opType === "db-conditional") {
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
if (task.preCondition) {
logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", {
taskId: task.id, preCondition: task.preCondition,
});
}
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
@@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
}
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[value, companyCode, lookupValues[i]],
let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
const queryParams: unknown[] = [value, companyCode, lookupValues[i]];
if (task.preCondition?.column && task.preCondition?.expectedValue) {
if (!isSafeIdentifier(task.preCondition.column)) {
throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
}
whereSql += ` AND "${task.preCondition.column}" = $4`;
queryParams.push(task.preCondition.expectedValue);
}
const updateResult = await client.query(
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`,
queryParams,
);
if (task.preCondition && updateResult.rowCount === 0) {
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
(err as any).isPreConditionFail = true;
throw err;
}
processedCount++;
}
}
@@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
});
} catch (error: any) {
await client.query("ROLLBACK");
if (error.isPreConditionFail) {
logger.warn("[pop/execute-action] preCondition 실패", { message: error.message });
return res.status(409).json({
success: false,
message: error.message,
errorCode: "PRE_CONDITION_FAIL",
});
}
logger.error("[pop/execute-action] 오류:", error);
return res.status(500).json({
success: false,

View File

@@ -0,0 +1,15 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
createWorkProcesses,
controlTimer,
} from "../controllers/popProductionController";
const router = Router();
router.use(authenticateToken);
router.post("/create-work-processes", createWorkProcesses);
router.post("/timer", controlTimer);
export default router;

View File

@@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
// 전체 저장 (일괄)
router.put("/save-all", ctrl.saveAll);
// 등록 품목 관리 (화면별 품목 목록)
router.get("/registered-items/:screenCode", ctrl.getRegisteredItems);
router.post("/registered-items", ctrl.registerItem);
router.post("/registered-items/batch", ctrl.registerItemsBatch);
router.delete("/registered-items/:id", ctrl.unregisterItem);
export default router;

View File

@@ -0,0 +1,42 @@
/**
* 생산계획 라우트
*/
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/preview", productionController.previewSchedule);
// 자동 스케줄 생성
router.post("/generate-schedule", productionController.generateSchedule);
// 스케줄 병합
router.post("/merge-schedules", productionController.mergeSchedules);
// 반제품 계획 미리보기
router.post("/generate-semi-schedule/preview", productionController.previewSemiSchedule);
// 반제품 계획 자동 생성
router.post("/generate-semi-schedule", productionController.generateSemiSchedule);
// 스케줄 분할
router.post("/plan/:id/split", productionController.splitSchedule);
export default router;

View File

@@ -823,6 +823,76 @@ export class EntityJoinService {
return [];
}
}
/**
* 콤마 구분 다중값 해결 (겸직 부서 등)
* entity join이 NULL인데 소스값에 콤마가 있으면 개별 코드를 각각 조회해서 라벨로 변환
*/
async resolveCommaValues(
data: Record<string, any>[],
joinConfigs: EntityJoinConfig[]
): Promise<Record<string, any>[]> {
if (!data.length || !joinConfigs.length) return data;
for (const config of joinConfigs) {
const sourceCol = config.sourceColumn;
const displayCol = config.displayColumns?.[0] || config.displayColumn;
if (!displayCol || displayCol === "none") continue;
const aliasCol = config.aliasColumn || `${sourceCol}_${displayCol}`;
const labelCol = `${sourceCol}_label`;
const codesSet = new Set<string>();
const rowsToResolve: number[] = [];
data.forEach((row, idx) => {
const srcVal = row[sourceCol];
if (!srcVal || typeof srcVal !== "string" || !srcVal.includes(",")) return;
const joinedVal = row[aliasCol] || row[labelCol];
if (joinedVal && joinedVal !== "") return;
rowsToResolve.push(idx);
srcVal.split(",").map((v: string) => v.trim()).filter(Boolean).forEach((code: string) => codesSet.add(code));
});
if (codesSet.size === 0) continue;
const codes = Array.from(codesSet);
const refCol = config.referenceColumn || "id";
const placeholders = codes.map((_, i) => `$${i + 1}`).join(",");
try {
const result = await query<Record<string, any>>(
`SELECT "${refCol}"::TEXT as _key, "${displayCol}"::TEXT as _label
FROM ${config.referenceTable}
WHERE "${refCol}"::TEXT IN (${placeholders})`,
codes
);
const labelMap = new Map<string, string>();
result.forEach((r) => labelMap.set(r._key, r._label));
for (const idx of rowsToResolve) {
const srcVal = data[idx][sourceCol] as string;
const resolvedLabels = srcVal
.split(",")
.map((v: string) => v.trim())
.filter(Boolean)
.map((code: string) => labelMap.get(code) || code)
.join(", ");
data[idx][aliasCol] = resolvedLabels;
data[idx][labelCol] = resolvedLabels;
}
logger.info(`콤마 구분 entity 값 해결: ${sourceCol}${codesSet.size}개 코드, ${rowsToResolve.length}개 행`);
} catch (err) {
logger.warn(`콤마 구분 entity 값 해결 실패: ${sourceCol}`, err);
}
}
return data;
}
}
export const entityJoinService = new EntityJoinService();

View File

@@ -39,7 +39,9 @@ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globa
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
result += sep;
if (val || !result.endsWith(sep)) {
result += sep;
}
}
});
return result;
@@ -74,16 +76,22 @@ class NumberingRuleService {
*/
private async buildPrefixKey(
rule: NumberingRuleConfig,
formData?: Record<string, any>
formData?: Record<string, any>,
manualValues?: string[]
): Promise<string> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const prefixParts: string[] = [];
let manualIndex = 0;
for (const part of sortedParts) {
if (part.partType === "sequence") continue;
if (part.generationMethod === "manual") {
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
const manualValue = manualValues?.[manualIndex] || "";
manualIndex++;
if (manualValue) {
prefixParts.push(manualValue);
}
continue;
}
@@ -227,6 +235,312 @@ class NumberingRuleService {
);
return result.rows[0].current_sequence;
}
/**
* 카운터를 특정 값 이상으로 동기화 (GREATEST 사용)
* 테이블 내 실제 최대값이 카운터보다 높을 때 카운터를 맞춰줌
*/
private async setSequenceForPrefix(
client: any,
ruleId: string,
companyCode: string,
prefixKey: string,
targetSequence: number
): Promise<number> {
const result = await client.query(
`INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (rule_id, company_code, prefix_key)
DO UPDATE SET current_sequence = GREATEST(numbering_rule_sequences.current_sequence, $4),
last_allocated_at = NOW()
RETURNING current_sequence`,
[ruleId, companyCode, prefixKey, targetSequence]
);
return result.rows[0].current_sequence;
}
/**
* 대상 테이블에서 해당 회사의 최대 시퀀스 번호를 조회
* 코드의 prefix/suffix 패턴을 기반으로 sequence 부분만 추출하여 MAX 계산
*/
private async getMaxSequenceFromTable(
client: any,
tableName: string,
columnName: string,
codePrefix: string,
codeSuffix: string,
seqLength: number,
companyCode: string
): Promise<number> {
try {
// 테이블에 company_code 컬럼이 있는지 확인
const colCheck = await client.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
const hasCompanyCode = colCheck.rows.length > 0;
// 대상 컬럼 존재 여부 확인
const targetColCheck = await client.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2`,
[tableName, columnName]
);
if (targetColCheck.rows.length === 0) {
logger.warn(`getMaxSequenceFromTable: 컬럼 없음 ${tableName}.${columnName}`);
return 0;
}
// prefix와 suffix 사이의 sequence 부분을 추출하기 위한 위치 계산
const prefixLen = codePrefix.length;
const seqStart = prefixLen + 1; // SQL SUBSTRING은 1-based
// LIKE 패턴: prefix + N자리 숫자 + suffix
const likePattern = codePrefix + "%" + codeSuffix;
let sql: string;
let params: any[];
if (hasCompanyCode && companyCode !== "*") {
sql = `
SELECT MAX(
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
) as max_seq
FROM "${tableName}"
WHERE "${columnName}" LIKE $3
AND company_code = $4
AND LENGTH("${columnName}") = $5
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
`;
params = [seqStart, seqLength, likePattern, companyCode, prefixLen + seqLength + codeSuffix.length];
} else {
sql = `
SELECT MAX(
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
) as max_seq
FROM "${tableName}"
WHERE "${columnName}" LIKE $3
AND LENGTH("${columnName}") = $4
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
`;
params = [seqStart, seqLength, likePattern, prefixLen + seqLength + codeSuffix.length];
}
const result = await client.query(sql, params);
const maxSeq = result.rows[0]?.max_seq ?? 0;
logger.info("getMaxSequenceFromTable 결과", {
tableName, columnName, codePrefix, codeSuffix,
seqLength, companyCode, maxSeq,
});
return maxSeq;
} catch (error: any) {
logger.warn("getMaxSequenceFromTable 실패 (카운터 폴백)", {
tableName, columnName, error: error.message,
});
return 0;
}
}
/**
* 규칙의 파트 구성에서 sequence 파트 전후의 prefix/suffix를 계산
* allocateCode/previewCode에서 비-sequence 파트 값이 이미 계산된 후 호출
*/
private buildCodePrefixSuffix(
partValues: string[],
sortedParts: any[],
globalSeparator: string
): { prefix: string; suffix: string; seqIndex: number; seqLength: number } | null {
const seqIndex = sortedParts.findIndex((p: any) => p.partType === "sequence");
if (seqIndex === -1) return null;
const seqLength = sortedParts[seqIndex].autoConfig?.sequenceLength || 3;
// prefix: sequence 파트 이전의 모든 파트값 + 구분자
let prefix = "";
for (let i = 0; i < seqIndex; i++) {
prefix += partValues[i];
const sep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
prefix += sep;
}
// suffix: sequence 파트 이후의 모든 파트값 + 구분자
let suffix = "";
for (let i = seqIndex + 1; i < partValues.length; i++) {
const sep = sortedParts[i - 1].separatorAfter ?? sortedParts[i - 1].autoConfig?.separatorAfter ?? globalSeparator;
if (i === seqIndex + 1) {
// sequence 파트 바로 뒤 구분자
const seqSep = sortedParts[seqIndex].separatorAfter ?? sortedParts[seqIndex].autoConfig?.separatorAfter ?? globalSeparator;
suffix += seqSep;
}
suffix += partValues[i];
if (i < partValues.length - 1) {
const nextSep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
suffix += nextSep;
}
}
return { prefix, suffix, seqIndex, seqLength };
}
/**
* 비-sequence 파트의 값을 계산하여 prefix/suffix 패턴 구축에 사용
* sequence 파트는 빈 문자열로 반환 (이후 buildCodePrefixSuffix에서 처리)
*/
private async computeNonSequenceValues(
rule: NumberingRuleConfig,
formData?: Record<string, any>
): Promise<string[]> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
return Promise.all(sortedParts.map(async (part: any) => {
if (part.partType === "sequence") return "";
if (part.generationMethod === "manual") return "";
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "text":
return autoConfig.textValue || "TEXT";
case "number": {
const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0");
}
case "date": {
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
const columnValue = formData[autoConfig.sourceColumnName];
if (columnValue) {
const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue);
if (!isNaN(dateValue.getTime())) {
return this.formatDate(dateValue, dateFormat);
}
}
}
return this.formatDate(new Date(), dateFormat);
}
case "category": {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) return "";
const colName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey;
const selectedValue = formData[colName];
if (!selectedValue) return "";
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (!mapping) {
try {
const pool = getPool();
const [ct, cc] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
const cvResult = await pool.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct, cc, selectedValueStr]
);
if (cvResult.rows.length > 0) {
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(cvResult.rows[0].value_id)) return true;
if (m.categoryValueLabel === cvResult.rows[0].value_label) return true;
return false;
});
}
} catch { /* ignore */ }
}
return mapping?.format || "";
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "";
}
default:
return "";
}
}));
}
/**
* 대상 테이블 기반으로 실제 최대 시퀀스를 확인하고,
* 카운터와 비교하여 더 높은 값 + 1을 반환
*/
private async resolveNextSequence(
client: any,
rule: NumberingRuleConfig,
companyCode: string,
ruleId: string,
prefixKey: string,
formData?: Record<string, any>
): Promise<number> {
// 1. 현재 저장된 카운터 조회
const currentCounter = await this.getSequenceForPrefix(
client, ruleId, companyCode, prefixKey
);
let baseSequence = currentCounter;
// 2. 규칙에 tableName/columnName이 설정되어 있으면 대상 테이블에서 MAX 조회
if (rule.tableName && rule.columnName) {
try {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const patternValues = await this.computeNonSequenceValues(rule, formData);
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedParts, rule.separator || "");
if (psInfo) {
const maxFromTable = await this.getMaxSequenceFromTable(
client, rule.tableName, rule.columnName,
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
);
if (maxFromTable > baseSequence) {
logger.info("테이블 내 최대값이 카운터보다 높음 → 동기화", {
ruleId, companyCode, currentCounter, maxFromTable,
});
baseSequence = maxFromTable;
}
}
} catch (error: any) {
logger.warn("테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
ruleId, error: error.message,
});
}
}
// 3. 다음 시퀀스 = base + 1
const nextSequence = baseSequence + 1;
// 4. 카운터를 동기화 (GREATEST 사용)
await this.setSequenceForPrefix(client, ruleId, companyCode, prefixKey, nextSequence);
// 5. 호환성을 위해 numbering_rules.current_sequence도 업데이트
await client.query(
"UPDATE numbering_rules SET current_sequence = GREATEST(COALESCE(current_sequence, 0), $3) WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode, nextSequence]
);
logger.info("resolveNextSequence 완료", {
ruleId, companyCode, prefixKey, currentCounter, baseSequence, nextSequence,
});
return nextSequence;
}
/**
* 규칙 목록 조회 (전체)
*/
@@ -1078,22 +1392,59 @@ class NumberingRuleService {
* @param ruleId 채번 규칙 ID
* @param companyCode 회사 코드
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
* @param manualInputValue 수동 입력 값 (접두어별 순번 조회용)
*/
async previewCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
formData?: Record<string, any>,
manualInputValue?: string
): Promise<string> {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번 조회
const prefixKey = await this.buildPrefixKey(rule, formData);
const pool = getPool();
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
// 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
const skipSequenceLookup = hasManualPart && !manualInputValue;
logger.info("미리보기: prefix_key 기반 순번 조회", {
ruleId, prefixKey, currentSeq,
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
const manualValues = manualInputValue ? [manualInputValue] : undefined;
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
const pool = getPool();
const currentSeq = skipSequenceLookup
? 0
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
// 대상 테이블에서 실제 최대 시퀀스 조회
let baseSeq = currentSeq;
if (rule.tableName && rule.columnName) {
try {
const sortedPartsForPattern = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const patternValues = await this.computeNonSequenceValues(rule, formData);
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedPartsForPattern, rule.separator || "");
if (psInfo) {
const maxFromTable = await this.getMaxSequenceFromTable(
pool, rule.tableName, rule.columnName,
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
);
if (maxFromTable > baseSeq) {
logger.info("미리보기: 테이블 내 최대값이 카운터보다 높음", {
ruleId, companyCode, currentSeq, maxFromTable,
});
baseSeq = maxFromTable;
}
}
} catch (error: any) {
logger.warn("미리보기: 테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
ruleId, error: error.message,
});
}
}
logger.info("미리보기: 순번 조회 완료", {
ruleId, prefixKey, currentSeq, baseSeq, skipSequenceLookup,
});
const parts = await Promise.all(rule.parts
@@ -1108,7 +1459,8 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
const nextSequence = currentSeq + 1;
const startFrom = autoConfig.startFrom || 1;
const nextSequence = baseSeq + startFrom;
return String(nextSequence).padStart(length, "0");
}
@@ -1150,110 +1502,8 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT";
}
case "category": {
// 카테고리 기반 코드 생성
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
logger.warn("카테고리 키 또는 폼 데이터 없음", {
categoryKey,
hasFormData: !!formData,
});
return "";
}
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName];
logger.info("카테고리 파트 처리", {
categoryKey,
columnName,
selectedValue,
formDataKeys: Object.keys(formData),
mappingsCount: categoryMappings.length,
});
if (!selectedValue) {
logger.warn("카테고리 값이 선택되지 않음", {
columnName,
formDataKeys: Object.keys(formData),
});
return "";
}
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
return true;
// 라벨로 매칭 (폴백)
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
if (!mapping) {
try {
const pool = getPool();
const [catTableName, catColumnName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const cvResult = await pool.query(
`SELECT value_id, value_code, value_label FROM category_values
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[catTableName, catColumnName, selectedValueStr]
);
if (cvResult.rows.length > 0) {
const resolvedId = cvResult.rows[0].value_id;
const resolvedLabel = cvResult.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
if (mapping) {
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
valueCode: selectedValueStr,
resolvedId,
resolvedLabel,
format: mapping.format,
});
}
}
} catch (lookupError: any) {
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
}
}
if (mapping) {
logger.info("카테고리 매핑 적용", {
selectedValue,
format: mapping.format,
categoryValueLabel: mapping.categoryValueLabel,
});
return mapping.format || "";
}
logger.warn("카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel,
})),
});
return "";
}
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
@@ -1302,154 +1552,46 @@ class NumberingRuleService {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성
const prefixKey = await this.buildPrefixKey(rule, formData);
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
let allocatedSequence = 0;
if (hasSequence) {
allocatedSequence = await this.incrementSequenceForPrefix(
client, ruleId, companyCode, prefixKey
);
// 호환성을 위해 기존 current_sequence도 업데이트
await client.query(
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
}
logger.info("allocateCode: prefix_key 기반 순번 할당", {
ruleId, prefixKey, allocatedSequence,
});
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
// 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능)
const manualParts = rule.parts.filter(
(p: any) => p.generationMethod === "manual"
);
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length);
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART";
case "category": {
const catKey2 = autoConfig.categoryKey;
const catMappings2 = autoConfig.categoryMappings || [];
extractedManualValues = await this.extractManualValuesFromInput(
rule, userInputCode, formData
);
if (!catKey2 || !formData) {
return "CATEGORY";
}
const colName2 = catKey2.includes(".")
? catKey2.split(".")[1]
: catKey2;
const selVal2 = formData[colName2];
if (!selVal2) {
return "CATEGORY";
}
const selValStr2 = String(selVal2);
let catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === selValStr2) return true;
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
if (m.categoryValueLabel === selValStr2) return true;
return false;
});
if (!catMapping2) {
try {
const pool2 = getPool();
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
const cvr2 = await pool2.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct2, cc2, selValStr2]
);
if (cvr2.rows.length > 0) {
const rid2 = cvr2.rows[0].value_id;
const rlabel2 = cvr2.rows[0].value_label;
catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid2)) return true;
if (m.categoryValueLabel === rlabel2) return true;
return false;
});
}
} catch { /* ignore */ }
}
return catMapping2?.format || "CATEGORY";
}
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default:
return "";
}
}));
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
if (suffix) {
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart)
: remainingCode.length;
if (manualEndIndex > 0) {
extractedManualValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedManualValues.push(remainingCode);
}
}
// 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만)
if (extractedManualValues.length === 0 && manualParts.length === 1) {
extractedManualValues = [userInputCode];
logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode });
}
}
logger.info(
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`
// 2단계: prefix_key 빌드 (수동 값 포함)
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 3단계: 순번이 있으면 prefix_key 기반 UPSERT + 테이블 내 최대값 비교하여 다음 순번 결정
let allocatedSequence = 0;
if (hasSequence) {
allocatedSequence = await this.resolveNextSequence(
client, rule, companyCode, ruleId, prefixKey, formData
);
}
logger.info("allocateCode: prefix_key + 테이블 기반 순번 할당", {
ruleId, prefixKey, allocatedSequence, extractedManualValues,
});
let manualPartIndex = 0;
const parts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
const manualValue =
extractedManualValues[manualPartIndex] ||
part.manualConfig?.value ||
"";
const manualValue = extractedManualValues[manualPartIndex] || "";
manualPartIndex++;
return manualValue;
}
@@ -1459,7 +1601,9 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return String(allocatedSequence).padStart(length, "0");
const startFrom = autoConfig.startFrom || 1;
const actualSequence = allocatedSequence + startFrom - 1;
return String(actualSequence).padStart(length, "0");
}
case "number": {
@@ -1496,65 +1640,14 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT";
}
case "category": {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
return "";
}
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) {
return "";
}
const selectedValueStr = String(selectedValue);
let allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (!allocMapping) {
try {
const pool3 = getPool();
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
const cvr3 = await pool3.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct3, cc3, selectedValueStr]
);
if (cvr3.rows.length > 0) {
const rid3 = cvr3.rows[0].value_id;
const rlabel3 = cvr3.rows[0].value_label;
allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid3)) return true;
if (m.categoryValueLabel === rlabel3) return true;
return false;
});
}
} catch { /* ignore */ }
}
if (allocMapping) {
return allocMapping.format || "";
}
return "";
}
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
return "";
}
@@ -1593,6 +1686,139 @@ class NumberingRuleService {
return this.allocateCode(ruleId, companyCode);
}
/**
* 사용자 입력 코드에서 수동 파트 값을 추출
* 템플릿 기반 파싱으로 수동 입력 위치("____")에 해당하는 값을 분리
*/
private async extractManualValuesFromInput(
rule: NumberingRuleConfig,
userInputCode: string,
formData?: Record<string, any>
): Promise<string[]> {
const extractedValues: string[] = [];
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length);
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART";
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "";
}
default:
return "";
}
}));
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
if (suffix) {
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart)
: remainingCode.length;
if (manualEndIndex > 0) {
extractedValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedValues.push(remainingCode);
}
}
}
logger.info(
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedValues)}`
);
return extractedValues;
}
/**
* 카테고리 매핑에서 format 값을 해석
* categoryKey + formData로 선택된 값을 찾고, 매핑 테이블에서 format 반환
*/
private async resolveCategoryFormat(
autoConfig: Record<string, any>,
formData?: Record<string, any>
): Promise<string> {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) return "";
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) return "";
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑 못 찾으면 category_values에서 valueCode → valueId 역변환
if (!mapping) {
try {
const pool = getPool();
const [tableName, colName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const result = await pool.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[tableName, colName, selectedValueStr]
);
if (result.rows.length > 0) {
const resolvedId = result.rows[0].value_id;
const resolvedLabel = result.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
}
} catch { /* ignore */ }
}
return mapping?.format || "";
}
private formatDate(date: Date, format: string): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");

View File

@@ -0,0 +1,879 @@
/**
* 생산계획 서비스
* - 수주 데이터 조회 (품목별 그룹핑)
* - 안전재고 부족분 조회
* - 자동 스케줄 생성
* - 스케줄 병합
* - 반제품 계획 자동 생성
* - 스케줄 분할
*/
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;
}
/**
* 자동 스케줄 미리보기 (DB 변경 없이 예상 결과만 반환)
*/
export async function previewSchedule(
companyCode: string,
items: GenerateScheduleItem[],
options: GenerateScheduleOptions
) {
const pool = getPool();
const productType = options.product_type || "완제품";
const safetyLeadTime = options.safety_lead_time || 1;
const previews: any[] = [];
const deletedSchedules: any[] = [];
const keptSchedules: any[] = [];
for (const item of items) {
if (options.recalculate_unstarted) {
// 삭제 대상(planned) 상세 조회
const deleteResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, item.item_code, productType]
);
deletedSchedules.push(...deleteResult.rows);
// 유지 대상(진행중 등) 상세 조회
const keptResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
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]
);
keptSchedules.push(...keptResult.rows);
}
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 orderCountResult = await pool.query(
`SELECT COUNT(*) AS cnt FROM sales_order_mng
WHERE company_code = $1 AND part_code = $2 AND part_code IS NOT NULL`,
[companyCode, item.item_code]
);
const orderCount = parseInt(orderCountResult.rows[0].cnt, 10);
previews.push({
item_code: item.item_code,
item_name: item.item_name,
required_qty: requiredQty,
daily_capacity: dailyCapacity,
hourly_capacity: item.hourly_capacity || 100,
production_days: productionDays,
start_date: startDate.toISOString().split("T")[0],
end_date: endDate.toISOString().split("T")[0],
due_date: item.earliest_due_date,
order_count: orderCount,
status: "planned",
});
}
const summary = {
total: previews.length + keptSchedules.length,
new_count: previews.length,
kept_count: keptSchedules.length,
deleted_count: deletedSchedules.length,
};
logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, previews, deletedSchedules, keptSchedules };
}
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);
}
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
const planNoResult = await client.query(
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-%`]
);
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "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();
}
}
// ─── 반제품 BOM 소요량 조회 (공통) ───
async function getBomChildItems(
client: any,
companyCode: string,
itemCode: string
) {
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_number 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 result = await client.query(bomQuery, [companyCode, itemCode]);
return result.rows;
}
// ─── 반제품 계획 미리보기 (실제 DB 변경 없음) ───
export async function previewSemiSchedule(
companyCode: string,
planIds: number[],
options: { considerStock?: boolean; excludeUsed?: boolean }
) {
const pool = getPool();
const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", ");
const plansResult = await pool.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND id IN (${placeholders})
AND product_type = '완제품'`,
[companyCode, ...planIds]
);
const previews: any[] = [];
const existingSemiPlans: any[] = [];
for (const plan of plansResult.rows) {
// 이미 존재하는 반제품 계획 조회
const existingResult = await pool.query(
`SELECT * FROM production_plan_mng
WHERE company_code = $1 AND parent_plan_id = $2 AND product_type = '반제품'`,
[companyCode, plan.id]
);
existingSemiPlans.push(...existingResult.rows);
const bomItems = await getBomChildItems(pool, companyCode, plan.item_code);
for (const bomItem of bomItems) {
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
if (options.considerStock) {
const stockResult = await pool.query(
`SELECT COALESCE(SUM(CAST(current_qty AS 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 semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
previews.push({
parent_plan_id: plan.id,
parent_plan_no: plan.plan_no,
parent_item_name: plan.item_name,
item_code: bomItem.child_item_code || bomItem.child_item_id,
item_name: bomItem.child_item_name || bomItem.child_item_id,
plan_qty: requiredQty,
bom_qty: parseFloat(bomItem.bom_qty) || 1,
start_date: semiStartDate.toISOString().split("T")[0],
end_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
due_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
product_type: "반제품",
status: "planned",
});
}
}
// 기존 반제품 중 삭제 대상 (status = planned)
const deletedSchedules = existingSemiPlans.filter(
(s) => s.status === "planned"
);
// 기존 반제품 중 유지 대상 (진행중 등)
const keptSchedules = existingSemiPlans.filter(
(s) => s.status !== "planned" && s.status !== "completed"
);
const summary = {
total: previews.length + keptSchedules.length,
new_count: previews.length,
deleted_count: deletedSchedules.length,
kept_count: keptSchedules.length,
parent_count: plansResult.rowCount,
};
return { summary, previews, deletedSchedules, keptSchedules };
}
// ─── 반제품 계획 자동 생성 ───
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})
AND product_type = '완제품'`,
[companyCode, ...planIds]
);
// 기존 planned 상태 반제품 삭제
for (const plan of plansResult.rows) {
await client.query(
`DELETE FROM production_plan_mng
WHERE company_code = $1 AND parent_plan_id = $2
AND product_type = '반제품' AND status = 'planned'`,
[companyCode, plan.id]
);
}
const newSemiPlans: any[] = [];
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
for (const plan of plansResult.rows) {
const bomItems = await getBomChildItems(client, companyCode, plan.item_code);
for (const bomItem of bomItems) {
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
if (options.considerStock) {
const stockResult = await client.query(
`SELECT COALESCE(SUM(CAST(current_qty AS 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() - (parseInt(plan.lead_time) || 1));
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
const planNoResult = await client.query(
`SELECT COUNT(*) + 1 AS next_no
FROM production_plan_mng
WHERE company_code = $1 AND plan_no LIKE $2`,
[companyCode, `PP-${todayStr}-S%`]
);
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
const planNo = `PP-${todayStr}-S${String(nextNo).padStart(3, "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.split("T")[0] : new Date(semiEndDate).toISOString().split("T")[0],
typeof semiDueDate === "string" ? semiDueDate.split("T")[0] : new Date(semiDueDate).toISOString().split("T")[0],
plan.id,
createdBy,
]
);
newSemiPlans.push(insertResult.rows[0]);
}
}
await client.query("COMMIT");
logger.info("반제품 계획 생성 완료", {
companyCode,
parentPlanIds: planIds,
semiPlanCount: newSemiPlans.length,
});
return { count: newSemiPlans.length, schedules: newSemiPlans };
} catch (error) {
await client.query("ROLLBACK");
logger.error("반제품 계획 생성 실패", { companyCode, error });
throw error;
} finally {
client.release();
}
}
// ─── 스케줄 분할 ───
export async function splitSchedule(
companyCode: string,
planId: number,
splitQty: number,
splitBy: string
) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const planResult = await client.query(
`SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`,
[planId, companyCode]
);
if (planResult.rowCount === 0) {
throw new Error("생산계획을 찾을 수 없습니다");
}
const plan = planResult.rows[0];
const originalQty = parseFloat(plan.plan_qty) || 0;
if (splitQty >= originalQty || splitQty <= 0) {
throw new Error("분할 수량은 0보다 크고 원래 수량보다 작아야 합니다");
}
// 원본 수량 감소
await client.query(
`UPDATE production_plan_mng SET plan_qty = $1, updated_date = NOW(), updated_by = $2
WHERE id = $3 AND company_code = $4`,
[originalQty - splitQty, splitBy, planId, companyCode]
);
// 분할된 새 계획 생성
const planNoResult = await client.query(
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
FROM production_plan_mng WHERE company_code = $1`,
[companyCode]
);
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
const insertResult = await client.query(
`INSERT INTO production_plan_mng (
company_code, plan_no, plan_date, item_code, item_name,
product_type, plan_qty, start_date, end_date, due_date,
status, priority, equipment_id, equipment_code, equipment_name,
order_no, parent_plan_id, created_by, created_date, updated_date
) VALUES (
$1, $2, CURRENT_DATE, $3, $4,
$5, $6, $7, $8, $9,
$10, $11, $12, $13, $14,
$15, $16, $17, NOW(), NOW()
) RETURNING *`,
[
companyCode, planNo, plan.item_code, plan.item_name,
plan.product_type, splitQty,
plan.start_date, plan.end_date, plan.due_date,
plan.status, plan.priority, plan.equipment_id, plan.equipment_code, plan.equipment_name,
plan.order_no, plan.parent_plan_id,
splitBy,
]
);
await client.query("COMMIT");
logger.info("스케줄 분할 완료", { companyCode, planId, splitQty });
return {
original: { id: planId, plan_qty: originalQty - splitQty },
split: insertResult.rows[0],
};
} catch (error) {
await client.query("ROLLBACK");
logger.error("스케줄 분할 실패", { companyCode, error });
throw error;
} finally {
client.release();
}
}

View File

@@ -3588,12 +3588,15 @@ export class TableManagementService {
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
);
const data = Array.isArray(dataResult) ? dataResult : [];
let data = Array.isArray(dataResult) ? dataResult : [];
const total =
Array.isArray(countResult) && countResult.length > 0
? Number((countResult[0] as any).total)
: 0;
// 콤마 구분 다중값 후처리 (겸직 부서 등)
data = await entityJoinService.resolveCommaValues(data, joinConfigs);
const queryTime = Date.now() - startTime;
return {