Add quote management functionality with API and UI integration

- Introduced a new `quote` module, including routes, controllers, and services for managing quotes.
- Implemented API endpoints for listing, creating, updating, and deleting quotes, ensuring proper company code filtering for data access.
- Developed a comprehensive UI for quote management, allowing users to create, edit, and view quotes seamlessly.
- Enhanced the admin layout to include the new quote management page, improving navigation and accessibility for users.

These additions significantly enhance the application's capabilities in managing quotes, providing users with essential tools for their sales processes.
This commit is contained in:
kjs
2026-04-02 15:30:44 +09:00
parent d8aaacb8f7
commit ce99001970
12 changed files with 1949 additions and 12 deletions

View File

@@ -161,6 +161,7 @@ import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import quoteRoutes from "./routes/quoteRoutes"; // 견적관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -379,6 +380,7 @@ app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재
app.use("/api/design", designRoutes); // 설계 모듈
app.use("/api/receiving", receivingRoutes); // 입고관리
app.use("/api/outbound", outboundRoutes); // 출고관리
app.use("/api/quotes", quoteRoutes); // 견적관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리

View File

@@ -0,0 +1,84 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as quoteService from "../services/quoteService";
import { logger } from "../utils/logger";
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { search, status, startDate, endDate } = req.query as Record<string, string>;
const data = await quoteService.getList(companyCode, { search, status, startDate, endDate });
return res.json({ success: true, data });
} catch (error: any) {
logger.error("견적 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function getById(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const data = await quoteService.getById(companyCode, parseInt(id));
if (!data) {
return res.status(404).json({ success: false, message: "견적을 찾을 수 없습니다." });
}
return res.json({ success: true, data });
} catch (error: any) {
logger.error("견적 상세 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const quoteNo = await quoteService.generateNumber(companyCode);
return res.json({ success: true, data: { quoteNo } });
} catch (error: any) {
logger.error("견적번호 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function create(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const data = await quoteService.create(companyCode, userId, req.body);
return res.status(201).json({ success: true, data, message: "견적이 등록되었습니다." });
} catch (error: any) {
logger.error("견적 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function update(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
await quoteService.update(companyCode, userId, parseInt(id), req.body);
return res.json({ success: true, message: "견적이 수정되었습니다." });
} catch (error: any) {
logger.error("견적 수정 실패", { error: error.message });
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 { id } = req.params;
await quoteService.remove(companyCode, parseInt(id));
return res.json({ success: true, message: "견적이 삭제되었습니다." });
} catch (error: any) {
logger.error("견적 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -0,0 +1,15 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as quoteController from "../controllers/quoteController";
const router = Router();
router.use(authenticateToken);
router.get("/list", quoteController.getList);
router.get("/generate-number", quoteController.generateNumber);
router.get("/:id", quoteController.getById);
router.post("/", quoteController.create);
router.put("/:id", quoteController.update);
router.delete("/:id", quoteController.remove);
export default router;

View File

@@ -0,0 +1,321 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
interface QuoteFilter {
search?: string;
status?: string;
startDate?: string;
endDate?: string;
}
interface QuoteBody {
quote_no?: string;
quote_date: string;
valid_until?: string;
customer_objid?: number;
customer_name?: string;
status?: string;
manager?: string;
domestic_type?: string;
payment_terms?: string;
delivery_method?: string;
notes?: string;
customer_ceo?: string;
customer_biz_no?: string;
customer_address?: string;
customer_contact?: string;
customer_phone?: string;
incoterms?: string;
currency?: string;
exchange_rate?: number;
port_of_loading?: string;
port_of_discharge?: string;
shipment_date?: string;
hs_code?: string;
country_of_origin?: string;
lc_number?: string;
trade_notes?: string;
items?: QuoteItem[];
}
interface QuoteItem {
item_no?: number;
item_code?: string;
item_name?: string;
spec?: string;
qty?: number;
unit?: string;
request_length?: number;
unit_price?: number;
supply_amount?: number;
vat_amount?: number;
total_amount?: number;
notes?: string;
}
export async function getList(companyCode: string, filter: QuoteFilter) {
const pool = getPool();
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`q.company_code = $${idx}`);
params.push(companyCode);
idx++;
}
conditions.push(`q.use_yn = 'Y'`);
if (filter.search) {
conditions.push(`(q.quote_no ILIKE $${idx} OR q.customer_name ILIKE $${idx})`);
params.push(`%${filter.search}%`);
idx++;
}
if (filter.status) {
conditions.push(`q.status = $${idx}`);
params.push(filter.status);
idx++;
}
if (filter.startDate) {
conditions.push(`q.quote_date >= $${idx}`);
params.push(filter.startDate);
idx++;
}
if (filter.endDate) {
conditions.push(`q.quote_date <= $${idx}`);
params.push(filter.endDate);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT q.objid, q.quote_no, q.quote_date, q.valid_until,
q.customer_objid, q.customer_name, q.status, q.manager,
q.domestic_type, q.payment_terms, q.delivery_method, q.notes,
q.total_supply, q.total_vat, q.total_amount,
q.customer_ceo, q.customer_biz_no, q.customer_address,
q.customer_contact, q.customer_phone,
q.incoterms, q.currency, q.exchange_rate,
q.port_of_loading, q.port_of_discharge, q.shipment_date,
q.hs_code, q.country_of_origin, q.lc_number, q.trade_notes,
q.revision_count, q.created_by, q.created_at, q.updated_at
FROM quote_mng q
${where}
ORDER BY q.created_at DESC
`;
const result = await pool.query(query, params);
logger.info("견적 목록 조회", { companyCode, count: result.rowCount });
return result.rows;
}
export async function getById(companyCode: string, objid: number) {
const pool = getPool();
// 마스터
const masterRes = await pool.query(
`SELECT * FROM quote_mng WHERE objid = $1 AND company_code = $2 AND use_yn = 'Y'`,
[objid, companyCode],
);
if (masterRes.rowCount === 0) return null;
// 품목
const detailRes = await pool.query(
`SELECT * FROM quote_detail WHERE quote_objid = $1 ORDER BY item_no`,
[objid],
);
return { ...masterRes.rows[0], items: detailRes.rows };
}
export async function generateNumber(companyCode: string): Promise<string> {
const pool = getPool();
const year = new Date().getFullYear();
const prefix = `QT-${year}-`;
const res = await pool.query(
`SELECT quote_no FROM quote_mng
WHERE company_code = $1 AND quote_no LIKE $2
ORDER BY quote_no DESC LIMIT 1`,
[companyCode, `${prefix}%`],
);
let seq = 1;
if (res.rowCount && res.rowCount > 0) {
const last = res.rows[0].quote_no as string;
const lastSeq = parseInt(last.replace(prefix, ""), 10);
if (!isNaN(lastSeq)) seq = lastSeq + 1;
}
return `${prefix}${String(seq).padStart(4, "0")}`;
}
export async function create(companyCode: string, userId: string, body: QuoteBody) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const quoteNo = body.quote_no || (await generateNumber(companyCode));
// 합계 계산
const items = body.items ?? [];
const totalSupply = items.reduce((s, i) => s + (i.supply_amount ?? 0), 0);
const totalVat = items.reduce((s, i) => s + (i.vat_amount ?? 0), 0);
const totalAmount = totalSupply + totalVat;
const masterRes = await client.query(
`INSERT INTO quote_mng (
quote_no, quote_date, valid_until, customer_objid, customer_name,
status, manager, domestic_type, payment_terms, delivery_method, notes,
total_supply, total_vat, total_amount,
customer_ceo, customer_biz_no, customer_address, customer_contact, customer_phone,
incoterms, currency, exchange_rate, port_of_loading, port_of_discharge,
shipment_date, hs_code, country_of_origin, lc_number, trade_notes,
company_code, created_by, updated_by
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,
$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$31
) RETURNING objid`,
[
quoteNo, body.quote_date, body.valid_until || null,
body.customer_objid || null, body.customer_name || null,
body.status || "draft", body.manager || null,
body.domestic_type || "국내", body.payment_terms || null,
body.delivery_method || null, body.notes || null,
totalSupply, totalVat, totalAmount,
body.customer_ceo || null, body.customer_biz_no || null,
body.customer_address || null, body.customer_contact || null,
body.customer_phone || null,
body.incoterms || null, body.currency || "KRW",
body.exchange_rate || null, body.port_of_loading || null,
body.port_of_discharge || null, body.shipment_date || null,
body.hs_code || null, body.country_of_origin || null,
body.lc_number || null, body.trade_notes || null,
companyCode, userId,
],
);
const quoteObjid = masterRes.rows[0].objid;
// 품목 INSERT
for (let i = 0; i < items.length; i++) {
const item = items[i];
await client.query(
`INSERT INTO quote_detail (
quote_objid, item_no, item_code, item_name, spec,
qty, unit, request_length, unit_price,
supply_amount, vat_amount, total_amount, notes, company_code
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
[
quoteObjid, i + 1, item.item_code || null, item.item_name || null,
item.spec || null, item.qty ?? 0, item.unit || "EA",
item.request_length || null, item.unit_price ?? 0,
item.supply_amount ?? 0, item.vat_amount ?? 0,
item.total_amount ?? 0, item.notes || null, companyCode,
],
);
}
await client.query("COMMIT");
logger.info("견적 등록 완료", { companyCode, quoteNo, quoteObjid });
return { objid: quoteObjid, quote_no: quoteNo };
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function update(companyCode: string, userId: string, objid: number, body: QuoteBody) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const items = body.items ?? [];
const totalSupply = items.reduce((s, i) => s + (i.supply_amount ?? 0), 0);
const totalVat = items.reduce((s, i) => s + (i.vat_amount ?? 0), 0);
const totalAmount = totalSupply + totalVat;
await client.query(
`UPDATE quote_mng SET
quote_date=$1, valid_until=$2, customer_objid=$3, customer_name=$4,
status=$5, manager=$6, domestic_type=$7, payment_terms=$8,
delivery_method=$9, notes=$10,
total_supply=$11, total_vat=$12, total_amount=$13,
customer_ceo=$14, customer_biz_no=$15, customer_address=$16,
customer_contact=$17, customer_phone=$18,
incoterms=$19, currency=$20, exchange_rate=$21,
port_of_loading=$22, port_of_discharge=$23, shipment_date=$24,
hs_code=$25, country_of_origin=$26, lc_number=$27, trade_notes=$28,
revision_count = revision_count + 1,
updated_by=$29, updated_at=CURRENT_TIMESTAMP
WHERE objid=$30 AND company_code=$31`,
[
body.quote_date, body.valid_until || null,
body.customer_objid || null, body.customer_name || null,
body.status || "draft", body.manager || null,
body.domestic_type || "국내", body.payment_terms || null,
body.delivery_method || null, body.notes || null,
totalSupply, totalVat, totalAmount,
body.customer_ceo || null, body.customer_biz_no || null,
body.customer_address || null, body.customer_contact || null,
body.customer_phone || null,
body.incoterms || null, body.currency || "KRW",
body.exchange_rate || null, body.port_of_loading || null,
body.port_of_discharge || null, body.shipment_date || null,
body.hs_code || null, body.country_of_origin || null,
body.lc_number || null, body.trade_notes || null,
userId, objid, companyCode,
],
);
// 기존 품목 삭제 후 재등록
await client.query(`DELETE FROM quote_detail WHERE quote_objid = $1`, [objid]);
for (let i = 0; i < items.length; i++) {
const item = items[i];
await client.query(
`INSERT INTO quote_detail (
quote_objid, item_no, item_code, item_name, spec,
qty, unit, request_length, unit_price,
supply_amount, vat_amount, total_amount, notes, company_code
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
[
objid, i + 1, item.item_code || null, item.item_name || null,
item.spec || null, item.qty ?? 0, item.unit || "EA",
item.request_length || null, item.unit_price ?? 0,
item.supply_amount ?? 0, item.vat_amount ?? 0,
item.total_amount ?? 0, item.notes || null, companyCode,
],
);
}
await client.query("COMMIT");
logger.info("견적 수정 완료", { companyCode, objid });
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function remove(companyCode: string, objid: number) {
const pool = getPool();
await pool.query(
`UPDATE quote_mng SET use_yn = 'N', updated_at = CURRENT_TIMESTAMP WHERE objid = $1 AND company_code = $2`,
[objid, companyCode],
);
logger.info("견적 삭제(소프트)", { companyCode, objid });
}