Merge branch 'mhkim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
29
backend-node/src/utils/categoryUtils.ts
Normal file
29
backend-node/src/utils/categoryUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user