Merge branch 'main' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
kjs
2026-04-02 17:43:30 +09:00
45 changed files with 13095 additions and 15 deletions

View File

@@ -118,7 +118,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
for (const item of items) {
const result = await client.query(
`INSERT INTO outbound_mng (
company_code, outbound_number, outbound_type, outbound_date,
id, company_code, outbound_number, outbound_type, outbound_date,
reference_number, customer_code, customer_name,
item_code, item_name, specification, material, unit,
outbound_qty, unit_price, total_amount,
@@ -128,7 +128,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
destination_code, delivery_destination, delivery_address,
created_date, created_by, writer, status
) VALUES (
$1, $2, $3, $4,
gen_random_uuid()::text, $1, $2, $3, $4,
$5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15,

View File

@@ -29,7 +29,7 @@ async function copyChecklistToSplit(
if (routingDetailId) {
const result = await client.query(
`INSERT INTO process_work_result (
company_code, work_order_process_id,
id, 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,
@@ -38,7 +38,7 @@ async function copyChecklistToSplit(
status, writer
)
SELECT
pwi.company_code, $1,
gen_random_uuid()::text, 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,
@@ -59,7 +59,7 @@ async function copyChecklistToSplit(
// B. routing_detail_id가 없으면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화)
const result = await client.query(
`INSERT INTO process_work_result (
company_code, work_order_process_id,
id, 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,
@@ -68,7 +68,7 @@ async function copyChecklistToSplit(
status, writer
)
SELECT
company_code, $1,
gen_random_uuid()::text, company_code, $1,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
@@ -168,10 +168,10 @@ export const createWorkProcesses = async (
// 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,
id, 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)
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[
companyCode,
@@ -778,13 +778,13 @@ export const saveResult = async (
const masterId = proc.parent_process_id || work_order_process_id;
const reworkInsert = await pool.query(
`INSERT INTO work_order_process (
wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
standard_time, equipment_code, routing_detail_id,
status, input_qty, good_qty, defect_qty, concession_qty, total_production_qty,
result_status, is_rework, rework_source_id,
parent_process_id, company_code, writer
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9,
'acceptable', $10, '0', '0', '0', '0',
'draft', 'Y', $11,
$12, $13, $14
@@ -1444,13 +1444,13 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
// 분할 행 INSERT (원본 행에서 공정 정보 복사)
const result = await pool.query(
`INSERT INTO work_order_process (
wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
standard_time, equipment_code, routing_detail_id,
status, input_qty, good_qty, defect_qty, total_production_qty,
result_status, accepted_by, accepted_at, started_at,
parent_process_id, company_code, writer
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9,
'in_progress', $10, '0', '0', '0',
'draft', $11, NOW()::text, NOW()::text,
$12, $13, $11
@@ -1607,3 +1607,355 @@ export const cancelAccept = async (
});
}
};
// ========================================
// POP 전용 함수 (PC 코드와 분리)
// ========================================
/**
* 내부 헬퍼: 단일 작업지시에 대해 work_order_process + process_work_result 일괄 생성
* syncWorkInstructions에서 사용한다.
*/
async function generateWorkProcessesForInstruction(
client: { query: (text: string, values?: any[]) => Promise<any> },
workInstructionId: string,
routingVersionId: string,
planQty: string | null,
companyCode: string,
userId: string
): Promise<{
processes: Array<{ id: string; seq_no: string; process_name: string; checklist_count: number }>;
total_checklists: number;
} | null> {
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[workInstructionId, companyCode]
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
return null;
}
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`,
[routingVersionId, companyCode]
);
if (routingDetails.rows.length === 0) {
return null;
}
const processes: Array<{
id: string; seq_no: string; process_name: string; checklist_count: number;
}> = [];
let totalChecklists = 0;
for (const rd of routingDetails.rows) {
const wopResult = await client.query(
`INSERT INTO work_order_process (
id, 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 (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[
companyCode, workInstructionId, rd.seq_no, rd.process_code, rd.process_name,
rd.is_required, rd.is_fixed_order, rd.standard_time, planQty || null,
parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting", rd.id, userId,
]
);
const wopId = wopResult.rows[0].id;
const checklistCount = await copyChecklistToSplit(
client, wopId, wopId, rd.id, companyCode, userId
);
totalChecklists += checklistCount;
processes.push({
id: wopId, seq_no: rd.seq_no, process_name: rd.process_name, checklist_count: checklistCount,
});
}
return { processes, total_checklists: totalChecklists };
}
/**
* POP: 미동기화 작업지시 일괄 동기화
*/
export const syncWorkInstructions = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const unsyncedResult = await pool.query(
`SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty
FROM work_instruction wi
WHERE wi.company_code = $1
AND wi.routing IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
)`,
[companyCode]
);
const unsynced = unsyncedResult.rows;
if (unsynced.length === 0) {
return res.json({ success: true, data: { synced: 0, skipped: 0, errors: 0, details: [] } });
}
let synced = 0, skipped = 0, errors = 0;
const details: Array<{
work_instruction_id: string; work_instruction_no: string;
status: "synced" | "skipped" | "error"; process_count?: number; error?: string;
}> = [];
for (const wi of unsynced) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await generateWorkProcessesForInstruction(
client, wi.id, wi.routing, wi.qty || null, companyCode, userId
);
if (!result) {
await client.query("ROLLBACK");
skipped++;
details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, status: "skipped" });
continue;
}
await client.query("COMMIT");
synced++;
details.push({
work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no,
status: "synced", process_count: result.processes.length,
});
} catch (err: any) {
await client.query("ROLLBACK");
errors++;
details.push({
work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no,
status: "error", error: err.message || "알 수 없는 오류",
});
} finally {
client.release();
}
}
return res.json({ success: true, data: { synced, skipped, errors, details } });
} catch (error: any) {
logger.error("[pop/production] sync-work-instructions 오류:", error);
return res.status(500).json({ success: false, message: error.message || "동기화 오류" });
}
};
export const getWarehouses = async (req: AuthenticatedRequest, res: Response) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const result = await pool.query(
`SELECT id, warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info WHERE company_code = $1 AND COALESCE(status, '') != '삭제' ORDER BY warehouse_name`,
[companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
return res.status(500).json({ success: false, message: error.message });
}
};
export const getWarehouseLocations = async (req: AuthenticatedRequest, res: Response) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { warehouseId } = req.params;
if (!warehouseId) return res.status(400).json({ success: false, message: "warehouseId 필수" });
const whInfo = await pool.query(
`SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`,
[warehouseId, companyCode]
);
if (whInfo.rowCount === 0) return res.json({ success: true, data: [] });
const result = await pool.query(
`SELECT id, location_code, location_name FROM warehouse_location
WHERE warehouse_code = $1 AND company_code = $2 ORDER BY location_name`,
[whInfo.rows[0].warehouse_code, companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
return res.status(500).json({ success: false, message: error.message });
}
};
export const isLastProcess = async (req: AuthenticatedRequest, res: Response) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { processId } = req.params;
if (!processId) return res.json({ success: true, data: { isLast: false } });
const process = await pool.query(
`SELECT wo_id, seq_no, parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`,
[processId, companyCode]
);
if (process.rowCount === 0) return res.json({ success: true, data: { isLast: false } });
const { wo_id, seq_no, parent_process_id } = process.rows[0];
let effectiveSeqNo = seq_no;
if (parent_process_id) {
const master = await pool.query(
`SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`,
[parent_process_id, companyCode]
);
if (master.rowCount > 0) effectiveSeqNo = master.rows[0].seq_no;
}
const next = await pool.query(
`SELECT id FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND CAST(seq_no AS int) > CAST($3 AS int) AND parent_process_id IS NULL LIMIT 1`,
[wo_id, companyCode, effectiveSeqNo]
);
const warehouseInfo = await pool.query(
`SELECT target_warehouse_id, target_location_code FROM work_order_process WHERE id = $1 AND company_code = $2`,
[processId, companyCode]
);
return res.json({
success: true,
data: {
isLast: next.rowCount === 0, woId: wo_id, seqNo: effectiveSeqNo,
targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null,
targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null,
},
});
} catch (error: any) {
return res.status(500).json({ success: false, message: error.message });
}
};
export const updateTargetWarehouse = async (req: AuthenticatedRequest, res: Response) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, target_warehouse_id, target_location_code } = req.body;
if (!work_order_process_id || !target_warehouse_id)
return res.status(400).json({ success: false, message: "work_order_process_id와 target_warehouse_id 필수" });
const procInfo = await pool.query(
`SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
const idsToUpdate = [work_order_process_id];
if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) idsToUpdate.push(procInfo.rows[0].parent_process_id);
for (const id of idsToUpdate) {
await pool.query(
`UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[id, companyCode, target_warehouse_id, target_location_code || null, userId]
);
}
return res.json({ success: true, data: { target_warehouse_id, target_location_code } });
} catch (error: any) {
return res.status(500).json({ success: false, message: error.message });
}
};
export const inventoryInbound = 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_order_process_id, warehouse_code, location_code } = req.body;
if (!work_order_process_id || !warehouse_code)
return res.status(400).json({ success: false, message: "work_order_process_id와 warehouse_code 필수" });
await client.query("BEGIN");
const procResult = await client.query(
`SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id FROM work_order_process WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
if (procResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "공정 없음" }); }
const proc = procResult.rows[0];
if (proc.target_warehouse_id) { await client.query("ROLLBACK"); return res.status(409).json({ success: false, message: "이미 입고 완료" }); }
const goodQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10);
if (goodQty <= 0) { await client.query("ROLLBACK"); return res.status(400).json({ success: false, message: "양품 0" }); }
const wiResult = await client.query(`SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, [proc.wo_id, companyCode]);
if (wiResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "작업지시 없음" }); }
const itemResult = await client.query(`SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [wiResult.rows[0].item_id, companyCode]);
if (itemResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "품목 없음" }); }
const itemCode = itemResult.rows[0].item_number;
const locCode = location_code || warehouse_code;
await client.query(
`INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6)
ON CONFLICT (company_code, item_code, warehouse_code, location_code)
DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text,
last_in_date = NOW(), updated_date = NOW(), writer = $6`,
[companyCode, itemCode, warehouse_code, locCode, String(goodQty), userId]
);
const idsToUpdate = [work_order_process_id];
if (proc.parent_process_id) idsToUpdate.push(proc.parent_process_id);
for (const id of idsToUpdate) {
await client.query(
`UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() WHERE id = $1 AND company_code = $2`,
[id, companyCode, warehouse_code, location_code || null, userId]
);
}
await client.query("COMMIT");
return res.json({ success: true, message: "재고 입고 완료", data: { item_code: itemCode, warehouse_code, location_code: locCode, qty: goodQty } });
} catch (error: any) {
await client.query("ROLLBACK").catch(() => {});
return res.status(500).json({ success: false, message: error.message });
} finally { client.release(); }
};
export const quickInventoryInbound = 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 { item_id, qty, warehouse_code, location_code, remark } = req.body;
if (!item_id || !qty || !warehouse_code)
return res.status(400).json({ success: false, message: "item_id, qty, warehouse_code 필수" });
const parsedQty = parseInt(String(qty), 10);
if (isNaN(parsedQty) || parsedQty <= 0)
return res.status(400).json({ success: false, message: "수량은 1 이상" });
await client.query("BEGIN");
const itemResult = await client.query(
`SELECT item_number, item_name, size, material, unit FROM item_info WHERE id = $1 AND company_code = $2`, [item_id, companyCode]
);
if (itemResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "품목 없음" }); }
const item = itemResult.rows[0];
const locCode = location_code || warehouse_code;
await client.query(
`INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6)
ON CONFLICT (company_code, item_code, warehouse_code, location_code)
DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text,
last_in_date = NOW(), updated_date = NOW(), writer = $6`,
[companyCode, item.item_number, warehouse_code, locCode, String(parsedQty), userId]
);
const seqResult = await client.query(
`SELECT COALESCE(MAX(CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$'
THEN CAST(SUBSTRING(inbound_number FROM '[0-9]+$') AS INTEGER) ELSE 0 END), 0) + 1 AS next_seq
FROM inbound_mng WHERE company_code = $1`, [companyCode]
);
const inboundNumber = `QIB-${new Date().getFullYear()}-${String(seqResult.rows[0].next_seq).padStart(4, "0")}`;
await client.query(
`INSERT INTO inbound_mng (id, company_code, inbound_number, inbound_type, inbound_date,
item_number, item_name, spec, material, unit, inbound_qty, warehouse_code, location_code,
inbound_status, memo, remark, created_date, updated_date, writer, created_by, updated_by
) VALUES (gen_random_uuid()::text, $1, $2, '간이입고', CURRENT_DATE,
$3, $4, $5, $6, $7, $8, $9, $10, '완료', $11, $12, NOW(), NOW(), $13, $13, $13)`,
[companyCode, inboundNumber, item.item_number, item.item_name, item.size, item.material, item.unit,
parsedQty, warehouse_code, locCode, remark || "POP 간이입고", remark || null, userId]
);
await client.query("COMMIT");
return res.json({ success: true, message: "간이 입고 완료",
data: { inbound_number: inboundNumber, item_code: item.item_number, item_name: item.item_name, warehouse_code, location_code: locCode, qty: parsedQty } });
} catch (error: any) {
await client.query("ROLLBACK").catch(() => {});
return res.status(500).json({ success: false, message: error.message });
} finally { client.release(); }
};

View File

@@ -246,10 +246,10 @@ export async function create(req: AuthenticatedRequest, res: Response) {
} else {
await client.query(
`INSERT INTO inventory_stock (
company_code, item_code, warehouse_code, location_code,
id, company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date,
created_date, updated_date, writer
) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
);
}