Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-03-30 17:02:59 +09:00
commit d25a9ce898
6 changed files with 450 additions and 160 deletions

View File

@ -218,37 +218,84 @@ export async function getPurchaseReportData(req: any, res: Response): Promise<vo
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
// company_code 필터 파라미터 ($1 또는 없음)
const cf = buildCompanyFilter(companyCode, "po", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
let companyConditionDetail = "";
let companyConditionLegacy = "";
if (cf.condition) {
// purchase_detail 쪽: pd.company_code
companyConditionDetail = `pd.company_code = $${idx}`;
// purchase_order_mng 쪽: po.company_code
companyConditionLegacy = `po.company_code = $${idx}`;
// NOT EXISTS 내부에서도 동일 파라미터 재사용
params.push(...cf.params);
idx = cf.nextIdx;
}
const df = buildDateFilter(startDate, endDate, "COALESCE(po.order_date, po.created_date::date::text)", idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
// 날짜 필터는 외부 쿼리에서 적용
const outerConditions: string[] = [];
const df = buildDateFilter(startDate, endDate, "date", idx);
outerConditions.push(...df.conditions);
params.push(...df.params);
idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
const outerWhereClause = buildWhereClause(outerConditions);
const dataQuery = `
SELECT
COALESCE(po.order_date, po.created_date::date::text) as date,
po.purchase_no,
COALESCE(po.supplier_name, po.supplier_code, '미지정') as supplier,
COALESCE(po.item_name, po.item_code, '미지정') as item,
po.item_code,
COALESCE(po.manager, '미지정') as manager,
po.status,
CAST(COALESCE(NULLIF(po.order_qty, ''), '0') AS numeric) as "orderQty",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "unitPrice",
CAST(COALESCE(NULLIF(po.amount, ''), '0') AS numeric) as "orderAmt",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
* CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "receiveAmt",
1 as "orderCnt",
po.company_code
FROM purchase_order_mng po
${whereClause}
WITH combined AS (
-- 신규: purchase_detail ( purchase_order_mng LEFT JOIN)
SELECT
COALESCE(po.order_date, po.created_date::date::text, pd.created_date::date::text) as date,
COALESCE(po.purchase_no, pd.purchase_no) as purchase_no,
COALESCE(pd.supplier_name, pd.supplier_code, po.supplier_name, po.supplier_code, '미지정') as supplier,
COALESCE(NULLIF(pd.item_name, ''), po.item_name, NULLIF(pd.item_code, ''), po.item_code, '미지정') as item,
COALESCE(NULLIF(pd.item_code, ''), po.item_code) as item_code,
COALESCE(po.manager, '미지정') as manager,
COALESCE(po.status, '') as status,
CAST(COALESCE(NULLIF(pd.order_qty, ''), '0') AS numeric) as "orderQty",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
CAST(COALESCE(NULLIF(pd.unit_price, ''), '0') AS numeric) as "unitPrice",
CAST(COALESCE(NULLIF(pd.order_qty, ''), '0') AS numeric)
* CAST(COALESCE(NULLIF(pd.unit_price, ''), '0') AS numeric) as "orderAmt",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
* CAST(COALESCE(NULLIF(pd.unit_price, ''), '0') AS numeric) as "receiveAmt",
1 as "orderCnt",
pd.company_code
FROM purchase_detail pd
LEFT JOIN purchase_order_mng po
ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code
${companyConditionDetail ? `WHERE ${companyConditionDetail}` : ""}
UNION ALL
-- 레거시: purchase_detail에 purchase_order_mng
SELECT
COALESCE(po.order_date, po.created_date::date::text) as date,
po.purchase_no,
COALESCE(po.supplier_name, po.supplier_code, '미지정') as supplier,
COALESCE(po.item_name, po.item_code, '미지정') as item,
po.item_code,
COALESCE(po.manager, '미지정') as manager,
po.status,
CAST(COALESCE(NULLIF(po.order_qty, ''), '0') AS numeric) as "orderQty",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "unitPrice",
CAST(COALESCE(NULLIF(po.amount, ''), '0') AS numeric) as "orderAmt",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
* CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "receiveAmt",
1 as "orderCnt",
po.company_code
FROM purchase_order_mng po
WHERE ${companyConditionLegacy ? `${companyConditionLegacy} AND ` : ""}NOT EXISTS (
SELECT 1 FROM purchase_detail pd
WHERE pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code
)
)
SELECT * FROM combined
${outerWhereClause}
ORDER BY date DESC NULLS LAST
`;

View File

@ -1,6 +1,6 @@
/**
*
* - ()
* - (work_instruction + work_instruction_detail)
* - BOM +
* -
*/
@ -10,7 +10,7 @@ import { AuthenticatedRequest } from "../types/auth";
import { pool } from "../database/db";
import { logger } from "../utils/logger";
// ─── 생산계획(작업지시) 조회 ───
// ─── 작업지시 조회 (work_instruction + work_instruction_detail) ───
export async function getWorkOrders(
req: AuthenticatedRequest,
@ -27,31 +27,31 @@ export async function getWorkOrders(
if (companyCode === "*") {
logger.info("최고 관리자 전체 작업지시 조회");
} else {
conditions.push(`p.company_code = $${paramIndex}`);
conditions.push(`wi.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (dateFrom) {
conditions.push(`p.plan_date >= $${paramIndex}::date`);
conditions.push(`wi.start_date::date >= $${paramIndex}::date`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
conditions.push(`p.plan_date <= $${paramIndex}::date`);
conditions.push(`wi.start_date::date <= $${paramIndex}::date`);
params.push(dateTo);
paramIndex++;
}
if (itemCode) {
conditions.push(`p.item_code ILIKE $${paramIndex}`);
conditions.push(`d.item_number ILIKE $${paramIndex}`);
params.push(`%${itemCode}%`);
paramIndex++;
}
if (itemName) {
conditions.push(`p.item_name ILIKE $${paramIndex}`);
conditions.push(`COALESCE(itm.item_name, '') ILIKE $${paramIndex}`);
params.push(`%${itemName}%`);
paramIndex++;
}
@ -60,22 +60,28 @@ export async function getWorkOrders(
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
p.id,
p.plan_no,
p.item_code,
p.item_name,
p.plan_qty,
p.completed_qty,
p.plan_date,
p.start_date,
p.end_date,
p.status,
p.work_order_no,
p.company_code
FROM production_plan_mng p
SELECT
d.id,
wi.work_instruction_no AS plan_no,
d.item_number AS item_code,
COALESCE(itm.item_name, '') AS item_name,
d.qty AS plan_qty,
wi.completed_qty,
wi.start_date AS plan_date,
wi.start_date,
wi.end_date,
wi.status,
wi.work_instruction_no AS work_order_no,
wi.company_code
FROM work_instruction wi
INNER JOIN work_instruction_detail d
ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
) itm ON true
${whereClause}
ORDER BY p.plan_date DESC, p.created_date DESC
ORDER BY wi.start_date DESC, wi.created_date DESC
`;
const result = await pool.query(query, params);
@ -108,14 +114,14 @@ export async function getMaterialStatus(
.json({ success: false, message: "작업지시를 선택해주세요." });
}
// 1) 선택된 작업지시의 품목코드 + 수량 조회
// 1) 선택된 작업지시 상세의 품목코드 + 수량 조회
const planPlaceholders = planIds
.map((_, i) => `$${i + 1}`)
.join(",");
let paramIndex = planIds.length + 1;
const companyCondition =
companyCode === "*" ? "" : `AND p.company_code = $${paramIndex}`;
companyCode === "*" ? "" : `AND d.company_code = $${paramIndex}`;
const planParams: any[] = [...planIds];
if (companyCode !== "*") {
planParams.push(companyCode);
@ -123,9 +129,13 @@ export async function getMaterialStatus(
}
const planQuery = `
SELECT p.item_code, p.item_name, p.plan_qty
FROM production_plan_mng p
WHERE p.id IN (${planPlaceholders})
SELECT d.item_number AS item_code, COALESCE(itm.item_name, '') AS item_name, d.qty AS plan_qty
FROM work_instruction_detail d
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = d.item_number AND company_code = d.company_code LIMIT 1
) itm ON true
WHERE d.id IN (${planPlaceholders})
${companyCondition}
`;

View File

@ -12,7 +12,7 @@ import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// 입고 목록 조회
// 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환)
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
@ -50,7 +50,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
if (search_keyword) {
conditions.push(
`(im.inbound_number ILIKE $${paramIdx} OR im.item_name ILIKE $${paramIdx} OR im.item_number ILIKE $${paramIdx} OR im.supplier_name ILIKE $${paramIdx} OR im.reference_number ILIKE $${paramIdx})`
`(im.inbound_number ILIKE $${paramIdx} OR COALESCE(id.item_name, im.item_name) ILIKE $${paramIdx} OR COALESCE(id.item_number, im.item_number) ILIKE $${paramIdx} OR COALESCE(id.supplier_name, im.supplier_name) ILIKE $${paramIdx} OR COALESCE(id.reference_number, im.reference_number) ILIKE $${paramIdx})`
);
params.push(`%${search_keyword}%`);
paramIdx++;
@ -72,14 +72,37 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
const query = `
SELECT
im.*,
im.id, im.company_code, im.inbound_number, im.inbound_type, im.inbound_date,
im.warehouse_code, im.location_code, im.inspector, im.manager,
im.inbound_status, im.memo AS header_memo, im.source_table, im.source_id,
im.created_date, im.created_by, im.updated_date, im.updated_by,
im.writer, im.status, im.prev_inbound_qty, im.remark,
COALESCE(id.reference_number, im.reference_number) AS reference_number,
COALESCE(id.supplier_code, im.supplier_code) AS supplier_code,
COALESCE(id.supplier_name, im.supplier_name) AS supplier_name,
COALESCE(id.item_number, im.item_number) AS item_number,
COALESCE(id.item_name, im.item_name) AS item_name,
COALESCE(id.spec, im.spec) AS spec,
COALESCE(id.material, im.material) AS material,
COALESCE(id.unit, im.unit) AS unit,
COALESCE(id.inbound_qty, im.inbound_qty) AS inbound_qty,
COALESCE(id.unit_price, im.unit_price) AS unit_price,
COALESCE(id.total_amount, im.total_amount) AS total_amount,
COALESCE(id.lot_number, im.lot_number) AS lot_number,
COALESCE(id.inspection_status, im.inspection_status) AS inspection_status,
COALESCE(id.memo, im.memo) AS memo,
id.id AS detail_id,
id.seq_no,
id.inbound_type AS detail_inbound_type,
wh.warehouse_name
FROM inbound_mng im
LEFT JOIN inbound_detail id
ON id.inbound_id = im.inbound_number AND id.company_code = im.company_code
LEFT JOIN warehouse_info wh
ON im.warehouse_code = wh.warehouse_code
AND im.company_code = wh.company_code
${whereClause}
ORDER BY im.created_date DESC
ORDER BY im.created_date DESC, id.seq_no ASC
`;
const pool = getPool();
@ -97,7 +120,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
}
}
// 입고 등록 (건)
// 입고 등록 (헤더 1건 + 디테일 N건)
export async function create(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
@ -111,41 +134,70 @@ export async function create(req: AuthenticatedRequest, res: Response) {
return res.status(400).json({ success: false, message: "입고 품목이 없습니다." });
}
// 첫 번째 아이템에서 inbound_type 추출 (헤더용)
const inboundType = items[0].inbound_type || null;
const inboundNumber = inbound_number || items[0].inbound_number;
await client.query("BEGIN");
const insertedRows: any[] = [];
// 1. 헤더 INSERT (inbound_mng) — 품목 컬럼은 NULL
const headerResult = await client.query(
`INSERT INTO inbound_mng (
id, company_code, inbound_number, inbound_type, inbound_date,
warehouse_code, location_code,
inbound_status, inspector, manager, memo,
created_date, created_by, writer, status
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4::date,
$5, $6,
$7, $8, $9, $10,
NOW(), $11, $11, '입고'
) RETURNING *`,
[
companyCode,
inboundNumber,
inboundType,
inbound_date || items[0].inbound_date,
warehouse_code || items[0].warehouse_code || null,
location_code || items[0].location_code || null,
items[0].inbound_status || "대기",
inspector || items[0].inspector || null,
manager || items[0].manager || null,
memo || items[0].memo || null,
userId,
]
);
for (const item of items) {
const result = await client.query(
`INSERT INTO inbound_mng (
company_code, inbound_number, inbound_type, inbound_date,
reference_number, supplier_code, supplier_name,
const headerRow = headerResult.rows[0];
const insertedDetails: any[] = [];
// 2. 디테일 INSERT (inbound_detail) + 재고/발주 업데이트
for (let i = 0; i < items.length; i++) {
const item = items[i];
const seqNo = i + 1;
// 2a. inbound_detail INSERT
const detailResult = await client.query(
`INSERT INTO inbound_detail (
id, company_code, inbound_id, seq_no, inbound_type,
item_number, item_name, spec, material, unit,
inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, manager, memo,
source_table, source_id,
lot_number, reference_number, supplier_code, supplier_name,
inspection_status, memo, item_id,
created_date, created_by, writer, status
) VALUES (
$1, $2, $3, $4::date,
$5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20,
$21, $22, $23,
$24, $25,
NOW(), $26, $26, '입고'
gen_random_uuid()::text, $1, $2, $3, $4,
$5, $6, $7, $8, $9,
$10, $11, $12,
$13, $14, $15, $16,
$17, $18, $19,
NOW(), $20, $20, '입고'
) RETURNING *`,
[
companyCode,
inbound_number || item.inbound_number,
item.inbound_type,
inbound_date || item.inbound_date,
item.reference_number || null,
item.supplier_code || null,
item.supplier_name || null,
inboundNumber,
seqNo,
item.inbound_type || inboundType,
item.item_number || null,
item.item_name || null,
item.spec || null,
@ -155,22 +207,19 @@ export async function create(req: AuthenticatedRequest, res: Response) {
item.unit_price || 0,
item.total_amount || 0,
item.lot_number || null,
warehouse_code || item.warehouse_code || null,
location_code || item.location_code || null,
item.inbound_status || "대기",
item.reference_number || null,
item.supplier_code || null,
item.supplier_name || null,
item.inspection_status || "대기",
inspector || item.inspector || null,
manager || item.manager || null,
memo || item.memo || null,
item.source_table || null,
item.source_id || null,
item.memo || null,
item.item_id || null,
userId,
]
);
insertedRows.push(result.rows[0]);
insertedDetails.push(detailResult.rows[0]);
// 재고 업데이트 (inventory_stock): 입고 수량 증가
// 2b. 재고 업데이트 (inventory_stock): 입고 수량 증가 — 기존 로직 유지
const itemCode = item.item_number || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
@ -206,7 +255,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
}
}
// 구매입고인 경우 발주의 received_qty 업데이트
// 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
await client.query(
`UPDATE purchase_order_mng
@ -229,29 +278,34 @@ export async function create(req: AuthenticatedRequest, res: Response) {
);
}
// 구매입고인 경우 purchase_detail 기반 발주의 헤더 상태 업데이트
// 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") {
// 해당 디테일의 발주번호 조회
// 1. 해당 purchase_detail의 received_qty 누적 업데이트
await client.query(
`UPDATE purchase_detail SET
received_qty = CAST(
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text
),
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode]
);
// 2. 발주 헤더 상태 업데이트
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode]
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
// 해당 발주의 모든 디테일 잔량 확인
// 잔량 있는 디테일이 있는지 확인
const unreceived = await client.query(
`SELECT pd.id
FROM purchase_detail pd
LEFT JOIN (
SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received
FROM inbound_mng
WHERE source_table = 'purchase_detail' AND company_code = $1
GROUP BY source_id
) r ON r.source_id = pd.id
WHERE pd.purchase_no = $2 AND pd.company_code = $1
AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(r.total_received, 0) > 0
`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`,
[companyCode, purchaseNo]
[purchaseNo, companyCode]
);
const newStatus = unreceived.rows.length === 0 ? '입고완료' : '부분입고';
await client.query(
@ -268,14 +322,15 @@ export async function create(req: AuthenticatedRequest, res: Response) {
logger.info("입고 등록 완료", {
companyCode,
userId,
count: insertedRows.length,
inbound_number,
headerCount: 1,
detailCount: insertedDetails.length,
inbound_number: inboundNumber,
});
return res.json({
success: true,
data: insertedRows,
message: `${insertedRows.length}건 입고 등록 완료`,
data: { header: headerRow, details: insertedDetails },
message: `${insertedDetails.length}건 입고 등록 완료`,
});
} catch (error: any) {
await client.query("ROLLBACK");
@ -286,8 +341,11 @@ export async function create(req: AuthenticatedRequest, res: Response) {
}
}
// 입고 수정
// 입고 수정 (헤더 + 디테일 분리 업데이트)
export async function update(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
@ -297,71 +355,253 @@ export async function update(req: AuthenticatedRequest, res: Response) {
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, manager: mgr, memo,
detail_id,
} = req.body;
const pool = getPool();
const result = await pool.query(
await client.query("BEGIN");
// 헤더 업데이트 (inbound_mng) — 헤더 레벨 필드만
const headerResult = await client.query(
`UPDATE inbound_mng SET
inbound_date = COALESCE($1::date, inbound_date),
inbound_qty = COALESCE($2, inbound_qty),
unit_price = COALESCE($3, unit_price),
total_amount = COALESCE($4, total_amount),
lot_number = COALESCE($5, lot_number),
warehouse_code = COALESCE($6, warehouse_code),
location_code = COALESCE($7, location_code),
inbound_status = COALESCE($8, inbound_status),
inspection_status = COALESCE($9, inspection_status),
inspector = COALESCE($10, inspector),
manager = COALESCE($11, manager),
memo = COALESCE($12, memo),
warehouse_code = COALESCE($2, warehouse_code),
location_code = COALESCE($3, location_code),
inbound_status = COALESCE($4, inbound_status),
inspector = COALESCE($5, inspector),
manager = COALESCE($6, manager),
memo = COALESCE($7, memo),
updated_date = NOW(),
updated_by = $13
WHERE id = $14 AND company_code = $15
updated_by = $8
WHERE id = $9 AND company_code = $10
RETURNING *`,
[
inbound_date, inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, mgr, memo,
inbound_date, warehouse_code, location_code,
inbound_status, inspector, mgr, memo,
userId, id, companyCode,
]
);
if (result.rowCount === 0) {
if (headerResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
}
logger.info("입고 수정", { companyCode, userId, id });
// 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트
let detailRow = null;
if (detail_id) {
const detailResult = await client.query(
`UPDATE inbound_detail SET
inbound_qty = COALESCE($1, inbound_qty),
unit_price = COALESCE($2, unit_price),
total_amount = COALESCE($3, total_amount),
lot_number = COALESCE($4, lot_number),
inspection_status = COALESCE($5, inspection_status),
memo = COALESCE($6, memo),
updated_date = NOW(),
updated_by = $7
WHERE id = $8 AND company_code = $9
RETURNING *`,
[
inbound_qty, unit_price, total_amount,
lot_number, inspection_status, memo,
userId, detail_id, companyCode,
]
);
detailRow = detailResult.rows[0] || null;
} else {
// 레거시 데이터: detail_id 없이 inbound_mng 자체에 품목 정보 업데이트
await client.query(
`UPDATE inbound_mng SET
inbound_qty = COALESCE($1, inbound_qty),
unit_price = COALESCE($2, unit_price),
total_amount = COALESCE($3, total_amount),
lot_number = COALESCE($4, lot_number),
inspection_status = COALESCE($5, inspection_status)
WHERE id = $6 AND company_code = $7`,
[
inbound_qty, unit_price, total_amount,
lot_number, inspection_status,
id, companyCode,
]
);
}
return res.json({ success: true, data: result.rows[0] });
await client.query("COMMIT");
logger.info("입고 수정", { companyCode, userId, id, detail_id });
return res.json({
success: true,
data: { header: headerResult.rows[0], detail: detailRow },
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("입고 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 입고 삭제
// 입고 삭제 (헤더 + 디테일, 재고/발주 롤백 포함)
export async function deleteReceiving(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
await client.query("BEGIN");
// 헤더 정보 조회 (inbound_number, warehouse_code 등)
const headerResult = await client.query(
`SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (result.rowCount === 0) {
if (headerResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
logger.info("입고 삭제", { companyCode, id });
const header = headerResult.rows[0];
const inboundNumber = header.inbound_number;
// 디테일 조회 (재고/발주 롤백용)
const detailResult = await client.query(
`SELECT * FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`,
[inboundNumber, companyCode]
);
// 디테일이 있으면 디테일 기반으로 롤백, 없으면 헤더(레거시) 기반으로 롤백
const rollbackItems = detailResult.rows.length > 0
? detailResult.rows.map((d: any) => ({
item_number: d.item_number,
inbound_qty: d.inbound_qty,
inbound_type: d.inbound_type || header.inbound_type,
source_table: header.source_table,
source_id: header.source_id,
}))
: [{
item_number: header.item_number,
inbound_qty: header.inbound_qty,
inbound_type: header.inbound_type,
source_table: header.source_table,
source_id: header.source_id,
}];
const whCode = header.warehouse_code || null;
const locCode = header.location_code || null;
for (const item of rollbackItems) {
const itemCode = item.item_number || null;
const inQty = Number(item.inbound_qty) || 0;
// 재고 롤백: 입고 수량만큼 차감
if (itemCode && inQty > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text
),
updated_date = NOW()
WHERE company_code = $2 AND item_code = $3
AND COALESCE(warehouse_code, '') = COALESCE($4, '')
AND COALESCE(location_code, '') = COALESCE($5, '')`,
[inQty, companyCode, itemCode, whCode || '', locCode || '']
);
}
// 구매입고 발주 롤백: purchase_order_mng 기반
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "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(
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text
),
status = CASE
WHEN GREATEST(COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) <= 0
THEN '발주확정'
ELSE '부분입고'
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[inQty, item.source_id, companyCode]
);
}
// 구매입고 발주 롤백: purchase_detail 기반
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") {
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode]
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
// 삭제 후 재계산을 위해 현재 입고 건 제외한 미입고 확인
const unreceived = await client.query(
`SELECT pd.id
FROM purchase_detail pd
LEFT JOIN (
SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received
FROM inbound_mng
WHERE source_table = 'purchase_detail' AND company_code = $1
AND inbound_number != $3
GROUP BY source_id
) r ON r.source_id = pd.id
WHERE pd.purchase_no = $2 AND pd.company_code = $1
AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(r.total_received, 0) > 0
LIMIT 1`,
[companyCode, purchaseNo, inboundNumber]
);
// 잔량 있으면 부분입고, 전량 미입고면 발주확정
const hasAnyReceived = await client.query(
`SELECT 1 FROM inbound_mng
WHERE source_table = 'purchase_detail' AND company_code = $1
AND inbound_number != $2
LIMIT 1`,
[companyCode, inboundNumber]
);
const newStatus = hasAnyReceived.rows.length > 0
? (unreceived.rows.length === 0 ? '입고완료' : '부분입고')
: '발주확정';
await client.query(
`UPDATE purchase_order_mng SET status = $1, updated_date = NOW()
WHERE purchase_no = $2 AND company_code = $3`,
[newStatus, purchaseNo, companyCode]
);
}
}
}
// 디테일 삭제
await client.query(
`DELETE FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`,
[inboundNumber, companyCode]
);
// 헤더 삭제
await client.query(
`DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
await client.query("COMMIT");
logger.info("입고 삭제", { companyCode, id, inboundNumber });
return res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("입고 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
@ -387,14 +627,8 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response
}
const baseQuery = `
WITH detail_received AS (
SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received
FROM inbound_mng
WHERE source_table = 'purchase_detail' AND company_code = $1
GROUP BY source_id
),
combined AS (
-- ( - , )
WITH combined AS (
-- (purchase_detail.received_qty로 )
SELECT
pd.id,
COALESCE(po.purchase_no, pd.purchase_no) AS purchase_no,
@ -406,8 +640,8 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response
COALESCE(NULLIF(pd.spec, ''), ii.size) AS spec,
COALESCE(NULLIF(pd.material, ''), ii.material) AS material,
COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(dr.total_received, 0) AS received_qty,
COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(dr.total_received, 0) AS remain_qty,
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,
COALESCE(po.status, '') AS status,
COALESCE(pd.due_date, po.due_date) AS due_date,
@ -416,9 +650,8 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response
LEFT JOIN purchase_order_mng po
ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code
LEFT JOIN item_info ii ON pd.item_id = ii.id
LEFT JOIN detail_received dr ON dr.source_id = pd.id
WHERE pd.company_code = $1
AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(dr.total_received, 0) > 0
AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) > 0
AND COALESCE(pd.approval_status, '') NOT IN ('반려')
AND COALESCE(po.status, '') NOT IN ('입고완료', '취소')
${keywordConditionDetail}

View File

@ -81,8 +81,8 @@ export default function MaterialStatusPage() {
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<number[]>([]);
const [selectedWoId, setSelectedWoId] = useState<number | null>(null);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
const [warehouse, setWarehouse] = useState("");
@ -137,13 +137,13 @@ export default function MaterialStatusPage() {
[workOrders]
);
const handleCheckWo = useCallback((id: number, checked: boolean) => {
const handleCheckWo = useCallback((id: string, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: number) => {
const handleSelectWo = useCallback((id: string) => {
setSelectedWoId((prev) => (prev === id ? null : id));
}, []);

View File

@ -81,8 +81,8 @@ export default function MaterialStatusPage() {
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<number[]>([]);
const [selectedWoId, setSelectedWoId] = useState<number | null>(null);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
const [warehouse, setWarehouse] = useState("");
@ -137,13 +137,13 @@ export default function MaterialStatusPage() {
[workOrders]
);
const handleCheckWo = useCallback((id: number, checked: boolean) => {
const handleCheckWo = useCallback((id: string, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: number) => {
const handleSelectWo = useCallback((id: string) => {
setSelectedWoId((prev) => (prev === id ? null : id));
}, []);

View File

@ -5,7 +5,7 @@
import { apiClient } from "./client";
export interface WorkOrder {
id: number;
id: string;
plan_no: string;
item_code: string;
item_name: string;
@ -69,7 +69,7 @@ export async function getWorkOrders(params: {
}
export async function getMaterialStatus(params: {
planIds: number[];
planIds: string[];
warehouseCode?: string;
}): Promise<ApiResponse<MaterialData[]>> {
try {