WIP: POP + packaging 작업 중

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kmh
2026-04-22 14:55:28 +09:00
parent f117534d6c
commit 4ed4d5f66e
30 changed files with 1705 additions and 907 deletions

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

@@ -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

@@ -155,8 +155,13 @@ 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))];
const 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");
@@ -331,12 +336,11 @@ export async function create(req: AuthenticatedRequest, res: Response) {
);
}
// 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 +358,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 +373,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 +413,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,
});
}
}
@@ -1044,6 +1060,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 +1074,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 +1147,88 @@ 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,
0 AS received_qty,
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 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
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 wop.target_warehouse_id IS NULL
${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

@@ -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,7 @@ import {
saveMaterialInput,
getMaterialInputs,
getChecklistItems,
getProcessList,
} from "../controllers/popProductionController";
const router = Router();
@@ -51,5 +52,6 @@ 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);
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);