세금계산서 발행 완료
This commit is contained in:
@@ -75,6 +75,7 @@ import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
@@ -245,6 +246,7 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
|
||||
331
backend-node/src/controllers/taxInvoiceController.ts
Normal file
331
backend-node/src/controllers/taxInvoiceController.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* 세금계산서 컨트롤러
|
||||
* 세금계산서 API 엔드포인트 처리
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { TaxInvoiceService } from "../services/taxInvoiceService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class TaxInvoiceController {
|
||||
/**
|
||||
* 세금계산서 목록 조회
|
||||
* GET /api/tax-invoice
|
||||
*/
|
||||
static async getList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
page = "1",
|
||||
pageSize = "20",
|
||||
invoice_type,
|
||||
invoice_status,
|
||||
start_date,
|
||||
end_date,
|
||||
search,
|
||||
buyer_name,
|
||||
} = req.query;
|
||||
|
||||
const result = await TaxInvoiceService.getList(companyCode, {
|
||||
page: parseInt(page as string, 10),
|
||||
pageSize: parseInt(pageSize as string, 10),
|
||||
invoice_type: invoice_type as "sales" | "purchase" | undefined,
|
||||
invoice_status: invoice_status as string | undefined,
|
||||
start_date: start_date as string | undefined,
|
||||
end_date: end_date as string | undefined,
|
||||
search: search as string | undefined,
|
||||
buyer_name: buyer_name as string | undefined,
|
||||
});
|
||||
|
||||
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/tax-invoice/:id
|
||||
*/
|
||||
static async 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 result = await TaxInvoiceService.getById(id, companyCode);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 상세 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 생성
|
||||
* POST /api/tax-invoice
|
||||
*/
|
||||
static async 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 data = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.invoice_type) {
|
||||
res.status(400).json({ success: false, message: "세금계산서 유형은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
if (!data.invoice_date) {
|
||||
res.status(400).json({ success: false, message: "작성일자는 필수입니다." });
|
||||
return;
|
||||
}
|
||||
if (data.supply_amount === undefined || data.supply_amount === null) {
|
||||
res.status(400).json({ success: false, message: "공급가액은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await TaxInvoiceService.create(data, companyCode, userId);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 생성되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 생성 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 수정
|
||||
* PUT /api/tax-invoice/:id
|
||||
*/
|
||||
static async update(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 { id } = req.params;
|
||||
const data = req.body;
|
||||
|
||||
const result = await TaxInvoiceService.update(id, data, companyCode, userId);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 수정되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 수정 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 수정 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 삭제
|
||||
* DELETE /api/tax-invoice/:id
|
||||
*/
|
||||
static async delete(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 { id } = req.params;
|
||||
const result = await TaxInvoiceService.delete(id, companyCode, userId);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "세금계산서가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 삭제 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 발행
|
||||
* POST /api/tax-invoice/:id/issue
|
||||
*/
|
||||
static async issue(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 { id } = req.params;
|
||||
const result = await TaxInvoiceService.issue(id, companyCode, userId);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "세금계산서를 찾을 수 없거나 이미 발행된 상태입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 발행되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 발행 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 발행 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 취소
|
||||
* POST /api/tax-invoice/:id/cancel
|
||||
*/
|
||||
static async cancel(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 { id } = req.params;
|
||||
const { reason } = req.body;
|
||||
|
||||
const result = await TaxInvoiceService.cancel(id, companyCode, userId, reason);
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "세금계산서를 찾을 수 없거나 취소할 수 없는 상태입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "세금계산서가 취소되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("세금계산서 취소 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "세금계산서 취소 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
* GET /api/tax-invoice/stats/monthly
|
||||
*/
|
||||
static async getMonthlyStats(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { year, month } = req.query;
|
||||
const now = new Date();
|
||||
const targetYear = year ? parseInt(year as string, 10) : now.getFullYear();
|
||||
const targetMonth = month ? parseInt(month as string, 10) : now.getMonth() + 1;
|
||||
|
||||
const result = await TaxInvoiceService.getMonthlyStats(companyCode, targetYear, targetMonth);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
period: { year: targetYear, month: targetMonth },
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("월별 통계 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@ export const errorHandler = (
|
||||
// PostgreSQL 에러 처리 (pg 라이브러리)
|
||||
if ((err as any).code) {
|
||||
const pgError = err as any;
|
||||
// 원본 에러 메시지 로깅 (디버깅용)
|
||||
console.error("🔴 PostgreSQL Error:", {
|
||||
code: pgError.code,
|
||||
message: pgError.message,
|
||||
detail: pgError.detail,
|
||||
hint: pgError.hint,
|
||||
table: pgError.table,
|
||||
column: pgError.column,
|
||||
constraint: pgError.constraint,
|
||||
});
|
||||
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||
if (pgError.code === "23505") {
|
||||
// unique_violation
|
||||
@@ -42,7 +52,7 @@ export const errorHandler = (
|
||||
// 기타 무결성 제약 조건 위반
|
||||
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
||||
} else {
|
||||
error = new AppError("데이터베이스 오류가 발생했습니다.", 500);
|
||||
error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
40
backend-node/src/routes/taxInvoiceRoutes.ts
Normal file
40
backend-node/src/routes/taxInvoiceRoutes.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 세금계산서 라우터
|
||||
* /api/tax-invoice 경로 처리
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { TaxInvoiceController } from "../controllers/taxInvoiceController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 목록 조회
|
||||
router.get("/", TaxInvoiceController.getList);
|
||||
|
||||
// 월별 통계 (상세 조회보다 먼저 정의해야 함)
|
||||
router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats);
|
||||
|
||||
// 상세 조회
|
||||
router.get("/:id", TaxInvoiceController.getById);
|
||||
|
||||
// 생성
|
||||
router.post("/", TaxInvoiceController.create);
|
||||
|
||||
// 수정
|
||||
router.put("/:id", TaxInvoiceController.update);
|
||||
|
||||
// 삭제
|
||||
router.delete("/:id", TaxInvoiceController.delete);
|
||||
|
||||
// 발행
|
||||
router.post("/:id/issue", TaxInvoiceController.issue);
|
||||
|
||||
// 취소
|
||||
router.post("/:id/cancel", TaxInvoiceController.cancel);
|
||||
|
||||
export default router;
|
||||
|
||||
612
backend-node/src/services/taxInvoiceService.ts
Normal file
612
backend-node/src/services/taxInvoiceService.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* 세금계산서 서비스
|
||||
* 세금계산서 CRUD 및 비즈니스 로직 처리
|
||||
*/
|
||||
|
||||
import { query, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 세금계산서 타입 정의
|
||||
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;
|
||||
|
||||
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[]; // 첨부파일
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
} = 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++;
|
||||
}
|
||||
|
||||
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, 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
|
||||
) 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,
|
||||
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,
|
||||
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,
|
||||
]
|
||||
);
|
||||
|
||||
// 품목 업데이트 (기존 삭제 후 재생성)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user