/** * 세금계산서 서비스 * 세금계산서 CRUD 및 비즈니스 로직 처리 */ import { query, transaction } from "../database/db"; import { logger } from "../utils/logger"; // 비용 유형 타입 export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other"; // 세금계산서 타입 정의 export interface TaxInvoice { id: string; company_code: string; invoice_number: string; invoice_type: "sales" | "purchase"; // 매출/매입 invoice_status: "draft" | "issued" | "sent" | "cancelled"; // 공급자 정보 supplier_business_no: string; supplier_name: string; supplier_ceo_name: string; supplier_address: string; supplier_business_type: string; supplier_business_item: string; // 공급받는자 정보 buyer_business_no: string; buyer_name: string; buyer_ceo_name: string; buyer_address: string; buyer_email: string; // 금액 정보 supply_amount: number; tax_amount: number; total_amount: number; // 날짜 정보 invoice_date: string; issue_date: string | null; // 기타 remarks: string; order_id: string | null; customer_id: string | null; // 첨부파일 (JSON 배열로 저장) attachments: TaxInvoiceAttachment[] | null; // 비용 유형 (구매/설치/수리/유지보수/폐기/기타) cost_type: CostType | null; created_date: string; updated_date: string; writer: string; } // 첨부파일 타입 export interface TaxInvoiceAttachment { id: string; file_name: string; file_path: string; file_size: number; file_type: string; uploaded_at: string; uploaded_by: string; } export interface TaxInvoiceItem { id: string; tax_invoice_id: string; company_code: string; item_seq: number; item_date: string; item_name: string; item_spec: string; quantity: number; unit_price: number; supply_amount: number; tax_amount: number; remarks: string; } export interface CreateTaxInvoiceDto { invoice_type: "sales" | "purchase"; supplier_business_no?: string; supplier_name?: string; supplier_ceo_name?: string; supplier_address?: string; supplier_business_type?: string; supplier_business_item?: string; buyer_business_no?: string; buyer_name?: string; buyer_ceo_name?: string; buyer_address?: string; buyer_email?: string; supply_amount: number; tax_amount: number; total_amount: number; invoice_date: string; remarks?: string; order_id?: string; customer_id?: string; items?: CreateTaxInvoiceItemDto[]; attachments?: TaxInvoiceAttachment[]; // 첨부파일 cost_type?: CostType; // 비용 유형 } export interface CreateTaxInvoiceItemDto { item_date?: string; item_name: string; item_spec?: string; quantity: number; unit_price: number; supply_amount: number; tax_amount: number; remarks?: string; } export interface TaxInvoiceListParams { page?: number; pageSize?: number; invoice_type?: "sales" | "purchase"; invoice_status?: string; start_date?: string; end_date?: string; search?: string; buyer_name?: string; cost_type?: CostType; // 비용 유형 필터 } export class TaxInvoiceService { /** * 세금계산서 번호 채번 * 형식: YYYYMM-NNNNN (예: 202512-00001) */ static async generateInvoiceNumber(companyCode: string): Promise { const now = new Date(); const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`; const prefix = `${yearMonth}-`; // 해당 월의 마지막 번호 조회 const result = await query<{ max_num: string }>( `SELECT invoice_number as max_num FROM tax_invoice WHERE company_code = $1 AND invoice_number LIKE $2 ORDER BY invoice_number DESC LIMIT 1`, [companyCode, `${prefix}%`] ); let nextNum = 1; if (result.length > 0 && result[0].max_num) { const lastNum = parseInt(result[0].max_num.split("-")[1], 10); nextNum = lastNum + 1; } return `${prefix}${String(nextNum).padStart(5, "0")}`; } /** * 세금계산서 목록 조회 */ static async getList( companyCode: string, params: TaxInvoiceListParams ): Promise<{ data: TaxInvoice[]; total: number; page: number; pageSize: number }> { const { page = 1, pageSize = 20, invoice_type, invoice_status, start_date, end_date, search, buyer_name, cost_type, } = params; const offset = (page - 1) * pageSize; const conditions: string[] = ["company_code = $1"]; const values: any[] = [companyCode]; let paramIndex = 2; if (invoice_type) { conditions.push(`invoice_type = $${paramIndex}`); values.push(invoice_type); paramIndex++; } if (invoice_status) { conditions.push(`invoice_status = $${paramIndex}`); values.push(invoice_status); paramIndex++; } if (start_date) { conditions.push(`invoice_date >= $${paramIndex}`); values.push(start_date); paramIndex++; } if (end_date) { conditions.push(`invoice_date <= $${paramIndex}`); values.push(end_date); paramIndex++; } if (search) { conditions.push( `(invoice_number ILIKE $${paramIndex} OR buyer_name ILIKE $${paramIndex} OR supplier_name ILIKE $${paramIndex})` ); values.push(`%${search}%`); paramIndex++; } if (buyer_name) { conditions.push(`buyer_name ILIKE $${paramIndex}`); values.push(`%${buyer_name}%`); paramIndex++; } if (cost_type) { conditions.push(`cost_type = $${paramIndex}`); values.push(cost_type); paramIndex++; } const whereClause = conditions.join(" AND "); // 전체 개수 조회 const countResult = await query<{ count: string }>( `SELECT COUNT(*) as count FROM tax_invoice WHERE ${whereClause}`, values ); const total = parseInt(countResult[0]?.count || "0", 10); // 데이터 조회 values.push(pageSize, offset); const data = await query( `SELECT * FROM tax_invoice WHERE ${whereClause} ORDER BY created_date DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, values ); return { data, total, page, pageSize }; } /** * 세금계산서 상세 조회 (품목 포함) */ static async getById( id: string, companyCode: string ): Promise<{ invoice: TaxInvoice; items: TaxInvoiceItem[] } | null> { const invoiceResult = await query( `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, [id, companyCode] ); if (invoiceResult.length === 0) { return null; } const items = await query( `SELECT * FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2 ORDER BY item_seq`, [id, companyCode] ); return { invoice: invoiceResult[0], items }; } /** * 세금계산서 생성 */ static async create( data: CreateTaxInvoiceDto, companyCode: string, userId: string ): Promise { return await transaction(async (client) => { // 세금계산서 번호 채번 const invoiceNumber = await this.generateInvoiceNumber(companyCode); // 세금계산서 생성 const invoiceResult = await client.query( `INSERT INTO tax_invoice ( company_code, invoice_number, invoice_type, invoice_status, supplier_business_no, supplier_name, supplier_ceo_name, supplier_address, supplier_business_type, supplier_business_item, buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email, supply_amount, tax_amount, total_amount, invoice_date, remarks, order_id, customer_id, attachments, cost_type, writer ) VALUES ( $1, $2, $3, 'draft', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24 ) RETURNING *`, [ companyCode, invoiceNumber, data.invoice_type, data.supplier_business_no || null, data.supplier_name || null, data.supplier_ceo_name || null, data.supplier_address || null, data.supplier_business_type || null, data.supplier_business_item || null, data.buyer_business_no || null, data.buyer_name || null, data.buyer_ceo_name || null, data.buyer_address || null, data.buyer_email || null, data.supply_amount, data.tax_amount, data.total_amount, data.invoice_date, data.remarks || null, data.order_id || null, data.customer_id || null, data.attachments ? JSON.stringify(data.attachments) : null, data.cost_type || null, userId, ] ); const invoice = invoiceResult.rows[0]; // 품목 생성 if (data.items && data.items.length > 0) { for (let i = 0; i < data.items.length; i++) { const item = data.items[i]; await client.query( `INSERT INTO tax_invoice_item ( tax_invoice_id, company_code, item_seq, item_date, item_name, item_spec, quantity, unit_price, supply_amount, tax_amount, remarks ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [ invoice.id, companyCode, i + 1, item.item_date || null, item.item_name, item.item_spec || null, item.quantity, item.unit_price, item.supply_amount, item.tax_amount, item.remarks || null, ] ); } } logger.info("세금계산서 생성 완료", { invoiceId: invoice.id, invoiceNumber, companyCode, userId, }); return invoice; }); } /** * 세금계산서 수정 */ static async update( id: string, data: Partial, companyCode: string, userId: string ): Promise { return await transaction(async (client) => { // 기존 세금계산서 확인 const existing = await client.query( `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, [id, companyCode] ); if (existing.rows.length === 0) { return null; } // 발행된 세금계산서는 수정 불가 if (existing.rows[0].invoice_status !== "draft") { throw new Error("발행된 세금계산서는 수정할 수 없습니다."); } // 세금계산서 수정 const updateResult = await client.query( `UPDATE tax_invoice SET supplier_business_no = COALESCE($3, supplier_business_no), supplier_name = COALESCE($4, supplier_name), supplier_ceo_name = COALESCE($5, supplier_ceo_name), supplier_address = COALESCE($6, supplier_address), supplier_business_type = COALESCE($7, supplier_business_type), supplier_business_item = COALESCE($8, supplier_business_item), buyer_business_no = COALESCE($9, buyer_business_no), buyer_name = COALESCE($10, buyer_name), buyer_ceo_name = COALESCE($11, buyer_ceo_name), buyer_address = COALESCE($12, buyer_address), buyer_email = COALESCE($13, buyer_email), supply_amount = COALESCE($14, supply_amount), tax_amount = COALESCE($15, tax_amount), total_amount = COALESCE($16, total_amount), invoice_date = COALESCE($17, invoice_date), remarks = COALESCE($18, remarks), attachments = $19, cost_type = COALESCE($20, cost_type), updated_date = NOW() WHERE id = $1 AND company_code = $2 RETURNING *`, [ id, companyCode, data.supplier_business_no, data.supplier_name, data.supplier_ceo_name, data.supplier_address, data.supplier_business_type, data.supplier_business_item, data.buyer_business_no, data.buyer_name, data.buyer_ceo_name, data.buyer_address, data.buyer_email, data.supply_amount, data.tax_amount, data.total_amount, data.invoice_date, data.remarks, data.attachments ? JSON.stringify(data.attachments) : null, data.cost_type, ] ); // 품목 업데이트 (기존 삭제 후 재생성) if (data.items) { await client.query( `DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`, [id, companyCode] ); for (let i = 0; i < data.items.length; i++) { const item = data.items[i]; await client.query( `INSERT INTO tax_invoice_item ( tax_invoice_id, company_code, item_seq, item_date, item_name, item_spec, quantity, unit_price, supply_amount, tax_amount, remarks ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [ id, companyCode, i + 1, item.item_date || null, item.item_name, item.item_spec || null, item.quantity, item.unit_price, item.supply_amount, item.tax_amount, item.remarks || null, ] ); } } logger.info("세금계산서 수정 완료", { invoiceId: id, companyCode, userId }); return updateResult.rows[0]; }); } /** * 세금계산서 삭제 */ static async delete(id: string, companyCode: string, userId: string): Promise { return await transaction(async (client) => { // 기존 세금계산서 확인 const existing = await client.query( `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, [id, companyCode] ); if (existing.rows.length === 0) { return false; } // 발행된 세금계산서는 삭제 불가 if (existing.rows[0].invoice_status !== "draft") { throw new Error("발행된 세금계산서는 삭제할 수 없습니다."); } // 품목 삭제 await client.query( `DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`, [id, companyCode] ); // 세금계산서 삭제 await client.query(`DELETE FROM tax_invoice WHERE id = $1 AND company_code = $2`, [ id, companyCode, ]); logger.info("세금계산서 삭제 완료", { invoiceId: id, companyCode, userId }); return true; }); } /** * 세금계산서 발행 (상태 변경) */ static async issue(id: string, companyCode: string, userId: string): Promise { const result = await query( `UPDATE tax_invoice SET invoice_status = 'issued', issue_date = NOW(), updated_date = NOW() WHERE id = $1 AND company_code = $2 AND invoice_status = 'draft' RETURNING *`, [id, companyCode] ); if (result.length === 0) { return null; } logger.info("세금계산서 발행 완료", { invoiceId: id, companyCode, userId }); return result[0]; } /** * 세금계산서 취소 */ static async cancel( id: string, companyCode: string, userId: string, reason?: string ): Promise { const result = await query( `UPDATE tax_invoice SET invoice_status = 'cancelled', remarks = CASE WHEN $3 IS NOT NULL THEN remarks || ' [취소사유: ' || $3 || ']' ELSE remarks END, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND invoice_status IN ('draft', 'issued') RETURNING *`, [id, companyCode, reason || null] ); if (result.length === 0) { return null; } logger.info("세금계산서 취소 완료", { invoiceId: id, companyCode, userId, reason }); return result[0]; } /** * 월별 통계 조회 */ static async getMonthlyStats( companyCode: string, year: number, month: number ): Promise<{ sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; }> { const startDate = `${year}-${String(month).padStart(2, "0")}-01`; const endDate = new Date(year, month, 0).toISOString().split("T")[0]; // 해당 월 마지막 날 const result = await query<{ invoice_type: string; count: string; supply_amount: string; tax_amount: string; total_amount: string; }>( `SELECT invoice_type, COUNT(*) as count, COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount, COALESCE(SUM(total_amount), 0) as total_amount FROM tax_invoice WHERE company_code = $1 AND invoice_date >= $2 AND invoice_date <= $3 AND invoice_status != 'cancelled' GROUP BY invoice_type`, [companyCode, startDate, endDate] ); const stats = { sales: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 }, purchase: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 }, }; for (const row of result) { const type = row.invoice_type as "sales" | "purchase"; stats[type] = { count: parseInt(row.count, 10), supply_amount: parseFloat(row.supply_amount), tax_amount: parseFloat(row.tax_amount), total_amount: parseFloat(row.total_amount), }; } return stats; } /** * 비용 유형별 통계 조회 */ static async getCostTypeStats( companyCode: string, year?: number, month?: number ): Promise<{ by_cost_type: Array<{ cost_type: CostType | null; count: number; supply_amount: number; tax_amount: number; total_amount: number; }>; by_month: Array<{ year_month: string; cost_type: CostType | null; count: number; total_amount: number; }>; summary: { total_count: number; total_amount: number; purchase_amount: number; installation_amount: number; repair_amount: number; maintenance_amount: number; disposal_amount: number; other_amount: number; }; }> { const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"]; const values: any[] = [companyCode]; let paramIndex = 2; // 연도/월 필터 if (year && month) { const startDate = `${year}-${String(month).padStart(2, "0")}-01`; const endDate = new Date(year, month, 0).toISOString().split("T")[0]; conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`); values.push(startDate, endDate); paramIndex += 2; } else if (year) { conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`); values.push(year); paramIndex++; } const whereClause = conditions.join(" AND "); // 비용 유형별 집계 const byCostType = await query<{ cost_type: CostType | null; count: string; supply_amount: string; tax_amount: string; total_amount: string; }>( `SELECT cost_type, COUNT(*) as count, COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount, COALESCE(SUM(total_amount), 0) as total_amount FROM tax_invoice WHERE ${whereClause} GROUP BY cost_type ORDER BY total_amount DESC`, values ); // 월별 비용 유형 집계 const byMonth = await query<{ year_month: string; cost_type: CostType | null; count: string; total_amount: string; }>( `SELECT TO_CHAR(invoice_date, 'YYYY-MM') as year_month, cost_type, COUNT(*) as count, COALESCE(SUM(total_amount), 0) as total_amount FROM tax_invoice WHERE ${whereClause} GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type ORDER BY year_month DESC, cost_type`, values ); // 전체 요약 const summaryResult = await query<{ total_count: string; total_amount: string; purchase_amount: string; installation_amount: string; repair_amount: string; maintenance_amount: string; disposal_amount: string; other_amount: string; }>( `SELECT COUNT(*) as total_count, COALESCE(SUM(total_amount), 0) as total_amount, COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount, COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount, COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount, COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount, COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount, COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount FROM tax_invoice WHERE ${whereClause}`, values ); const summary = summaryResult[0] || { total_count: "0", total_amount: "0", purchase_amount: "0", installation_amount: "0", repair_amount: "0", maintenance_amount: "0", disposal_amount: "0", other_amount: "0", }; return { by_cost_type: byCostType.map((row) => ({ cost_type: row.cost_type, count: parseInt(row.count, 10), supply_amount: parseFloat(row.supply_amount), tax_amount: parseFloat(row.tax_amount), total_amount: parseFloat(row.total_amount), })), by_month: byMonth.map((row) => ({ year_month: row.year_month, cost_type: row.cost_type, count: parseInt(row.count, 10), total_amount: parseFloat(row.total_amount), })), summary: { total_count: parseInt(summary.total_count, 10), total_amount: parseFloat(summary.total_amount), purchase_amount: parseFloat(summary.purchase_amount), installation_amount: parseFloat(summary.installation_amount), repair_amount: parseFloat(summary.repair_amount), maintenance_amount: parseFloat(summary.maintenance_amount), disposal_amount: parseFloat(summary.disposal_amount), other_amount: parseFloat(summary.other_amount), }, }; } }