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:
kjs
2026-03-25 10:48:47 +09:00
parent 5d4cf8d462
commit e2f18b19bc
22 changed files with 5603 additions and 115 deletions

View File

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

View File

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