feat: add shipping order and design management features
- Introduced new routes and controllers for managing shipping orders, including listing, saving, and previewing next order numbers. - Added design management routes and controller for handling design requests, projects, tasks, and work logs. - Implemented company code filtering for multi-tenancy support in both shipping order and design request functionalities. - Enhanced the shipping plan routes to include listing and updating plans, improving overall shipping management capabilities. These changes aim to provide comprehensive management features for shipping orders and design processes, facilitating better organization and tracking within the application.
This commit is contained in:
482
backend-node/src/controllers/shippingOrderController.ts
Normal file
482
backend-node/src/controllers/shippingOrderController.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* 출하지시 컨트롤러 (shipment_instruction + shipment_instruction_detail)
|
||||
*/
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
|
||||
// ─── 출하지시 목록 조회 ───
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, customer, keyword } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`si.company_code = $${idx}`);
|
||||
params.push(companyCode);
|
||||
idx++;
|
||||
}
|
||||
if (dateFrom) {
|
||||
conditions.push(`si.instruction_date >= $${idx}::date`);
|
||||
params.push(dateFrom);
|
||||
idx++;
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push(`si.instruction_date <= $${idx}::date`);
|
||||
params.push(dateTo);
|
||||
idx++;
|
||||
}
|
||||
if (status) {
|
||||
conditions.push(`si.status = $${idx}`);
|
||||
params.push(status);
|
||||
idx++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${idx} OR si.partner_id ILIKE $${idx})`);
|
||||
params.push(`%${customer}%`);
|
||||
idx++;
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(`(si.instruction_no ILIKE $${idx} OR si.memo ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
si.*,
|
||||
COALESCE(c.customer_name, si.partner_id, '') AS customer_name,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', sid.id,
|
||||
'item_code', sid.item_code,
|
||||
'item_name', COALESCE(i.item_name, sid.item_name, sid.item_code),
|
||||
'spec', sid.spec,
|
||||
'material', sid.material,
|
||||
'order_qty', sid.order_qty,
|
||||
'plan_qty', sid.plan_qty,
|
||||
'ship_qty', sid.ship_qty,
|
||||
'source_type', sid.source_type,
|
||||
'shipment_plan_id', sid.shipment_plan_id,
|
||||
'sales_order_id', sid.sales_order_id,
|
||||
'detail_id', sid.detail_id
|
||||
)
|
||||
) FILTER (WHERE sid.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS items
|
||||
FROM shipment_instruction si
|
||||
LEFT JOIN customer_mng c
|
||||
ON si.partner_id = c.customer_code AND si.company_code = c.company_code
|
||||
LEFT JOIN shipment_instruction_detail sid
|
||||
ON si.id = sid.instruction_id AND si.company_code = sid.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = sid.item_code AND company_code = si.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
${where}
|
||||
GROUP BY si.id, c.customer_name
|
||||
ORDER BY si.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출하지시 목록 조회", { companyCode, count: 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 previewNextNo(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
let instructionNo: string;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode, "shipment_instruction", "instruction_no"
|
||||
);
|
||||
if (rule) {
|
||||
instructionNo = await numberingRuleService.previewCode(
|
||||
rule.ruleId, companyCode, {}
|
||||
);
|
||||
} else {
|
||||
throw new Error("채번 규칙 없음");
|
||||
}
|
||||
} catch {
|
||||
const pool = getPool();
|
||||
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const seqRes = await pool.query(
|
||||
`SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
|
||||
[companyCode, `SI-${today}-%`]
|
||||
);
|
||||
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
|
||||
instructionNo = `SI-${today}-${seq}`;
|
||||
}
|
||||
|
||||
return res.json({ success: true, instructionNo });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시번호 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 출하지시 저장 (신규/수정) ───
|
||||
export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const {
|
||||
id: editId,
|
||||
instructionDate,
|
||||
partnerId,
|
||||
status: orderStatus,
|
||||
memo,
|
||||
carrierName,
|
||||
vehicleNo,
|
||||
driverName,
|
||||
driverContact,
|
||||
arrivalTime,
|
||||
deliveryAddress,
|
||||
items,
|
||||
} = req.body;
|
||||
|
||||
if (!instructionDate) {
|
||||
return res.status(400).json({ success: false, message: "출하지시일은 필수입니다" });
|
||||
}
|
||||
if (!items || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
let instructionId: number;
|
||||
let instructionNo: string;
|
||||
|
||||
if (editId) {
|
||||
// 수정
|
||||
const check = await client.query(
|
||||
`SELECT id, instruction_no FROM shipment_instruction WHERE id = $1 AND company_code = $2`,
|
||||
[editId, companyCode]
|
||||
);
|
||||
if (check.rowCount === 0) {
|
||||
throw new Error("출하지시를 찾을 수 없습니다");
|
||||
}
|
||||
instructionId = editId;
|
||||
instructionNo = check.rows[0].instruction_no;
|
||||
|
||||
await client.query(
|
||||
`UPDATE shipment_instruction SET
|
||||
instruction_date = $1::date, partner_id = $2, status = $3, memo = $4,
|
||||
carrier_name = $5, vehicle_no = $6, driver_name = $7, driver_contact = $8,
|
||||
arrival_time = $9, delivery_address = $10,
|
||||
updated_date = NOW(), updated_by = $11
|
||||
WHERE id = $12 AND company_code = $13`,
|
||||
[
|
||||
instructionDate, partnerId, orderStatus || "READY", memo,
|
||||
carrierName, vehicleNo, driverName, driverContact,
|
||||
arrivalTime || null, deliveryAddress,
|
||||
userId, editId, companyCode,
|
||||
]
|
||||
);
|
||||
|
||||
// 기존 디테일 삭제 후 재삽입
|
||||
await client.query(
|
||||
`DELETE FROM shipment_instruction_detail WHERE instruction_id = $1 AND company_code = $2`,
|
||||
[editId, companyCode]
|
||||
);
|
||||
} else {
|
||||
// 신규 - 채번 규칙이 있으면 사용, 없으면 자체 생성
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode, "shipment_instruction", "instruction_no"
|
||||
);
|
||||
if (rule) {
|
||||
instructionNo = await numberingRuleService.allocateCode(
|
||||
rule.ruleId, companyCode, { instruction_date: instructionDate }
|
||||
);
|
||||
logger.info("채번 규칙으로 출하지시번호 생성", { ruleId: rule.ruleId, instructionNo });
|
||||
} else {
|
||||
throw new Error("채번 규칙 없음 - 폴백");
|
||||
}
|
||||
} catch {
|
||||
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const seqRes = await client.query(
|
||||
`SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
|
||||
[companyCode, `SI-${today}-%`]
|
||||
);
|
||||
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
|
||||
instructionNo = `SI-${today}-${seq}`;
|
||||
logger.info("폴백으로 출하지시번호 생성", { instructionNo });
|
||||
}
|
||||
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO shipment_instruction
|
||||
(company_code, instruction_no, instruction_date, partner_id, status, memo,
|
||||
carrier_name, vehicle_no, driver_name, driver_contact, arrival_time, delivery_address,
|
||||
created_date, created_by)
|
||||
VALUES ($1, $2, $3::date, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), $13)
|
||||
RETURNING id`,
|
||||
[
|
||||
companyCode, instructionNo, instructionDate, partnerId,
|
||||
orderStatus || "READY", memo,
|
||||
carrierName, vehicleNo, driverName, driverContact,
|
||||
arrivalTime || null, deliveryAddress, userId,
|
||||
]
|
||||
);
|
||||
instructionId = insertRes.rows[0].id;
|
||||
}
|
||||
|
||||
// 디테일 삽입
|
||||
for (const item of items) {
|
||||
await client.query(
|
||||
`INSERT INTO shipment_instruction_detail
|
||||
(company_code, instruction_id, shipment_plan_id, sales_order_id, detail_id,
|
||||
item_code, item_name, spec, material, order_qty, plan_qty, ship_qty,
|
||||
source_type, created_date, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14)`,
|
||||
[
|
||||
companyCode, instructionId,
|
||||
item.shipmentPlanId || null, item.salesOrderId || null, item.detailId || null,
|
||||
item.itemCode, item.itemName, item.spec, item.material,
|
||||
item.orderQty || 0, item.planQty || 0, item.shipQty || 0,
|
||||
item.sourceType || "shipmentPlan", userId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출하지시 저장 완료", { companyCode, instructionId, instructionNo, itemCount: items.length });
|
||||
return res.json({ success: true, data: { id: instructionId, instructionNo } });
|
||||
} catch (txErr) {
|
||||
await client.query("ROLLBACK");
|
||||
throw txErr;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 저장 실패", { error: error.message, stack: error.stack });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 출하지시 삭제 ───
|
||||
export async function remove(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "삭제할 ID가 필요합니다" });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
// CASCADE로 디테일도 자동 삭제
|
||||
const result = await pool.query(
|
||||
`DELETE FROM shipment_instruction WHERE id = ANY($1::int[]) AND company_code = $2 RETURNING id`,
|
||||
[ids, companyCode]
|
||||
);
|
||||
|
||||
logger.info("출하지시 삭제", { companyCode, deletedCount: result.rowCount });
|
||||
return res.json({ success: true, deletedCount: result.rowCount });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 출하계획 목록 (모달 왼쪽 패널용) ───
|
||||
export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
|
||||
const page = Math.max(1, parseInt(pageStr as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions = ["sp.company_code = $1", "sp.status = 'READY'"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(COALESCE(d.part_code, m.part_code, '') ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${idx})`);
|
||||
params.push(`%${customer}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const fromClause = `
|
||||
FROM shipment_plan sp
|
||||
LEFT JOIN sales_order_detail d ON sp.detail_id = d.id AND sp.company_code = d.company_code
|
||||
LEFT JOIN sales_order_mng m ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = COALESCE(d.part_code, m.part_code) AND company_code = sp.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
LEFT JOIN customer_mng c
|
||||
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
|
||||
const totalCount = parseInt(countResult.rows[0].total);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
sp.id, sp.plan_qty, sp.plan_date, sp.status, sp.shipment_plan_no,
|
||||
COALESCE(m.order_no, d.order_no, '') AS order_no,
|
||||
COALESCE(d.part_code, m.part_code, '') AS item_code,
|
||||
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name,
|
||||
COALESCE(d.spec, m.spec, '') AS spec,
|
||||
COALESCE(m.material, '') AS material,
|
||||
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
|
||||
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
|
||||
sp.detail_id, sp.sales_order_id
|
||||
${fromClause}
|
||||
ORDER BY sp.created_date DESC
|
||||
LIMIT $${idx} OFFSET $${idx + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
|
||||
} catch (error: any) {
|
||||
logger.error("출하계획 소스 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 수주 목록 (모달 왼쪽 패널용) ───
|
||||
export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
|
||||
const page = Math.max(1, parseInt(pageStr as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions = ["d.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(d.delivery_partner_code, m.partner_id, '') ILIKE $${idx})`);
|
||||
params.push(`%${customer}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const fromClause = `
|
||||
FROM sales_order_detail d
|
||||
LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = d.part_code AND company_code = d.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
LEFT JOIN customer_mng c
|
||||
ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code AND d.company_code = c.company_code
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
|
||||
const totalCount = parseInt(countResult.rows[0].total);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
d.id, d.order_no, d.part_code AS item_code,
|
||||
COALESCE(i.item_name, d.part_name, d.part_code) AS item_name,
|
||||
COALESCE(d.spec, '') AS spec, COALESCE(m.material, '') AS material,
|
||||
COALESCE(NULLIF(d.qty,'')::numeric, 0) AS qty,
|
||||
COALESCE(NULLIF(d.balance_qty,'')::numeric, 0) AS balance_qty,
|
||||
COALESCE(c.customer_name, COALESCE(d.delivery_partner_code, m.partner_id, '')) AS customer_name,
|
||||
COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code,
|
||||
m.id AS master_id
|
||||
${fromClause}
|
||||
ORDER BY d.created_date DESC
|
||||
LIMIT $${idx} OFFSET $${idx + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
|
||||
} catch (error: any) {
|
||||
logger.error("수주 소스 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 품목 목록 (모달 왼쪽 패널용) ───
|
||||
export async function getItemSource(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, page: pageStr, pageSize: pageSizeStr } = req.query;
|
||||
const page = Math.max(1, parseInt(pageStr as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const pool = getPool();
|
||||
const countResult = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, params);
|
||||
const totalCount = parseInt(countResult.rows[0].total);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
item_number AS item_code, item_name,
|
||||
COALESCE(size, '') AS spec, COALESCE(material, '') AS material
|
||||
FROM item_info
|
||||
WHERE ${whereClause}
|
||||
ORDER BY item_name
|
||||
LIMIT $${idx} OFFSET $${idx + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
|
||||
} 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