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:
321
backend-node/src/services/quoteService.ts
Normal file
321
backend-node/src/services/quoteService.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user