Merge remote-tracking branch 'origin/main' into ksh

This commit is contained in:
SeongHyun Kim
2025-12-09 09:24:33 +09:00
122 changed files with 10018 additions and 1710 deletions

View File

@@ -907,8 +907,27 @@ class DataService {
return validation.error!;
}
const columns = Object.keys(data);
const values = Object.values(data);
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
const tableColumns = await this.getTableColumnsSimple(tableName);
const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name));
const invalidColumns: string[] = [];
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key]) => {
if (validColumnNames.has(key)) {
return true;
}
invalidColumns.push(key);
return false;
})
);
if (invalidColumns.length > 0) {
console.log(`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
}
const columns = Object.keys(filteredData);
const values = Object.values(filteredData);
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
const columnNames = columns.map((col) => `"${col}"`).join(", ");
@@ -951,9 +970,28 @@ class DataService {
// _relationInfo 추출 (조인 관계 업데이트용)
const relationInfo = data._relationInfo;
const cleanData = { ...data };
let cleanData = { ...data };
delete cleanData._relationInfo;
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
const tableColumns = await this.getTableColumnsSimple(tableName);
const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name));
const invalidColumns: string[] = [];
cleanData = Object.fromEntries(
Object.entries(cleanData).filter(([key]) => {
if (validColumnNames.has(key)) {
return true;
}
invalidColumns.push(key);
return false;
})
);
if (invalidColumns.length > 0) {
console.log(`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
}
// Primary Key 컬럼 찾기
const pkResult = await query<{ attname: string }>(
`SELECT a.attname

View File

@@ -506,6 +506,24 @@ export class DynamicFormService {
// 헤더 + 품목을 병합
const rawMergedData = { ...dataToInsert, ...item };
// 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함
// _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE)
// 그 외의 경우는 모두 새 레코드로 처리 (INSERT)
const isExistingRecord = rawMergedData._existingRecord === true;
if (!isExistingRecord) {
// 새 레코드: id 제거하여 새 UUID 자동 생성
const oldId = rawMergedData.id;
delete rawMergedData.id;
console.log(`🆕 새 레코드로 처리 (id 제거됨: ${oldId})`);
} else {
console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`);
}
// 메타 플래그 제거
delete rawMergedData._isNewItem;
delete rawMergedData._existingRecord;
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
const validColumnNames = columnInfo.map((col) => col.column_name);
const mergedData: Record<string, any> = {};
@@ -1160,7 +1178,15 @@ export class DynamicFormService {
console.log("📝 실행할 DELETE SQL:", deleteQuery);
console.log("📊 SQL 파라미터:", [id]);
const result = await query<any>(deleteQuery, [id]);
// 🔥 트랜잭션 내에서 app.user_id 설정 후 DELETE 실행 (이력 트리거용)
const result = await transaction(async (client) => {
// 이력 트리거에서 사용할 사용자 정보 설정
if (userId) {
await client.query(`SET LOCAL app.user_id = '${userId}'`);
}
const res = await client.query(deleteQuery, [id]);
return res.rows;
});
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);

View File

@@ -27,6 +27,7 @@ export type NodeType =
| "restAPISource"
| "condition"
| "dataTransform"
| "aggregate"
| "insertAction"
| "updateAction"
| "deleteAction"
@@ -528,6 +529,9 @@ export class NodeFlowExecutionService {
case "dataTransform":
return this.executeDataTransform(node, inputData, context);
case "aggregate":
return this.executeAggregate(node, inputData, context);
case "insertAction":
return this.executeInsertAction(node, inputData, context, client);
@@ -830,11 +834,18 @@ export class NodeFlowExecutionService {
const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`;
logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`);
const result = await query(sql, whereResult.values);
logger.info(
`📊 테이블 전체 데이터 조회: ${tableName}, ${result.length}`
);
// 디버깅: 조회된 데이터 샘플 출력
if (result.length > 0) {
logger.info(`📊 조회된 데이터 샘플: ${JSON.stringify(result[0])?.substring(0, 300)}`);
}
return result;
}
@@ -1355,57 +1366,64 @@ export class NodeFlowExecutionService {
let updatedCount = 0;
const updatedDataArray: any[] = [];
// 🆕 table-all 모드: 단일 SQL로 일괄 업데이트
// 🆕 table-all 모드: 각 그룹별로 UPDATE 실행 (집계 결과 반영)
if (context.currentNodeDataSourceType === "table-all") {
console.log("🚀 table-all 모드: 단일 SQL로 일괄 업데이트 시작");
console.log("🚀 table-all 모드: 그룹별 업데이트 시작 (총 " + dataArray.length + "개 그룹)");
// 첫 번째 데이터를 참조하여 SET 절 생성
const firstData = dataArray[0];
const setClauses: string[] = [];
const values: any[] = [];
let paramIndex = 1;
// 🔥 각 그룹(데이터)별로 UPDATE 실행
for (let i = 0; i < dataArray.length; i++) {
const data = dataArray[i];
const setClauses: string[] = [];
const values: any[] = [];
let paramIndex = 1;
console.log("🗺️ 필드 매핑 처리 중...");
fieldMappings.forEach((mapping: any) => {
const value =
mapping.staticValue !== undefined
? mapping.staticValue
: firstData[mapping.sourceField];
console.log(`\n📦 그룹 ${i + 1}/${dataArray.length} 처리 중...`);
console.log("🗺️ 필드 매핑 처리 중...");
fieldMappings.forEach((mapping: any) => {
const value =
mapping.staticValue !== undefined
? mapping.staticValue
: data[mapping.sourceField];
console.log(
` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
console.log(
` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
);
if (mapping.targetField) {
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
});
// WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함)
const whereResult = this.buildWhereClause(
whereConditions,
data,
paramIndex
);
if (mapping.targetField) {
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
});
values.push(...whereResult.values);
// WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함)
const whereResult = this.buildWhereClause(
whereConditions,
firstData,
paramIndex
);
const sql = `
UPDATE ${targetTable}
SET ${setClauses.join(", ")}
${whereResult.clause}
`;
values.push(...whereResult.values);
console.log("📝 실행할 SQL:", sql);
console.log("📊 바인딩 값:", values);
const sql = `
UPDATE ${targetTable}
SET ${setClauses.join(", ")}
${whereResult.clause}
`;
console.log("📝 실행할 SQL (일괄 처리):", sql);
console.log("📊 바인딩 값:", values);
const result = await txClient.query(sql, values);
updatedCount = result.rowCount || 0;
const result = await txClient.query(sql, values);
const rowCount = result.rowCount || 0;
updatedCount += rowCount;
console.log(`✅ 그룹 ${i + 1} UPDATE 완료: ${rowCount}`);
}
logger.info(
`✅ UPDATE 완료 (내부 DB, 일괄 처리): ${targetTable}, ${updatedCount}`
`✅ UPDATE 완료 (내부 DB, 그룹별 처리): ${targetTable}, ${updatedCount}`
);
// 업데이트된 데이터는 원본 배열 반환 (실제 DB에서 다시 조회하지 않음)
@@ -3197,4 +3215,168 @@ export class NodeFlowExecutionService {
"upsertAction",
].includes(nodeType);
}
/**
* 집계 노드 실행 (SUM, COUNT, AVG, MIN, MAX 등)
*/
private static async executeAggregate(
node: FlowNode,
inputData: any,
context: ExecutionContext
): Promise<any[]> {
const { groupByFields = [], aggregations = [], havingConditions = [] } = node.data;
logger.info(`📊 집계 노드 실행: ${node.data.displayName || node.id}`);
// 입력 데이터가 없으면 빈 배열 반환
if (!inputData || !Array.isArray(inputData) || inputData.length === 0) {
logger.warn("⚠️ 집계할 입력 데이터가 없습니다.");
logger.warn(`⚠️ inputData 타입: ${typeof inputData}, 값: ${JSON.stringify(inputData)?.substring(0, 200)}`);
return [];
}
logger.info(`📥 입력 데이터: ${inputData.length}`);
logger.info(`📥 입력 데이터 샘플: ${JSON.stringify(inputData[0])?.substring(0, 300)}`);
logger.info(`📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}`);
logger.info(`📊 집계 연산: ${aggregations.length}`);
// 그룹화 수행
const groups = new Map<string, any[]>();
for (const row of inputData) {
// 그룹 키 생성
const groupKey = groupByFields.length > 0
? groupByFields.map((f: any) => String(row[f.field] ?? "")).join("|||")
: "__ALL__";
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey)!.push(row);
}
logger.info(`📊 그룹 수: ${groups.size}`);
// 디버깅: 각 그룹의 데이터 출력
for (const [groupKey, groupRows] of groups) {
logger.info(`📊 그룹 [${groupKey}]: ${groupRows.length}건, inbound_qty 합계: ${groupRows.reduce((sum, row) => sum + parseFloat(row.inbound_qty || 0), 0)}`);
}
// 각 그룹에 대해 집계 수행
const results: any[] = [];
for (const [groupKey, groupRows] of groups) {
const resultRow: any = {};
// 그룹 기준 필드값 추가
if (groupByFields.length > 0) {
const keyValues = groupKey.split("|||");
groupByFields.forEach((field: any, idx: number) => {
resultRow[field.field] = keyValues[idx];
});
}
// 각 집계 연산 수행
for (const agg of aggregations) {
const { sourceField, function: aggFunc, outputField } = agg;
if (!outputField) continue;
let aggregatedValue: any;
switch (aggFunc) {
case "SUM":
aggregatedValue = groupRows.reduce((sum: number, row: any) => {
const val = parseFloat(row[sourceField]);
return sum + (isNaN(val) ? 0 : val);
}, 0);
break;
case "COUNT":
aggregatedValue = groupRows.length;
break;
case "AVG":
const sum = groupRows.reduce((acc: number, row: any) => {
const val = parseFloat(row[sourceField]);
return acc + (isNaN(val) ? 0 : val);
}, 0);
aggregatedValue = groupRows.length > 0 ? sum / groupRows.length : 0;
break;
case "MIN":
aggregatedValue = groupRows.reduce((min: number | null, row: any) => {
const val = parseFloat(row[sourceField]);
if (isNaN(val)) return min;
return min === null ? val : Math.min(min, val);
}, null);
break;
case "MAX":
aggregatedValue = groupRows.reduce((max: number | null, row: any) => {
const val = parseFloat(row[sourceField]);
if (isNaN(val)) return max;
return max === null ? val : Math.max(max, val);
}, null);
break;
case "FIRST":
aggregatedValue = groupRows.length > 0 ? groupRows[0][sourceField] : null;
break;
case "LAST":
aggregatedValue = groupRows.length > 0 ? groupRows[groupRows.length - 1][sourceField] : null;
break;
default:
logger.warn(`⚠️ 지원하지 않는 집계 함수: ${aggFunc}`);
aggregatedValue = null;
}
resultRow[outputField] = aggregatedValue;
logger.info(` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}`);
}
results.push(resultRow);
}
// HAVING 조건 적용 (집계 후 필터링)
let filteredResults = results;
if (havingConditions && havingConditions.length > 0) {
filteredResults = results.filter((row) => {
return havingConditions.every((condition: any) => {
const fieldValue = row[condition.field];
const compareValue = parseFloat(condition.value);
switch (condition.operator) {
case "=":
return fieldValue === compareValue;
case "!=":
return fieldValue !== compareValue;
case ">":
return fieldValue > compareValue;
case ">=":
return fieldValue >= compareValue;
case "<":
return fieldValue < compareValue;
case "<=":
return fieldValue <= compareValue;
default:
return true;
}
});
});
logger.info(`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}`);
}
logger.info(`✅ 집계 완료: ${filteredResults.length}건 결과`);
// 결과 샘플 출력
if (filteredResults.length > 0) {
logger.info(`📄 결과 샘플:`, JSON.stringify(filteredResults[0], null, 2));
}
return filteredResults;
}
}

View File

@@ -1258,6 +1258,70 @@ class TableCategoryValueService {
throw error;
}
}
/**
* 카테고리 코드로 라벨 조회
*
* @param valueCodes - 카테고리 코드 배열
* @param companyCode - 회사 코드
* @returns { [code]: label } 형태의 매핑 객체
*/
async getCategoryLabelsByCodes(
valueCodes: string[],
companyCode: string
): Promise<Record<string, string>> {
try {
if (!valueCodes || valueCodes.length === 0) {
return {};
}
logger.info("카테고리 코드로 라벨 조회", { valueCodes, companyCode });
const pool = getPool();
// 동적으로 파라미터 플레이스홀더 생성
const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders})
AND is_active = true
`;
params = valueCodes;
} else {
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders})
AND is_active = true
AND (company_code = $${valueCodes.length + 1} OR company_code = '*')
`;
params = [...valueCodes, companyCode];
}
const result = await pool.query(query, params);
// { [code]: label } 형태로 변환
const labels: Record<string, string> = {};
for (const row of result.rows) {
labels[row.value_code] = row.value_label;
}
logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode });
return labels;
} catch (error: any) {
logger.error(`카테고리 코드로 라벨 조회 실패: ${error.message}`, { error });
throw error;
}
}
}
export default new TableCategoryValueService();

View File

@@ -0,0 +1,784 @@
/**
* 세금계산서 서비스
* 세금계산서 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<string> {
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<TaxInvoice>(
`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<TaxInvoice>(
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (invoiceResult.length === 0) {
return null;
}
const items = await query<TaxInvoiceItem>(
`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<TaxInvoice> {
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<CreateTaxInvoiceDto>,
companyCode: string,
userId: string
): Promise<TaxInvoice | null> {
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<boolean> {
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<TaxInvoice | null> {
const result = await query<TaxInvoice>(
`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<TaxInvoice | null> {
const result = await query<TaxInvoice>(
`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),
},
};
}
}