Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
Made-with: Cursor ; Conflicts: ; backend-node/src/services/numberingRuleService.ts
This commit is contained in:
318
backend-node/scripts/btn-bulk-update-company7.ts
Normal file
318
backend-node/scripts/btn-bulk-update-company7.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 탑씰(company_7) 버튼 스타일 일괄 변경 스크립트
|
||||
*
|
||||
* 사용법:
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --test # 1건만 테스트 (ROLLBACK)
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --run # 전체 실행 (COMMIT)
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --backup # 백업 테이블만 생성
|
||||
* npx ts-node scripts/btn-bulk-update-company7.ts --restore # 백업에서 원복
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
|
||||
// ── 배포 DB 연결 ──
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
"postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor",
|
||||
});
|
||||
|
||||
const COMPANY_CODE = "COMPANY_7";
|
||||
const BACKUP_TABLE = "screen_layouts_v2_backup_20260313";
|
||||
|
||||
// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ──
|
||||
const actionIconMap: Record<string, string> = {
|
||||
save: "Check",
|
||||
delete: "Trash2",
|
||||
edit: "Pencil",
|
||||
navigate: "ArrowRight",
|
||||
modal: "Maximize2",
|
||||
transferData: "SendHorizontal",
|
||||
excel_download: "Download",
|
||||
excel_upload: "Upload",
|
||||
quickInsert: "Zap",
|
||||
control: "Settings",
|
||||
barcode_scan: "ScanLine",
|
||||
operation_control: "Truck",
|
||||
event: "Send",
|
||||
copy: "Copy",
|
||||
};
|
||||
const FALLBACK_ICON = "SquareMousePointer";
|
||||
|
||||
function getIconForAction(actionType?: string): string {
|
||||
if (actionType && actionIconMap[actionType]) {
|
||||
return actionIconMap[actionType];
|
||||
}
|
||||
return FALLBACK_ICON;
|
||||
}
|
||||
|
||||
// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ──
|
||||
function isTopLevelButton(comp: any): boolean {
|
||||
return (
|
||||
comp.url?.includes("v2-button-primary") ||
|
||||
comp.overrides?.type === "v2-button-primary"
|
||||
);
|
||||
}
|
||||
|
||||
function isTabChildButton(comp: any): boolean {
|
||||
return comp.componentType === "v2-button-primary";
|
||||
}
|
||||
|
||||
function isButtonComponent(comp: any): boolean {
|
||||
return isTopLevelButton(comp) || isTabChildButton(comp);
|
||||
}
|
||||
|
||||
// ── 탭 위젯인지 판별 ──
|
||||
function isTabsWidget(comp: any): boolean {
|
||||
return (
|
||||
comp.url?.includes("v2-tabs-widget") ||
|
||||
comp.overrides?.type === "v2-tabs-widget"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ──
|
||||
function applyButtonStyle(config: any, actionType: string | undefined) {
|
||||
const iconName = getIconForAction(actionType);
|
||||
|
||||
config.displayMode = "icon-text";
|
||||
|
||||
config.icon = {
|
||||
name: iconName,
|
||||
type: "lucide",
|
||||
size: "보통",
|
||||
...(config.icon?.color ? { color: config.icon.color } : {}),
|
||||
};
|
||||
|
||||
config.iconTextPosition = "right";
|
||||
config.iconGap = 6;
|
||||
|
||||
if (!config.style) config.style = {};
|
||||
delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용)
|
||||
config.style.borderRadius = "8px";
|
||||
config.style.labelColor = "#FFFFFF";
|
||||
config.style.fontSize = "12px";
|
||||
config.style.fontWeight = "normal";
|
||||
config.style.labelTextAlign = "left";
|
||||
|
||||
if (actionType === "delete") {
|
||||
config.style.backgroundColor = "#F04544";
|
||||
} else if (actionType === "excel_upload" || actionType === "excel_download") {
|
||||
config.style.backgroundColor = "#212121";
|
||||
} else {
|
||||
config.style.backgroundColor = "#3B83F6";
|
||||
}
|
||||
}
|
||||
|
||||
function updateButtonStyle(comp: any): boolean {
|
||||
if (isTopLevelButton(comp)) {
|
||||
const overrides = comp.overrides || {};
|
||||
const actionType = overrides.action?.type;
|
||||
|
||||
if (!comp.size) comp.size = {};
|
||||
comp.size.height = 40;
|
||||
|
||||
applyButtonStyle(overrides, actionType);
|
||||
comp.overrides = overrides;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTabChildButton(comp)) {
|
||||
const config = comp.componentConfig || {};
|
||||
const actionType = config.action?.type;
|
||||
|
||||
if (!comp.size) comp.size = {};
|
||||
comp.size.height = 40;
|
||||
|
||||
applyButtonStyle(config, actionType);
|
||||
comp.componentConfig = config;
|
||||
|
||||
// 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음
|
||||
if (!comp.style) comp.style = {};
|
||||
comp.style.borderRadius = "8px";
|
||||
comp.style.labelColor = "#FFFFFF";
|
||||
comp.style.fontSize = "12px";
|
||||
comp.style.fontWeight = "normal";
|
||||
comp.style.labelTextAlign = "left";
|
||||
comp.style.backgroundColor = config.style.backgroundColor;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 백업 테이블 생성 ──
|
||||
async function createBackup() {
|
||||
console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`);
|
||||
|
||||
const exists = await pool.query(
|
||||
`SELECT to_regclass($1) AS tbl`,
|
||||
[BACKUP_TABLE],
|
||||
);
|
||||
if (exists.rows[0].tbl) {
|
||||
console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`);
|
||||
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
|
||||
console.log(`기존 백업 레코드 수: ${count.rows[0].count}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`CREATE TABLE ${BACKUP_TABLE} AS
|
||||
SELECT * FROM screen_layouts_v2
|
||||
WHERE company_code = $1`,
|
||||
[COMPANY_CODE],
|
||||
);
|
||||
|
||||
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
|
||||
console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`);
|
||||
}
|
||||
|
||||
// ── 백업에서 원복 ──
|
||||
async function restoreFromBackup() {
|
||||
console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE screen_layouts_v2 AS target
|
||||
SET layout_data = backup.layout_data,
|
||||
updated_at = backup.updated_at
|
||||
FROM ${BACKUP_TABLE} AS backup
|
||||
WHERE target.screen_id = backup.screen_id
|
||||
AND target.company_code = backup.company_code
|
||||
AND target.layer_id = backup.layer_id`,
|
||||
);
|
||||
console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`);
|
||||
}
|
||||
|
||||
// ── 메인: 버튼 일괄 변경 ──
|
||||
async function updateButtons(testMode: boolean) {
|
||||
const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)";
|
||||
console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`);
|
||||
|
||||
// company_7 레코드 조회
|
||||
const rows = await pool.query(
|
||||
`SELECT screen_id, layer_id, company_code, layout_data
|
||||
FROM screen_layouts_v2
|
||||
WHERE company_code = $1
|
||||
ORDER BY screen_id, layer_id`,
|
||||
[COMPANY_CODE],
|
||||
);
|
||||
console.log(`대상 레코드 수: ${rows.rowCount}`);
|
||||
|
||||
if (!rows.rowCount) {
|
||||
console.log("변경할 레코드가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
let totalUpdated = 0;
|
||||
let totalButtons = 0;
|
||||
const targetRows = testMode ? [rows.rows[0]] : rows.rows;
|
||||
|
||||
for (const row of targetRows) {
|
||||
const layoutData = row.layout_data;
|
||||
if (!layoutData?.components || !Array.isArray(layoutData.components)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let buttonsInRow = 0;
|
||||
for (const comp of layoutData.components) {
|
||||
// 최상위 버튼 처리
|
||||
if (updateButtonStyle(comp)) {
|
||||
buttonsInRow++;
|
||||
}
|
||||
|
||||
// 탭 위젯 내부 버튼 처리
|
||||
if (isTabsWidget(comp)) {
|
||||
const tabs = comp.overrides?.tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const tabComps = tab.components || [];
|
||||
for (const tabComp of tabComps) {
|
||||
if (updateButtonStyle(tabComp)) {
|
||||
buttonsInRow++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buttonsInRow > 0) {
|
||||
await client.query(
|
||||
`UPDATE screen_layouts_v2
|
||||
SET layout_data = $1, updated_at = NOW()
|
||||
WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`,
|
||||
[JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id],
|
||||
);
|
||||
totalUpdated++;
|
||||
totalButtons += buttonsInRow;
|
||||
|
||||
console.log(
|
||||
` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`,
|
||||
);
|
||||
|
||||
// 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력
|
||||
if (testMode) {
|
||||
const sampleBtn = layoutData.components.find(isButtonComponent);
|
||||
if (sampleBtn) {
|
||||
console.log("\n--- 변경 후 샘플 버튼 ---");
|
||||
console.log(JSON.stringify(sampleBtn, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n--- 결과 ---`);
|
||||
console.log(`변경된 레코드: ${totalUpdated}개`);
|
||||
console.log(`변경된 버튼: ${totalButtons}개`);
|
||||
|
||||
if (testMode) {
|
||||
await client.query("ROLLBACK");
|
||||
console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음.");
|
||||
} else {
|
||||
await client.query("COMMIT");
|
||||
console.log("\nCOMMIT 완료.");
|
||||
}
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("\n에러 발생. ROLLBACK 완료.", err);
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI 진입점 ──
|
||||
async function main() {
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) {
|
||||
console.log("사용법:");
|
||||
console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)");
|
||||
console.log(" --run : 전체 실행 (COMMIT)");
|
||||
console.log(" --backup : 백업 테이블 생성");
|
||||
console.log(" --restore : 백업에서 원복");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
if (arg === "--backup") {
|
||||
await createBackup();
|
||||
} else if (arg === "--restore") {
|
||||
await restoreFromBackup();
|
||||
} else if (arg === "--test") {
|
||||
await createBackup();
|
||||
await updateButtons(true);
|
||||
} else if (arg === "--run") {
|
||||
await createBackup();
|
||||
await updateButtons(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("스크립트 실행 실패:", err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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); // 카테고리 값 관리
|
||||
|
||||
@@ -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) {
|
||||
|
||||
291
backend-node/src/controllers/popProductionController.ts
Normal file
291
backend-node/src/controllers/popProductionController.ts
Normal 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 || "타이머 처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
233
backend-node/src/controllers/productionController.ts
Normal file
233
backend-node/src/controllers/productionController.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
15
backend-node/src/routes/popProductionRoutes.ts
Normal file
15
backend-node/src/routes/popProductionRoutes.ts
Normal 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;
|
||||
42
backend-node/src/routes/productionRoutes.ts
Normal file
42
backend-node/src/routes/productionRoutes.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1384,19 +1392,28 @@ 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("규칙을 찾을 수 없습니다");
|
||||
|
||||
// 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀
|
||||
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
|
||||
const skipSequenceLookup = hasManualPart && !manualInputValue;
|
||||
|
||||
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
||||
const manualValues = manualInputValue ? [manualInputValue] : undefined;
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
|
||||
const pool = getPool();
|
||||
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
const currentSeq = skipSequenceLookup
|
||||
? 0
|
||||
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
|
||||
// 대상 테이블에서 실제 최대 시퀀스 조회
|
||||
let baseSeq = currentSeq;
|
||||
@@ -1427,7 +1444,7 @@ class NumberingRuleService {
|
||||
}
|
||||
|
||||
logger.info("미리보기: 순번 조회 완료", {
|
||||
ruleId, prefixKey, currentSeq, baseSeq,
|
||||
ruleId, prefixKey, currentSeq, baseSeq, skipSequenceLookup,
|
||||
});
|
||||
|
||||
const parts = await Promise.all(rule.parts
|
||||
@@ -1442,7 +1459,8 @@ class NumberingRuleService {
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const nextSequence = baseSeq + 1;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
const nextSequence = baseSeq + startFrom;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
@@ -1484,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;
|
||||
@@ -1636,11 +1552,29 @@ class NumberingRuleService {
|
||||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
// prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
||||
// 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능)
|
||||
const manualParts = rule.parts.filter(
|
||||
(p: any) => p.generationMethod === "manual"
|
||||
);
|
||||
let extractedManualValues: string[] = [];
|
||||
|
||||
if (manualParts.length > 0 && userInputCode) {
|
||||
extractedManualValues = await this.extractManualValuesFromInput(
|
||||
rule, userInputCode, formData
|
||||
);
|
||||
|
||||
// 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만)
|
||||
if (extractedManualValues.length === 0 && manualParts.length === 1) {
|
||||
extractedManualValues = [userInputCode];
|
||||
logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode });
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -1648,137 +1582,16 @@ class NumberingRuleService {
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("allocateCode: 테이블 기반 순번 할당", {
|
||||
ruleId, prefixKey, allocatedSequence,
|
||||
logger.info("allocateCode: prefix_key + 테이블 기반 순번 할당", {
|
||||
ruleId, prefixKey, allocatedSequence, extractedManualValues,
|
||||
});
|
||||
|
||||
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
|
||||
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 || [];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(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;
|
||||
}
|
||||
@@ -1788,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": {
|
||||
@@ -1825,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 "";
|
||||
}
|
||||
|
||||
@@ -1922,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");
|
||||
|
||||
879
backend-node/src/services/productionPlanService.ts
Normal file
879
backend-node/src/services/productionPlanService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user