Implement outbound management features with new routes and controller
- Added outbound management routes for listing, creating, updating, and deleting outbound records. - Introduced a new outbound controller to handle business logic for outbound operations, including inventory updates and source data retrieval. - Enhanced the application by integrating outbound management functionalities into the existing logistics module. - Improved user experience with responsive design and real-time data handling for outbound operations.
This commit is contained in:
@@ -151,6 +151,7 @@ import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석
|
||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
|
||||
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
|
||||
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
|
||||
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
@@ -353,6 +354,7 @@ app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
app.use("/api/receiving", receivingRoutes); // 입고관리
|
||||
app.use("/api/outbound", outboundRoutes); // 출고관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
|
||||
509
backend-node/src/controllers/outboundController.ts
Normal file
509
backend-node/src/controllers/outboundController.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* 출고관리 컨트롤러
|
||||
*
|
||||
* 출고유형별 소스 테이블:
|
||||
* - 판매출고 → shipment_instruction + shipment_instruction_detail (출하지시)
|
||||
* - 반품출고 → purchase_order_mng (발주/입고)
|
||||
* - 기타출고 → item_info (품목)
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 출고 목록 조회
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const {
|
||||
outbound_type,
|
||||
outbound_status,
|
||||
search_keyword,
|
||||
date_from,
|
||||
date_to,
|
||||
} = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 전체 조회
|
||||
} else {
|
||||
conditions.push(`om.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (outbound_type && outbound_type !== "all") {
|
||||
conditions.push(`om.outbound_type = $${paramIdx}`);
|
||||
params.push(outbound_type);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (outbound_status && outbound_status !== "all") {
|
||||
conditions.push(`om.outbound_status = $${paramIdx}`);
|
||||
params.push(outbound_status);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (search_keyword) {
|
||||
conditions.push(
|
||||
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${search_keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (date_from) {
|
||||
conditions.push(`om.outbound_date >= $${paramIdx}`);
|
||||
params.push(date_from);
|
||||
paramIdx++;
|
||||
}
|
||||
if (date_to) {
|
||||
conditions.push(`om.outbound_date <= $${paramIdx}`);
|
||||
params.push(date_to);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
om.*,
|
||||
wh.warehouse_name
|
||||
FROM outbound_mng om
|
||||
LEFT JOIN warehouse_info wh
|
||||
ON om.warehouse_code = wh.warehouse_code
|
||||
AND om.company_code = wh.company_code
|
||||
${whereClause}
|
||||
ORDER BY om.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출고 목록 조회", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 등록 (다건)
|
||||
export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { items, outbound_number, outbound_date, warehouse_code, location_code, manager_id, memo } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
const insertedRows: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO outbound_mng (
|
||||
company_code, outbound_number, outbound_type, outbound_date,
|
||||
reference_number, customer_code, customer_name,
|
||||
item_code, item_name, specification, material, unit,
|
||||
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,
|
||||
destination_code, delivery_destination, delivery_address,
|
||||
created_date, created_by, writer, status
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7,
|
||||
$8, $9, $10, $11, $12,
|
||||
$13, $14, $15,
|
||||
$16, $17, $18,
|
||||
$19, $20, $21,
|
||||
$22, $23, $24, $25,
|
||||
$26, $27, $28,
|
||||
NOW(), $29, $29, '출고'
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
outbound_number || item.outbound_number,
|
||||
item.outbound_type,
|
||||
outbound_date || item.outbound_date,
|
||||
item.reference_number || null,
|
||||
item.customer_code || null,
|
||||
item.customer_name || null,
|
||||
item.item_code || item.item_number || null,
|
||||
item.item_name || null,
|
||||
item.spec || item.specification || null,
|
||||
item.material || null,
|
||||
item.unit || "EA",
|
||||
item.outbound_qty || 0,
|
||||
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.outbound_status || "대기",
|
||||
manager_id || item.manager_id || null,
|
||||
memo || item.memo || null,
|
||||
item.source_type || null,
|
||||
item.sales_order_id || null,
|
||||
item.shipment_plan_id || null,
|
||||
item.item_info_id || null,
|
||||
item.destination_code || null,
|
||||
item.delivery_destination || null,
|
||||
item.delivery_address || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 재고 업데이트 (inventory_stock): 출고 수량 차감
|
||||
const itemCode = item.item_code || item.item_number || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
const locCode = location_code || item.location_code || null;
|
||||
const outQty = Number(item.outbound_qty) || 0;
|
||||
if (itemCode && outQty > 0) {
|
||||
const existingStock = await client.query(
|
||||
`SELECT id FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
|
||||
if (existingStock.rows.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text),
|
||||
last_out_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[outQty, existingStock.rows[0].id]
|
||||
);
|
||||
} else {
|
||||
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
company_code, item_code, warehouse_code, location_code,
|
||||
current_qty, safety_qty, last_out_date,
|
||||
created_date, updated_date, writer
|
||||
) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
|
||||
[companyCode, itemCode, whCode, locCode, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 판매출고인 경우 출하지시의 ship_qty 업데이트
|
||||
if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") {
|
||||
await client.query(
|
||||
`UPDATE shipment_instruction_detail
|
||||
SET ship_qty = COALESCE(ship_qty, 0) + $1,
|
||||
updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[item.outbound_qty || 0, item.source_id, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출고 등록 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
count: insertedRows.length,
|
||||
outbound_number,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: insertedRows,
|
||||
message: `${insertedRows.length}건 출고 등록 완료`,
|
||||
});
|
||||
} 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 update(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
outbound_date, outbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
outbound_status, manager_id: mgr, memo,
|
||||
} = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`UPDATE outbound_mng SET
|
||||
outbound_date = COALESCE($1, outbound_date),
|
||||
outbound_qty = COALESCE($2, outbound_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),
|
||||
outbound_status = COALESCE($8, outbound_status),
|
||||
manager_id = COALESCE($9, manager_id),
|
||||
memo = COALESCE($10, memo),
|
||||
updated_date = NOW(),
|
||||
updated_by = $11
|
||||
WHERE id = $12 AND company_code = $13
|
||||
RETURNING *`,
|
||||
[
|
||||
outbound_date, outbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
outbound_status, mgr, memo,
|
||||
userId, id, companyCode,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("출고 수정", { companyCode, userId, id });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 삭제
|
||||
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("출고 삭제", { companyCode, id });
|
||||
|
||||
return res.json({ success: true, message: "삭제 완료" });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 판매출고용: 출하지시 데이터 조회
|
||||
export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["si.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
sid.id AS detail_id,
|
||||
si.id AS instruction_id,
|
||||
si.instruction_no,
|
||||
si.instruction_date,
|
||||
si.partner_id,
|
||||
si.status AS instruction_status,
|
||||
sid.item_code,
|
||||
sid.item_name,
|
||||
sid.spec,
|
||||
sid.material,
|
||||
COALESCE(sid.plan_qty, 0) AS plan_qty,
|
||||
COALESCE(sid.ship_qty, 0) AS ship_qty,
|
||||
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
|
||||
FROM shipment_instruction si
|
||||
JOIN shipment_instruction_detail sid
|
||||
ON si.id = sid.instruction_id
|
||||
AND si.company_code = sid.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0)
|
||||
ORDER BY si.instruction_date DESC, si.instruction_no`,
|
||||
params
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 반품출고용: 발주(입고) 데이터 조회
|
||||
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
// 입고된 것만 (반품 대상)
|
||||
conditions.push(
|
||||
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`
|
||||
);
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, purchase_no, order_date, supplier_code, supplier_name,
|
||||
item_code, item_name, spec, material,
|
||||
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
|
||||
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
|
||||
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
|
||||
status, due_date
|
||||
FROM purchase_order_mng
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY order_date DESC, purchase_no`,
|
||||
params
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 기타출고용: 품목 데이터 조회
|
||||
export async function getItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, item_number, item_name, size AS spec, material, unit,
|
||||
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
params
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고번호 자동생성
|
||||
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const prefix = `OUT-${yyyy}-`;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT outbound_number FROM outbound_mng
|
||||
WHERE company_code = $1 AND outbound_number LIKE $2
|
||||
ORDER BY outbound_number DESC LIMIT 1`,
|
||||
[companyCode, `${prefix}%`]
|
||||
);
|
||||
|
||||
let seq = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNo = result.rows[0].outbound_number;
|
||||
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
|
||||
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
|
||||
return res.json({ success: true, data: newNumber });
|
||||
} catch (error: any) {
|
||||
logger.error("출고번호 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 창고 목록 조회
|
||||
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
WHERE company_code = $1 AND status != '삭제'
|
||||
ORDER BY warehouse_name`,
|
||||
[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 });
|
||||
}
|
||||
}
|
||||
@@ -476,3 +476,112 @@ export async function deleteLoadingUnitPkg(
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 품목정보 연동 (division별 item_info 조회)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export async function getItemsByDivision(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { divisionLabel } = req.params;
|
||||
const { keyword } = req.query;
|
||||
const pool = getPool();
|
||||
|
||||
// division 카테고리에서 해당 라벨의 코드 찾기
|
||||
const catResult = await pool.query(
|
||||
`SELECT value_code FROM category_values
|
||||
WHERE table_name = 'item_info' AND column_name = 'division'
|
||||
AND value_label = $1 AND company_code = $2
|
||||
LIMIT 1`,
|
||||
[divisionLabel, companyCode]
|
||||
);
|
||||
|
||||
if (catResult.rows.length === 0) {
|
||||
res.json({ success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const divisionCode = catResult.rows[0].value_code;
|
||||
|
||||
const conditions: string[] = ["company_code = $1", `$2 = ANY(string_to_array(division, ','))`];
|
||||
const params: any[] = [companyCode, divisionCode];
|
||||
let paramIdx = 3;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size, material, unit, division
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
params
|
||||
);
|
||||
|
||||
logger.info(`품목 조회 (division=${divisionLabel})`, { companyCode, count: result.rowCount });
|
||||
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,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
const pool = getPool();
|
||||
|
||||
// 포장재/적재함 division 코드 조회
|
||||
const catResult = await pool.query(
|
||||
`SELECT value_code FROM category_values
|
||||
WHERE table_name = 'item_info' AND column_name = 'division'
|
||||
AND value_label IN ('포장재', '적재함') AND company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const excludeCodes = catResult.rows.map((r: any) => r.value_code);
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (excludeCodes.length > 0) {
|
||||
// 다중 값(콤마 구분) 지원: 포장재/적재함 코드가 포함된 품목 제외
|
||||
const excludeConditions = excludeCodes.map((_: any, i: number) => `$${paramIdx + i} = ANY(string_to_array(division, ','))`);
|
||||
conditions.push(`(division IS NULL OR division = '' OR NOT (${excludeConditions.join(" OR ")}))`);
|
||||
params.push(...excludeCodes);
|
||||
paramIdx += excludeCodes.length;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size AS spec, material, unit, division
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name
|
||||
LIMIT 200`,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,42 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 재고 업데이트 (inventory_stock): 입고 수량 증가
|
||||
const itemCode = item.item_number || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
const locCode = location_code || item.location_code || null;
|
||||
const inQty = Number(item.inbound_qty) || 0;
|
||||
if (itemCode && inQty > 0) {
|
||||
const existingStock = await client.query(
|
||||
`SELECT id FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
|
||||
if (existingStock.rows.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
|
||||
last_in_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[inQty, existingStock.rows[0].id]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
company_code, item_code, warehouse_code, location_code,
|
||||
current_qty, safety_qty, last_in_date,
|
||||
created_date, updated_date, writer
|
||||
) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
|
||||
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 구매입고인 경우 발주의 received_qty 업데이트
|
||||
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
|
||||
await client.query(
|
||||
|
||||
40
backend-node/src/routes/outboundRoutes.ts
Normal file
40
backend-node/src/routes/outboundRoutes.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 출고관리 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as outboundController from "../controllers/outboundController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 출고 목록 조회
|
||||
router.get("/list", outboundController.getList);
|
||||
|
||||
// 출고번호 자동생성
|
||||
router.get("/generate-number", outboundController.generateNumber);
|
||||
|
||||
// 창고 목록 조회
|
||||
router.get("/warehouses", outboundController.getWarehouses);
|
||||
|
||||
// 소스 데이터: 출하지시 (판매출고)
|
||||
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
|
||||
|
||||
// 소스 데이터: 발주 (반품출고)
|
||||
router.get("/source/purchase-orders", outboundController.getPurchaseOrders);
|
||||
|
||||
// 소스 데이터: 품목 (기타출고)
|
||||
router.get("/source/items", outboundController.getItems);
|
||||
|
||||
// 출고 등록
|
||||
router.post("/", outboundController.create);
|
||||
|
||||
// 출고 수정
|
||||
router.put("/:id", outboundController.update);
|
||||
|
||||
// 출고 삭제
|
||||
router.delete("/:id", outboundController.deleteOutbound);
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
||||
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
||||
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
||||
getItemsByDivision, getGeneralItems,
|
||||
} from "../controllers/packagingController";
|
||||
|
||||
const router = Router();
|
||||
@@ -33,4 +34,8 @@ router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
|
||||
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
|
||||
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
|
||||
|
||||
// 품목정보 연동 (division별)
|
||||
router.get("/items/general", getGeneralItems);
|
||||
router.get("/items/:divisionLabel", getItemsByDivision);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function getOrderSummary(
|
||||
SELECT
|
||||
item_number,
|
||||
id AS item_id,
|
||||
COALESCE(lead_time, 0) AS lead_time
|
||||
COALESCE(lead_time::int, 0) AS lead_time
|
||||
FROM item_info
|
||||
WHERE company_code = $1
|
||||
),`
|
||||
@@ -371,43 +371,51 @@ export async function previewSchedule(
|
||||
const deletedSchedules: any[] = [];
|
||||
const keptSchedules: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (options.recalculate_unstarted) {
|
||||
// 삭제 대상(planned) 상세 조회
|
||||
// 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
|
||||
if (options.recalculate_unstarted) {
|
||||
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
|
||||
for (const itemCode of uniqueItemCodes) {
|
||||
const deleteResult = await pool.query(
|
||||
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
deletedSchedules.push(...deleteResult.rows);
|
||||
|
||||
// 유지 대상(진행중 등) 상세 조회
|
||||
const keptResult = await pool.query(
|
||||
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
keptSchedules.push(...keptResult.rows);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const dailyCapacity = item.daily_capacity || 800;
|
||||
const itemLeadTime = item.lead_time || 0;
|
||||
|
||||
let requiredQty = item.required_qty;
|
||||
|
||||
// recalculate_unstarted가 true이면 기존 planned 삭제 후 재생성이므로,
|
||||
// 프론트에서 이미 차감된 기존 계획 수량을 다시 더해줘야 정확한 필요 수량이 됨
|
||||
// recalculate_unstarted 시, 삭제된 수량을 비율로 분배
|
||||
if (options.recalculate_unstarted) {
|
||||
const deletedQtyForItem = deletedSchedules
|
||||
.filter((d: any) => d.item_code === item.item_code)
|
||||
.reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0);
|
||||
requiredQty += deletedQtyForItem;
|
||||
if (deletedQtyForItem > 0) {
|
||||
const totalRequestedForItem = items
|
||||
.filter((i) => i.item_code === item.item_code)
|
||||
.reduce((sum, i) => sum + i.required_qty, 0);
|
||||
if (totalRequestedForItem > 0) {
|
||||
requiredQty += Math.round(deletedQtyForItem * (item.required_qty / totalRequestedForItem));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredQty <= 0) continue;
|
||||
@@ -492,24 +500,22 @@ export async function generateSchedule(
|
||||
let deletedCount = 0;
|
||||
let keptCount = 0;
|
||||
const newSchedules: any[] = [];
|
||||
const deletedQtyByItem = new Map<string, number>();
|
||||
|
||||
for (const item of items) {
|
||||
// 삭제 전에 기존 planned 수량 먼저 조회
|
||||
let deletedQtyForItem = 0;
|
||||
if (options.recalculate_unstarted) {
|
||||
// 같은 item_code에 대한 삭제는 한 번만 수행
|
||||
if (options.recalculate_unstarted) {
|
||||
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
|
||||
for (const itemCode of uniqueItemCodes) {
|
||||
const deletedQtyResult = await client.query(
|
||||
`SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
deletedQtyForItem = parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0;
|
||||
}
|
||||
deletedQtyByItem.set(itemCode, parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0);
|
||||
|
||||
// 기존 미진행(planned) 스케줄 삭제
|
||||
if (options.recalculate_unstarted) {
|
||||
const deleteResult = await client.query(
|
||||
`DELETE FROM production_plan_mng
|
||||
WHERE company_code = $1
|
||||
@@ -517,7 +523,7 @@ export async function generateSchedule(
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'
|
||||
RETURNING id`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
deletedCount += deleteResult.rowCount || 0;
|
||||
|
||||
@@ -527,15 +533,29 @@ export async function generateSchedule(
|
||||
AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
keptCount += parseInt(keptResult.rows[0].cnt, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// 필요 수량 계산 (삭제된 planned 수량을 복원)
|
||||
for (const item of items) {
|
||||
// 필요 수량 계산 (삭제된 planned 수량을 비율로 분배)
|
||||
const dailyCapacity = item.daily_capacity || 800;
|
||||
const itemLeadTime = item.lead_time || 0;
|
||||
let requiredQty = item.required_qty + deletedQtyForItem;
|
||||
let requiredQty = item.required_qty;
|
||||
|
||||
if (options.recalculate_unstarted) {
|
||||
const deletedQty = deletedQtyByItem.get(item.item_code) || 0;
|
||||
if (deletedQty > 0) {
|
||||
const totalRequestedForItem = items
|
||||
.filter((i) => i.item_code === item.item_code)
|
||||
.reduce((sum, i) => sum + i.required_qty, 0);
|
||||
if (totalRequestedForItem > 0) {
|
||||
requiredQty += Math.round(deletedQty * (item.required_qty / totalRequestedForItem));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
|
||||
@@ -739,7 +759,7 @@ async function getBomChildItems(
|
||||
) AS has_lead_time
|
||||
`);
|
||||
const hasLeadTime = colCheck.rows[0]?.has_lead_time === true;
|
||||
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time, 0)" : "0";
|
||||
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time::int, 0)" : "0";
|
||||
|
||||
const bomQuery = `
|
||||
SELECT
|
||||
|
||||
@@ -1575,7 +1575,7 @@ export class TableManagementService {
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
|
||||
values: [actualValue],
|
||||
paramCount: 1,
|
||||
};
|
||||
@@ -1859,10 +1859,10 @@ export class TableManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
|
||||
// select 필터(equals)인 경우 — 다중 값(콤마 구분) 지원
|
||||
if (operator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
|
||||
values: [String(value)],
|
||||
paramCount: 1,
|
||||
};
|
||||
@@ -3357,16 +3357,20 @@ export class TableManagementService {
|
||||
const safeColumn = `main."${columnName}"`;
|
||||
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
case "equals": {
|
||||
const safeVal = String(value).replace(/'/g, "''");
|
||||
filterConditions.push(
|
||||
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
|
||||
`('${safeVal}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal}')`
|
||||
);
|
||||
break;
|
||||
case "not_equals":
|
||||
}
|
||||
case "not_equals": {
|
||||
const safeVal2 = String(value).replace(/'/g, "''");
|
||||
filterConditions.push(
|
||||
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
|
||||
`NOT ('${safeVal2}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal2}')`
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "in": {
|
||||
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||
if (inArr.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user