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

This commit is contained in:
kjs
2026-04-24 17:59:00 +09:00
126 changed files with 27780 additions and 896 deletions

View File

@@ -0,0 +1,378 @@
/**
* copyChecklistToSplit 단위 테스트
*
* 대상: /src/controllers/popProductionController.ts 의 copyChecklistToSplit
*
* 전략: 실제 DB 연결 없이 client.query 를 Jest mock 으로 주입한다.
* 테스트 대상 함수는 외부에서 주입된 client 만을 사용하여 쿼리를 실행하므로
* pg/Pool 전체를 모킹할 필요가 없다 (순수한 query router 로직 검증).
*
* 커버 분기:
* - A-1 wi_* 커스텀 템플릿 존재 (wi_process_work_item) -> wi_* 에서 복사
* - A-2 wi_* 미존재 또는 workInstructionNo 미지정 -> 원본 process_work_item 에서 복사
* - skipAStrategy=true -> A 전략 전체 skip, B 전략 진입
* - A 에서 0 건 -> B 전략 fallthrough
* - routingDetailId=null -> 곧장 B 전략
* - B 전략 = 마스터 wop 의 기존 process_work_result 구조 복사
*/
import { copyChecklistToSplit } from "../popProductionController";
type QueryCall = { text: string; values?: unknown[] };
/**
* client.query mock 헬퍼.
* calls 배열에 호출 인자를 순서대로 저장하고, responses 큐에서 응답을 순서대로 반환한다.
* responses 가 고갈되면 기본값 { rows: [], rowCount: 0 } 을 반환한다.
*/
function makeClient(
responses: Array<{ rows?: unknown[]; rowCount?: number }>,
): {
client: { query: jest.Mock };
calls: QueryCall[];
} {
const calls: QueryCall[] = [];
const queue = [...responses];
const client = {
query: jest.fn((text: string, values?: unknown[]) => {
calls.push({ text, values });
const next = queue.shift();
return Promise.resolve(next ?? { rows: [], rowCount: 0 });
}),
};
return { client, calls };
}
const COMPANY = "TESTCO";
const USER = "tester01";
const MASTER_WOP = "master-wop-id";
const WOP_RESULT = "wop-result-id";
const ROUTING_DETAIL = "routing-detail-id";
const WI_NO = "WI-20260424-001";
describe("copyChecklistToSplit", () => {
describe("A-1: wi_* 커스텀 템플릿 우선 복사", () => {
it("workInstructionNo 지정 + wi_process_work_item row 존재 시 wi_* 템플릿에서 복사한다", async () => {
const { client, calls } = makeClient([
{ rows: [{ "?column?": 1 }], rowCount: 1 },
{ rows: [], rowCount: 3 },
]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
expect(inserted).toBe(3);
expect(client.query).toHaveBeenCalledTimes(2);
expect(calls[0].text).toContain("FROM wi_process_work_item");
expect(calls[0].values).toEqual([
WI_NO,
ROUTING_DETAIL,
COMPANY,
]);
expect(calls[1].text).toContain("INSERT INTO process_work_result");
expect(calls[1].text).toContain("FROM wi_process_work_item wi");
expect(calls[1].text).toContain("wi_process_work_item_detail wid");
expect(calls[1].values).toEqual([
WOP_RESULT,
USER,
ROUTING_DETAIL,
COMPANY,
WI_NO,
]);
});
it("A-1 결과가 0건이면 B 전략으로 fallthrough 하여 마스터 스냅샷에서 복사한다", async () => {
const { client, calls } = makeClient([
{ rows: [{ "?column?": 1 }], rowCount: 1 },
{ rows: [], rowCount: 0 },
{ rows: [], rowCount: 7 },
]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
expect(inserted).toBe(7);
expect(client.query).toHaveBeenCalledTimes(3);
expect(calls[2].text).toContain("FROM process_work_result");
expect(calls[2].text).toContain("WHERE work_order_process_id = $3");
expect(calls[2].values).toEqual([
WOP_RESULT,
USER,
MASTER_WOP,
COMPANY,
]);
});
});
describe("A-2: 원본 템플릿 fallback", () => {
it("workInstructionNo 미지정 시 원본 process_work_item 에서 복사한다", async () => {
const { client, calls } = makeClient([{ rows: [], rowCount: 5 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
);
expect(inserted).toBe(5);
expect(client.query).toHaveBeenCalledTimes(1);
expect(calls[0].text).toContain("FROM process_work_item pwi");
expect(calls[0].text).toContain("process_work_item_detail pwd");
expect(calls[0].text).not.toContain("wi_process_work_item");
expect(calls[0].values).toEqual([
WOP_RESULT,
USER,
ROUTING_DETAIL,
COMPANY,
]);
});
it("workInstructionNo 지정됐지만 wi_* row 가 0개면 원본 템플릿에서 복사한다", async () => {
const { client, calls } = makeClient([
{ rows: [], rowCount: 0 },
{ rows: [], rowCount: 4 },
]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
expect(inserted).toBe(4);
expect(client.query).toHaveBeenCalledTimes(2);
expect(calls[0].text).toContain("SELECT 1 FROM wi_process_work_item");
expect(calls[1].text).toContain("FROM process_work_item pwi");
expect(calls[1].text).not.toContain("wi_process_work_item");
});
it("A-2 결과가 0건이면 B 전략으로 fallthrough 한다", async () => {
const { client, calls } = makeClient([
{ rows: [], rowCount: 0 },
{ rows: [], rowCount: 2 },
]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
);
expect(inserted).toBe(2);
expect(client.query).toHaveBeenCalledTimes(2);
expect(calls[1].text).toContain("FROM process_work_result");
expect(calls[1].text).toContain("WHERE work_order_process_id = $3");
});
});
describe("skipAStrategy: A 전략 전체 건너뛰기", () => {
it("skipAStrategy=true 이면 routingDetailId 와 workInstructionNo 가 있어도 B 전략만 실행한다", async () => {
const { client, calls } = makeClient([{ rows: [], rowCount: 10 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO, skipAStrategy: true },
);
expect(inserted).toBe(10);
expect(client.query).toHaveBeenCalledTimes(1);
expect(calls[0].text).toContain("FROM process_work_result");
expect(calls[0].text).toContain("WHERE work_order_process_id = $3");
expect(calls[0].text).not.toContain("wi_process_work_item");
expect(calls[0].text).not.toContain("process_work_item pwi");
expect(calls[0].values).toEqual([
WOP_RESULT,
USER,
MASTER_WOP,
COMPANY,
]);
});
it("skipAStrategy=false (명시) 는 기본 동작과 동일하다", async () => {
const { client } = makeClient([{ rows: [], rowCount: 2 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ skipAStrategy: false },
);
expect(inserted).toBe(2);
expect(client.query).toHaveBeenCalledTimes(1);
});
});
describe("B: routingDetailId 없음", () => {
it("routingDetailId=null 이면 A 전략 skip, 곧장 B 전략 실행", async () => {
const { client, calls } = makeClient([{ rows: [], rowCount: 6 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
null,
COMPANY,
USER,
);
expect(inserted).toBe(6);
expect(client.query).toHaveBeenCalledTimes(1);
expect(calls[0].text).toContain("FROM process_work_result");
expect(calls[0].values).toEqual([
WOP_RESULT,
USER,
MASTER_WOP,
COMPANY,
]);
});
it("routingDetailId=null + workInstructionNo 지정은 workInstructionNo 무시하고 B 전략 실행", async () => {
const { client, calls } = makeClient([{ rows: [], rowCount: 1 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
null,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
expect(inserted).toBe(1);
expect(client.query).toHaveBeenCalledTimes(1);
expect(calls[0].text).not.toContain("wi_process_work_item");
expect(calls[0].text).toContain("FROM process_work_result");
});
});
describe("엣지 케이스", () => {
it("B 전략에서도 0 건이면 0 을 반환한다", async () => {
const { client } = makeClient([{ rows: [], rowCount: 0 }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
null,
COMPANY,
USER,
);
expect(inserted).toBe(0);
});
it("rowCount 가 undefined 면 0 을 반환한다 (null safety)", async () => {
const { client } = makeClient([{ rows: [] }]);
const inserted = await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
null,
COMPANY,
USER,
);
expect(inserted).toBe(0);
});
it("wi_* 체크 쿼리에 company_code 가 필터로 포함된다 (멀티테넌시)", async () => {
const { client, calls } = makeClient([
{ rows: [], rowCount: 0 },
{ rows: [], rowCount: 1 },
]);
await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
expect(calls[0].text).toContain("company_code = $3");
expect(calls[0].values?.[2]).toBe(COMPANY);
});
it("모든 INSERT 쿼리는 파라미터 바인딩을 사용한다 (문자열 삽입 금지)", async () => {
const { client, calls } = makeClient([
{ rows: [{ "?column?": 1 }], rowCount: 1 },
{ rows: [], rowCount: 1 },
]);
await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
ROUTING_DETAIL,
COMPANY,
USER,
{ workInstructionNo: WI_NO },
);
// 모든 호출에서 values 가 존재하고 query 텍스트에 placeholder 가 있어야 한다
for (const call of calls) {
expect(call.values).toBeDefined();
expect(Array.isArray(call.values)).toBe(true);
expect(call.text).toMatch(/\$\d+/);
}
});
it("B 전략은 항상 masterProcessId 로 소스 스냅샷을 조회한다", async () => {
const { client, calls } = makeClient([{ rows: [], rowCount: 3 }]);
await copyChecklistToSplit(
client,
MASTER_WOP,
WOP_RESULT,
null,
COMPANY,
USER,
);
const insertSql = calls[0].text;
expect(insertSql).toContain("FROM process_work_result");
expect(insertSql).toContain("WHERE work_order_process_id = $3");
expect(calls[0].values?.[2]).toBe(MASTER_WOP);
expect(calls[0].values?.[3]).toBe(COMPANY);
});
});
});

View File

@@ -227,10 +227,24 @@ export class AuthController {
}
}
// 새로운 JWT 토큰 발급 (company_code만 변경)
// 전환 대상 회사명 조회
let targetCompanyName: string | undefined;
if (companyCode === "*") {
targetCompanyName = "공통";
} else {
const { query: dbQuery } = await import("../database/db");
const companyRows = await dbQuery<{ company_name: string }>(
"SELECT company_name FROM company_mng WHERE company_code = $1",
[companyCode.trim()]
);
targetCompanyName = companyRows[0]?.company_name || companyCode.trim();
}
// 새로운 JWT 토큰 발급 (company_code + company_name 변경)
const newPersonBean: PersonBean = {
...currentUser,
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
companyCode: companyCode.trim(),
companyName: targetCompanyName,
};
const newToken = JwtUtils.generateToken(newPersonBean);
@@ -355,6 +369,7 @@ export class AuthController {
deptName: dbUserInfo.deptName || "",
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
companyName: userInfo.companyName || dbUserInfo.companyName || "", // JWT 토큰 우선 (회사 전환 시 갱신됨)
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
userTypeName: dbUserInfo.userTypeName || "일반사용자",
email: dbUserInfo.email || "",

View File

@@ -10,6 +10,7 @@
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { resolveCategoryCode } from "../utils/categoryUtils";
import { adjustInventory } from "../utils/inventoryUtils";
import { logger } from "../utils/logger";
@@ -127,6 +128,14 @@ export async function create(req: AuthenticatedRequest, res: Response) {
const insertedRows: any[] = [];
for (const item of items) {
// 저장용 value_code (조건 분기는 원본 item.outbound_type 유지)
const resolvedItemOutboundType = await resolveCategoryCode(
client,
"outbound_mng",
"outbound_type",
item.outbound_type,
);
const result = await client.query(
`INSERT INTO outbound_mng (
id, company_code, outbound_number, outbound_type, outbound_date,
@@ -152,7 +161,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
[
companyCode,
outbound_number || item.outbound_number,
item.outbound_type,
resolvedItemOutboundType,
outbound_date || item.outbound_date,
item.reference_number || null,
item.customer_code || null,
@@ -260,7 +269,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
locCode,
String(-outQty),
afterQty,
item.outbound_type || "출고",
resolvedItemOutboundType || "출고",
userId,
],
);

View File

@@ -161,6 +161,38 @@ export async function deletePkgUnit(
}
}
// ──────────────────────────────────────────────
// 품목별 포장단위 조회 (item_number → pkg_unit 목록)
// ──────────────────────────────────────────────
export async function getPkgUnitsByItem(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { itemNumber } = req.params;
const pool = getPool();
const result = await pool.query(
`SELECT pu.id, pu.pkg_code, pu.pkg_name, pu.pkg_type, pu.status,
pu.width_mm, pu.length_mm, pu.height_mm,
pu.self_weight_kg, pu.max_load_kg, pu.volume_l,
pui.pkg_qty
FROM pkg_unit_item pui
JOIN pkg_unit pu ON pui.pkg_code = pu.pkg_code AND pui.company_code = pu.company_code
WHERE pui.item_number = $1 AND pui.company_code = $2 AND pu.status = 'ACTIVE'
ORDER BY pu.pkg_name`,
[itemNumber, companyCode]
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목별 포장단위 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ──────────────────────────────────────────────
// 포장단위 매칭품목 (pkg_unit_item) CRUD
// ──────────────────────────────────────────────
@@ -405,6 +437,38 @@ export async function deleteLoadingUnit(
}
}
// ──────────────────────────────────────────────
// 포장코드별 적재함 조회 (pkg_code → loading_unit 목록)
// ──────────────────────────────────────────────
export async function getLoadingUnitsByPkg(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { pkgCode } = req.params;
const pool = getPool();
const result = await pool.query(
`SELECT lu.id, lu.loading_code, lu.loading_name, lu.loading_type, lu.status,
lu.width_mm, lu.length_mm, lu.height_mm,
lu.self_weight_kg, lu.max_load_kg, lu.max_stack,
lup.max_load_qty, lup.load_method
FROM loading_unit_pkg lup
JOIN loading_unit lu ON lup.loading_code = lu.loading_code AND lup.company_code = lu.company_code
WHERE lup.pkg_code = $1 AND lup.company_code = $2 AND lu.status = 'ACTIVE'
ORDER BY lu.loading_name`,
[pkgCode, companyCode]
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("포장별 적재함 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ──────────────────────────────────────────────
// 적재함 포장구성 (loading_unit_pkg) CRUD
// ──────────────────────────────────────────────

View File

@@ -10,6 +10,7 @@
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { resolveCategoryCode } from "../utils/categoryUtils";
import { adjustInventory } from "../utils/inventoryUtils";
import { logger } from "../utils/logger";
@@ -155,12 +156,19 @@ export async function create(req: AuthenticatedRequest, res: Response) {
.json({ success: false, message: "입고 품목이 없습니다." });
}
// 첫 번째 아이템에서 inbound_type 추출 (헤더용)
const inboundType = items[0].inbound_type || null;
// 헤더용 inbound_type: 단일이면 그 값, 혼합이면 "혼합입고"
const uniqueInboundTypes = [...new Set(items.map((i: any) => i.inbound_type).filter(Boolean))];
let inboundType = uniqueInboundTypes.length === 1
? uniqueInboundTypes[0]
: uniqueInboundTypes.length > 1
? "혼합입고"
: (items[0].inbound_type || null);
const inboundNumber = inbound_number || items[0].inbound_number;
await client.query("BEGIN");
inboundType = await resolveCategoryCode(client, "inbound_mng", "inbound_type", inboundType);
// 1. 헤더 — 같은 (company_code, inbound_number) 헤더가 있으면 reuse, 없으면 INSERT (멱등성)
let headerRow: any;
const existingHeader = await client.query(
@@ -183,11 +191,13 @@ export async function create(req: AuthenticatedRequest, res: Response) {
id, company_code, inbound_number, inbound_type, inbound_date,
warehouse_code, location_code,
inbound_status, inspector, manager, memo,
source_table, source_id,
created_date, created_by, writer, status
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4::date,
$5, $6,
$7, $8, $9, $10,
$12, $13,
NOW(), $11, $11, '입고'
) RETURNING *`,
[
@@ -202,6 +212,8 @@ export async function create(req: AuthenticatedRequest, res: Response) {
manager || items[0].manager || null,
memo || items[0].memo || null,
userId,
items.length === 1 ? (items[0].source_table || null) : null,
items.length === 1 ? (items[0].source_id || null) : null,
],
);
headerRow = headerResult.rows[0];
@@ -224,6 +236,14 @@ export async function create(req: AuthenticatedRequest, res: Response) {
const item = items[i];
const seqNo = i + 1;
// 저장용 value_code (조건 분기는 원본 item.inbound_type 유지)
const resolvedItemInboundType = await resolveCategoryCode(
client,
"inbound_mng",
"inbound_type",
item.inbound_type || inboundType,
);
// 2a. inbound_detail INSERT
const detailResult = await client.query(
`INSERT INTO inbound_detail (
@@ -245,7 +265,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
companyCode,
inboundNumber,
seqNo,
item.inbound_type || inboundType,
resolvedItemInboundType,
item.item_number || null,
item.item_name || null,
item.spec || null,
@@ -325,18 +345,17 @@ export async function create(req: AuthenticatedRequest, res: Response) {
locCode,
String(inQty),
afterQty,
item.inbound_type || "입고",
resolvedItemInboundType || "입고",
userId,
],
);
}
// 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지
if (
item.inbound_type === "구매입고" &&
item.source_id &&
item.source_table === "purchase_order_mng"
) {
// 2c. source_table 기준 소스 데이터 업데이트 (이중 입고 방지)
const srcTable = item.source_table;
const srcId = item.source_id;
if (srcTable === "purchase_order_mng" && srcId) {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
@@ -354,17 +373,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode],
[item.inbound_qty || 0, srcId, companyCode],
);
}
// 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트
if (
item.inbound_type === "구매입고" &&
item.source_id &&
item.source_table === "purchase_detail"
) {
// 1. 해당 purchase_detail의 received_qty 누적 업데이트
} else if (srcTable === "purchase_detail" && srcId) {
await client.query(
`UPDATE purchase_detail SET
received_qty = CAST(
@@ -377,17 +388,15 @@ export async function create(req: AuthenticatedRequest, res: Response) {
),
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode],
[item.inbound_qty || 0, srcId, companyCode],
);
// 2. 발주 헤더 상태 업데이트
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode],
[srcId, companyCode],
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
// 잔량 있는 디테일이 있는지 확인
const unreceived = await client.query(
`SELECT id FROM purchase_detail
WHERE purchase_no = $1 AND company_code = $2
@@ -419,6 +428,28 @@ export async function create(req: AuthenticatedRequest, res: Response) {
[newStatus, purchaseNo, companyCode],
);
}
} else if (srcTable === "work_order_process" && srcId) {
// 생산입고: target_warehouse_id 세팅 (이중 입고 방지)
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
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
AND target_warehouse_id IS NULL`,
[srcId, companyCode, whCode, locCode || null, userId],
);
} else if (srcTable && srcId) {
// 미처리 소스 테이블 — 추후 업데이트 로직 추가 필요
logger.warn("입고 소스 업데이트 미처리", {
source_table: srcTable,
source_id: srcId,
inbound_type: item.inbound_type,
item_number: item.item_number,
});
}
}
@@ -502,6 +533,9 @@ export async function update(req: AuthenticatedRequest, res: Response) {
const oldLocCode = oldHeader.location_code || null;
const itemCode = oldDetail?.item_number || oldHeader.item_number || null;
const inboundNumber = oldHeader.inbound_number;
const inboundType = oldDetail?.inbound_type || oldHeader.inbound_type;
const srcTable = oldHeader.source_table;
const srcId = oldHeader.source_id;
const newQty =
inbound_qty !== undefined && inbound_qty !== null
@@ -645,6 +679,122 @@ export async function update(req: AuthenticatedRequest, res: Response) {
}
}
// 발주 롤백: 구매입고인 경우 수량 delta를 원본 purchase_order_mng / purchase_detail에 반영
if (
qtyChanged &&
inboundType === "구매입고" &&
srcId &&
(srcTable === "purchase_order_mng" || srcTable === "purchase_detail")
) {
const delta = newQty - oldQty;
if (srcTable === "purchase_order_mng") {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0) AS text
),
remain_qty = CAST(
GREATEST(
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0),
0
) AS text
),
status = CASE
WHEN GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0) <= 0
THEN '발주확정'
WHEN GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0)
>= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
THEN '입고완료'
ELSE '부분입고'
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[delta, srcId, companyCode],
);
} else if (srcTable === "purchase_detail") {
await client.query(
`UPDATE purchase_detail SET
received_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0) AS text
),
balance_qty = CAST(
GREATEST(
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1, 0),
0
) AS text
),
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[delta, srcId, companyCode],
);
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[srcId, companyCode],
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
const unreceived = await client.query(
`SELECT id FROM purchase_detail
WHERE purchase_no = $1 AND company_code = $2
AND COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0
LIMIT 1`,
[purchaseNo, companyCode],
);
const anyReceived = await client.query(
`SELECT id FROM purchase_detail
WHERE purchase_no = $1 AND company_code = $2
AND COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0
LIMIT 1`,
[purchaseNo, companyCode],
);
const newStatus =
anyReceived.rows.length === 0
? "발주확정"
: unreceived.rows.length === 0
? "입고완료"
: "부분입고";
await client.query(
`UPDATE purchase_order_mng SET
status = $1,
received_qty = (
SELECT CAST(COALESCE(SUM(CAST(NULLIF(received_qty, '') AS numeric)), 0) AS text)
FROM purchase_detail
WHERE purchase_no = $2 AND company_code = $3
),
remain_qty = (
SELECT CAST(COALESCE(SUM(
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
), 0) AS text)
FROM purchase_detail
WHERE purchase_no = $2 AND company_code = $3
),
updated_date = NOW()
WHERE purchase_no = $2 AND company_code = $3`,
[newStatus, purchaseNo, companyCode],
);
}
}
}
// 생산입고 롤백: 수량 변경 시 work_order_process.target_warehouse_id를 NULL로 복귀
// → POP 생산입고 화면에서 잔량 기준으로 다시 조회 (received_qty는 inbound_detail 집계)
if (qtyChanged && srcTable === "work_order_process" && srcId) {
await client.query(
`UPDATE work_order_process
SET target_warehouse_id = NULL,
target_location_code = NULL,
updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[srcId, companyCode],
);
}
await client.query("COMMIT");
logger.info("입고 수정", {
@@ -656,6 +806,9 @@ export async function update(req: AuthenticatedRequest, res: Response) {
newQty,
oldWhCode,
newWhCode,
inboundType,
srcTable,
srcId,
});
return res.json({
@@ -1044,6 +1197,8 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
si.instruction_no,
si.instruction_date,
si.partner_id,
si.partner_id AS partner_code,
COALESCE(cm.customer_name, si.partner_id) AS partner_name,
si.status AS instruction_status,
sid.item_code,
sid.item_name,
@@ -1056,6 +1211,9 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
LEFT JOIN customer_mng cm
ON cm.customer_code = si.partner_id
AND cm.company_code = si.company_code
WHERE ${whereClause}
ORDER BY si.instruction_date DESC, si.instruction_no
LIMIT ${limit} OFFSET ${offset}`,
@@ -1126,6 +1284,104 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
}
}
// 생산입고용: 실적이 등록된 작업지시 공정 데이터 조회 (미입고분)
export async function getProductionResults(
req: AuthenticatedRequest,
res: Response,
) {
try {
const companyCode = req.user!.companyCode;
const { processCode, keyword, pageSize } = req.query;
if (!processCode) {
return res
.status(400)
.json({ success: false, message: "processCode 필수" });
}
const limit = Math.min(500, Math.max(1, Number(pageSize) || 50));
const params: any[] = [companyCode, processCode];
let paramIdx = 3;
let keywordCondition = "";
if (keyword) {
keywordCondition = `AND (wi.work_instruction_no ILIKE $${paramIdx} OR COALESCE(ii.item_name, '') ILIKE $${paramIdx} OR COALESCE(ii.item_number, '') ILIKE $${paramIdx})`;
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const dataResult = await pool.query(
`SELECT
wop.id,
wop.wo_id,
wi.work_instruction_no,
wi.start_date AS order_date,
wop.process_code,
wop.process_name,
wop.seq_no,
COALESCE(ii.item_number, wi.item_id) AS item_code,
COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name,
COALESCE(ii.size, '') AS spec,
COALESCE(ii.material, '') AS material,
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) AS order_qty,
COALESCE(rcv.received_qty, 0) AS received_qty,
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0)
- COALESCE(rcv.received_qty, 0) AS remain_qty,
'work_order_process' AS source_table,
wop.result_status,
COALESCE(ii.image, NULL) AS image,
CASE WHEN EXISTS (
SELECT 1 FROM item_inspection_info iii
WHERE iii.company_code = wop.company_code
AND COALESCE(iii.is_active, 'Y') = 'Y'
AND iii.item_code = COALESCE(ii.item_number, wi.item_id)
) THEN 'self' ELSE NULL END AS inspection_type
FROM work_order_process wop
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
LEFT JOIN (
SELECT DISTINCT ON (id, company_code)
id, item_number, item_name, size, material, image, company_code
FROM item_info
ORDER BY id, company_code, created_date DESC
) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code
LEFT JOIN (
SELECT im.source_id,
SUM(COALESCE(CAST(NULLIF(id.inbound_qty::text, '') AS numeric), 0)) AS received_qty
FROM inbound_detail id
JOIN inbound_mng im
ON id.inbound_id = im.inbound_number
AND id.company_code = im.company_code
WHERE im.source_table = 'work_order_process'
AND im.company_code = $1
GROUP BY im.source_id
) rcv ON rcv.source_id = wop.id
WHERE wop.company_code = $1
AND wop.process_code = $2
AND wop.parent_process_id IS NULL
AND (wop.is_rework IS NULL OR wop.is_rework != 'Y')
AND COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0
AND (
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0)
- COALESCE(rcv.received_qty, 0)
) > 0
${keywordCondition}
ORDER BY wi.work_instruction_no, CAST(wop.seq_no AS int)
LIMIT ${limit}`,
params,
);
return res.json({ success: true, data: dataResult.rows });
} catch (error: any) {
logger.error("생산입고 소스 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 입고번호 자동생성
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {

View File

@@ -6,6 +6,7 @@ import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { numberingRuleService } from "../services/numberingRuleService";
import { copyChecklistToSplit } from "./popProductionController";
// 자동 마이그레이션: work_instruction_detail에 routing_version_id + 품목별 일정/설비/작업조/작업자 컬럼 추가
let _migrationDone = false;
@@ -717,6 +718,80 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
}
}
/**
* wi_* 편집 시 마스터 체크리스트 스냅샷을 재투영한다.
* 접수(work_order_process_result) 가 0건일 때만 동기화되며, 1건 이상이면 스냅샷 불변.
* 트랜잭션 내에서 호출되어야 한다 (caller 가 BEGIN/COMMIT 관리).
*
* @param routingDetailId null 이면 해당 작업지시의 모든 routing detail 동기화
* @returns synced: 실제 동기화 수행 여부, affectedProcesses: 재복사된 마스터 공정 수
*/
async function syncMasterChecklistFromWi(
client: { query: (text: string, values?: any[]) => Promise<any> },
workInstructionNo: string,
routingDetailId: string | null,
companyCode: string,
userId: string,
): Promise<{ synced: boolean; affectedProcesses: number; reason?: string }> {
// 1. 작업지시 id 조회
const wiRow = await client.query(
`SELECT id FROM work_instruction WHERE work_instruction_no = $1 AND company_code = $2`,
[workInstructionNo, companyCode],
);
if (wiRow.rowCount === 0) {
return { synced: false, affectedProcesses: 0, reason: "work_instruction not found" };
}
const wiId = wiRow.rows[0].id as string;
// 2. advisory lock — 편집/접수 동시성 보호
await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [
`wi_snapshot:${companyCode}:${wiId}`,
]);
// 3. 접수 건수 확인
const acceptCount = await client.query(
`SELECT COUNT(*)::int AS cnt FROM work_order_process_result wopr
JOIN work_order_process wop ON wop.id = wopr.work_order_process_id
WHERE wop.wo_id = $1 AND wop.company_code = $2 AND wopr.company_code = $2`,
[wiId, companyCode],
);
if ((acceptCount.rows[0]?.cnt ?? 0) > 0) {
return { synced: false, affectedProcesses: 0, reason: "accepted_count > 0" };
}
// 4. 대상 마스터 공정 목록
const masterQuery = routingDetailId
? `SELECT id, routing_detail_id FROM work_order_process
WHERE wo_id = $1 AND routing_detail_id = $2 AND company_code = $3`
: `SELECT id, routing_detail_id FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`;
const masterParams = routingDetailId
? [wiId, routingDetailId, companyCode]
: [wiId, companyCode];
const masters = await client.query(masterQuery, masterParams);
let affected = 0;
for (const m of masters.rows) {
// 5. 기존 마스터 스냅샷 삭제
await client.query(
`DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`,
[m.id, companyCode],
);
// 6. 재복사 — copyChecklistToSplit 재활용 (wi_* 우선, 없으면 원본 fallback)
await copyChecklistToSplit(
client,
m.id,
m.id,
m.routing_detail_id,
companyCode,
userId,
{ workInstructionNo },
);
affected++;
}
return { synced: true, affectedProcesses: affected };
}
// ─── 원본 공정작업기준 -> 작업지시 전용 복사 ───
export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
@@ -783,6 +858,8 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response)
}
}
const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId);
logger.info("[work-instruction] wi_* copy 후 마스터 스냅샷 동기화", { wiNo, ...sync });
await client.query("COMMIT");
logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId });
return res.json({ success: true });
@@ -850,6 +927,8 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
}
}
const sync = await syncMasterChecklistFromWi(client, wiNo, routingDetailId, companyCode, userId);
logger.info("[work-instruction] wi_* save 후 마스터 스냅샷 동기화", { wiNo, routingDetailId, ...sync });
await client.query("COMMIT");
logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId });
return res.json({ success: true });
@@ -869,6 +948,7 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
export async function resetWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { wiNo } = req.params;
const pool = getPool();
const client = await pool.connect();
@@ -889,6 +969,8 @@ export async function resetWorkStandard(req: AuthenticatedRequest, res: Response
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId);
logger.info("[work-instruction] wi_* reset 후 마스터 스냅샷 원본 복원", { wiNo, ...sync });
await client.query("COMMIT");
logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo });
return res.json({ success: true });

View File

@@ -2,8 +2,10 @@ import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
getPkgUnitsByItem,
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitsByPkg,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
} from "../controllers/packagingController";
@@ -18,6 +20,9 @@ router.post("/pkg-units", createPkgUnit);
router.put("/pkg-units/:id", updatePkgUnit);
router.delete("/pkg-units/:id", deletePkgUnit);
// 품목별 포장단위 조회
router.get("/pkg-units-by-item/:itemNumber", getPkgUnitsByItem);
// 포장단위 매칭품목
router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems);
router.post("/pkg-unit-items", createPkgUnitItem);
@@ -29,6 +34,9 @@ router.post("/loading-units", createLoadingUnit);
router.put("/loading-units/:id", updateLoadingUnit);
router.delete("/loading-units/:id", deleteLoadingUnit);
// 포장코드별 적재함 조회
router.get("/loading-units-by-pkg/:pkgCode", getLoadingUnitsByPkg);
// 적재함 포장구성
router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
router.post("/loading-unit-pkgs", createLoadingUnitPkg);

View File

@@ -23,6 +23,8 @@ import {
saveMaterialInput,
getMaterialInputs,
getChecklistItems,
getProcessList,
getProcessResult,
} from "../controllers/popProductionController";
const router = Router();
@@ -51,5 +53,7 @@ router.get("/bom-materials/:processId", getBomMaterials);
router.post("/material-input", saveMaterialInput);
router.get("/material-inputs/:processId", getMaterialInputs);
router.get("/checklist-items/:processId", getChecklistItems);
router.get("/processes", getProcessList);
router.get("/result/:id", getProcessResult);
export default router;

View File

@@ -28,6 +28,9 @@ router.get("/source/shipments", receivingController.getShipments);
// 소스 데이터: 품목 (기타입고)
router.get("/source/items", receivingController.getItems);
// 소스 데이터: 생산실적 (생산입고)
router.get("/source/production-results", receivingController.getProductionResults);
// 입고 등록
router.post("/", receivingController.create);

View File

@@ -0,0 +1,29 @@
import type { PoolClient } from "pg";
/**
* value_label 로 category_values 를 조회해 value_code 를 반환한다.
* 매칭되는 카테고리가 없으면 입력 label 을 그대로 돌려준다.
*
* company_code 조건은 걸지 않는다 — 같은 label 은 전사에서 동일한 value_code 로
* 관리되는 것을 전제로, 업체 간 데이터 복사 시에도 값이 깨지지 않게 하기 위함.
*/
export async function resolveCategoryCode(
client: PoolClient,
tableName: string,
columnName: string,
label: string | null | undefined,
): Promise<string | null> {
if (!label) return label ?? null;
const result = await client.query(
`SELECT DISTINCT value_code FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_label = $3
AND is_active = true
LIMIT 1`,
[tableName, columnName, label],
);
return result.rows[0]?.value_code ?? label;
}

View File

@@ -19,6 +19,11 @@ function getAiAssistantDir(): string {
* AI 어시스턴트 서비스 기동 (있으면 띄움, 실패해도 backend는 계속 동작)
*/
export function startAiAssistant(): void {
if (process.env.DISABLE_AI_ASSISTANT === "1") {
logger.info("⏭️ AI 어시스턴트 스킵 (DISABLE_AI_ASSISTANT=1)");
return;
}
const aiDir = getAiAssistantDir();
const appPath = path.join(aiDir, "src", "app.js");