대진 오류사항 일부 수정
This commit is contained in:
@@ -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); // 차량 운행 이력 관리
|
||||
|
||||
157
backend-node/src/controllers/deliveryNoteController.ts
Normal file
157
backend-node/src/controllers/deliveryNoteController.ts
Normal 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 || "거래명세서 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
24
backend-node/src/routes/deliveryNoteRoutes.ts
Normal file
24
backend-node/src/routes/deliveryNoteRoutes.ts
Normal 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;
|
||||
277
backend-node/src/services/deliveryNoteService.ts
Normal file
277
backend-node/src/services/deliveryNoteService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user