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:
kjs
2026-03-19 15:08:31 +09:00
parent 1064397be2
commit 160b78e70f
20 changed files with 11212 additions and 37 deletions

View 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 });
}
}