G8 견적/주문 삭제 가드 — 프로젝트가 생성된 건은 차단 (wace deleteContractMngInfo 1:1)

- salesOrderMgmtService에 assertNoProjectExists(client, contractObjid) 신설. wace 매퍼 getProjectListBycontractObjid:3909와 동일하게 SELECT 1 FROM project_mgmt WHERE contract_objid=$1 LIMIT 1 (status 필터 없음).
- salesEstimateService.remove + salesOrderMgmtService.remove 둘 다 BEGIN 직후 가드 호출. 차단 시 AppError("프로젝트가 생성된 건은 삭제할 수 없습니다.", 409) throw.
- 컨트롤러 remove 두 곳에서 error.statusCode ?? 500으로 응답 → 409 정확히 전달.
- DB 검증: 26C-0698(project 11건) 차단, project 없는 contract 통과.

G7(contract_item UPSERT)는 사실상 이미 구현 — upsertItems 두 서비스 모두 INSERT ... ON CONFLICT (objid) DO UPDATE 패턴으로 OBJID 유지 보장 중. 별도 코드 변경 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-11 16:33:28 +09:00
parent 902118d46e
commit 0208a072c2
4 changed files with 43 additions and 8 deletions
@@ -68,7 +68,8 @@ export async function remove(req: AuthenticatedRequest, res: Response) {
return res.json({ success: true, message: "견적이 삭제되었습니다." });
} catch (error: any) {
logger.error("견적 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
// AppError(statusCode) 우선 — G8 guard 409 등
return res.status(error?.statusCode ?? 500).json({ success: false, message: error.message });
}
}
@@ -49,7 +49,11 @@ export async function remove(req: AuthenticatedRequest, res: Response) {
const { id } = req.params;
await svc.remove(id);
return res.json({ success: true, message: "주문서가 삭제되었습니다." });
} catch (e: any) { logger.error("주문서 삭제 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); }
} catch (e: any) {
logger.error("주문서 삭제 실패", { error: e.message });
// AppError(statusCode) 우선 — G8 guard 409 등
return res.status(e?.statusCode ?? 500).json({ success: false, message: e.message });
}
}
export async function getFormView(req: AuthenticatedRequest, res: Response) {
@@ -10,7 +10,7 @@ import { PDFDocument } from "pdf-lib";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { sendMailUTF8 } from "../utils/mailUtil";
import { generateContractNo } from "./salesOrderMgmtService";
import { generateContractNo, assertNoProjectExists } from "./salesOrderMgmtService";
// ─── 타입 ─────────────────────────────────────────────────────
@@ -837,13 +837,16 @@ function textToHtml(text: string): string {
// ─── 삭제 ─────────────────────────────────────────────────────
// 시리얼 → 라인 → 헤더(contract_mgmt) 순.
// (G8 — 프로젝트 존재 시 삭제 방지는 별도 PR)
// G8 — 프로젝트가 생성된 건은 삭제 차단 (wace ContractMgmtService.deleteContractMngInfo:794-808 동일).
// helper는 project_mgmt 도메인 주인인 salesOrderMgmtService에 정의 (assertNoProjectExists).
export async function remove(objid: string) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// G8 guard
await assertNoProjectExists(client, objid);
await client.query(
`UPDATE contract_item_serial SET status='INACTIVE'
WHERE item_objid IN (SELECT objid FROM contract_item WHERE contract_objid=$1)`,
@@ -8,6 +8,7 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { AppError } from "../middleware/errorHandler";
export interface OrderListFilter {
category_cd?: string;
@@ -519,12 +520,38 @@ export async function update(userId: string, objid: string, body: OrderBody) {
} finally { client.release(); }
}
// G8 — 프로젝트가 생성된 contract는 견적/주문 모두 삭제 차단.
// wace deleteContractMngInfo:794-808 + mapper getProjectListBycontractObjid:3909 1:1.
// 원본 매퍼는 status 필터 없이 단순 contract_objid 매칭 → 그대로 따름.
// salesEstimateService.remove에서도 import해서 사용.
export async function assertNoProjectExists(client: any, contractObjid: string) {
const r = await client.query(
`SELECT 1 FROM project_mgmt WHERE contract_objid=$1 LIMIT 1`,
[contractObjid],
);
if (r.rowCount > 0) {
throw new AppError("프로젝트가 생성된 건은 삭제할 수 없습니다.", 409);
}
}
export async function remove(objid: string) {
const pool = getPool();
await pool.query(`DELETE FROM contract_mgmt WHERE objid=$1`, [objid]);
// contract_item / contract_item_serial은 ON DELETE CASCADE 이지만,
// estimate_template (contract_objid 참조)는 FK가 없어 남을 수 있음 — 정책 결정 필요.
logger.info("주문서 삭제", { objid });
const client = await pool.connect();
try {
await client.query("BEGIN");
// G8 guard
await assertNoProjectExists(client, objid);
await client.query(`DELETE FROM contract_mgmt WHERE objid=$1`, [objid]);
await client.query("COMMIT");
// contract_item / contract_item_serial은 ON DELETE CASCADE.
// estimate_template (contract_objid 참조)는 FK 없어 남을 수 있음 — 별도 정책 결정 영역.
logger.info("주문서 삭제", { objid });
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
// ─── 수주확정 → 프로젝트 자동생성 (wace ContractMgmtService.updateOrderStatus 이식) ──