대진 오류사항 일부 수정

This commit is contained in:
kjs
2026-05-19 11:57:05 +09:00
parent fa9f5451f6
commit 6731ca4183
17 changed files with 1673 additions and 132 deletions

View File

@@ -176,6 +176,7 @@ import outsourcePurchaseRoutes from "./routes/outsourcePurchaseRoutes"; // 외
import subcontractorStockRoutes from "./routes/subcontractorStockRoutes"; // 외주사재고관리 (TASK:ERP-026)
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import quoteRoutes from "./routes/quoteRoutes"; // 견적관리
import deliveryNoteRoutes from "./routes/deliveryNoteRoutes"; // 거래명세서 관리 (TASK:ERP-070)
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -409,6 +410,7 @@ app.use("/api/outsourcing-outbound", outsourcingOutboundRoutes); // 외주출고
app.use("/api/outsource-purchase", outsourcePurchaseRoutes); // 외주발주관리 (TASK:ERP-019)
app.use("/api/subcontractor-stock", subcontractorStockRoutes); // 외주사재고관리 (TASK:ERP-026)
app.use("/api/quotes", quoteRoutes); // 견적관리
app.use("/api/delivery-note", deliveryNoteRoutes); // 거래명세서 관리 (TASK:ERP-070)
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리

View File

@@ -0,0 +1,157 @@
/**
* 거래명세서 컨트롤러
* Task: ERP-070 (COMPANY_8 대진산업)
*/
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import {
createDeliveryNote,
listDeliveryNotes,
getDeliveryNoteById,
} from "../services/deliveryNoteService";
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
companyCode: string;
userName?: string;
};
}
/**
* POST /api/delivery-note
* 거래명세서 생성
*/
export async function create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { outbound_ids, issue_date, remark } = req.body as {
outbound_ids: string[];
issue_date: string;
remark?: string;
};
if (!outbound_ids || outbound_ids.length === 0) {
res.status(400).json({ success: false, message: "출고 건을 선택해주세요." });
return;
}
if (!issue_date) {
res.status(400).json({ success: false, message: "발행일을 입력해주세요." });
return;
}
const result = await createDeliveryNote({
companyCode,
issuedBy: userId,
issuedByName: (req.user as any)?.userName,
issueDate: issue_date,
outboundIds: outbound_ids,
remark,
});
res.status(201).json({
success: true,
message: "거래명세서가 생성되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("거래명세서 생성 실패:", error);
const statusCode =
error.message?.includes("동일 거래처") ||
error.message?.includes("출고완료") ||
error.message?.includes("찾을 수 없")
? 400
: 500;
res.status(statusCode).json({
success: false,
message: error.message || "거래명세서 생성 중 오류가 발생했습니다.",
});
}
}
/**
* GET /api/delivery-note
* 거래명세서 목록 조회
*/
export async function getList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const {
customer_code,
date_from,
date_to,
search,
page = "1",
page_size = "20",
} = req.query as Record<string, string>;
const result = await listDeliveryNotes({
companyCode,
customerCode: customer_code,
dateFrom: date_from,
dateTo: date_to,
search,
page: parseInt(page, 10),
pageSize: parseInt(page_size, 10),
});
res.json({
success: true,
data: result.data,
pagination: {
page: result.page,
pageSize: result.pageSize,
total: result.total,
totalPages: Math.ceil(result.total / result.pageSize),
},
});
} catch (error: any) {
logger.error("거래명세서 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "거래명세서 목록 조회 중 오류가 발생했습니다.",
});
}
}
/**
* GET /api/delivery-note/:id
* 거래명세서 단건 상세 조회
*/
export async function getById(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const data = await getDeliveryNoteById(id, companyCode);
if (!data) {
res.status(404).json({ success: false, message: "거래명세서를 찾을 수 없습니다." });
return;
}
res.json({ success: true, data });
} catch (error: any) {
logger.error("거래명세서 상세 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "거래명세서 조회 중 오류가 발생했습니다.",
});
}
}

View File

@@ -1669,6 +1669,20 @@ const checkAndCompleteWorkInstruction = async (
if (itemResult.rowCount === 0) return;
const itemCode = itemResult.rows[0].item_number;
// 담당자 한글명 조회 — inventory_history manager_name 기록용
// (CLAUDE.md "사용자 식별 표시 필수": DB에는 user_id 저장, 표시는 user_name)
let autoUserName = userId;
try {
const mgrRes = await pool.query(
`SELECT COALESCE(NULLIF(user_name, ''), user_id) AS user_name
FROM user_info WHERE user_id = $1 AND company_code = $2 LIMIT 1`,
[userId, companyCode],
);
if (mgrRes.rows[0]?.user_name) autoUserName = mgrRes.rows[0].user_name;
} catch {
/* user_info 조회 실패 시 userId fallback 유지 */
}
const warehouseResult = await pool.query(
`SELECT target_warehouse_id, target_location_code
FROM work_order_process
@@ -1722,7 +1736,7 @@ const checkAndCompleteWorkInstruction = async (
completedQty,
userId,
{
userName: userId,
userName: autoUserName,
source: "auto_cascade",
woId,
},

View File

@@ -0,0 +1,24 @@
/**
* 거래명세서 라우터
* /api/delivery-note 경로 처리
* Task: ERP-070 (COMPANY_8 대진산업)
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as deliveryNoteController from "../controllers/deliveryNoteController";
const router = Router();
router.use(authenticateToken);
// 거래명세서 목록 조회
router.get("/", deliveryNoteController.getList);
// 거래명세서 상세 조회 (목록보다 먼저 정의)
router.get("/:id", deliveryNoteController.getById);
// 거래명세서 생성
router.post("/", deliveryNoteController.create);
export default router;

View File

@@ -0,0 +1,277 @@
/**
* 거래명세서 서비스
* Task: ERP-070 (COMPANY_8 대진산업)
*
* 백엔드는 공통 — company_code 스코프 필수
*/
import { getPool } from "../database/db";
import { v4 as uuidv4 } from "uuid";
import { logger } from "../utils/logger";
/** 거래명세서 생성 파라미터 */
export interface CreateDeliveryNoteParams {
companyCode: string;
issuedBy: string; // 발행자 user_id
issuedByName?: string; // 발행자 user_name
issueDate: string; // 발행일 (YYYY-MM-DD)
outboundIds: string[]; // 선택된 outbound_mng.id 목록
remark?: string;
}
/** 거래명세서 목록 조회 파라미터 */
export interface ListDeliveryNoteParams {
companyCode: string;
customerCode?: string;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
pageSize?: number;
}
/**
* 발행번호 자동채번: DN-YYYYMMDD-XXXX
*/
async function generateNoteNumber(companyCode: string, issueDate: string): Promise<string> {
const pool = getPool();
const datePart = issueDate.replace(/-/g, "").slice(0, 8);
const prefix = `DN-${datePart}-`;
const res = await pool.query(
`SELECT note_number FROM delivery_note_mng
WHERE company_code = $1 AND note_number LIKE $2
ORDER BY note_number DESC LIMIT 1`,
[companyCode, `${prefix}%`],
);
let seq = 1;
if (res.rows.length > 0) {
const last = res.rows[0].note_number as string;
const seqStr = last.split("-").pop() || "0000";
seq = parseInt(seqStr, 10) + 1;
}
return `${prefix}${String(seq).padStart(4, "0")}`;
}
/**
* 거래명세서 생성 (선택 출고건 → 집계 → 저장)
*/
export async function createDeliveryNote(params: CreateDeliveryNoteParams) {
const pool = getPool();
const {
companyCode,
issuedBy,
issuedByName,
issueDate,
outboundIds,
remark,
} = params;
if (!outboundIds || outboundIds.length === 0) {
throw new Error("출고 건을 최소 1개 이상 선택해야 합니다.");
}
// 1) 선택된 출고 건 조회 (company_code 스코프)
const placeholders = outboundIds.map((_, i) => `$${i + 2}`).join(", ");
const outboundRes = await pool.query(
`SELECT id, outbound_number, outbound_date, customer_code, customer_name,
item_code, item_name, specification, unit,
outbound_qty, unit_price, total_amount, outbound_status
FROM outbound_mng
WHERE company_code = $1 AND id IN (${placeholders})`,
[companyCode, ...outboundIds],
);
if (outboundRes.rows.length === 0) {
throw new Error("선택한 출고 건을 찾을 수 없습니다.");
}
// 2) 검증: 동일 거래처 확인
const customerCodes = new Set(outboundRes.rows.map((r: any) => r.customer_code));
if (customerCodes.size > 1) {
throw new Error("동일 거래처의 출고 건만 선택할 수 있습니다.");
}
// 3) 검증: 출고완료 상태 확인
const nonCompleted = outboundRes.rows.filter((r: any) => r.outbound_status !== "출고완료");
if (nonCompleted.length > 0) {
throw new Error(
`출고완료 상태가 아닌 건이 포함되어 있습니다: ${nonCompleted.map((r: any) => r.outbound_number).join(", ")}`,
);
}
const rows = outboundRes.rows as any[];
const firstRow = rows[0];
const customerCode = firstRow.customer_code || "";
const customerName = firstRow.customer_name || "";
// 4) 금액 집계 (공급가액 × 10% = 부가세)
const totalSupply = rows.reduce((sum: number, r: any) => sum + (Number(r.total_amount) || 0), 0);
const totalTax = Math.round(totalSupply * 0.1);
const totalAmount = totalSupply + totalTax;
// 5) 채번
const noteNumber = await generateNoteNumber(companyCode, issueDate);
const noteId = uuidv4();
// 6) 트랜잭션으로 저장
const client = await pool.connect();
try {
await client.query("BEGIN");
// 헤더
await client.query(
`INSERT INTO delivery_note_mng
(id, company_code, note_number, issue_date, customer_code, customer_name,
supply_amount, tax_amount, total_amount, issued_by, issued_by_name,
remark, created_date, updated_date)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),NOW())`,
[
noteId, companyCode, noteNumber, issueDate, customerCode, customerName,
totalSupply, totalTax, totalAmount,
issuedBy, issuedByName || issuedBy,
remark || null,
],
);
// 출고건 매핑
for (const row of rows) {
await client.query(
`INSERT INTO delivery_note_outbound
(id, note_id, outbound_id, outbound_number, outbound_date, created_date)
VALUES ($1,$2,$3,$4,$5,NOW())`,
[uuidv4(), noteId, row.id, row.outbound_number, row.outbound_date],
);
}
// 품목 상세 (출고건별 1행)
let sortOrder = 1;
for (const row of rows) {
const supplyAmt = Number(row.total_amount) || 0;
const taxAmt = Math.round(supplyAmt * 0.1);
await client.query(
`INSERT INTO delivery_note_detail
(id, note_id, outbound_id, item_code, item_name, specification, unit,
outbound_qty, unit_price, supply_amount, tax_amount, total_amount,
sort_order, created_date)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())`,
[
uuidv4(), noteId, row.id,
row.item_code, row.item_name, row.specification || "", row.unit || "EA",
Number(row.outbound_qty) || 0,
Number(row.unit_price) || 0,
supplyAmt,
taxAmt,
supplyAmt + taxAmt,
sortOrder++,
],
);
}
await client.query("COMMIT");
return {
id: noteId,
note_number: noteNumber,
customer_name: customerName,
supply_amount: totalSupply,
tax_amount: totalTax,
total_amount: totalAmount,
};
} catch (err) {
await client.query("ROLLBACK");
logger.error("거래명세서 생성 실패:", err);
throw err;
} finally {
client.release();
}
}
/**
* 거래명세서 목록 조회
*/
export async function listDeliveryNotes(params: ListDeliveryNoteParams) {
const pool = getPool();
const { companyCode, customerCode, dateFrom, dateTo, search, page = 1, pageSize = 20 } = params;
const conditions: string[] = [];
const queryParams: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`n.company_code = $${idx++}`);
queryParams.push(companyCode);
}
if (customerCode) {
conditions.push(`n.customer_code = $${idx++}`);
queryParams.push(customerCode);
}
if (dateFrom) {
conditions.push(`n.issue_date >= $${idx++}`);
queryParams.push(dateFrom);
}
if (dateTo) {
conditions.push(`n.issue_date <= $${idx++}`);
queryParams.push(dateTo);
}
if (search) {
conditions.push(
`(n.note_number ILIKE $${idx} OR n.customer_name ILIKE $${idx})`,
);
queryParams.push(`%${search}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const countRes = await pool.query(
`SELECT COUNT(*) FROM delivery_note_mng n ${where}`,
queryParams,
);
const total = parseInt(countRes.rows[0].count, 10);
const offset = (page - 1) * pageSize;
const listRes = await pool.query(
`SELECT n.*
FROM delivery_note_mng n
${where}
ORDER BY n.created_date DESC
LIMIT $${idx} OFFSET $${idx + 1}`,
[...queryParams, pageSize, offset],
);
return { data: listRes.rows, total, page, pageSize };
}
/**
* 거래명세서 단건 상세 조회 (헤더 + 매핑 출고건 + 품목상세)
*/
export async function getDeliveryNoteById(noteId: string, companyCode: string) {
const pool = getPool();
const headerRes = await pool.query(
`SELECT * FROM delivery_note_mng WHERE id = $1 AND company_code = $2`,
[noteId, companyCode],
);
if (headerRes.rows.length === 0) {
return null;
}
const detailRes = await pool.query(
`SELECT * FROM delivery_note_detail WHERE note_id = $1 ORDER BY sort_order`,
[noteId],
);
const outboundRes = await pool.query(
`SELECT * FROM delivery_note_outbound WHERE note_id = $1`,
[noteId],
);
return {
...headerRes.rows[0],
details: detailRes.rows,
outbounds: outboundRes.rows,
};
}