Add inventory transfer API and enhance POP backend controllers
- Add inventoryTransferController/routes for POP inventory move (send/receive) - Add transactionPackagingService for transactional packaging operations - Enhance popProduction/popInventoryMove/popInventoryAdjust controllers - Enhance receiving/outbound/packaging/workInstruction controllers Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1005
backend-node/src/controllers/inventoryTransferController.ts
Normal file
1005
backend-node/src/controllers/inventoryTransferController.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,12 @@ import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { resolveCategoryCode } from "../utils/categoryUtils";
|
||||
import { adjustInventory } from "../utils/inventoryUtils";
|
||||
import { logger } from "../utils/logger";
|
||||
import {
|
||||
ensureLoadingInstance,
|
||||
insertPackagingRows,
|
||||
processBoxOutbound,
|
||||
type PackageEntryInput,
|
||||
} from "../services/transactionPackagingService";
|
||||
|
||||
// 출고 목록 조회
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
@@ -144,7 +150,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
outbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
outbound_status, manager_id, memo,
|
||||
source_type, sales_order_id, shipment_plan_id, item_info_id,
|
||||
source_table, source_id, sales_order_id, shipment_plan_id, item_info_id,
|
||||
destination_code, delivery_destination, delivery_address,
|
||||
created_date, created_by, writer, status
|
||||
) VALUES (
|
||||
@@ -154,9 +160,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
$13, $14, $15,
|
||||
$16, $17, $18,
|
||||
$19, $20, $21,
|
||||
$22, $23, $24, $25,
|
||||
$26, $27, $28,
|
||||
NOW(), $29, $29, '출고'
|
||||
$22, $23, $24, $25, $26,
|
||||
$27, $28, $29,
|
||||
NOW(), $30, $30, '출고'
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
@@ -181,6 +187,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
manager_id || item.manager_id || null,
|
||||
memo || item.memo || null,
|
||||
item.source_type || item.source_table || null,
|
||||
item.source_id || null,
|
||||
item.sales_order_id || null,
|
||||
item.shipment_plan_id || null,
|
||||
item.item_info_id || null,
|
||||
@@ -193,6 +200,54 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// transaction_loading + transaction_packaging
|
||||
// - loading_code 가 있으면 적재함 인스턴스 ensure (같은 row + 코드는 reuse)
|
||||
// - packages 가 있으면 박스 단위로 펼쳐 N row INSERT (라벨 자동 발번)
|
||||
try {
|
||||
const detailId = result.rows[0].id as string;
|
||||
const loadingId = await ensureLoadingInstance(client, {
|
||||
companyCode,
|
||||
sourceType: "outbound",
|
||||
sourceDocId: detailId,
|
||||
loadingCode: item.loading_code || null,
|
||||
loadingName: item.loading_name || null,
|
||||
loadingSeq: item.loading_seq ?? 1,
|
||||
writer: userId,
|
||||
});
|
||||
|
||||
const rawPkgs: any[] = Array.isArray(item.packages) ? item.packages : [];
|
||||
const packagesInput: PackageEntryInput[] = rawPkgs
|
||||
.map((p) => ({
|
||||
pkg_code: String(p?.pkg_code ?? p?.unit?.value ?? ""),
|
||||
count: Number(p?.count ?? 0),
|
||||
qty_per_unit: Number(p?.qty_per_unit ?? p?.qtyPerUnit ?? 0),
|
||||
}))
|
||||
.filter((p) => p.pkg_code && p.count > 0 && p.qty_per_unit > 0);
|
||||
|
||||
if (packagesInput.length > 0) {
|
||||
const labels = await insertPackagingRows(client, {
|
||||
companyCode,
|
||||
sourceType: "outbound_detail",
|
||||
sourceId: detailId,
|
||||
inboundNumber: outbound_number || item.outbound_number,
|
||||
packages: packagesInput,
|
||||
loadingId,
|
||||
warehouseCode: warehouse_code || item.warehouse_code || null,
|
||||
locationCode: location_code || item.location_code || null,
|
||||
lotNo: item.lot_number || null,
|
||||
writer: userId,
|
||||
});
|
||||
insertedRows[insertedRows.length - 1].package_labels = labels;
|
||||
}
|
||||
} catch (pkgErr: any) {
|
||||
logger.error("transaction_packaging/loading 저장 실패", {
|
||||
error: pkgErr.message,
|
||||
outbound_number,
|
||||
item_number: item.item_code || item.item_number,
|
||||
});
|
||||
throw pkgErr;
|
||||
}
|
||||
|
||||
// 재고 업데이트 (inventory_stock): 출고 수량 차감
|
||||
const itemCode = item.item_code || item.item_number || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
@@ -255,6 +310,42 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
// Phase 2-b: transaction_packaging 박스 단위 출고 처리
|
||||
// FIFO 매칭으로 박스 전체 출고(status=SHIPPED) + 마지막 박스 부분 차감(quantity UPDATE)
|
||||
// 박스 row 가 0건이면 (Phase 1 도입 전 입고 데이터) 자동 스킵 → 기존 흐름 유지
|
||||
try {
|
||||
const boxResult = await processBoxOutbound(client, {
|
||||
companyCode,
|
||||
itemCode,
|
||||
warehouseCode: stockRow.warehouse_code ?? whCode,
|
||||
locationCode: stockRow.location_code ?? locCode,
|
||||
qty: outQty,
|
||||
writer: userId,
|
||||
});
|
||||
if (
|
||||
boxResult.shippedLabels.length > 0 ||
|
||||
boxResult.partialLabel ||
|
||||
boxResult.untrackedQty !== outQty
|
||||
) {
|
||||
logger.info("출고 박스 처리", {
|
||||
companyCode,
|
||||
item_code: itemCode,
|
||||
requested_qty: outQty,
|
||||
shipped_count: boxResult.shippedLabels.length,
|
||||
partial_label: boxResult.partialLabel,
|
||||
partial_before: boxResult.partialBefore,
|
||||
partial_after: boxResult.partialAfter,
|
||||
untracked_qty: boxResult.untrackedQty,
|
||||
});
|
||||
}
|
||||
} catch (pkgErr: any) {
|
||||
logger.error("출고 박스 처리 실패", {
|
||||
error: pkgErr.message,
|
||||
item_code: itemCode,
|
||||
});
|
||||
throw pkgErr;
|
||||
}
|
||||
}
|
||||
|
||||
// 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영 + master status 자동 전환
|
||||
@@ -615,6 +706,7 @@ export async function getShipmentInstructions(
|
||||
COALESCE(sid.order_qty, 0) AS order_qty,
|
||||
GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty,
|
||||
sid.source_type,
|
||||
NULLIF(ii.unit, '') AS unit,
|
||||
-- 단가 fallback chain: 수주 원본 → 거래처-품목 매핑 단가 → 품목 표준가 → 0
|
||||
COALESCE(
|
||||
CAST(NULLIF(sod.unit_price, '') AS numeric),
|
||||
@@ -625,7 +717,7 @@ export async function getShipmentInstructions(
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM item_inspection_info iii
|
||||
WHERE iii.company_code = si.company_code
|
||||
AND COALESCE(iii.is_active, 'Y') = 'Y'
|
||||
AND iii.is_active = '사용'
|
||||
AND iii.inspection_type = '출하검사'
|
||||
AND iii.item_code = sid.item_code
|
||||
) THEN 'self' ELSE NULL END AS inspection_type
|
||||
@@ -855,3 +947,156 @@ export async function getLocations(req: AuthenticatedRequest, res: Response) {
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 생산출고용: 마지막 공정까지 진행된 작업지시 실적 조회
|
||||
// - work_order_process 의 max(seq_no) = 마지막 공정
|
||||
// - 마지막 공정의 work_order_process_result 가 등록되어 있고 (good_qty + concession_qty) > 0
|
||||
// - 출고 잔량 = (good_qty + concession_qty) - 이미 출고된 outbound_mng.outbound_qty 합계
|
||||
// - processCode 옵션: 해당 WO 의 마지막 공정이 그 공정인 것만 필터
|
||||
// - keyword 옵션: work_instruction_no / item_number / item_name ILIKE
|
||||
export async function getProductionResults(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { processCode, keyword, pageSize } = req.query;
|
||||
|
||||
const limit = Math.min(500, Math.max(1, Number(pageSize) || 100));
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
let processCondition = "";
|
||||
if (processCode) {
|
||||
processCondition = `AND wop.process_code = $${paramIdx}`;
|
||||
params.push(processCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
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
|
||||
wr.id,
|
||||
wop.id AS wop_id,
|
||||
wop.wo_id,
|
||||
wi.work_instruction_no,
|
||||
wi.start_date AS order_date,
|
||||
COALESCE(CAST(NULLIF(wi.qty, '') AS numeric), 0) AS instruction_qty,
|
||||
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,
|
||||
NULLIF(ii.unit, '') AS unit,
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) AS good_qty,
|
||||
COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS concession_qty,
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS order_qty,
|
||||
COALESCE(ship.shipped_qty, 0) AS shipped_qty,
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
||||
- COALESCE(ship.shipped_qty, 0) AS remain_qty,
|
||||
'work_order_process_result' AS source_table,
|
||||
wr.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') IN ('Y', '사용')
|
||||
AND iii.item_code = COALESCE(ii.item_number, wi.item_id)
|
||||
) THEN 'self' ELSE NULL END AS inspection_type,
|
||||
tp_agg.packages,
|
||||
tl.loading_code,
|
||||
tl.loading_name
|
||||
FROM work_order_process_result wr
|
||||
JOIN work_order_process wop
|
||||
ON wop.id = wr.wop_id
|
||||
AND wop.company_code = wr.company_code
|
||||
JOIN work_instruction wi
|
||||
ON wi.id = wop.wo_id
|
||||
AND wi.company_code = wop.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (id, company_code)
|
||||
id, item_number, item_name, size, material, unit, 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 source_id, company_code,
|
||||
SUM(COALESCE(CAST(NULLIF(outbound_qty::text, '') AS numeric), 0)) AS shipped_qty
|
||||
FROM outbound_mng
|
||||
WHERE source_table = 'work_order_process_result'
|
||||
AND company_code = $1
|
||||
AND source_id IS NOT NULL
|
||||
GROUP BY source_id, company_code
|
||||
) ship ON ship.source_id = wr.id AND ship.company_code = wr.company_code
|
||||
LEFT JOIN (
|
||||
SELECT packed.source_id, packed.company_code,
|
||||
JSON_AGG(JSON_BUILD_OBJECT(
|
||||
'pkg_code', packed.pkg_code,
|
||||
'pkg_name', COALESCE(pu.pkg_name, packed.pkg_code),
|
||||
'count', packed.cnt,
|
||||
'qty_per_unit', packed.qty_per_unit
|
||||
)) AS packages
|
||||
FROM (
|
||||
SELECT source_id, company_code, pkg_code,
|
||||
CAST(quantity AS numeric) AS qty_per_unit,
|
||||
COUNT(*)::int AS cnt
|
||||
FROM transaction_packaging
|
||||
WHERE company_code = $1
|
||||
AND source_type = 'work_order_process_result'
|
||||
GROUP BY source_id, company_code, pkg_code, quantity
|
||||
) packed
|
||||
LEFT JOIN pkg_unit pu
|
||||
ON pu.pkg_code = packed.pkg_code
|
||||
AND pu.company_code = packed.company_code
|
||||
GROUP BY packed.source_id, packed.company_code
|
||||
) tp_agg ON tp_agg.source_id = wr.id AND tp_agg.company_code = wr.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (source_doc_id, company_code)
|
||||
source_doc_id, company_code,
|
||||
loading_code, loading_name
|
||||
FROM transaction_loading
|
||||
WHERE company_code = $1
|
||||
AND source_type = 'work_order_process_result'
|
||||
ORDER BY source_doc_id, company_code, COALESCE(loading_seq, 1) ASC
|
||||
) tl ON tl.source_doc_id = wr.id AND tl.company_code = wr.company_code
|
||||
WHERE wr.company_code = $1
|
||||
AND CAST(wop.seq_no AS int) = (
|
||||
SELECT MAX(CAST(wop2.seq_no AS int))
|
||||
FROM work_order_process wop2
|
||||
WHERE wop2.wo_id = wop.wo_id
|
||||
AND wop2.company_code = wop.company_code
|
||||
)
|
||||
AND (
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
||||
) > 0
|
||||
AND (
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
||||
- COALESCE(ship.shipped_qty, 0)
|
||||
) > 0
|
||||
${processCondition}
|
||||
${keywordCondition}
|
||||
ORDER BY wi.work_instruction_no, wr.created_date NULLS LAST
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +611,140 @@ export async function getItemsByDivision(
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 거래 단위 포장재·적재함 추적 (transaction_loading / transaction_packaging)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// 적재함 인스턴스 조회 (작업 헤더 기준)
|
||||
// GET /packaging/transaction-loadings?source_type=inbound&source_doc_id=xxx
|
||||
export async function getTransactionLoadings(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { source_type, source_doc_id } = req.query as {
|
||||
source_type?: string;
|
||||
source_doc_id?: string;
|
||||
};
|
||||
const pool = getPool();
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`company_code = $${idx++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
if (source_type) {
|
||||
conditions.push(`source_type = $${idx++}`);
|
||||
params.push(source_type);
|
||||
}
|
||||
if (source_doc_id) {
|
||||
conditions.push(`source_doc_id = $${idx++}`);
|
||||
params.push(source_doc_id);
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM transaction_loading ${where}
|
||||
ORDER BY source_doc_id, loading_code, COALESCE(loading_seq, 1)`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("적재함 인스턴스 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 박스(라벨) 조회
|
||||
// GET /packaging/transaction-packagings?source_type=inbound_detail&source_id=xxx
|
||||
// GET /packaging/transaction-packagings?inbound_number=INB-2026-001
|
||||
// GET /packaging/transaction-packagings?loading_id=xxx
|
||||
// GET /packaging/transaction-packagings?status=STORED
|
||||
export async function getTransactionPackagings(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const {
|
||||
source_type,
|
||||
source_id,
|
||||
inbound_number,
|
||||
loading_id,
|
||||
status,
|
||||
pkg_code,
|
||||
keyword,
|
||||
} = req.query as Record<string, string | undefined>;
|
||||
const pool = getPool();
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`tp.company_code = $${idx++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
if (source_type) {
|
||||
conditions.push(`tp.source_type = $${idx++}`);
|
||||
params.push(source_type);
|
||||
}
|
||||
if (source_id) {
|
||||
conditions.push(`tp.source_id = $${idx++}`);
|
||||
params.push(source_id);
|
||||
}
|
||||
if (inbound_number) {
|
||||
conditions.push(`tp.package_label LIKE $${idx++}`);
|
||||
params.push(`${inbound_number}-P%`);
|
||||
}
|
||||
if (loading_id) {
|
||||
conditions.push(`tp.loading_id = $${idx++}`);
|
||||
params.push(loading_id);
|
||||
}
|
||||
if (status) {
|
||||
conditions.push(`tp.status = $${idx++}`);
|
||||
params.push(status);
|
||||
}
|
||||
if (pkg_code) {
|
||||
conditions.push(`tp.pkg_code = $${idx++}`);
|
||||
params.push(pkg_code);
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(`tp.package_label ILIKE $${idx++}`);
|
||||
params.push(`%${keyword}%`);
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
tp.*,
|
||||
tl.loading_code AS tl_loading_code,
|
||||
tl.loading_seq AS tl_loading_seq,
|
||||
tl.loading_name AS tl_loading_name,
|
||||
pu.pkg_name AS pkg_name
|
||||
FROM transaction_packaging tp
|
||||
LEFT JOIN transaction_loading tl ON tl.id = tp.loading_id
|
||||
LEFT JOIN pkg_unit pu
|
||||
ON pu.pkg_code = tp.pkg_code
|
||||
AND pu.company_code = tp.company_code
|
||||
${where}
|
||||
ORDER BY tp.package_label
|
||||
LIMIT 1000`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("박스(라벨) 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 품목 조회 (포장재/적재함 제외, 매칭용)
|
||||
export async function getGeneralItems(
|
||||
req: AuthenticatedRequest,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* 동작:
|
||||
* - inventory_history.reason 컬럼 직접 저장
|
||||
* - remark = 메모 평문 (미입력 시 NULL)
|
||||
* - 이상없음(confirm) 케이스는 받지 않음 (UI only, DB 안 찍음)
|
||||
* - 이상없음(confirm) 케이스는 quantity='0', balance_qty=systemQty, reason='이상없음' 으로 INSERT
|
||||
* (inventory_stock UPDATE 없음, 실재고 변동 0)
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
@@ -20,6 +21,9 @@ interface CommitItem {
|
||||
actual_qty: number;
|
||||
reason: string;
|
||||
memo?: string;
|
||||
// 'confirm' = 이상없음(수량 변화 없음, 사유 '이상없음' 으로 INSERT)
|
||||
// 'adjust' = 일반 조정 (default)
|
||||
type?: "confirm" | "adjust";
|
||||
// 위치불일치 케이스만 사용
|
||||
new_warehouse?: string;
|
||||
new_location?: string;
|
||||
@@ -58,6 +62,19 @@ export const getAdjustStockList = async (req: any, res: Response) => {
|
||||
ist.item_code ILIKE $${idx}
|
||||
OR COALESCE(ii.item_name, '') ILIKE $${idx}
|
||||
OR COALESCE(ii.item_number, '') ILIKE $${idx}
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM transaction_packaging tp
|
||||
WHERE tp.company_code = ist.company_code
|
||||
AND COALESCE(tp.warehouse_code, '') = COALESCE(ist.warehouse_code, '')
|
||||
AND COALESCE(tp.location_code, '') = COALESCE(ist.location_code, '')
|
||||
AND tp.lot_no ILIKE $${idx}
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM inbound_detail idd
|
||||
WHERE idd.company_code = ist.company_code
|
||||
AND idd.item_number = ist.item_code
|
||||
AND idd.lot_number ILIKE $${idx}
|
||||
)
|
||||
)`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
@@ -100,11 +117,12 @@ export const getAdjustStockList = async (req: any, res: Response) => {
|
||||
|
||||
/**
|
||||
* POST /api/pop/inventory/adjust/commit
|
||||
* 재고조정 일괄 확정 — 조정 건만 처리 (이상없음은 클라이언트가 보내지 않음)
|
||||
* 재고조정 일괄 확정 — 조정/이상없음 모두 처리
|
||||
*
|
||||
* Body: { items: CommitItem[] }
|
||||
*
|
||||
* 처리:
|
||||
* - 이상없음(type='confirm'): inventory_history INSERT 1건만 (quantity='0', balance_qty=systemQty, reason='이상없음'). stock UPDATE 없음
|
||||
* - 일반 조정: inventory_stock.current_qty UPDATE + inventory_history INSERT
|
||||
* - 위치불일치: 출발 위치 차감 + 새 위치 UPSERT + inventory_history 2건 INSERT (출발 -, 도착 +)
|
||||
* - 모든 INSERT 의 transaction_type='조정', reason 컬럼 직접 저장, remark=memo 평문 또는 NULL
|
||||
@@ -154,6 +172,24 @@ export const commitAdjust = async (req: any, res: Response) => {
|
||||
const diff = actualQty - systemQty;
|
||||
const memo = item.memo?.trim() || null;
|
||||
|
||||
// 이상없음(confirm): 수량 변화 없이 inventory_history 에 흔적만 남김
|
||||
// - inventory_stock UPDATE 없음 (실재고 변동 0)
|
||||
// - transaction_type='조정', quantity='0', balance_qty=systemQty(=확인 시점 실재고)
|
||||
// - reason='이상없음' (옵션설정 카테고리에 등록되어 있으면 라벨 매핑됨)
|
||||
if (item.type === "confirm") {
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, reason, remark, writer, manager_name, created_date)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4,
|
||||
'조정', NOW(), '0', $5, '이상없음', NULL, $6, $7, NOW())`,
|
||||
[companyCode, stock.item_code, stock.warehouse_code, stock.location_code || "",
|
||||
String(systemQty), userId, userName]
|
||||
);
|
||||
adjustCount++;
|
||||
results.push({ stock_id: item.stock_id, status: "ok" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.reason === "위치불일치" && item.new_warehouse) {
|
||||
// 위치불일치: 출발 차감 + 도착 UPSERT + 이력 2건
|
||||
const newLoc = item.new_location || item.new_warehouse;
|
||||
@@ -353,3 +389,90 @@ export const saveTempAdjust = async (req: any, res: Response) => {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/pop/inventory/adjust/history
|
||||
* 재고조정 이력 조회 (단일 날짜)
|
||||
*
|
||||
* Query: date (YYYY-MM-DD, 필수), reason (선택, 사유 valueCode), keyword (선택, 품목명/코드)
|
||||
* 대상: inventory_history WHERE transaction_type='조정' AND DATE(transaction_date)=$date
|
||||
*/
|
||||
export const getAdjustHistory = async (req: any, res: Response) => {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
|
||||
const { date, reason, keyword } = req.query as {
|
||||
date?: string;
|
||||
reason?: string;
|
||||
keyword?: string;
|
||||
};
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return res.status(400).json({ success: false, message: "date 파라미터(YYYY-MM-DD)가 필요합니다" });
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
"ih.company_code = $1",
|
||||
"ih.transaction_type = '조정'",
|
||||
"DATE(ih.transaction_date) = $2",
|
||||
];
|
||||
const params: any[] = [companyCode, date];
|
||||
let idx = 3;
|
||||
|
||||
if (reason && reason !== "all") {
|
||||
conditions.push(`ih.reason = $${idx++}`);
|
||||
params.push(reason);
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(
|
||||
ih.item_code ILIKE $${idx}
|
||||
OR COALESCE(ii.item_name, '') ILIKE $${idx}
|
||||
)`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
ih.id,
|
||||
ih.item_code,
|
||||
COALESCE(ii.item_name, ih.item_code) AS item_name,
|
||||
COALESCE(ii.item_number, ih.item_code) AS item_number,
|
||||
COALESCE(ii.size, '') AS spec,
|
||||
COALESCE(NULLIF(ii.inventory_unit, ''), NULLIF(ii.unit, ''), 'EA') AS unit,
|
||||
ih.warehouse_code,
|
||||
COALESCE(wi.warehouse_name, ih.warehouse_code) AS warehouse_name,
|
||||
COALESCE(ih.location_code, '') AS location_code,
|
||||
COALESCE(ih.quantity, '0') AS quantity,
|
||||
COALESCE(ih.balance_qty, '0') AS balance_qty,
|
||||
COALESCE(ih.reason, '') AS reason,
|
||||
COALESCE(ih.remark, '') AS remark,
|
||||
COALESCE(ih.manager_name, ih.writer, '') AS manager_name,
|
||||
ih.transaction_date
|
||||
FROM inventory_history ih
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, size, unit, inventory_unit, company_code
|
||||
FROM item_info
|
||||
ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON ih.item_code = ii.item_number AND ih.company_code = ii.company_code
|
||||
LEFT JOIN warehouse_info wi ON ih.warehouse_code = wi.warehouse_code
|
||||
AND ih.company_code = wi.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY ih.transaction_date DESC
|
||||
LIMIT 500`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/inventory/adjust] history 오류:", error);
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,12 +14,17 @@
|
||||
import { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { insertSpilloverBox } from "../services/transactionPackagingService";
|
||||
|
||||
const CART_TYPE = "inventory-move";
|
||||
|
||||
interface CommitItem {
|
||||
stock_id: string;
|
||||
qty: number;
|
||||
// Phase 2: 박스 매칭 잔여 처리 모드
|
||||
// 'none' — 박스 없이 이동 (inventory_stock 만 차감/도착, 잔여 박스 INSERT 안 함)
|
||||
// 'new-box' — 잔여를 새 박스에 담아 이동 (직전 박스 정보 자동 복사)
|
||||
spillover_mode?: "none" | "new-box";
|
||||
}
|
||||
|
||||
interface CommitBody {
|
||||
@@ -60,6 +65,19 @@ export const getMoveStockList = async (req: any, res: Response) => {
|
||||
ist.item_code ILIKE $${idx}
|
||||
OR COALESCE(ii.item_name, '') ILIKE $${idx}
|
||||
OR COALESCE(ii.item_number, '') ILIKE $${idx}
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM transaction_packaging tp
|
||||
WHERE tp.company_code = ist.company_code
|
||||
AND COALESCE(tp.warehouse_code, '') = COALESCE(ist.warehouse_code, '')
|
||||
AND COALESCE(tp.location_code, '') = COALESCE(ist.location_code, '')
|
||||
AND tp.lot_no ILIKE $${idx}
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM inbound_detail idd
|
||||
WHERE idd.company_code = ist.company_code
|
||||
AND idd.item_number = ist.item_code
|
||||
AND idd.lot_number ILIKE $${idx}
|
||||
)
|
||||
)`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
@@ -100,6 +118,160 @@ export const getMoveStockList = async (req: any, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/pop/inventory/move/process-list
|
||||
* 재공품 보유 공정 distinct 목록 (출발 = 공정 탭 칩용)
|
||||
*
|
||||
* 필터 (생산입고 패턴):
|
||||
* - good_qty > 0 (실적 등록)
|
||||
* - target_warehouse_id IS NULL (미입고)
|
||||
* - parent_process_id IS NULL (마스터만)
|
||||
* - is_rework 제외
|
||||
*/
|
||||
export const getMoveProcessList = async (req: any, res: Response) => {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT wop.process_code, wop.process_name
|
||||
FROM work_order_process wop
|
||||
WHERE wop.company_code = $1
|
||||
AND wop.parent_process_id IS NULL
|
||||
AND wop.target_warehouse_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM work_order_process_result wr
|
||||
WHERE wr.wop_id = wop.id
|
||||
AND wr.company_code = wop.company_code
|
||||
AND COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) > 0
|
||||
AND COALESCE(wr.is_rework, '') NOT IN ('Y', 'true', '1')
|
||||
)
|
||||
ORDER BY wop.process_name`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/inventory/move] process-list 오류:", error);
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/pop/inventory/move/process-stock-list
|
||||
* 공정 탭 카드 — 재공품 row (생산입고 후보와 동일 SQL 패턴)
|
||||
*
|
||||
* Query: process_code (선택, 'all' 또는 미지정 시 전체), keyword (선택)
|
||||
*
|
||||
* SQL 패턴: receivingController.getProductionResults 와 동일하되
|
||||
* - process_code 옵셔널 (전체일 땐 모든 공정 합산)
|
||||
* - 잔여 가능 수량 > 0 (good_qty + concession_qty - received_qty > 0)
|
||||
*/
|
||||
export const getMoveProcessStockList = async (req: any, res: Response) => {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
|
||||
const { process_code, keyword } = req.query;
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
let processCondition = "";
|
||||
if (process_code && process_code !== "all") {
|
||||
processCondition = `AND wop.process_code = $${idx++}`;
|
||||
params.push(process_code);
|
||||
}
|
||||
|
||||
let keywordCondition = "";
|
||||
if (keyword) {
|
||||
keywordCondition = `AND (
|
||||
wi.work_instruction_no ILIKE $${idx}
|
||||
OR COALESCE(ii.item_name, '') ILIKE $${idx}
|
||||
OR COALESCE(ii.item_number, '') ILIKE $${idx}
|
||||
)`;
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
wr.id,
|
||||
wop.id AS wop_id,
|
||||
wop.wo_id,
|
||||
wi.work_instruction_no,
|
||||
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.item_number, wi.item_id) AS item_number,
|
||||
COALESCE(ii.size, '') AS spec,
|
||||
COALESCE(NULLIF(ii.inventory_unit, ''), NULLIF(ii.unit, ''), 'EA') AS unit,
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) AS good_qty,
|
||||
COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS concession_qty,
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
||||
- COALESCE(rcv.received_qty, 0) AS remain_qty,
|
||||
'work_order_process_result' AS source_table
|
||||
FROM work_order_process_result wr
|
||||
JOIN work_order_process wop
|
||||
ON wop.id = wr.wop_id
|
||||
AND wop.company_code = wr.company_code
|
||||
JOIN work_instruction wi
|
||||
ON wi.id = wop.wo_id
|
||||
AND wi.company_code = wop.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (id, company_code)
|
||||
id, item_number, item_name, size, unit, inventory_unit, 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_result'
|
||||
AND im.company_code = $1
|
||||
GROUP BY im.source_id
|
||||
) rcv ON rcv.source_id = wr.id
|
||||
WHERE wr.company_code = $1
|
||||
AND COALESCE(wr.is_rework, '') NOT IN ('Y', 'true', '1')
|
||||
AND (
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
||||
) > 0
|
||||
AND (
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
||||
- COALESCE(rcv.received_qty, 0)
|
||||
) > 0
|
||||
${processCondition}
|
||||
${keywordCondition}
|
||||
ORDER BY wop.process_name, wi.work_instruction_no, wr.created_date NULLS LAST
|
||||
LIMIT 500`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/inventory/move] process-stock-list 오류:", error);
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/pop/inventory/move/commit
|
||||
* 재고이동 일괄 확정
|
||||
@@ -267,6 +439,90 @@ export const commitMove = async (req: any, res: Response) => {
|
||||
String(qty), String(destBalance), userId, userName]
|
||||
);
|
||||
|
||||
// 7. transaction_packaging 박스 단위 이동 (Phase 2)
|
||||
// FIFO 매칭: 출발 위치의 STORED 박스 중 누적 합산이 qty 이하가 되는 박스 N개를
|
||||
// 새 위치로 UPDATE. 잔여 = qty - 박스합산 만큼은 spillover_mode 에 따라 처리.
|
||||
// 박스 row 가 0건이면 (Phase 1 도입 전 입고 데이터) 전체 스킵 → 기존 흐름 유지.
|
||||
try {
|
||||
const boxRows = await client.query(
|
||||
`SELECT id, package_label, pkg_code, quantity, loading_id, source_type, source_id,
|
||||
lot_no, expire_date
|
||||
FROM transaction_packaging
|
||||
WHERE company_code = $1
|
||||
AND status = 'STORED'
|
||||
AND COALESCE(warehouse_code,'') = COALESCE($2,'')
|
||||
AND COALESCE(location_code,'') = COALESCE($3,'')
|
||||
AND source_id IN (
|
||||
SELECT id FROM inbound_detail
|
||||
WHERE company_code = $1 AND item_number = $4
|
||||
)
|
||||
ORDER BY created_date ASC, package_label ASC`,
|
||||
[companyCode, stock.warehouse_code || "", fromLocation || "", stock.item_code],
|
||||
);
|
||||
|
||||
let remaining = qty;
|
||||
const movedLabels: string[] = [];
|
||||
let lastBox: any = null;
|
||||
|
||||
for (const box of boxRows.rows) {
|
||||
const boxQty = parseFloat(box.quantity) || 0;
|
||||
if (boxQty <= 0) continue;
|
||||
if (boxQty > remaining) break; // 박스 분할 안 함
|
||||
await client.query(
|
||||
`UPDATE transaction_packaging
|
||||
SET warehouse_code = $1,
|
||||
location_code = $2,
|
||||
updated_date = NOW()
|
||||
WHERE id = $3 AND company_code = $4`,
|
||||
[to_warehouse_code, toLocation, box.id, companyCode],
|
||||
);
|
||||
movedLabels.push(box.package_label);
|
||||
lastBox = box;
|
||||
remaining -= boxQty;
|
||||
if (remaining <= 0) break;
|
||||
}
|
||||
|
||||
let spilloverLabel: string | null = null;
|
||||
if (remaining > 0 && lastBox && item.spillover_mode === "new-box") {
|
||||
spilloverLabel = await insertSpilloverBox(
|
||||
client,
|
||||
{
|
||||
companyCode,
|
||||
itemCode: stock.item_code,
|
||||
warehouseCode: to_warehouse_code,
|
||||
locationCode: toLocation,
|
||||
quantity: remaining,
|
||||
templateSourceType: lastBox.source_type,
|
||||
templateSourceId: lastBox.source_id,
|
||||
templatePkgCode: lastBox.pkg_code,
|
||||
templateLoadingId: lastBox.loading_id,
|
||||
templateLotNo: lastBox.lot_no,
|
||||
templateExpireDate: lastBox.expire_date,
|
||||
writer: userId,
|
||||
},
|
||||
lastBox.package_label,
|
||||
);
|
||||
}
|
||||
|
||||
if (movedLabels.length > 0 || spilloverLabel) {
|
||||
logger.info("재고이동 박스 처리", {
|
||||
companyCode,
|
||||
item_code: stock.item_code,
|
||||
requested_qty: qty,
|
||||
moved_boxes: movedLabels.length,
|
||||
spillover_qty: remaining > 0 ? remaining : 0,
|
||||
spillover_mode: item.spillover_mode || "none",
|
||||
spillover_label: spilloverLabel,
|
||||
});
|
||||
}
|
||||
} catch (pkgErr: any) {
|
||||
logger.error("재고이동 박스 처리 실패", {
|
||||
error: pkgErr.message,
|
||||
stock_id: item.stock_id,
|
||||
});
|
||||
throw pkgErr;
|
||||
}
|
||||
|
||||
moveCount++;
|
||||
results.push({ stock_id: item.stock_id, status: "ok" });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,11 @@ import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { resolveCategoryCode } from "../utils/categoryUtils";
|
||||
import { adjustInventory } from "../utils/inventoryUtils";
|
||||
import { logger } from "../utils/logger";
|
||||
import {
|
||||
ensureLoadingInstance,
|
||||
insertPackagingRows,
|
||||
type PackageEntryInput,
|
||||
} from "../services/transactionPackagingService";
|
||||
|
||||
// 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환)
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
@@ -294,6 +299,54 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
insertedDetails.push(detailResult.rows[0]);
|
||||
|
||||
// 2a-bis. transaction_loading + transaction_packaging
|
||||
// - loading_code 가 있으면 적재함 인스턴스 ensure (같은 헤더 + 코드는 reuse)
|
||||
// - packages 가 있으면 박스 단위로 펼쳐 N row INSERT (라벨 자동 발번)
|
||||
try {
|
||||
const detailId = detailResult.rows[0].id as string;
|
||||
const loadingId = await ensureLoadingInstance(client, {
|
||||
companyCode,
|
||||
sourceType: "inbound",
|
||||
sourceDocId: headerRow.id as string,
|
||||
loadingCode: item.loading_code || null,
|
||||
loadingName: item.loading_name || null,
|
||||
loadingSeq: item.loading_seq ?? 1,
|
||||
writer: userId,
|
||||
});
|
||||
|
||||
const rawPkgs: any[] = Array.isArray(item.packages) ? item.packages : [];
|
||||
const packagesInput: PackageEntryInput[] = rawPkgs
|
||||
.map((p) => ({
|
||||
pkg_code: String(p?.pkg_code ?? p?.unit?.value ?? ""),
|
||||
count: Number(p?.count ?? 0),
|
||||
qty_per_unit: Number(p?.qty_per_unit ?? p?.qtyPerUnit ?? 0),
|
||||
}))
|
||||
.filter((p) => p.pkg_code && p.count > 0 && p.qty_per_unit > 0);
|
||||
|
||||
if (packagesInput.length > 0) {
|
||||
const labels = await insertPackagingRows(client, {
|
||||
companyCode,
|
||||
sourceType: "inbound_detail",
|
||||
sourceId: detailId,
|
||||
inboundNumber,
|
||||
packages: packagesInput,
|
||||
loadingId,
|
||||
warehouseCode: warehouse_code || item.warehouse_code || null,
|
||||
locationCode: location_code || item.location_code || null,
|
||||
lotNo: item.lot_number || null,
|
||||
writer: userId,
|
||||
});
|
||||
insertedDetails[insertedDetails.length - 1].package_labels = labels;
|
||||
}
|
||||
} catch (pkgErr: any) {
|
||||
logger.error("transaction_packaging/loading 저장 실패", {
|
||||
error: pkgErr.message,
|
||||
inbound_number: inboundNumber,
|
||||
item_number: item.item_number,
|
||||
});
|
||||
throw pkgErr;
|
||||
}
|
||||
|
||||
// 2b. 재고 업데이트 (inventory_stock): 입고 수량 증가 — 기존 로직 유지
|
||||
const itemCode = item.item_number || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
@@ -1085,6 +1138,7 @@ export async function getPurchaseOrders(
|
||||
COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS received_qty,
|
||||
COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS remain_qty,
|
||||
COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price,
|
||||
NULLIF(ii.unit, '') AS unit,
|
||||
COALESCE(po.status, '') AS status,
|
||||
COALESCE(pd.due_date, po.due_date) AS due_date,
|
||||
'purchase_detail' AS source_table,
|
||||
@@ -1125,6 +1179,7 @@ export async function getPurchaseOrders(
|
||||
- COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0)
|
||||
) AS remain_qty,
|
||||
COALESCE(CAST(NULLIF(po.unit_price, '') AS numeric), 0) AS unit_price,
|
||||
NULL::text AS unit,
|
||||
po.status,
|
||||
po.due_date,
|
||||
'purchase_order_mng' AS source_table,
|
||||
@@ -1219,7 +1274,8 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
|
||||
sid.material,
|
||||
COALESCE(sid.ship_qty, 0) AS ship_qty,
|
||||
COALESCE(sid.order_qty, 0) AS order_qty,
|
||||
sid.source_type
|
||||
sid.source_type,
|
||||
NULLIF(ii.unit, '') AS unit
|
||||
FROM shipment_instruction si
|
||||
JOIN shipment_instruction_detail sid
|
||||
ON si.id = sid.instruction_id
|
||||
@@ -1227,6 +1283,9 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
|
||||
LEFT JOIN customer_mng cm
|
||||
ON cm.customer_code = si.partner_id
|
||||
AND cm.company_code = si.company_code
|
||||
LEFT JOIN item_info ii
|
||||
ON ii.item_number = sid.item_code
|
||||
AND ii.company_code = sid.company_code
|
||||
WHERE ${whereClause}
|
||||
ORDER BY si.instruction_date DESC, si.instruction_no
|
||||
LIMIT ${limit} OFFSET ${offset}`,
|
||||
@@ -1240,6 +1299,88 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// 불량입고용: 실제 출고 완료(outbound_mng) 데이터 조회
|
||||
// outbound_status IN ('출고완료', '부분출고') 만 노출
|
||||
export async function getOutbounds(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, page, pageSize } = req.query;
|
||||
const currentPage = Math.max(1, Number(page) || 1);
|
||||
const limit = Math.min(500, Math.max(1, Number(pageSize) || 50));
|
||||
const offset = (currentPage - 1) * limit;
|
||||
|
||||
const conditions: string[] = [
|
||||
"om.company_code = $1",
|
||||
"om.outbound_status IN ('출고완료', '부분출고')",
|
||||
];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx})`,
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const pool = getPool();
|
||||
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*) AS total
|
||||
FROM outbound_mng om
|
||||
WHERE ${whereClause}`,
|
||||
params,
|
||||
);
|
||||
const totalCount = parseInt(countResult.rows[0].total, 10);
|
||||
|
||||
const dataResult = await pool.query(
|
||||
`SELECT
|
||||
om.id AS outbound_id,
|
||||
om.outbound_number,
|
||||
om.outbound_date,
|
||||
om.customer_code,
|
||||
COALESCE(cm.customer_name, om.customer_name, om.customer_code) AS customer_name,
|
||||
om.outbound_status,
|
||||
om.item_code,
|
||||
om.item_name,
|
||||
om.specification AS spec,
|
||||
om.material,
|
||||
COALESCE(NULLIF(om.outbound_qty, '')::numeric, 0) AS outbound_qty,
|
||||
COALESCE(sid_match.order_qty, 0) AS sales_order_qty,
|
||||
(COALESCE(sid_match.order_qty, 0)
|
||||
- COALESCE(NULLIF(om.outbound_qty, '')::numeric, 0)) AS remain_qty,
|
||||
om.source_type,
|
||||
NULLIF(om.unit, '') AS unit
|
||||
FROM outbound_mng om
|
||||
LEFT JOIN customer_mng cm
|
||||
ON cm.customer_code = om.customer_code
|
||||
AND cm.company_code = om.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(sid.order_qty, 0) AS order_qty
|
||||
FROM shipment_instruction si
|
||||
JOIN shipment_instruction_detail sid
|
||||
ON sid.instruction_id = si.id
|
||||
AND sid.company_code = si.company_code
|
||||
WHERE si.company_code = om.company_code
|
||||
AND si.instruction_no = om.reference_number
|
||||
AND sid.item_code = om.item_code
|
||||
LIMIT 1
|
||||
) sid_match ON true
|
||||
WHERE ${whereClause}
|
||||
ORDER BY om.outbound_date DESC, om.outbound_number DESC
|
||||
LIMIT ${limit} OFFSET ${offset}`,
|
||||
params,
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: dataResult.rows, totalCount });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 데이터 조회 실패(불량입고용)", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 기타입고용: 품목 데이터 조회
|
||||
export async function getItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
@@ -1342,6 +1483,7 @@ export async function getProductionResults(
|
||||
COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name,
|
||||
COALESCE(ii.size, '') AS spec,
|
||||
COALESCE(ii.material, '') AS material,
|
||||
NULLIF(ii.unit, '') AS unit,
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) AS good_qty,
|
||||
COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS concession_qty,
|
||||
COALESCE(wr.is_rework, '') AS is_rework,
|
||||
@@ -1360,7 +1502,10 @@ export async function getProductionResults(
|
||||
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
|
||||
) THEN 'self' ELSE NULL END AS inspection_type,
|
||||
tp_agg.packages,
|
||||
tl.loading_code,
|
||||
tl.loading_name
|
||||
FROM work_order_process_result wr
|
||||
JOIN work_order_process wop
|
||||
ON wop.id = wr.wop_id
|
||||
@@ -1370,7 +1515,7 @@ export async function getProductionResults(
|
||||
AND wi.company_code = wop.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (id, company_code)
|
||||
id, item_number, item_name, size, material, image, company_code
|
||||
id, item_number, item_name, size, material, unit, 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
|
||||
@@ -1410,6 +1555,37 @@ export async function getProductionResults(
|
||||
AND CAST(wop3.seq_no AS int) < CAST(wop.seq_no AS int)
|
||||
)
|
||||
) prev ON true
|
||||
LEFT JOIN (
|
||||
SELECT packed.source_id, packed.company_code,
|
||||
JSON_AGG(JSON_BUILD_OBJECT(
|
||||
'pkg_code', packed.pkg_code,
|
||||
'pkg_name', COALESCE(pu.pkg_name, packed.pkg_code),
|
||||
'count', packed.cnt,
|
||||
'qty_per_unit', packed.qty_per_unit
|
||||
)) AS packages
|
||||
FROM (
|
||||
SELECT source_id, company_code, pkg_code,
|
||||
CAST(quantity AS numeric) AS qty_per_unit,
|
||||
COUNT(*)::int AS cnt
|
||||
FROM transaction_packaging
|
||||
WHERE company_code = $1
|
||||
AND source_type = 'work_order_process_result'
|
||||
GROUP BY source_id, company_code, pkg_code, quantity
|
||||
) packed
|
||||
LEFT JOIN pkg_unit pu
|
||||
ON pu.pkg_code = packed.pkg_code
|
||||
AND pu.company_code = packed.company_code
|
||||
GROUP BY packed.source_id, packed.company_code
|
||||
) tp_agg ON tp_agg.source_id = wr.id AND tp_agg.company_code = wr.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (source_doc_id, company_code)
|
||||
source_doc_id, company_code,
|
||||
loading_code, loading_name
|
||||
FROM transaction_loading
|
||||
WHERE company_code = $1
|
||||
AND source_type = 'work_order_process_result'
|
||||
ORDER BY source_doc_id, company_code, COALESCE(loading_seq, 1) ASC
|
||||
) tl ON tl.source_doc_id = wr.id AND tl.company_code = wr.company_code
|
||||
WHERE wr.company_code = $1
|
||||
AND wop.process_code = $2
|
||||
AND (
|
||||
|
||||
@@ -147,12 +147,19 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
COALESCE(itm.item_name, '') AS item_name,
|
||||
COALESCE(itm.type, '') AS item_type,
|
||||
COALESCE(itm.size, '') AS item_spec,
|
||||
NULLIF(itm.unit, '') AS item_unit,
|
||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||
COALESCE(e.equipment_code, '') AS equipment_code,
|
||||
wi.routing AS routing_version_id,
|
||||
COALESCE(rv.version_name, '') AS routing_name,
|
||||
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq,
|
||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count,
|
||||
EXISTS (
|
||||
SELECT 1 FROM work_order_process_result wopr
|
||||
JOIN work_order_process wop ON wop.id = wopr.wop_id AND wop.company_code = wopr.company_code
|
||||
WHERE wop.company_code = wi.company_code
|
||||
AND (wop.batch_id = d.id OR wop.batch_id = d.item_number)
|
||||
) AS is_locked
|
||||
FROM work_instruction wi
|
||||
INNER JOIN work_instruction_detail d
|
||||
ON d.work_instruction_id = wi.id
|
||||
@@ -210,12 +217,19 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
COALESCE(itm.item_name, '') AS item_name,
|
||||
COALESCE(itm.type, '') AS item_type,
|
||||
COALESCE(itm.size, '') AS item_spec,
|
||||
NULLIF(itm.unit, '') AS item_unit,
|
||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||
COALESCE(e.equipment_code, '') AS equipment_code,
|
||||
wi.routing AS routing_version_id,
|
||||
COALESCE(rv.version_name, '') AS routing_name,
|
||||
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq,
|
||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count,
|
||||
EXISTS (
|
||||
SELECT 1 FROM work_order_process_result wopr
|
||||
JOIN work_order_process wop ON wop.id = wopr.wop_id AND wop.company_code = wopr.company_code
|
||||
WHERE wop.company_code = wi.company_code
|
||||
AND (wop.batch_id = d.id OR wop.batch_id = d.item_number)
|
||||
) AS is_locked
|
||||
FROM work_instruction wi
|
||||
INNER JOIN work_instruction_detail d
|
||||
ON d.work_instruction_id = wi.id
|
||||
@@ -281,15 +295,139 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
let wiNo: string;
|
||||
|
||||
if (editId) {
|
||||
// ─── 수정 모드: row별 UPDATE/INSERT/DELETE + 잠금 가드 ───
|
||||
const check = await client.query(`SELECT id, work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode]);
|
||||
if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다");
|
||||
wiId = editId;
|
||||
wiNo = check.rows[0].work_instruction_no;
|
||||
|
||||
// 1) 기존 detail rows + 잠금 상태 (해당 batch에 wop_result 1건이라도 있으면 잠김)
|
||||
const existingRes = await client.query(
|
||||
`SELECT wid.id, wid.item_number, wid.qty, wid.routing_version_id,
|
||||
EXISTS (
|
||||
SELECT 1 FROM work_order_process_result wopr
|
||||
JOIN work_order_process wop ON wop.id = wopr.wop_id AND wop.company_code = wopr.company_code
|
||||
WHERE wop.company_code = $2
|
||||
AND (wop.batch_id = wid.id OR wop.batch_id = wid.item_number)
|
||||
) AS is_locked
|
||||
FROM work_instruction_detail wid
|
||||
WHERE wid.work_instruction_id = $1`,
|
||||
[wiId, companyCode]
|
||||
);
|
||||
const existingMap = new Map<string, { id: string; item_number: string; qty: string; routing_version_id: string | null; is_locked: boolean }>();
|
||||
for (const row of existingRes.rows) {
|
||||
existingMap.set(String(row.id), {
|
||||
id: String(row.id),
|
||||
item_number: String(row.item_number || ""),
|
||||
qty: String(row.qty || ""),
|
||||
routing_version_id: row.routing_version_id ?? null,
|
||||
is_locked: row.is_locked === true,
|
||||
});
|
||||
}
|
||||
|
||||
// 2) payload items 분류 (detailId 매칭 → UPDATE, 없음 → INSERT, 누락 → DELETE)
|
||||
const payloadIds = new Set<string>();
|
||||
const updates: Array<{ item: any; detailId: string; locked: boolean }> = [];
|
||||
const inserts: any[] = [];
|
||||
for (const item of items) {
|
||||
const did = item.detailId ? String(item.detailId) : "";
|
||||
if (did && existingMap.has(did)) {
|
||||
payloadIds.add(did);
|
||||
updates.push({ item, detailId: did, locked: existingMap.get(did)!.is_locked });
|
||||
} else {
|
||||
inserts.push(item);
|
||||
}
|
||||
}
|
||||
const deleteIds: string[] = [];
|
||||
for (const id of existingMap.keys()) {
|
||||
if (!payloadIds.has(id)) deleteIds.push(id);
|
||||
}
|
||||
|
||||
// 3) 잠금 가드 — UPDATE: 잠긴 row의 품목/수량/라우팅 변경 거부
|
||||
for (const u of updates) {
|
||||
if (!u.locked) continue;
|
||||
const orig = existingMap.get(u.detailId)!;
|
||||
const reqItem = String(u.item.itemNumber || u.item.itemCode || "");
|
||||
const reqQty = String(u.item.qty ?? "");
|
||||
const reqRouting = String(u.item.routing ?? "");
|
||||
if (reqItem && reqItem !== orig.item_number) {
|
||||
throw new Error(`이미 생산접수된 row의 품목은 변경할 수 없습니다 (id=${u.detailId}, 기존=${orig.item_number})`);
|
||||
}
|
||||
if (reqQty !== "" && reqQty !== String(orig.qty)) {
|
||||
throw new Error(`이미 생산접수된 row의 수량은 변경할 수 없습니다 (id=${u.detailId})`);
|
||||
}
|
||||
if (reqRouting && reqRouting !== String(orig.routing_version_id || "")) {
|
||||
throw new Error(`이미 생산접수된 row의 라우팅은 변경할 수 없습니다 (id=${u.detailId})`);
|
||||
}
|
||||
}
|
||||
// 잠금 가드 — DELETE: 잠긴 row 삭제 거부
|
||||
for (const did of deleteIds) {
|
||||
if (existingMap.get(did)!.is_locked) {
|
||||
throw new Error(`이미 생산접수된 row는 삭제할 수 없습니다 (id=${did})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 헤더 UPDATE (qty는 마지막에 detail 합계로 재계산)
|
||||
await client.query(
|
||||
`UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, batch_no=COALESCE($11, batch_no), cutting_plan_id=COALESCE($12, cutting_plan_id), updated_date=NOW(), writer=$13 WHERE id=$14 AND company_code=$15`,
|
||||
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId, editId, companyCode]
|
||||
);
|
||||
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=$1`, [wiId]);
|
||||
|
||||
// 5) DELETE 처리 (process_work_result 마스터 체크리스트 → wop → detail 순)
|
||||
for (const did of deleteIds) {
|
||||
const orig = existingMap.get(did)!;
|
||||
await client.query(
|
||||
`DELETE FROM process_work_result
|
||||
WHERE work_order_process_id IN (
|
||||
SELECT id FROM work_order_process
|
||||
WHERE company_code = $1 AND (batch_id = $2 OR batch_id = $3)
|
||||
)`,
|
||||
[companyCode, did, orig.item_number]
|
||||
);
|
||||
await client.query(
|
||||
`DELETE FROM work_order_process
|
||||
WHERE company_code = $1 AND (batch_id = $2 OR batch_id = $3)`,
|
||||
[companyCode, did, orig.item_number]
|
||||
);
|
||||
await client.query(`DELETE FROM work_instruction_detail WHERE id = $1`, [did]);
|
||||
}
|
||||
|
||||
// 6) UPDATE 처리 (잠금: 일정/설비/작업자만, 비잠금: 전체)
|
||||
for (const u of updates) {
|
||||
if (u.locked) {
|
||||
await client.query(
|
||||
`UPDATE work_instruction_detail SET start_date=$1, end_date=$2, equipment_ids=$3, work_teams=$4, workers=$5, remark=$6, updated_date=NOW(), writer=$7 WHERE id=$8`,
|
||||
[u.item.startDate||"", u.item.endDate||"", u.item.equipmentIds||"", u.item.workTeams||"", u.item.workers||"", u.item.remark||"", userId, u.detailId]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`UPDATE work_instruction_detail SET item_number=$1, qty=$2, remark=$3, source_table=$4, source_id=$5, part_code=$6, routing_version_id=$7, start_date=$8, end_date=$9, equipment_ids=$10, work_teams=$11, workers=$12, updated_date=NOW(), writer=$13 WHERE id=$14`,
|
||||
[u.item.itemNumber||u.item.itemCode||"", u.item.qty||"0", u.item.remark||"", u.item.sourceTable||"", u.item.sourceId||"", u.item.partCode||u.item.itemNumber||u.item.itemCode||"", u.item.routing||null, u.item.startDate||"", u.item.endDate||"", u.item.equipmentIds||"", u.item.workTeams||"", u.item.workers||"", userId, u.detailId]
|
||||
);
|
||||
// 비잠금 row의 wop plan_qty 동기화
|
||||
await client.query(
|
||||
`UPDATE work_order_process SET plan_qty=$1, updated_date=NOW() WHERE company_code=$2 AND batch_id=$3`,
|
||||
[u.item.qty||"0", companyCode, u.detailId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 7) INSERT 처리 (신규 detail — wop는 다음 POP 진입 시 sync가 자동 생성)
|
||||
for (const item of inserts) {
|
||||
await client.query(
|
||||
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,clock_timestamp(),$16)`,
|
||||
[companyCode, wiNo, wiId, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", item.routing||null, item.startDate||"", item.endDate||"", item.equipmentIds||"", item.workTeams||"", item.workers||"", userId]
|
||||
);
|
||||
}
|
||||
|
||||
// 8) 헤더 qty 자동 동기화 (detail 합계)
|
||||
await client.query(
|
||||
`UPDATE work_instruction SET qty = COALESCE((SELECT SUM(CAST(NULLIF(qty, '') AS numeric))::text FROM work_instruction_detail WHERE work_instruction_id = $1), '0') WHERE id = $1`,
|
||||
[wiId]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } });
|
||||
} else {
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
|
||||
@@ -1038,3 +1176,56 @@ export async function getBomBaseQtyMap(req: AuthenticatedRequest, res: Response)
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 작업지시 공정별 실적 (생산실적 우측 패널용)
|
||||
// work_order_process LEFT JOIN work_order_process_result, 카드(result) 단위로 flat 반환.
|
||||
// 공정에 카드가 없으면 wr.* 컬럼들이 모두 NULL 인 한 행을 반환.
|
||||
export async function getProcessResults(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { wiId } = req.params;
|
||||
if (!wiId) return res.status(400).json({ success: false, message: "wiId는 필수입니다." });
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
wop.id AS wop_id,
|
||||
wop.wo_id,
|
||||
wop.seq_no AS process_seq_no,
|
||||
wop.process_code,
|
||||
wop.process_name,
|
||||
wr.id AS result_id,
|
||||
wr.seq AS result_seq,
|
||||
wr.equipment_code,
|
||||
wr.input_qty,
|
||||
wr.good_qty,
|
||||
wr.defect_qty,
|
||||
wr.concession_qty,
|
||||
wr.total_production_qty,
|
||||
wr.started_at,
|
||||
wr.completed_at,
|
||||
wr.status,
|
||||
wr.result_status,
|
||||
wr.result_note,
|
||||
wr.defect_detail,
|
||||
wr.is_rework,
|
||||
wr.rework_source_id
|
||||
FROM work_order_process wop
|
||||
LEFT JOIN work_order_process_result wr
|
||||
ON wr.wop_id = wop.id
|
||||
AND wr.company_code = wop.company_code
|
||||
WHERE wop.wo_id = $1 AND wop.company_code = $2
|
||||
ORDER BY
|
||||
CASE WHEN wop.seq_no::text ~ '^[0-9]+$' THEN wop.seq_no::text::int ELSE NULL END NULLS LAST,
|
||||
wop.seq_no::text,
|
||||
CASE WHEN wr.seq::text ~ '^[0-9]+$' THEN wr.seq::text::int ELSE NULL END NULLS LAST,
|
||||
wr.seq::text`,
|
||||
[wiId, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("작업지시 공정 실적 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user