Merge remote-tracking branch 'origin/main' into ksh
This commit is contained in:
@@ -8,6 +8,7 @@ import path from "path";
|
||||
import config from "./config/environment";
|
||||
import { logger } from "./utils/logger";
|
||||
import { errorHandler } from "./middleware/errorHandler";
|
||||
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||
|
||||
// 라우터 임포트
|
||||
import authRoutes from "./routes/authRoutes";
|
||||
@@ -74,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"; // 임시 주석
|
||||
@@ -168,6 +170,10 @@ const limiter = rateLimit({
|
||||
});
|
||||
app.use("/api/", limiter);
|
||||
|
||||
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
||||
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
||||
app.use("/api/", refreshTokenIfNeeded);
|
||||
|
||||
// 헬스 체크 엔드포인트
|
||||
app.get("/health", (req, res) => {
|
||||
res.status(200).json({
|
||||
@@ -240,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); // 임시 주석
|
||||
|
||||
@@ -527,6 +527,53 @@ export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, re
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 코드로 라벨 조회
|
||||
*
|
||||
* POST /api/table-categories/labels-by-codes
|
||||
*
|
||||
* Body:
|
||||
* - valueCodes: 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"])
|
||||
*
|
||||
* Response:
|
||||
* - { [code]: label } 형태의 매핑 객체
|
||||
*/
|
||||
export const getCategoryLabelsByCodes = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { valueCodes } = req.body;
|
||||
|
||||
if (!valueCodes || !Array.isArray(valueCodes) || valueCodes.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("카테고리 코드로 라벨 조회", {
|
||||
valueCodes,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const labels = await tableCategoryValueService.getCategoryLabelsByCodes(
|
||||
valueCodes,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: labels,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 라벨 조회 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 라벨 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 2레벨 메뉴 목록 조회
|
||||
*
|
||||
|
||||
@@ -67,7 +67,7 @@ export class TableHistoryController {
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
|
||||
// 이력 조회 쿼리
|
||||
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
log_id,
|
||||
@@ -84,7 +84,7 @@ export class TableHistoryController {
|
||||
full_row_after
|
||||
FROM ${logTableName}
|
||||
WHERE ${whereClause}
|
||||
ORDER BY changed_at DESC
|
||||
ORDER BY log_id DESC
|
||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
@@ -196,7 +196,7 @@ export class TableHistoryController {
|
||||
|
||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
|
||||
// 이력 조회 쿼리
|
||||
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
log_id,
|
||||
@@ -213,7 +213,7 @@ export class TableHistoryController {
|
||||
full_row_after
|
||||
FROM ${logTableName}
|
||||
${whereClause}
|
||||
ORDER BY changed_at DESC
|
||||
ORDER BY log_id DESC
|
||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
|
||||
365
backend-node/src/controllers/taxInvoiceController.ts
Normal file
365
backend-node/src/controllers/taxInvoiceController.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 세금계산서 컨트롤러
|
||||
* 세금계산서 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,
|
||||
cost_type,
|
||||
} = 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,
|
||||
cost_type: cost_type as any,
|
||||
});
|
||||
|
||||
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 || "통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 유형별 통계 조회
|
||||
* GET /api/tax-invoice/stats/cost-type
|
||||
*/
|
||||
static async getCostTypeStats(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 targetYear = year ? parseInt(year as string, 10) : undefined;
|
||||
const targetMonth = month ? parseInt(month as string, 10) : undefined;
|
||||
|
||||
const result = await TaxInvoiceService.getCostTypeStats(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 || "통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,16 +54,17 @@ export const authenticateToken = (
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
|
||||
);
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
|
||||
|
||||
// 토큰 만료 에러인지 확인
|
||||
const isTokenExpired = errorMessage.includes("만료");
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_TOKEN",
|
||||
details:
|
||||
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
|
||||
code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
|
||||
details: errorMessage || "토큰 검증에 실패했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
deleteColumnMapping,
|
||||
deleteColumnMappingsByColumn,
|
||||
getSecondLevelMenus,
|
||||
getCategoryLabelsByCodes,
|
||||
} from "../controllers/tableCategoryValueController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
@@ -42,6 +43,9 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
|
||||
// 카테고리 값 순서 변경
|
||||
router.post("/values/reorder", reorderCategoryValues);
|
||||
|
||||
// 카테고리 코드로 라벨 조회
|
||||
router.post("/labels-by-codes", getCategoryLabelsByCodes);
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
||||
43
backend-node/src/routes/taxInvoiceRoutes.ts
Normal file
43
backend-node/src/routes/taxInvoiceRoutes.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 세금계산서 라우터
|
||||
* /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("/stats/cost-type", TaxInvoiceController.getCostTypeStats);
|
||||
|
||||
// 상세 조회
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
784
backend-node/src/services/taxInvoiceService.ts
Normal file
784
backend-node/src/services/taxInvoiceService.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user