diff --git a/backend-node/.env.example b/backend-node/.env.example index 807ae916..ba41810f 100644 --- a/backend-node/.env.example +++ b/backend-node/.env.example @@ -15,3 +15,25 @@ DOCUMENT_DATA_SOURCE=memory # https://openweathermap.org/api 에서 무료 가입 후 발급 OPENWEATHER_API_KEY=your_openweathermap_api_key_here + +# ==================== SMTP (견적서/발주서 등 메일 발송) ==================== +# wace Constants.Mail 1:1. 호스트는 ERP/SALES/PURCHASE 모두 동일. +# 운영 발송 OFF: SMTP_SEND_SWITCH=N (mail_log INSERT만 수행, Transport.send 스킵) + +SMTP_HOST=erp.rps-korea.com +SMTP_PORT=25 +SMTP_TLS=false +SMTP_SEND_SWITCH=Y + +# 기본/ERP 계정 +SMTP_USER_ERP=erp@example.com +SMTP_PW_ERP=your_erp_password + +# 영업팀 (견적서 등) — accountType=SALES 사용 시 +SMTP_USER_SALES=sales@example.com +SMTP_PW_SALES=your_sales_password + +# 구매팀 (발주서 등) — accountType=PURCHASE 사용 시 +SMTP_USER_PURCHASE=purchase@example.com +SMTP_PW_PURCHASE=your_purchase_password + diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index c68b1172..c4708087 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -38,6 +38,7 @@ "node-pop3": "^0.11.0", "nodemailer": "^6.10.1", "oracledb": "^6.9.0", + "pdf-lib": "^1.17.1", "pg": "^8.16.3", "quill": "^2.0.3", "react-quill": "^2.0.0", @@ -2363,6 +2364,24 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -9686,6 +9705,24 @@ "node": ">=8" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index e827da0c..d9410550 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -52,6 +52,7 @@ "node-pop3": "^0.11.0", "nodemailer": "^6.10.1", "oracledb": "^6.9.0", + "pdf-lib": "^1.17.1", "pg": "^8.16.3", "quill": "^2.0.3", "react-quill": "^2.0.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 4c112738..97a13d9b 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -175,6 +175,7 @@ import salesEstimateRoutes from "./routes/salesEstimateRoutes"; // 영업관리> import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식) import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인) import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers) +import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식) import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목) import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리 import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리 @@ -419,6 +420,7 @@ app.use("/api/sales/estimate", salesEstimateRoutes); // 영업관리>견적 (wac app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문서 (wace_plm 도메인) app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인) app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers) +app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인) app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/projectMgmtController.ts b/backend-node/src/controllers/projectMgmtController.ts new file mode 100644 index 00000000..04556e3e --- /dev/null +++ b/backend-node/src/controllers/projectMgmtController.ts @@ -0,0 +1,49 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/projectMgmtService"; +import { logger } from "../utils/logger"; + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const filter = req.query as Record; + const data = await svc.listProgress(filter); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("진행관리 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getProjectNoOptions(_req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.listProjectNoOptions(); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("프로젝트번호 옵션 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getById(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const data = await svc.getById(id); + if (!data) return res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("진행관리 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function update(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const rowCount = await svc.updateProject(id, req.body); + if (rowCount === 0) return res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); + return res.json({ success: true, message: "프로젝트가 수정되었습니다." }); + } catch (e: any) { + logger.error("진행관리 수정 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/controllers/salesEstimateController.ts b/backend-node/src/controllers/salesEstimateController.ts index 4f8b7fb5..33d9b1a4 100644 --- a/backend-node/src/controllers/salesEstimateController.ts +++ b/backend-node/src/controllers/salesEstimateController.ts @@ -68,35 +68,67 @@ 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 }); } } export async function sendMail(req: AuthenticatedRequest, res: Response) { try { const userId = req.user!.userId; - const { contractObjid, toEmails, ccEmails, subject, contents, isSend } = req.body ?? {}; + const { contractObjid, toEmails, ccEmails, subject, contents, pdfBase64, useAddEstOnly } = req.body ?? {}; if (!contractObjid || !toEmails || !subject) { return res.status(400).json({ success: false, message: "필수값 누락 (contractObjid, toEmails, subject)", }); } - const data = await salesEstimateService.sendMail(userId, { + const result = await salesEstimateService.sendMail(userId, { contractObjid, toEmails, ccEmails, subject, contents: contents ?? "", - isSend, + pdfBase64, + useAddEstOnly, }); - return res.json({ success: true, data, message: "메일 발송 이력이 등록되었습니다." }); + if (!result.success) { + return res.status(500).json(result); + } + return res.json(result); } catch (error: any) { logger.error("견적 메일 발송 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); } } +/** GET /api/sales/estimate/:contractObjid/mail-info — 메일 다이얼로그 자동 채움용 (제목/수신/참조) */ +export async function getMailInfo(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const info = await salesEstimateService.getContractInfoForMail(id); + if (!info) { + return res.status(404).json({ success: false, message: "계약 정보를 찾을 수 없습니다." }); + } + return res.json({ success: true, data: info }); + } catch (error: any) { + logger.error("메일 자동채움 정보 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** GET /api/sales/estimate/customer/:customerObjid/managers — 고객사 담당자 리스트 */ +export async function getMailManagers(req: AuthenticatedRequest, res: Response) { + try { + const { customerObjid } = req.params; + const managers = await salesEstimateService.getCustomerManagerList(customerObjid); + return res.json({ success: true, data: managers }); + } catch (error: any) { + logger.error("고객사 담당자 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ────────────────────────────────────────────────────────────── // G5 견적작성 — estimate_template // ────────────────────────────────────────────────────────────── @@ -145,3 +177,20 @@ export async function listTemplates(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: error.message }); } } + +// G4/G11 동일 패턴 — 견적 결재상신 (Amaranth SSO URL 발급 + amaranth_approval 매핑) +// wace estimateList_new.jsp:887 fn_openAmaranthApproval + ApprovalService.getAmaranthSsoUrl 1:1 +export async function startApproval(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const { id } = req.params; + const { approvalTitle, subjectStr } = req.body || {}; + const data = await salesEstimateService.startEstimateApproval(userId, id, { + approvalTitle, subjectStr, + }); + return res.json({ success: true, data, message: "결재 SSO URL이 발급되었습니다." }); + } catch (e: any) { + logger.error("견적 결재상신 실패", { error: e.message }); + return res.status(e?.statusCode ?? 500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/controllers/salesOrderMgmtController.ts b/backend-node/src/controllers/salesOrderMgmtController.ts index d086fbe3..00318a15 100644 --- a/backend-node/src/controllers/salesOrderMgmtController.ts +++ b/backend-node/src/controllers/salesOrderMgmtController.ts @@ -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) { @@ -71,6 +75,34 @@ export async function updateStatus(req: AuthenticatedRequest, res: Response) { } catch (e: any) { logger.error("수주 상태 변경 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); } } +// G4/G11 수주 결재상신 — Amaranth SSO URL 발급 + amaranth_approval 매핑 +// wace orderMgmtList.btnApproval + ApprovalService.getAmaranthSsoUrl 1:1 +export async function startApproval(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const { id } = req.params; + const { approvalTitle, subjectStr } = req.body || {}; + const data = await svc.startOrderApproval(userId, id, { approvalTitle, subjectStr }); + return res.json({ success: true, data, message: "결재 SSO URL이 발급되었습니다." }); + } catch (e: any) { + logger.error("수주 결재상신 실패", { error: e.message }); + return res.status(e?.statusCode ?? 500).json({ success: false, message: e.message }); + } +} + +// G9 수주복사 (wace orderMgmtList btnCopy → copyEstimateAndOrderInfo 1:1) +export async function copyOrder(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const { id } = req.params; + const data = await svc.copyOrder(userId, id); + return res.status(201).json({ success: true, data, message: "수주가 복사되었습니다." }); + } catch (e: any) { + logger.error("수주복사 실패", { error: e.message }); + return res.status(e?.statusCode ?? 500).json({ success: false, message: e.message }); + } +} + // 수주취소: 라인별 cancel_qty 다중 UPDATE (wace saveOrderCancelQty 이식) export async function saveCancelQty(req: AuthenticatedRequest, res: Response) { try { diff --git a/backend-node/src/routes/projectMgmtRoutes.ts b/backend-node/src/routes/projectMgmtRoutes.ts new file mode 100644 index 00000000..2dbc0e54 --- /dev/null +++ b/backend-node/src/routes/projectMgmtRoutes.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/projectMgmtController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/list", ctrl.getList); +router.get("/project-no-options", ctrl.getProjectNoOptions); +router.get("/:id", ctrl.getById); +router.put("/:id", ctrl.update); + +export default router; diff --git a/backend-node/src/routes/salesEstimateRoutes.ts b/backend-node/src/routes/salesEstimateRoutes.ts index 4fcbcda2..276704dc 100644 --- a/backend-node/src/routes/salesEstimateRoutes.ts +++ b/backend-node/src/routes/salesEstimateRoutes.ts @@ -7,7 +7,11 @@ router.use(authenticateToken); router.get("/list", salesEstimateController.getList); router.get("/generate-number", salesEstimateController.generateNumber); + +// G6 메일 발송 — /:id 라우트보다 위에 (mail-info/managers는 path param 충돌 방지) router.post("/mail", salesEstimateController.sendMail); +router.get("/mail-info/:id", salesEstimateController.getMailInfo); +router.get("/customer/:customerObjid/managers", salesEstimateController.getMailManagers); // G5 견적작성 (estimate_template) — /:id 라우트보다 위에 router.post("/template1", salesEstimateController.saveTemplate1); @@ -15,6 +19,9 @@ router.post("/template2", salesEstimateController.saveTemplate2); router.get("/template/:templateObjid", salesEstimateController.getTemplate); router.get("/templates/:contractObjid", salesEstimateController.listTemplates); +// G4/G11 동일 패턴 — 견적 결재상신 (Amaranth SSO URL 발급) — /:id 라우트보다 위에 +router.post("/:id/amaranth-approval", salesEstimateController.startApproval); + router.get("/:id", salesEstimateController.getById); router.post("/", salesEstimateController.create); router.put("/:id", salesEstimateController.update); diff --git a/backend-node/src/routes/salesOrderMgmtRoutes.ts b/backend-node/src/routes/salesOrderMgmtRoutes.ts index dffda35d..c18e5eb8 100644 --- a/backend-node/src/routes/salesOrderMgmtRoutes.ts +++ b/backend-node/src/routes/salesOrderMgmtRoutes.ts @@ -14,5 +14,7 @@ router.put("/:id", ctrl.update); router.delete("/:id", ctrl.remove); router.patch("/:id/status", ctrl.updateStatus); router.post("/:id/cancel-qty", ctrl.saveCancelQty); +router.post("/:id/copy", ctrl.copyOrder); +router.post("/:id/amaranth-approval", ctrl.startApproval); export default router; diff --git a/backend-node/src/services/approvalTableMigration.ts b/backend-node/src/services/approvalTableMigration.ts index 6847a0a2..b81363c6 100644 --- a/backend-node/src/services/approvalTableMigration.ts +++ b/backend-node/src/services/approvalTableMigration.ts @@ -89,10 +89,12 @@ const STATEMENTS: string[] = [ // ── AMARANTH_APPROVAL : Wehago/Amaranth SSO 매핑 ──────────────── // 우리 시스템 결재(approval.objid) ↔ 아마란스 docId / approKey 연결 + // target_objid: wace 운영은 VARCHAR — `T.OBJID::VARCHAR = AMR_ORDER.TARGET_OBJID` (contractMgmt.xml:662) + // vexplor_rps contract_mgmt.objid가 varchar(`CM-...`) 형식이라 cast 불가 → wace와 동일하게 VARCHAR로 유지 `CREATE TABLE IF NOT EXISTS amaranth_approval ( objid BIGINT PRIMARY KEY, approval_objid BIGINT, - target_objid BIGINT, + target_objid VARCHAR(80), target_type VARCHAR(50), appro_key VARCHAR(120), -- 외부시스템 연동키 ('UB_' + UUID) out_process_code VARCHAR(80), -- 아마란스 결재연동코드 diff --git a/backend-node/src/services/projectMgmtService.ts b/backend-node/src/services/projectMgmtService.ts new file mode 100644 index 00000000..7421f062 --- /dev/null +++ b/backend-node/src/services/projectMgmtService.ts @@ -0,0 +1,331 @@ +// ============================================================ +// 프로젝트관리 > 진행관리 (wace_plm 도메인 이식) +// 메인 화면: wace `/project/projectMgmtWbsList3.do` (= 운영판 RPS 진행관리) +// 그리드 SQL: wace_plm/src/com/pms/mapper/project.xml:3854 projectMgmtWbsGridList +// JSP 원본: wace_plm/WebContent/WEB-INF/view/project/projectMgmtWbsList3.jsp +// +// 1:1 이식 + RPS 매핑 변경: +// · wace의 CLIENT_MNG/SUPPLY_MNG CASE WHEN LIKE 'C_%' 분기 → RPS는 customer_mng 단일 LEFT JOIN +// · CODE_NAME() 함수는 RPS DB 보유 — 그대로 사용 +// · PMS_WBS_TASK/SETUP_WBS_TASK 의존 컬럼은 그리드 표시 컬럼에 없으므로 미반영 (P2) +// ============================================================ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +export interface ProgressListFilter { + Year?: string; // 년도 (TO_CHAR(REGDATE,'YYYY')) + project_nos?: string; // wace project_nos (쉼표 직렬화 OBJID — 다중 또는 단일) + category_cd?: string; + customer_objid?: string; + product?: string; + status_cd?: string; + result_cd?: string; + contract_start_date?: string; // 요청납기 시작 + contract_end_date?: string; // 요청납기 끝 + area_cd?: string; // '국내'/'해외' 라벨로 비교 (wace 1:1) + free_of_charge?: string; // '유상'/'무상' 라벨로 비교 + // 영업관리 패턴(search_partObjId 단일) 통일 — wace의 product_item_code/_name LIKE 두 필드는 부재화 + search_partObjId?: string; // part_mng.objid::varchar = project_mgmt.part_objid + serial_no?: string; // contract_item_serial EXISTS + pm_user_id?: string; // (검색폼 주석처리되어 있으나 SQL은 받음) + location?: string; + setup?: string; +} + +// ─── 목록: 운영판 projectMgmtWbsGridList 1:1 ────────────────── + +export async function listProgress(filter: ProgressListFilter) { + const pool = getPool(); + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + // wace L4187: Year — TO_CHAR(REGDATE,'YYYY') + if (filter.Year) { + conditions.push(`TO_CHAR(T.REGDATE,'YYYY') = $${idx++}`); + params.push(filter.Year); + } + + // wace L4195: project_nos 쉼표 split → OBJID IN + if (filter.project_nos) { + const list = filter.project_nos.split(",").map(s => s.trim()).filter(Boolean); + if (list.length > 0) { + const placeholders = list.map(() => `$${idx++}`).join(","); + conditions.push(`T.OBJID IN (${placeholders})`); + params.push(...list); + } + } + + if (filter.category_cd) { conditions.push(`T.CATEGORY_CD = $${idx++}`); params.push(filter.category_cd); } + if (filter.customer_objid) { conditions.push(`T.CUSTOMER_OBJID = $${idx++}`); params.push(filter.customer_objid); } + if (filter.product) { conditions.push(`T.PRODUCT = $${idx++}`); params.push(filter.product); } + if (filter.status_cd) { conditions.push(`T.STATUS_CD = $${idx++}`); params.push(filter.status_cd); } + if (filter.result_cd) { conditions.push(`T.RESULT_CD = $${idx++}`); params.push(filter.result_cd); } + + // wace L4224-4237: 요청납기 범위 — COALESCE(contract_item.due_date, project_mgmt.due_date, contract_mgmt.due_date) + const dueDateCoalesce = `COALESCE( + (SELECT CI.DUE_DATE FROM contract_item CI WHERE CI.contract_objid = T.CONTRACT_OBJID AND CI.part_objid = T.PART_OBJID AND CI.status = 'ACTIVE' ORDER BY CI.objid DESC LIMIT 1), + T.DUE_DATE, + (SELECT CM.due_date FROM contract_mgmt CM WHERE CM.OBJID = T.CONTRACT_OBJID LIMIT 1) + )`; + if (filter.contract_start_date) { + conditions.push(`TO_DATE(${dueDateCoalesce},'YYYY-MM-DD') >= TO_DATE($${idx++},'YYYY-MM-DD')`); + params.push(filter.contract_start_date); + } + if (filter.contract_end_date) { + conditions.push(`TO_DATE(${dueDateCoalesce},'YYYY-MM-DD') <= TO_DATE($${idx++},'YYYY-MM-DD')`); + params.push(filter.contract_end_date); + } + + if (filter.pm_user_id) { conditions.push(`T.PM_USER_ID = $${idx++}`); params.push(filter.pm_user_id); } + if (filter.location) { conditions.push(`UPPER(T.LOCATION) LIKE UPPER($${idx++})`); params.push(`%${filter.location}%`); } + if (filter.setup) { conditions.push(`UPPER(T.SETUP) LIKE UPPER($${idx++})`); params.push(`%${filter.setup}%`); } + + // wace L4249: area_cd — CODE_NAME(area_cd) 라벨 비교 ('국내'/'해외') + if (filter.area_cd) { + conditions.push(`CODE_NAME(T.AREA_CD) = $${idx++}`); + params.push(filter.area_cd); + } + + // wace L4253: free_of_charge — contract_mgmt.paid_type → '유상'/'무상' 매핑 비교 + if (filter.free_of_charge) { + conditions.push(`(SELECT + CASE + WHEN O.PAID_TYPE = 'paid' THEN '유상' + WHEN O.PAID_TYPE = 'free' THEN '무상' + ELSE O.PAID_TYPE + END + FROM contract_mgmt AS O WHERE O.OBJID = T.CONTRACT_OBJID) = $${idx++}`); + params.push(filter.free_of_charge); + } + + // 영업관리 패턴 통일: search_partObjId 단일 (PartSelect의 part_mng.objid 값 매칭) + // wace 매퍼는 product_item_code/_name LIKE 텍스트 검색이지만 RPS는 part_objid 직접 매칭. + if (filter.search_partObjId) { + conditions.push(`T.PART_OBJID = $${idx++}`); + params.push(filter.search_partObjId); + } + // wace L4271: serial_no — EXISTS contract_item_serial + if (filter.serial_no) { + conditions.push(`EXISTS ( + SELECT 1 FROM contract_item_serial CIS + WHERE CIS.item_objid::varchar = T.CONTRACT_ITEM_OBJID + AND UPPER(CIS.status) = 'ACTIVE' + AND UPPER(CIS.serial_no) LIKE UPPER($${idx++}) + )`); + params.push(`%${filter.serial_no}%`); + } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + + // wace projectMgmtWbsGridList 활성 본문 1:1 (그리드 표시 18셀 + 필수 부속) + const sql = ` + SELECT + T.OBJID + ,T.CATEGORY_CD + ,CODE_NAME(T.CATEGORY_CD) AS CATEGORY_NAME + ,T.CUSTOMER_OBJID + ,C.customer_name AS CUSTOMER_NAME + ,T.PRODUCT + ,CODE_NAME(T.PRODUCT) AS PRODUCT_NAME + ,T.STATUS_CD + ,CODE_NAME(T.STATUS_CD) AS STATUS_NAME + ,T.RESULT_CD + ,CODE_NAME(T.RESULT_CD) AS RESULT_NAME + ,T.DUE_DATE + ,T.LOCATION + ,T.SETUP + ,T.FACILITY + ,CODE_NAME(T.FACILITY) AS FACILITY_NAME + ,T.FACILITY_QTY + ,T.FACILITY_TYPE + ,T.FACILITY_DEPTH + ,T.PROJECT_NO + ,T.PM_USER_ID + ,(SELECT user_name FROM user_info WHERE user_id = (SELECT pm_user_id FROM contract_mgmt WHERE OBJID = T.CONTRACT_OBJID)) AS PM_USER_NAME + ,T.CONTRACT_PRICE + ,T.CONTRACT_PRICE_CURRENCY + ,T.CONTRACT_CURRENCY + ,CODE_NAME(T.CONTRACT_CURRENCY) AS CONTRACT_CURRENCY_NAME + ,T.REGDATE + ,TO_CHAR(T.REGDATE,'YYYY-MM-DD') AS REG_DATE + ,T.WRITER + ,(SELECT user_name FROM user_info WHERE user_id = T.WRITER) AS WRITER_NAME + ,(SELECT COUNT(1) FROM attach_file_info WHERE target_objid = T.OBJID AND doc_type='contractMgmt01' AND UPPER(status)='ACTIVE') AS CU01_CNT + ,(SELECT COUNT(1) FROM attach_file_info WHERE target_objid = T.OBJID AND doc_type='contractMgmt02' AND UPPER(status)='ACTIVE') AS CU02_CNT + ,T.CONTRACT_NO + ,T.CUSTOMER_EQUIP_NAME + ,T.CONTRACT_DEL_DATE + ,T.CONTRACT_COMPANY + ,CODE_NAME(T.CONTRACT_COMPANY) AS CONTRACT_COMPANY_NAME + ,T.CONTRACT_DATE + ,T.PO_NO + ,T.MANUFACTURE_PLANT + ,CODE_NAME(T.MANUFACTURE_PLANT) AS MANUFACTURE_PLANT_NAME + ,T.CONTRACT_RESULT + ,CODE_NAME(T.CONTRACT_RESULT) AS CONTRACT_RESULT_NAME + ,CODE_NAME(T.AREA_CD) AS AREA_NAME + ,T.PROJECT_NAME + + -- 운영판 그리드 핵심 컬럼들 + ,(SELECT CASE + WHEN O.PAID_TYPE = 'paid' THEN '유상' + WHEN O.PAID_TYPE = 'free' THEN '무상' + ELSE O.PAID_TYPE + END + FROM contract_mgmt AS O WHERE O.OBJID = T.CONTRACT_OBJID) AS FREE_OF_CHARGE + ,COALESCE(NULLIF(T.QUANTITY,'')::numeric, 0) AS CONTRACT_QTY + ,T.PART_NO AS PRODUCT_ITEM_CODE + ,T.PART_NAME AS PRODUCT_ITEM_NAME + -- S/N: contract_item_serial (CIS.item_objid = T.contract_item_objid) + ,(SELECT + CASE + WHEN COUNT(*) = 0 THEN '' + WHEN COUNT(*) = 1 THEN MIN(CIS.serial_no) + ELSE MIN(CIS.serial_no) || ' 외 ' || (COUNT(*) - 1)::text || '건' + END + FROM contract_item_serial CIS + WHERE CIS.item_objid::varchar = T.CONTRACT_ITEM_OBJID + AND UPPER(CIS.status) = 'ACTIVE' + AND CIS.serial_no IS NOT NULL + AND CIS.serial_no != '' + ) AS SERIAL_NO + -- 요청납기: contract_item.due_date → project_mgmt.due_date → contract_mgmt.due_date + ,COALESCE( + (SELECT CI.due_date FROM contract_item CI + WHERE CI.contract_objid = T.CONTRACT_OBJID + AND CI.part_objid = T.PART_OBJID + AND CI.status = 'ACTIVE' + ORDER BY CI.objid DESC LIMIT 1), + T.DUE_DATE, + (SELECT CM.due_date FROM contract_mgmt CM WHERE CM.OBJID = T.CONTRACT_OBJID LIMIT 1) + ) AS REQ_DEL_DATE + ,T.EBOM_STATUS + ,T.MBOM_STATUS + -- 발주일: contract_mgmt.order_date + ,(SELECT O.order_date FROM contract_mgmt AS O WHERE O.OBJID = T.CONTRACT_OBJID) AS ORDER_DATE + ,T.RECEIVING_RATE + ,T.PRODUCTION_TEAM_12 + ,T.PRODUCTION_TEAM_3 + -- 출하일: sales_registration.shipping_date (project_no 매칭) + ,COALESCE( + (SELECT TO_CHAR(SR.shipping_date, 'YYYY-MM-DD') + FROM sales_registration SR + WHERE SR.project_no = T.PROJECT_NO + ORDER BY SR.sale_no DESC LIMIT 1), + '' + ) AS SHIPMENT_DATE + ,T.MECHANICAL_TYPE + ,T.OVERHAUL_ORDER + ,T.CONTRACT_OBJID + + -- P2: PMS_WBS_TASK 의존 컬럼들은 그리드 표시엔 없으므로 자리만 + ,0 AS TOTAL_RATE + ,0 AS DESIGN_RATE + ,0 AS PURCHASE_RATE + ,0 AS PRODUCE_RATE + ,0 AS SELFINS_RATE + ,0 AS FINALINS_RATE + ,0 AS SHIP_RATE + ,0 AS SETUP_RATE + ,0 AS WBS_CNT + ,NULL::text AS ASSEMBLY + ,NULL::text AS VERIFICATION + FROM project_mgmt T + LEFT JOIN customer_mng C + ON C.customer_code = CASE WHEN T.CUSTOMER_OBJID LIKE 'C_%' THEN substring(T.CUSTOMER_OBJID, 3) ELSE T.CUSTOMER_OBJID END + ${where} + ORDER BY SUBSTRING(T.PROJECT_NO, POSITION('-' IN T.PROJECT_NO)+1) DESC, + T.OVERHAUL_ORDER DESC NULLS LAST + `; + + const res = await pool.query(sql, params); + logger.info("진행관리 목록 조회", { count: res.rowCount, filter }); + return res.rows; +} + +// ─── 옵션: 프로젝트번호 셀렉트박스용 ────────────────────────── +// wace `common.getCusProjectNoList` 대응 — 진행관리 메뉴 검색폼 project_no 옵션 채움. + +export async function listProjectNoOptions() { + const pool = getPool(); + const sql = ` + SELECT OBJID AS value, PROJECT_NO AS label + FROM project_mgmt + WHERE PROJECT_NO IS NOT NULL AND PROJECT_NO != '' + ORDER BY SUBSTRING(PROJECT_NO, POSITION('-' IN PROJECT_NO)+1) DESC + `; + const res = await pool.query(sql); + return res.rows; +} + +// ─── 단건/수정: P1.5에서 영업관리 OrderRegistDialog 재사용 결정 시 제거 가능 ── + +export async function getById(objid: string) { + const pool = getPool(); + const sql = ` + SELECT + T.OBJID + ,T.PROJECT_NO + ,T.PROJECT_NAME + ,T.FACILITY + ,CODE_NAME(T.FACILITY) AS FACILITY_NAME + ,T.FACILITY_QTY + ,T.FACILITY_DEPTH + ,T.CONTRACT_DEL_DATE + ,T.CONTRACT_CURRENCY + ,CODE_NAME(T.CONTRACT_CURRENCY) AS CONTRACT_CURRENCY_NAME + ,T.CONTRACT_PRICE + ,T.CONTRACT_PRICE_CURRENCY + FROM project_mgmt T + WHERE T.OBJID = $1 + LIMIT 1 + `; + const res = await pool.query(sql, [objid]); + return res.rows[0] ?? null; +} + +export interface ProjectUpdateBody { + project_no?: string; + project_name?: string; + facility?: string; + facility_qty?: string; + facility_depth?: string; + contract_del_date?: string; + contract_currency?: string; + contract_price_currency?: string; + contract_price?: string; +} + +export async function updateProject(objid: string, body: ProjectUpdateBody) { + const pool = getPool(); + const sql = ` + UPDATE project_mgmt + SET FACILITY_QTY = $1 + ,PROJECT_NO = $2 + ,CONTRACT_PRICE = $3 + ,CONTRACT_PRICE_CURRENCY = $4 + ,CONTRACT_CURRENCY = $5 + ,PROJECT_NAME = $6 + ,FACILITY = $7 + ,FACILITY_DEPTH = $8 + ,CONTRACT_DEL_DATE = $9 + WHERE OBJID = $10 + `; + const params = [ + body.facility_qty ?? "", + body.project_no ?? "", + body.contract_price ?? "", + body.contract_price_currency ?? "", + body.contract_currency ?? "", + body.project_name ?? "", + body.facility ?? "", + body.facility_depth ?? "", + body.contract_del_date ?? "", + objid, + ]; + const res = await pool.query(sql, params); + logger.info("진행관리 수정", { objid, rowCount: res.rowCount }); + return res.rowCount ?? 0; +} diff --git a/backend-node/src/services/salesEstimateService.ts b/backend-node/src/services/salesEstimateService.ts index bb094edc..4a6ea99b 100644 --- a/backend-node/src/services/salesEstimateService.ts +++ b/backend-node/src/services/salesEstimateService.ts @@ -4,9 +4,15 @@ // 헤더 컨텍스트: contract_mgmt (영업번호 = contract_no) // ============================================================ +import fs from "fs"; +import path from "path"; +import { PDFDocument } from "pdf-lib"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; -import { generateContractNo } from "./salesOrderMgmtService"; +import { sendMailUTF8 } from "../utils/mailUtil"; +import { AppError } from "../middleware/errorHandler"; +import * as amaranth from "./amaranthApprovalClient"; +import { generateContractNo, assertNoProjectExists } from "./salesOrderMgmtService"; // ─── 타입 ───────────────────────────────────────────────────── @@ -158,9 +164,15 @@ export async function getList(filter: EstimateListFilter) { // 견적관리는 통합 직발(IS_DIRECT_ORDER='Y') 제외 conditions.push(`COALESCE(T.IS_DIRECT_ORDER, 'N') != 'Y'`); - // 결재상태 필터 (한글 라벨 매칭) + // 결재상태 필터 (한글 라벨 매칭) — SELECT 절 APPR_STATUS와 동일 CASE. + // AMR은 동일 LEFT JOIN이 WHERE 단계 전에 평가되므로 직접 참조 가능. if (filter.appr_status) { conditions.push(`(CASE + WHEN AMR.status = 'complete' THEN '결재완료' + WHEN AMR.status = 'inProcess' THEN '결재중' + WHEN AMR.status = 'reject' THEN '반려' + WHEN AMR.status = 'create' THEN '작성중' + WHEN AMR.status = 'notRequired' THEN '결재불필요' WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요' ELSE '' END) = $${idx++}`); @@ -244,15 +256,19 @@ export async function getList(filter: EstimateListFilter) { /* 메일 발송 정보 (mail_log: TITLE의 [OBJID:NN]에서 OBJID 매칭) */ ,ML.mail_send_status AS MAIL_SEND_STATUS ,ML.mail_send_date AS MAIL_SEND_DATE - /* 결재상태 (vexplor_rps에 amaranth_approval 미도입 — approval_required='N'만 우선 처리) */ + /* 결재상태 (wace contractMgmt.xml:513~522 1:1 — CONTRACT_ESTIMATE 매핑) */ ,CASE + WHEN AMR.status = 'complete' THEN '결재완료' + WHEN AMR.status = 'inProcess' THEN '결재중' + WHEN AMR.status = 'reject' THEN '반려' + WHEN AMR.status = 'create' THEN '작성중' + WHEN AMR.status = 'notRequired' THEN '결재불필요' WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요' ELSE '' END AS APPR_STATUS - ,CASE - WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN 'notRequired' - ELSE '' - END AS AMARANTH_STATUS + ,COALESCE(AMR.status, + CASE WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN 'notRequired' ELSE '' END + ) AS AMARANTH_STATUS /* 추가견적/주문서 첨부 카운트 (attach_file_info DOC_TYPE 기반) */ ,COALESCE(AF.add_est_cnt, 0) AS ADD_EST_CNT ,COALESCE(AF.cu01_cnt, 0) AS CU01_CNT @@ -280,6 +296,10 @@ export async function getList(filter: EstimateListFilter) { ORDER BY regdate DESC LIMIT 1 ) ET ON true + /* 아마란스 결재 — 견적 (target_objid = 최신 차수 estimate_template.objid) */ + LEFT JOIN amaranth_approval AMR + ON AMR.target_objid = ET.objid::VARCHAR + AND AMR.target_type = 'CONTRACT_ESTIMATE' /* 견적 라인 수량 합계 */ LEFT JOIN LATERAL ( SELECT COALESCE(SUM(NULLIF(replace(quantity, ',', ''), '')::numeric), 0) AS estimate_quantity @@ -552,10 +572,13 @@ export async function update(userId: string, objid: string, body: EstimateBody) } } -// ─── 메일 발송 이력 등록 ────────────────────────────────────── -// 실제 SMTP 발송은 mailSendSimpleService(별도 인프라) 연동 시 추가. -// 1차에서는 mail_log INSERT만 수행 → 그리드 mail_send_status/date 컬럼 표시. -// title에 [OBJID:nnn] 패턴을 포함시켜 LEFT JOIN의 SUBSTRING 매칭과 호환. +// ─── 메일 발송 (G6) ─────────────────────────────────────────── +// wace ContractMgmtService.sendEstimateMailCustom (line 3582) 1:1. +// - 본문(contents)는 다이얼로그 입력 그대로 사용 (HTML 견적 양식 본문 생성기는 미이식 — 양식은 PDF 첨부에 포함됨) +// - 첨부: 기본 견적 PDF (프론트 hidden iframe에서 base64 생성 후 전달) + estimate02 N건을 pdf-lib로 합본 +// - useAddEstOnly='Y': 견적 PDF 생략, estimate02만 첨부 (수가 0이면 에러) +// - mail_log title에 [OBJID:nnn] 토큰 부착 (그리드 LEFT JOIN의 LIKE 매칭과 호환) +// - SMTP 계정: SALES (Constants.Mail.ACCOUNT_TYPE_SALES) export interface EstimateMailBody { contractObjid: string; @@ -563,53 +586,283 @@ export interface EstimateMailBody { ccEmails?: string; subject: string; contents: string; - isSend?: "Y" | "N"; // 실제 SMTP 발송 결과 (생략 시 Y로 기록 — UI 단계에서 결과 반영) + /** 프론트에서 hidden iframe으로 template1/2 페이지 렌더 후 html2canvas+jsPDF로 생성한 base64 (data URL 또는 raw base64) */ + pdfBase64?: string; + /** 'Y'면 견적 PDF 생성 스킵하고 estimate02만 첨부 */ + useAddEstOnly?: "Y" | "N"; } -export async function sendMail(userId: string, body: EstimateMailBody) { +// ─── 메일 발송 helper SQL ───────────────────────────────────── + +/** wace getContractInfoForMail — 메일 다이얼로그 자동채움용 (제목/수신/참조) */ +export async function getContractInfoForMail(contractObjid: string) { const pool = getPool(); - const objid = genVarcharObjid("ML"); - const titleWithTag = body.subject.includes("[OBJID:") - ? body.subject - : `${body.subject} [OBJID:${body.contractObjid}]`; + const sql = ` + SELECT + cm.objid AS contract_objid, + cm.contract_no AS contract_no, + cm.customer_objid AS customer_objid, + cm.writer AS writer, + c.id AS customer_pk, + c.customer_name AS customer_name, + c.email AS customer_email, + uw.email AS writer_email, + uw.user_name AS writer_name + FROM contract_mgmt cm + LEFT JOIN customer_mng c + ON c.customer_code = CASE + WHEN cm.customer_objid LIKE 'C_%' THEN substring(cm.customer_objid, 3) + ELSE cm.customer_objid + END + LEFT JOIN user_info uw ON uw.user_id = cm.writer + WHERE cm.objid = $1 + LIMIT 1`; + const r = await pool.query(sql, [contractObjid]); + return r.rows[0] ?? null; +} - await pool.query( - `INSERT INTO mail_log ( - objid, system_name, send_user_id, from_addr, - reception_user_id, receiver_to, - title, contents, log_time, is_send, mail_type - ) VALUES ( - $1, 'vexplor_rps', $2, NULL, - NULL, $3, - $4, $5, NOW(), $6, 'CONTRACT_ESTIMATE' - )`, - [ - objid, - userId, - body.toEmails + (body.ccEmails ? `; cc: ${body.ccEmails}` : ""), - titleWithTag, - body.contents, - body.isSend ?? "Y", - ], +/** wace getCustomerManagerList — 메일 다이얼로그 담당자 체크박스 리스트용. + * RPS는 wace manager1~5 슬롯 패턴이 아니라 customer_contact 테이블 N건 구조. + */ +export async function getCustomerManagerList(customerObjid: string) { + const pool = getPool(); + // customer_objid (C_xxxx) → customer_mng.customer_code → customer_mng.id → customer_contact.customer_id + const code = customerObjid.startsWith("C_") ? customerObjid.substring(2) : customerObjid; + const sql = ` + SELECT + cc.contact_name AS name, + cc.contact_email AS email, + cc.contact_phone AS phone, + cc.department AS department, + cc.is_main AS is_main + FROM customer_contact cc + JOIN customer_mng cm ON cm.id::text = cc.customer_id::text + WHERE cm.customer_code = $1 + AND COALESCE(cc.contact_name, '') <> '' + ORDER BY (CASE WHEN cc.is_main = 'Y' THEN 0 ELSE 1 END), cc.contact_name`; + const r = await pool.query(sql, [code]); + return r.rows; +} + +/** wace getLatestEstimateTemplate — 최신 차수 견적서 */ +export async function getLatestEstimateTemplate(contractObjid: string) { + const pool = getPool(); + const r = await pool.query( + `SELECT * FROM estimate_template + WHERE contract_objid = $1 + ORDER BY regdate DESC + LIMIT 1`, + [contractObjid], ); + return r.rows[0] ?? null; +} - logger.info("견적 메일 발송 이력 등록", { - objid, - contractObjid: body.contractObjid, - to: body.toEmails, +// ─── 메일 발송 본체 ─────────────────────────────────────────── + +export async function sendMail(userId: string, body: EstimateMailBody) { + const useAddEstOnly = body.useAddEstOnly === "Y"; + + // 1. 계약 정보 조회 (수신/참조 fallback + 자동 cc: writer) + const contractInfo = await getContractInfoForMail(body.contractObjid); + if (!contractInfo) { + return { success: false, message: "계약 정보를 찾을 수 없습니다." }; + } + + // 2. 최신 차수 견적서 (useAddEstOnly='Y'면 생략 가능) + const estimateTemplate = useAddEstOnly ? null : await getLatestEstimateTemplate(body.contractObjid); + if (!useAddEstOnly && !estimateTemplate) { + return { success: false, message: "견적서를 찾을 수 없습니다." }; + } + + // 3. 수신/참조 정리 (쉼표·세미콜론 모두 split) + const splitEmails = (s: string | undefined) => (s || "") + .split(/[;,]/) + .map((e) => e.trim()) + .filter((e) => e !== ""); + + const toEmails = splitEmails(body.toEmails); + if (toEmails.length === 0) { + return { success: false, message: "수신인 이메일이 없습니다." }; + } + + const ccEmails = splitEmails(body.ccEmails); + // 작성자 이메일 자동 추가 (wace sendEstimateMailCustom과 동일) + if (contractInfo.writer_email && !ccEmails.includes(contractInfo.writer_email)) { + ccEmails.push(contractInfo.writer_email); + } + + // 4. 첨부 PDF 준비 + const attachments: { filename: string; content: Buffer; contentType: string }[] = []; + + // 4-1. estimate02 (추가견적) 파일 목록 조회 + const addEstFiles = await fetchEstimate02Files(body.contractObjid); + + if (useAddEstOnly) { + // 추가견적만 발송 — 견적 PDF 없이 estimate02 파일들을 그대로 첨부 + if (addEstFiles.length === 0) { + return { success: false, message: "발송할 추가견적 PDF가 없습니다." }; + } + for (const f of addEstFiles) { + const buf = readUploadFileBuffer(f.file_path, f.saved_file_name); + if (buf) { + attachments.push({ + filename: f.real_file_name || f.saved_file_name, + content: buf, + contentType: "application/pdf", + }); + } + } + if (attachments.length === 0) { + return { success: false, message: "추가견적 PDF 파일을 디스크에서 찾을 수 없습니다." }; + } + } else { + // 견적 PDF (프론트 base64) + estimate02 합본 + if (!body.pdfBase64) { + return { success: false, message: "견적 PDF가 전달되지 않았습니다." }; + } + const basePdfBuf = decodeBase64Pdf(body.pdfBase64); + + // estimate02 파일들을 같이 받아 PDF 합본 + const addEstBufs: Buffer[] = []; + for (const f of addEstFiles) { + const buf = readUploadFileBuffer(f.file_path, f.saved_file_name); + if (buf) addEstBufs.push(buf); + } + + let finalPdf: Buffer; + if (addEstBufs.length > 0) { + try { + finalPdf = await mergePdfBuffers([basePdfBuf, ...addEstBufs]); + logger.info("PDF 합본 완료", { contractObjid: body.contractObjid, addEstCount: addEstBufs.length }); + } catch (mergeErr) { + logger.warn("PDF 합본 실패 — 견적서만 첨부", { error: (mergeErr as Error).message }); + finalPdf = basePdfBuf; + } + } else { + finalPdf = basePdfBuf; + } + + const estimateNo = (estimateTemplate?.estimate_no || "견적서").toString().replace(/[^\w가-힣\-_.]/g, "_"); + const ts = new Date().toISOString().replace(/[-:.TZ]/g, "").substring(0, 14); + attachments.push({ + filename: `${estimateNo}_${ts}.pdf`, + content: finalPdf, + contentType: "application/pdf", + }); + } + + // 5. mail_log title에 [OBJID:nnn] 토큰 부착 (그리드 LEFT JOIN과 호환) + const subject = body.subject.trim(); + const subjectForLog = subject.includes("[OBJID:") + ? subject + : `${subject} [OBJID:${body.contractObjid}]`; + + // 6. HTML 본문 (다이얼로그 plain text →
변환) + const html = textToHtml(body.contents); + + // 7. SMTP 발송 (SALES 계정) + const result = await sendMailUTF8({ + accountType: "SALES", + fromUserId: userId, + toEmails, + ccEmails: ccEmails.length > 0 ? ccEmails : undefined, + subject, + subjectForLog, + html, + attachments, + mailType: "CONTRACT_ESTIMATE", }); - return { objid }; + + logger.info("견적 메일 발송 요청 완료", { + contractObjid: body.contractObjid, + mailLogObjid: result.objid, + sent: result.sent, + to: toEmails, + cc: ccEmails, + attachmentCount: attachments.length, + }); + + if (!result.sent) { + return { + success: false, + message: result.error || "메일 발송에 실패했습니다.", + objid: result.objid, + }; + } + return { success: true, message: "견적서가 성공적으로 발송되었습니다.", objid: result.objid }; +} + +// ─── 메일 발송 내부 helper ──────────────────────────────────── + +async function fetchEstimate02Files(contractObjid: string) { + const pool = getPool(); + const r = await pool.query( + `SELECT objid, real_file_name, saved_file_name, file_path + FROM attach_file_info + WHERE target_objid = $1 + AND doc_type = 'estimate02' + AND UPPER(status) = 'ACTIVE' + ORDER BY regdate ASC`, + [contractObjid], + ); + return r.rows; +} + +function decodeBase64Pdf(input: string): Buffer { + // data URL prefix(`data:application/pdf;base64,...`) 또는 raw base64 둘 다 수용 + const comma = input.indexOf(","); + const raw = input.startsWith("data:") && comma !== -1 ? input.substring(comma + 1) : input; + return Buffer.from(raw, "base64"); +} + +function readUploadFileBuffer(filePath: string | null, savedFileName: string | null): Buffer | null { + if (!filePath || !savedFileName) return null; + // attach_file_info.file_path는 보통 `/uploads/.../...` 형식. + // fileController의 해석 패턴과 동일하게 `/uploads/` 이후를 cwd/uploads와 결합. + const idx = filePath.indexOf("/uploads/"); + const relative = idx !== -1 ? filePath.substring(idx + "/uploads/".length) : filePath; + const baseUpload = path.join(process.cwd(), "uploads"); + const fullPath = path.join(baseUpload, relative, savedFileName); + if (!fs.existsSync(fullPath)) { + logger.warn("첨부 파일 없음 — 스킵", { fullPath }); + return null; + } + return fs.readFileSync(fullPath); +} + +async function mergePdfBuffers(buffers: Buffer[]): Promise { + const merged = await PDFDocument.create(); + for (const buf of buffers) { + const src = await PDFDocument.load(buf, { ignoreEncryption: true }); + const pages = await merged.copyPages(src, src.getPageIndices()); + for (const p of pages) merged.addPage(p); + } + const bytes = await merged.save(); + return Buffer.from(bytes); +} + +function textToHtml(text: string): string { + // wace는 textarea contents를 그대로 HTML body로 사용 (\n은 줄바꿈으로 보이게
변환) + const escaped = text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + return `
${escaped.replace(/\n/g, "
")}
`; } // ─── 삭제 ───────────────────────────────────────────────────── // 시리얼 → 라인 → 헤더(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)`, @@ -933,6 +1186,152 @@ export async function getTemplateById(templateObjid: string) { return { ...header, items: itemsRes.rows }; } +// ─── 결재상신 ──────────────────────────────────────────────── +// +// G4/G11 동일 패턴 — 견적관리 결재상신 (Amaranth SSO). +// wace estimateList_new.jsp:887 fn_openAmaranthApproval + ApprovalService.getAmaranthSsoUrl +// target_type = 'CONTRACT_ESTIMATE' +// target_objid = 최신 차수 estimate_template.objid (수주는 contract_mgmt.objid와 달리 차수 단위) +// formId = '1162' (수주는 '1161') +// compSeq = '1000' +// amaranth_approval 신규/기존 매핑 분기: +// - 없음: 신규 approKey 생성 + INSERT +// - reject/delete/create: 새 approKey + UPDATE (재상신) +// - inProcess/complete: 기존 approKey 재사용 (프론트에서 가드되지만 백엔드도 방어) +// 첨부파일 원챔버 업로드(wace uploadEstimateFilesToOneChamber)는 미구현 — 영업 첨부 흐름 정리 후 연동. +export async function startEstimateApproval( + userId: string, + contractObjid: string, + opts: { approvalTitle?: string; subjectStr?: string } = {}, +): Promise<{ fullUrl: string; approKey: string; status: string; estObjid: string }> { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1) 사용자 emp_seq 조회 (wace PersonBean.getEmpseq 동등) + const userRes = await client.query( + `SELECT user_id, user_name, emp_seq FROM user_info WHERE user_id=$1 LIMIT 1`, + [userId], + ); + const u = userRes.rows[0]; + if (!u) throw new AppError("사용자 정보를 찾을 수 없습니다.", 401); + const empSeq: string = String(u.emp_seq ?? "").trim(); + if (!empSeq) { + throw new AppError(`empSeq 정보가 없습니다. (userId: ${userId}) 관리자에게 문의하세요.`, 400); + } + + // 2) contract_mgmt 헤더 + 최신 차수 estimate_template 조회 + // (wace estimateList_new.jsp:169 — estObjId 없으면 "견적서를 먼저 작성해주세요" 가드) + const cmRes = await client.query( + `SELECT cm.objid, cm.contract_no, + (SELECT objid FROM estimate_template + WHERE contract_objid = cm.objid + ORDER BY regdate DESC LIMIT 1) AS est_objid + FROM contract_mgmt cm + WHERE cm.objid = $1`, + [contractObjid], + ); + if (cmRes.rowCount === 0) throw new AppError("견적을 찾을 수 없습니다.", 404); + const { contract_no, est_objid } = cmRes.rows[0]; + if (!est_objid) { + throw new AppError("견적서를 먼저 작성해주세요.", 400); + } + const targetObjid = String(est_objid); + + const targetType = "CONTRACT_ESTIMATE"; + const approvalTitle = opts.approvalTitle || `견적서 결재${contract_no ? " - " + contract_no : ""}`; + const outProcessCode = + process.env.AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE || + process.env.AMARANTH_OUT_PROCESS_CODE || ""; + const formId = process.env.AMARANTH_FORM_ID_CONTRACT_ESTIMATE || "1162"; + const compSeq = process.env.AMARANTH_COMP_SEQ || "1000"; + + // 3) amaranth_approval 기존 매핑 조회 (wace selectAmaranthApprovalByTarget) + const existRes = await client.query( + `SELECT objid, appro_key, status FROM amaranth_approval + WHERE target_type=$1 AND target_objid=$2 + ORDER BY regdate DESC LIMIT 1`, + [targetType, targetObjid], + ); + + let approKey: string; + let mode: "insert" | "update_resubmit" | "update_reuse"; + let existingObjid: number | null = null; + + if (existRes.rowCount === 0) { + approKey = "UB_" + Date.now().toString(36).toUpperCase(); + mode = "insert"; + } else { + existingObjid = existRes.rows[0].objid; + const existingStatus = String(existRes.rows[0].status || ""); + if (["reject", "delete", "create"].includes(existingStatus)) { + // 재상신: 새 approKey (아마란스가 기존 approKey의 원챔버 첨부를 재사용하므로 수정 미반영 회피) + approKey = "UB_" + Date.now().toString(36).toUpperCase(); + mode = "update_resubmit"; + } else { + approKey = String(existRes.rows[0].appro_key || ""); + mode = "update_reuse"; + } + } + + // 4) SSO URL 발급 (chpark amaranthApprovalClient.getSsoUrl) + const ssoRes = await amaranth.getSsoUrl({ + empSeq, + outProcessCode: outProcessCode || undefined, + formId, + approKey, + subjectStr: opts.subjectStr || approvalTitle, + mod: "W", + compSeq, + deptSeq: "", + loginId: u.user_id, + }); + const fullUrl: string = ssoRes?.resultData?.fullUrl || ssoRes?.fullUrl || ""; + const resultCode = String(ssoRes?.resultCode ?? ssoRes?.resultData?.resultCode ?? ""); + + if (!fullUrl || (resultCode !== "0" && resultCode !== "")) { + const msg = ssoRes?.resultMsg || ssoRes?.resultData?.resultMsg || "SSO URL 생성 실패"; + throw new AppError(`결재 연동 오류: ${msg}`, 502); + } + + // 5) amaranth_approval INSERT/UPDATE + if (mode === "insert") { + const objid = Date.now(); + await client.query( + `INSERT INTO amaranth_approval + (objid, target_objid, target_type, appro_key, out_process_code, form_id, + status, emp_seq, comp_seq, dept_seq, writer, sso_url, regdate) + VALUES ($1, $2, $3, $4, $5, $6, 'create', $7, $8, '', $9, $10, NOW())`, + [ + objid, targetObjid, targetType, approKey, + outProcessCode || null, formId, empSeq, compSeq, userId, fullUrl, + ], + ); + } else { + // 재상신: approKey/sso_url/status 초기화, 재사용: sso_url만 갱신 + const resetStatus = mode === "update_resubmit" ? "create" : null; + await client.query( + `UPDATE amaranth_approval + SET appro_key=$2, sso_url=$3, writer=$4, + status=COALESCE($5, status), + editdate=NOW() + WHERE objid=$1`, + [existingObjid, approKey, fullUrl, userId, resetStatus], + ); + } + + await client.query("COMMIT"); + logger.info("견적 결재상신", { contractObjid, contract_no, estObjid: targetObjid, approKey, mode }); + return { fullUrl, approKey, status: "create", estObjid: targetObjid }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + // 견적 차수 리스트 (wace estimateList.jsp fn_showEstimateList — contract_objid별 차수들) export async function listTemplatesByContract(contractObjid: string) { const pool = getPool(); diff --git a/backend-node/src/services/salesOrderMgmtService.ts b/backend-node/src/services/salesOrderMgmtService.ts index e6d84cc3..b7cb32f6 100644 --- a/backend-node/src/services/salesOrderMgmtService.ts +++ b/backend-node/src/services/salesOrderMgmtService.ts @@ -8,6 +8,8 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { AppError } from "../middleware/errorHandler"; +import * as amaranth from "./amaranthApprovalClient"; export interface OrderListFilter { category_cd?: string; @@ -181,8 +183,17 @@ export async function getList(filter: OrderListFilter) { WHEN COALESCE(SER_AGG.serial_count, 0) > 1 THEN SER_AGG.first_serial_no || ' 외 ' || (SER_AGG.serial_count - 1) || '건' ELSE T.SERIAL_NO END AS SERIAL_NO - ,NULL::text AS ORDER_APPR_STATUS - ,NULL::text AS AMARANTH_STATUS + /* G4/G11 — 수주 결재상태 (wace contractMgmt.xml:523~530 1:1) + target_type='CONTRACT_ORDER' amaranth_approval 매핑 결과를 한글 라벨/원본 상태로 노출 */ + ,CASE + WHEN AMR_ORDER.status = 'complete' THEN '결재완료' + WHEN AMR_ORDER.status = 'inProcess' THEN '결재중' + WHEN AMR_ORDER.status = 'reject' THEN '반려' + WHEN AMR_ORDER.status = 'create' THEN '작성중' + ELSE '' + END AS ORDER_APPR_STATUS + ,COALESCE(AMR_ORDER.status, '') AS ORDER_AMARANTH_STATUS + ,COALESCE(AMR_ORDER.status, '') AS AMARANTH_STATUS ,0 AS CU01_CNT ,T.IS_DIRECT_ORDER AS IS_DIRECT_ORDER FROM contract_mgmt T @@ -224,6 +235,10 @@ export async function getList(filter: OrderListFilter) { WHERE CI.status='ACTIVE' AND CIS.serial_no IS NOT NULL AND CIS.serial_no != '' GROUP BY CI.contract_objid ) SER_AGG ON SER_AGG.contract_objid = T.OBJID + /* G4/G11 — 아마란스 결재(수주) — wace 1:1, target_objid는 VARCHAR */ + LEFT JOIN amaranth_approval AMR_ORDER + ON AMR_ORDER.target_objid = T.OBJID + AND AMR_ORDER.target_type = 'CONTRACT_ORDER' ${where} ORDER BY T.regdate DESC `; @@ -519,12 +534,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 이식) ── @@ -795,6 +836,242 @@ export async function saveOrderCancelQty(userId: string, entries: CancelQtyEntry } } +// G9 — 수주복사 (wace copyEstimateAndOrderInfo 1:1) +// contract_mgmt 헤더 + contract_item 라인 + contract_item_serial 통째로 새 영업번호로 복제. +// 복사본은 contract_result='', is_direct_order='Y'로 시작 (주문관리에만 노출). +// 매퍼 출처: contractMgmt.xml copyContractMgmt / copyContractItems / copyContractItemSerials / getNextContractNo. +export async function copyOrder(userId: string, sourceObjid: string): Promise<{ newObjid: string; newContractNo: string }> { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. 새 영업번호 채번 — wace getNextContractNo 1:1 ({YY}C-{NNNN}, MAX+1) + const numRes = await client.query( + `SELECT TO_CHAR(NOW(),'YY') || 'C-' || LPAD(( + COALESCE(MAX(SUBSTRING(contract_no FROM 5)::integer), 0) + 1 + )::VARCHAR, 4, '0') AS contract_no + FROM contract_mgmt + WHERE contract_no LIKE TO_CHAR(NOW(),'YY') || 'C-%'`, + ); + const newContractNo = numRes.rows[0].contract_no as string; + const newObjid = genObjid("CM"); + + // 2. CONTRACT_MGMT 복사 (wace copyContractMgmt 1:1) + const r = await client.query( + `INSERT INTO contract_mgmt ( + objid, category_cd, customer_objid, product, area_cd, + customer_equip_name, customer_project_name, customer_production_no, mechanical_type, + paid_type, receipt_date, req_del_date, contract_result, + po_no, order_date, contract_currency, exchange_rate, + regdate, writer, contract_no, is_direct_order, + order_supply_price, order_vat, order_total_amount + ) + SELECT + $1, category_cd, customer_objid, product, area_cd, + customer_equip_name, customer_project_name, customer_production_no, mechanical_type, + paid_type, receipt_date, req_del_date, '', + po_no, order_date, contract_currency, exchange_rate, + NOW(), $2, $3, 'Y', + order_supply_price, order_vat, order_total_amount + FROM contract_mgmt + WHERE objid = $4`, + [newObjid, userId, newContractNo, sourceObjid], + ); + if (r.rowCount === 0) { + throw new AppError("복사할 원본 주문이 없습니다.", 404); + } + + // 3. 원본 활성 라인 OBJID 목록 (wace getActiveItemObjIds) + const itemsRes = await client.query( + `SELECT objid FROM contract_item + WHERE contract_objid=$1 AND status='ACTIVE' + ORDER BY seq`, + [sourceObjid], + ); + + // 4. CONTRACT_ITEM + CONTRACT_ITEM_SERIAL 복사 + for (const row of itemsRes.rows) { + const sourceItemObjid: string = row.objid; + const newItemObjid = genObjid("CI"); + + // wace copyContractItems 1:1 + await client.query( + `INSERT INTO contract_item ( + objid, contract_objid, seq, part_objid, part_no, part_name, + quantity, due_date, customer_request, return_reason, + regdate, writer, status, + order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount + ) + SELECT + $1, $2, seq, part_objid, part_no, part_name, + quantity, due_date, customer_request, return_reason, + NOW(), $3, 'ACTIVE', + order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount + FROM contract_item + WHERE objid=$4 AND status='ACTIVE'`, + [newItemObjid, newObjid, userId, sourceItemObjid], + ); + + // wace copyContractItemSerials 1:1 — objid는 prefix + seq + const snPrefix = `${genObjid("CIS")}_`; + await client.query( + `INSERT INTO contract_item_serial ( + objid, item_objid, seq, serial_no, regdate, writer, status + ) + SELECT + $1 || seq::varchar, $2, seq, serial_no, NOW(), $3, 'ACTIVE' + FROM contract_item_serial + WHERE item_objid=$4 AND status='ACTIVE'`, + [snPrefix, newItemObjid, userId, sourceItemObjid], + ); + } + + await client.query("COMMIT"); + logger.info("수주복사 완료", { sourceObjid, newObjid, newContractNo, lines: itemsRes.rowCount }); + return { newObjid, newContractNo }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + +// G4/G11 — 수주 결재상신 (wace ApprovalService.getAmaranthSsoUrl + orderMgmtList.btnApproval 1:1) +// target_type='CONTRACT_ORDER', formId='1161', compSeq='1000' (wace orderMgmtList:554~559) +// amaranth_approval 신규/기존 매핑 분기: +// - 없음: 신규 approKey 생성 + INSERT +// - reject/delete/create: 새 approKey + UPDATE (재상신) +// - inProcess/complete: 기존 approKey 재사용 (프론트에서 가드되지만 백엔드도 방어) +// wace 1:1 — 첨부파일 원챔버 업로드는 미구현 (영업관리 첨부 흐름 별도 작업). +// wace ApprovalService.java:1782~1909. +export async function startOrderApproval(userId: string, contractObjid: string, opts: { + approvalTitle?: string; + subjectStr?: string; +} = {}): Promise<{ fullUrl: string; approKey: string; status: string }> { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1) 사용자 emp_seq 조회 (wace PersonBean.getEmpseq 동등) + const userRes = await client.query( + `SELECT user_id, user_name, emp_seq FROM user_info WHERE user_id=$1 LIMIT 1`, + [userId], + ); + const u = userRes.rows[0]; + if (!u) throw new AppError("사용자 정보를 찾을 수 없습니다.", 401); + const empSeq: string = String(u.emp_seq ?? "").trim(); + if (!empSeq) { + throw new AppError(`empSeq 정보가 없습니다. (userId: ${userId}) 관리자에게 문의하세요.`, 400); + } + + // 2) contract_mgmt 헤더 + 라인 수 확인 (hasOrderData 가드는 프론트지만 백엔드도 방어) + const cmRes = await client.query( + `SELECT cm.objid, cm.contract_no, + (SELECT count(*) FROM contract_item WHERE contract_objid=cm.objid AND status='ACTIVE') AS line_cnt + FROM contract_mgmt cm + WHERE cm.objid=$1`, + [contractObjid], + ); + if (cmRes.rowCount === 0) throw new AppError("주문서를 찾을 수 없습니다.", 404); + const { contract_no, line_cnt } = cmRes.rows[0]; + if (Number(line_cnt) === 0) { + throw new AppError("수주 품목을 먼저 등록해주세요.", 400); + } + + const targetType = "CONTRACT_ORDER"; + const approvalTitle = opts.approvalTitle || `주문서 결재${contract_no ? " - " + contract_no : ""}`; + const outProcessCode = process.env.AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER || process.env.AMARANTH_OUT_PROCESS_CODE || ""; + const formId = process.env.AMARANTH_FORM_ID_CONTRACT_ORDER || "1161"; + const compSeq = process.env.AMARANTH_COMP_SEQ || "1000"; + + // 3) amaranth_approval 기존 매핑 조회 (wace selectAmaranthApprovalByTarget) + const existRes = await client.query( + `SELECT objid, appro_key, status FROM amaranth_approval + WHERE target_type=$1 AND target_objid=$2 + ORDER BY regdate DESC LIMIT 1`, + [targetType, contractObjid], + ); + + let approKey: string; + let mode: "insert" | "update_resubmit" | "update_reuse"; + let existingObjid: number | null = null; + + if (existRes.rowCount === 0) { + approKey = "UB_" + Date.now().toString(36).toUpperCase(); + mode = "insert"; + } else { + existingObjid = existRes.rows[0].objid; + const existingStatus = String(existRes.rows[0].status || ""); + if (["reject", "delete", "create"].includes(existingStatus)) { + // 재상신: 새 approKey (아마란스가 기존 approKey의 원챔버 첨부를 재사용하므로 수정 미반영 회피) + approKey = "UB_" + Date.now().toString(36).toUpperCase(); + mode = "update_resubmit"; + } else { + approKey = String(existRes.rows[0].appro_key || ""); + mode = "update_reuse"; + } + } + + // 4) SSO URL 발급 (chpark amaranthApprovalClient.getSsoUrl) + const ssoRes = await amaranth.getSsoUrl({ + empSeq, + outProcessCode: outProcessCode || undefined, + formId, + approKey, + subjectStr: opts.subjectStr || approvalTitle, + mod: "W", + compSeq, + deptSeq: "", + loginId: u.user_id, + }); + const fullUrl: string = ssoRes?.resultData?.fullUrl || ssoRes?.fullUrl || ""; + const resultCode = String(ssoRes?.resultCode ?? ssoRes?.resultData?.resultCode ?? ""); + + if (!fullUrl || (resultCode !== "0" && resultCode !== "")) { + const msg = ssoRes?.resultMsg || ssoRes?.resultData?.resultMsg || "SSO URL 생성 실패"; + throw new AppError(`결재 연동 오류: ${msg}`, 502); + } + + // 5) amaranth_approval INSERT/UPDATE + if (mode === "insert") { + const objid = Date.now(); + await client.query( + `INSERT INTO amaranth_approval + (objid, target_objid, target_type, appro_key, out_process_code, form_id, + status, emp_seq, comp_seq, dept_seq, writer, sso_url, regdate) + VALUES ($1, $2, $3, $4, $5, $6, 'create', $7, $8, '', $9, $10, NOW())`, + [ + objid, contractObjid, targetType, approKey, + outProcessCode || null, formId, empSeq, compSeq, userId, fullUrl, + ], + ); + } else { + // 재상신: approKey/sso_url/status 초기화, 재사용: sso_url만 갱신 + const resetStatus = mode === "update_resubmit" ? "create" : null; + await client.query( + `UPDATE amaranth_approval + SET appro_key=$2, sso_url=$3, writer=$4, + status=COALESCE($5, status), + editdate=NOW() + WHERE objid=$1`, + [existingObjid, approKey, fullUrl, userId, resetStatus], + ); + } + + await client.query("COMMIT"); + logger.info("수주 결재상신", { contractObjid, contract_no, approKey, mode }); + return { fullUrl, approKey, status: "create" }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + export async function updateStatus(userId: string, objid: string, contractResult: string) { const pool = getPool(); const client = await pool.connect(); diff --git a/backend-node/src/utils/mailUtil.ts b/backend-node/src/utils/mailUtil.ts new file mode 100644 index 00000000..53ab5787 --- /dev/null +++ b/backend-node/src/utils/mailUtil.ts @@ -0,0 +1,196 @@ +// ============================================================ +// SMTP 발송 유틸 — wace MailUtil.sendMailWithAttachFileUTF8 1:1 +// ([MailUtil.java:810-1034](../../../../wace_plm/src/com/pms/common/utils/MailUtil.java)) +// +// 계정 타입(ERP/SALES/PURCHASE)별 SMTP 분기 — wace Constants.Mail 동일. +// 발송 흐름: mail_log INSERT(is_send='N') → nodemailer 발송 +// → 성공: UPDATE is_send='Y', log_time=NOW() +// → 실패: UPDATE is_send='N', error_log +// ============================================================ + +import nodemailer from "nodemailer"; +import { getPool } from "../database/db"; +import { logger } from "./logger"; + +export type SmtpAccountType = "ERP" | "SALES" | "PURCHASE"; + +export interface MailAttachment { + filename: string; // 표시될 파일명 (UTF-8 가능) + path?: string; // 디스크 경로 (path 또는 content 둘 중 하나) + content?: Buffer; // 메모리 바이너리 + contentType?: string; +} + +export interface SendMailUTF8Params { + accountType?: SmtpAccountType; // 기본 ERP + fromUserId: string; // mail_log.send_user_id + toEmails: string[]; // 수신 + ccEmails?: string[]; // 참조 + bccEmails?: string[]; // 숨은참조 + toUserIds?: string[]; // mail_log.reception_user_id (참고용) + subject: string; // 실제 메일 제목 + subjectForLog?: string; // mail_log.title (기본: subject 그대로). 그리드 LEFT JOIN용 [OBJID:nnn] 토큰은 호출 측에서 부착. + html: string; // 본문 HTML (text/plain은 \n →
로 직접 변환) + attachments?: MailAttachment[]; + mailType: string; // mail_log.mail_type + important?: "High" | "Normal" | "Low"; +} + +interface SmtpConfig { + host: string; + port: number; + user: string; + pass: string; + secure: boolean; +} + +function readSmtpConfig(accountType: SmtpAccountType): SmtpConfig { + const host = process.env.SMTP_HOST || ""; + const port = parseInt(process.env.SMTP_PORT || "25", 10); + const secure = (process.env.SMTP_TLS || "false").toLowerCase() === "true"; + + let user: string; + let pass: string; + switch (accountType) { + case "SALES": + user = process.env.SMTP_USER_SALES || ""; + pass = process.env.SMTP_PW_SALES || ""; + break; + case "PURCHASE": + user = process.env.SMTP_USER_PURCHASE || ""; + pass = process.env.SMTP_PW_PURCHASE || ""; + break; + case "ERP": + default: + user = process.env.SMTP_USER_ERP || ""; + pass = process.env.SMTP_PW_ERP || ""; + break; + } + + return { host, port, user, pass, secure }; +} + +function genMailLogObjid(): string { + // mail_log.objid (VARCHAR). wace는 CommonUtils.createObjId() 사용 — 우리는 ML-{ms}-{rand} 패턴 통일. + const ms = Date.now(); + const rand = Math.floor(Math.random() * 1000).toString().padStart(3, "0"); + return `ML-${ms}-${rand}`; +} + +/** + * UTF-8 HTML 본문 + 첨부파일 메일 발송. + * wace MailUtil.sendMailWithAttachFileUTF8 1:1. + * + * @returns { objid: mail_log.objid, sent: 실제 발송 성공 여부 } + * SMTP_SEND_SWITCH !== 'Y'면 INSERT만 하고 sent=false 리턴 (wace sendMailSwitch와 동일). + */ +export async function sendMailUTF8(params: SendMailUTF8Params): Promise<{ objid: string; sent: boolean; error?: string }> { + const accountType: SmtpAccountType = params.accountType || "ERP"; + const cfg = readSmtpConfig(accountType); + const sendSwitch = (process.env.SMTP_SEND_SWITCH || "Y").toUpperCase() === "Y"; + + const subjectForLog = params.subjectForLog && params.subjectForLog.trim() !== "" + ? params.subjectForLog + : params.subject; + + // ─── 1. mail_log INSERT (is_send 기본 NULL/N, 발송 후 갱신) ─── + const pool = getPool(); + const objid = genMailLogObjid(); + const receiverTo = params.toEmails.join(", ") + (params.ccEmails && params.ccEmails.length > 0 ? `; cc: ${params.ccEmails.join(", ")}` : ""); + + try { + await pool.query( + `INSERT INTO mail_log ( + objid, system_name, send_user_id, from_addr, + reception_user_id, receiver_to, + title, contents, log_time, is_send, mail_type + ) VALUES ( + $1, 'vexplor_rps', $2, $3, + $4, $5, + $6, $7, NOW(), 'N', $8 + )`, + [ + objid, + params.fromUserId, + cfg.user || null, + params.toUserIds && params.toUserIds.length > 0 ? params.toUserIds.join(", ") : null, + receiverTo, + subjectForLog, + params.html, + params.mailType, + ], + ); + } catch (logErr) { + logger.error("mail_log INSERT 실패", { objid, error: (logErr as Error).message }); + throw logErr; + } + + // ─── 2. 발송 스킵 모드: wace Constants.Mail.sendMailSwitch == false ─── + if (!sendSwitch) { + logger.warn("SMTP_SEND_SWITCH !== 'Y' — 실제 발송 스킵, mail_log만 기록", { objid }); + return { objid, sent: false }; + } + + // ─── 3. 실제 SMTP 발송 ─── + try { + if (!cfg.host || !cfg.user) { + throw new Error(`SMTP 설정 누락 (accountType=${accountType}, host=${cfg.host}, user=${cfg.user})`); + } + if (params.toEmails.length === 0) { + throw new Error("수신인 이메일이 없습니다."); + } + + const transporter = nodemailer.createTransport({ + host: cfg.host, + port: cfg.port, + secure: cfg.secure, + auth: { user: cfg.user, pass: cfg.pass }, + // wace JavaMail: mail.smtp.starttls.enable=false, mail.smtp.ssl.enable=false, plain auth on 25 + tls: { rejectUnauthorized: false }, + logger: false, + debug: false, + }); + + const info = await transporter.sendMail({ + from: cfg.user, + to: params.toEmails.join(", "), + cc: params.ccEmails && params.ccEmails.length > 0 ? params.ccEmails.join(", ") : undefined, + bcc: params.bccEmails && params.bccEmails.length > 0 ? params.bccEmails.join(", ") : undefined, + subject: params.subject, + html: params.html, + priority: params.important === "High" ? "high" : params.important === "Low" ? "low" : "normal", + attachments: params.attachments?.map((a) => ({ + filename: a.filename, + path: a.path, + content: a.content, + contentType: a.contentType, + })), + }); + + logger.info("메일 발송 성공", { objid, messageId: info.messageId, accountType, to: params.toEmails }); + + // mail_log 갱신 — 성공 + await pool.query( + `UPDATE mail_log SET is_send='Y', log_time=NOW() WHERE objid=$1`, + [objid], + ); + + return { objid, sent: true }; + } catch (err) { + const msg = (err as Error).message; + logger.error("메일 발송 실패", { objid, accountType, error: msg }); + + // mail_log 갱신 — 실패. 컬럼이 없으면 best-effort. + try { + await pool.query( + `UPDATE mail_log SET is_send='N', error_log=$2 WHERE objid=$1`, + [objid, msg], + ); + } catch (updErr) { + // error_log 컬럼이 없는 환경에서는 silently skip + logger.warn("mail_log 실패 상태 기록 스킵 (error_log 컬럼 없을 수 있음)", { objid }); + } + + return { objid, sent: false, error: msg }; + } +} diff --git a/deploy/onpremise/docker-compose.yml b/deploy/onpremise/docker-compose.yml index a779cad7..df209f85 100644 --- a/deploy/onpremise/docker-compose.yml +++ b/deploy/onpremise/docker-compose.yml @@ -53,6 +53,26 @@ services: DEFAULT_COMPANY_CODE: ${COMPANY_CODE:-SPIFOX} # 로깅 LOG_LEVEL: ${LOG_LEVEL:-info} + # SMTP (견적서/발주서 등 메일 발송) — 호스트 .env에서 실값 주입 + SMTP_HOST: ${SMTP_HOST:-erp.rps-korea.com} + SMTP_PORT: ${SMTP_PORT:-25} + SMTP_TLS: ${SMTP_TLS:-false} + SMTP_SEND_SWITCH: ${SMTP_SEND_SWITCH:-Y} + SMTP_USER_ERP: ${SMTP_USER_ERP:-} + SMTP_PW_ERP: ${SMTP_PW_ERP:-} + SMTP_USER_SALES: ${SMTP_USER_SALES:-} + SMTP_PW_SALES: ${SMTP_PW_SALES:-} + SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-} + SMTP_PW_PURCHASE: ${SMTP_PW_PURCHASE:-} + # Amaranth 전자결재 (수주/견적 등) — 호스트 .env에서 실값 주입 + # OUT_PROCESS_CODE: wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1 + # 도메인별 fallback: AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER → AMARANTH_OUT_PROCESS_CODE + AMARANTH_OUT_PROCESS_CODE: ${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001} + AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-} + AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE:-} + AMARANTH_FORM_ID_CONTRACT_ORDER: ${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161} + AMARANTH_FORM_ID_CONTRACT_ESTIMATE: ${AMARANTH_FORM_ID_CONTRACT_ESTIMATE:-1162} + AMARANTH_COMP_SEQ: ${AMARANTH_COMP_SEQ:-1000} volumes: - backend_uploads:/app/uploads - backend_data:/app/data diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index 115aef91..4e47ddc1 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -24,6 +24,24 @@ services: EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-} SMART_FACTORY_API_KEY_COMPANY_10: ${SMART_FACTORY_API_KEY_COMPANY_10:-} SMART_FACTORY_API_KEY_COMPANY_9: ${SMART_FACTORY_API_KEY_COMPANY_9:-} + # SMTP (견적서/발주서 등 메일 발송) + SMTP_HOST: ${SMTP_HOST:-erp.rps-korea.com} + SMTP_PORT: ${SMTP_PORT:-25} + SMTP_TLS: ${SMTP_TLS:-false} + SMTP_SEND_SWITCH: ${SMTP_SEND_SWITCH:-Y} + SMTP_USER_ERP: ${SMTP_USER_ERP:-} + SMTP_PW_ERP: ${SMTP_PW_ERP:-} + SMTP_USER_SALES: ${SMTP_USER_SALES:-} + SMTP_PW_SALES: ${SMTP_PW_SALES:-} + SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-} + SMTP_PW_PURCHASE: ${SMTP_PW_PURCHASE:-} + # Amaranth 전자결재 (수주/견적 등) — wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1 + AMARANTH_OUT_PROCESS_CODE: ${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001} + AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-} + AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE:-} + AMARANTH_FORM_ID_CONTRACT_ORDER: ${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161} + AMARANTH_FORM_ID_CONTRACT_ESTIMATE: ${AMARANTH_FORM_ID_CONTRACT_ESTIMATE:-1162} + AMARANTH_COMP_SEQ: ${AMARANTH_COMP_SEQ:-1000} volumes: - backend_uploads:/app/uploads - backend_data:/app/data diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index 8ab60253..aabfae5b 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -28,6 +28,24 @@ services: - SMART_FACTORY_API_KEY_COMPANY_9=${SMART_FACTORY_API_KEY_COMPANY_9:-} - SMART_FACTORY_API_KEY_COMPANY_10=${SMART_FACTORY_API_KEY_COMPANY_10:-} - SMART_FACTORY_API_KEY_COMPANY_16=${SMART_FACTORY_API_KEY_COMPANY_16:-} + # SMTP (견적서/발주서 등 메일 발송) + - SMTP_HOST=${SMTP_HOST:-erp.rps-korea.com} + - SMTP_PORT=${SMTP_PORT:-25} + - SMTP_TLS=${SMTP_TLS:-false} + - SMTP_SEND_SWITCH=${SMTP_SEND_SWITCH:-Y} + - SMTP_USER_ERP=${SMTP_USER_ERP:-} + - SMTP_PW_ERP=${SMTP_PW_ERP:-} + - SMTP_USER_SALES=${SMTP_USER_SALES:-} + - SMTP_PW_SALES=${SMTP_PW_SALES:-} + - SMTP_USER_PURCHASE=${SMTP_USER_PURCHASE:-} + - SMTP_PW_PURCHASE=${SMTP_PW_PURCHASE:-} + # Amaranth 전자결재 (수주/견적 등) — wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1 + - AMARANTH_OUT_PROCESS_CODE=${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001} + - AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER=${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-} + - AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE=${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE:-} + - AMARANTH_FORM_ID_CONTRACT_ORDER=${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161} + - AMARANTH_FORM_ID_CONTRACT_ESTIMATE=${AMARANTH_FORM_ID_CONTRACT_ESTIMATE:-1162} + - AMARANTH_COMP_SEQ=${AMARANTH_COMP_SEQ:-1000} restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3001/health"] diff --git a/docs/migration/project/00-gap.md b/docs/migration/project/00-gap.md new file mode 100644 index 00000000..7d84411a --- /dev/null +++ b/docs/migration/project/00-gap.md @@ -0,0 +1,195 @@ +# 프로젝트관리 이식 GAP 분석 (원본 wace_plm 대비) + +> 작성: 2026-05-11 / 작성자: hjjeong +> **개정 2026-05-11**: 운영판 실 화면은 `projectMgmtList.jsp`(옛 화면)가 **아니라** `projectMgmtWbsList3.jsp` + 매퍼 `projectMgmtWbsGridList`인 것이 확인되어 GAP 전면 개정. + +--- + +## 0. 한 문장 요약 + +운영판 진행관리는 **`/project/projectMgmtWbsList3.do`** endpoint가 반환하는 화면이고, 그리드 SQL은 매퍼 `projectMgmtWbsGridList`(project.xml:3854~4280). 그리드 컬럼은 8그룹/18셀 + 검색폼은 11필드. 의존 테이블 대부분 RPS에 보유 — `project_mgmt`/`contract_mgmt`/`contract_item`/`contract_item_serial`/`customer_mng`/`user_info`/`comm_code`/`attach_file_info`/`sales_registration`/`part_mng`. `pms_wbs_task`/`setup_wbs_task`만 부재(P2 의존, 그리드 표시 컬럼엔 영향 거의 없음). **P1에서 그리드 18셀 전부 실데이터로 채울 수 있음.** + +## 0.1 이식 원칙 + +> JSP/Java/매퍼XML 안의 `/* */`, ``, `//` 주석 블록은 비활성. 활성 코드만 이식. + +- 운영 화면(waceplm.esgrin.com) = 진실의 기준. +- wace 컨트롤러에 `projectMgmtList.do`(옛) + `projectMgmtList1.do` + **`projectMgmtWbsList3.do`(운영판)** 셋이 동시 존재 — 메뉴가 가리키는 것이 무엇인지 확인 필수. +- `client_mng`/`supply_mng` → RPS는 `customer_mng`로 통합. CASE WHEN LIKE 'C_%' 분기는 모두 customer_mng 단일 LEFT JOIN으로 변환. +- `CODE_NAME()` 함수 RPS DB에 존재. 다만 영업관리 패턴(`LEFT JOIN comm_code CC_X ON CC_X.code_id=...`)으로 통일. +- 금액 1,234.00 / 수량 1,234 / 모든 숫자 right-align. + +--- + +## 1. 운영판 진행관리 흐름 + +### 1.1 endpoint + +| URL | Controller | view / 결과 | +|---|---|---| +| `/project/projectMgmtWbsList3.do` | `projectMgmtWbsList3` (L3243) | view 반환 + 코드맵(`category_cd`/`customer_cd`/`project_no`/`product_cd`/`status_cd`/`result_cd`/`pm_user_id`) | +| `/project/projectMgmtWbsGridList.do` | (`projectMgmtWbsGridList` 매퍼 호출) | 그리드 JSON | +| `fn_openSaleRegPopup(orderNo)` → `/salesMgmt/salesRegForm.do?orderNo={PROJECT_NO}` | (영업관리 주문서 등록 화면) | 행 클릭 → 영업관리 주문서 등록 폼 오픈. **진행관리 자체 수정 폼 없음.** | + +→ 본 이식의 RPS 측 라우트: +- `GET /api/project/progress/list` — 그리드 +- 행 클릭 동작은 **P1.5에서 결정** (영업관리 `OrderRegistDialog` 재사용 vs 미연결) + +### 1.2 그리드 컬럼 (운영판 그대로) + +8그룹 / 18셀 (frozen 1개 포함): + +| # | dataField | 라벨 | 의존 | P1 채움 | +|---|---|---|---|---| +| 1 | **PROJECT_NO** (frozen) | 프로젝트번호 | project_mgmt | ✅ | +| 2 | CATEGORY_NAME | 주문유형 | comm_code(0000167) | ✅ | +| 3 | PRODUCT_NAME | 제품구분 | comm_code(0000001) | ✅ | +| 4 | AREA_NAME | 국내/해외 | comm_code(area_cd) | ✅ | +| 5 | REG_DATE | 접수일 | TO_CHAR(regdate,'YYYY-MM-DD') | ✅ | +| 6 | CUSTOMER_NAME | 고객사 | customer_mng | ✅ | +| 7 | FREE_OF_CHARGE | 유/무상 | contract_mgmt.paid_type → '유상'/'무상' | ✅ | +| 8 | PRODUCT_ITEM_CODE | 품번 | project_mgmt.part_no | ✅ | +| 9 | PRODUCT_ITEM_NAME | 품명 | project_mgmt.part_name | ✅ | +| 10 | SERIAL_NO | S/N | contract_item_serial (CIS.item_objid=T.contract_item_objid) | ✅ | +| 11 | CONTRACT_QTY | 수주수량 | project_mgmt.quantity | ✅ | +| 12 | REQ_DEL_DATE | 요청납기 | COALESCE(contract_item.due_date, project_mgmt.due_date, contract_mgmt.due_date) | ✅ | +| 13 | EBOM_STATUS | E-BOM | project_mgmt.ebom_status | ✅ (대부분 빈값, 운영DB도 빈값) | +| 14 | MBOM_STATUS | M-BOM | project_mgmt.mbom_status | ✅ (대부분 빈값) | +| 15 | ORDER_DATE | 발주일 | contract_mgmt.order_date | ✅ | +| 16 | RECEIVING_RATE | 입고율 | project_mgmt.receiving_rate | ✅ | +| 17 | PRODUCTION_TEAM_12 | 제조1,2팀 | project_mgmt.production_team_12 | ✅ | +| 18 | PRODUCTION_TEAM_3 | 제조3팀 | project_mgmt.production_team_3 | ✅ | +| 19 | ASSEMBLY | 조립 | (wace SQL에 없음 — 빈값) | (빈값) | +| 20 | VERIFICATION | 검증 | (wace SQL에 없음 — 빈값) | (빈값) | +| 21 | SHIPMENT_DATE | 출하일 | sales_registration.shipping_date (project_no 매칭) | ✅ | + +→ **18셀 모두 P1에서 채움 가능** (장비 조립/검증 2개는 wace 원본도 빈값). PMS_WBS_TASK 의존 컬럼들은 그리드 표시에 없음. + +### 1.3 검색 폼 (11필드) + +| # | name | 라벨 | 입력 | 매핑 | +|---|---|---|---|---| +| 1 | Year | 년도 | select(sysYear±4) | TO_CHAR(REGDATE,'YYYY') | +| 2 | project_nos | 프로젝트번호 | multi-select | OBJID IN (쉼표 split) | +| 3 | category_cd | 주문유형 | select(0000167) | T.CATEGORY_CD | +| 4 | customer_objid | 고객사 | select | T.CUSTOMER_OBJID | +| 5 | product | 제품구분 | select(0000001) | T.PRODUCT | +| 6 | contract_start_date / contract_end_date | 요청납기일 | date range | COALESCE(contract_item.due_date, project_mgmt.due_date, contract_mgmt.due_date) | +| 7 | area_cd | 국내/해외 | select(국내/해외 라벨) | CODE_NAME(AREA_CD) = '국내'/'해외' | +| 8 | free_of_charge | 유/무상 | select(유상/무상 라벨) | contract_mgmt.paid_type 매핑 비교 | +| 9 | product_item_code | 품번 | autocomplete | T.PART_NO ILIKE '%?%' | +| 10 | product_item_name | 품명 | autocomplete | T.PART_NAME ILIKE '%?%' | +| 11 | serial_no | S/N | text | EXISTS contract_item_serial ILIKE | + +비활성(주석블록): location / setup / pm_user_id — 무시. + +### 1.4 ORDER BY + +```sql +ORDER BY SUBSTRING(PROJECT_NO,POSITION('-' IN PROJECT_NO)+1) DESC, + OVERHAUL_ORDER DESC NULLS LAST +``` + +### 1.5 행 클릭 동작 + +`fn_openSaleRegPopup(PROJECT_NO)` → `/salesMgmt/salesRegForm.do?orderNo={PROJECT_NO}` (영업관리 주문서 등록 폼). **진행관리 자체 수정 폼 없음.** RPS 이식 시 P1.5 별도 결정. + +--- + +## 2. RPS DB 보유 매트릭스 (개정) + +| 테이블 | 보유 | 진행관리에서의 용도 | P1 | +|---|---|---|---| +| project_mgmt | ✅ | 메인 (90건) | ✅ | +| contract_mgmt | ✅ | paid_type / order_date / due_date / pm_user_id | ✅ | +| **contract_item** | ✅ | 요청납기 COALESCE 1순위 | ✅ | +| **contract_item_serial** | ✅ | S/N 집계 | ✅ | +| **sales_registration** | ✅ | 출하일 COALESCE | ✅ | +| customer_mng | ✅ | 고객사명 (wace의 client_mng/supply_mng 분기 흡수) | ✅ | +| user_info | ✅ | PM명 / writer명 / chg_user 등 | ✅ | +| comm_code + CODE_NAME() | ✅ | 카테고리/제품/지역/통화 등 라벨 | ✅ | +| attach_file_info | ✅ | contractMgmt01/02 카운트 (CU01_CNT/CU02_CNT) | ✅ | +| part_mng | ✅ | (이번 P1에서는 직접 사용 안 함 — project_mgmt.part_no/part_name 직접 사용) | — | +| purchase_order_master | ✅ | (이번 P1에서는 사용 안 함 — P2의 투입원가 컬럼에서만 사용) | — | +| pms_wbs_task | ❌ | 진척율/연체 카운트 — 그리드 표시 컬럼엔 없음 | (P2) | +| setup_wbs_task | ❌ | 셋업 진척율 — 그리드 표시 컬럼엔 없음 | (P2) | +| client_mng / supply_mng | ➖ | wace 전용 (customer_mng로 흡수) | — | + +→ **P1에서 18셀 전부 실데이터 채울 수 있음.** + +--- + +## 3. GAP 매트릭스 (개정) + +| # | 우선 | 항목 | 권장 작업 | +|---|---|---|---| +| **PRJ-1** | 🔴 | 진행관리 메뉴 자체 부재 → 운영판 화면 1:1 이식 | **본 PR (P1)** — 18셀 + 검색 11필드 | +| **PRJ-2** | 🟠 | 행 클릭 → 영업관리 주문서 등록 폼 오픈 (`fn_openSaleRegPopup`) | **P1.5** — `OrderRegistDialog` 재사용 vs 미연결. 사용자 결정 | +| **PRJ-3** | 🟠 | 다중 선택 프로젝트번호 검색 (multi-select) | **본 PR** — frontend SmartSelect multi 모드, backend는 `project_nos` 쉼표 split | +| **PRJ-4** | 🟡 | 검색 코드맵 옵션 (`project_no` 다중 옵션 리스트) | wace는 코드맵에서 동적 생성. RPS는 grid 데이터 기반 옵션 또는 별도 API. **P1 보수적 처리**: 사용자가 OBJID 또는 PROJECT_NO 직접 입력 | +| **PRJ-5** | 🟢 | 엑셀 다운로드 | wace에 없음 — 본 PR 제외 | +| **PRJ-6** | 🟢 | 진척율(PMS_WBS_TASK 의존) — 그리드 표시 컬럼엔 영향 없으나 SQL은 계산 | **P2 (WBS 메뉴 이식 후)**. 본 P1은 진척율 컬럼 자체가 그리드에 없으므로 미반영 | +| **PRJ-7** | 🟢 | 설계/생산 ASSEMBLY/VERIFICATION 컬럼 (운영판 빈값) | 자리만, 빈값 | + +--- + +## 4. P1 스코프 — 본 GAP 결정사항 + +### 4.1 백엔드 갱신 (`backend-node/src/`) + +| 파일 | 동작 | +|---|---| +| `services/projectMgmtService.ts` | **전면 교체** — `listProgress` SQL을 `projectMgmtWbsGridList` 1:1로 재작성. `getById`/`updateProject`는 P1.5에서 결정될 때까지 일단 유지 (사용 안 됨) | +| `controllers/projectMgmtController.ts` | 현행 유지 | +| `routes/projectMgmtRoutes.ts` | 현행 유지 (`GET /list` + 향후 추가 endpoint 자리) | +| `app.ts` | 현행 유지 (이미 마운트됨) | + +### 4.2 프론트엔드 갱신 (`frontend/`) + +| 파일 | 동작 | +|---|---| +| `app/(main)/COMPANY_16/project/progress/page.tsx` | **전면 교체** — 검색폼 11필드(2행) + 그리드 18셀(8그룹 평탄화) | +| `components/project/ProjectProgressEditDialog.tsx` | **폐기** (옛 jsp 기반). 행 클릭은 P1.5에서 결정 | +| `lib/api/projectMgmt.ts` | 검색 타입/결과 행 타입 갱신 | +| `components/layout/AdminPageRenderer.tsx` | 현행 유지 | + +### 4.3 검색 옵션 컴포넌트 매핑 + +| 검색 필드 | RPS 컴포넌트 | +|---|---| +| Year | `` (쉼표 직렬화 텍스트) — 다중 SmartSelect는 P1.5에서 보강 가능 | +| category_cd / product | `` | +| customer_objid | `` | +| 요청납기 범위 | `` × 2 | +| area_cd | `` 정적 옵션 ('유상'/'무상') | +| product_item_code / product_item_name | `` (자동완성은 P1.5 보강) | +| serial_no | `` | + +### 4.4 본 PR에서 **하지 않을 것** +- WBS관리 메뉴(별도 PR P2) +- PMS_WBS_TASK / SETUP_WBS_TASK 의존 컬럼 (그리드 표시 컬럼엔 없으므로 영향 없음) +- 행 클릭 → 영업관리 주문서 등록 폼 라우팅 (P1.5 별도) +- 다중 select / 품번품명 자동완성 (P1.5 보강) +- 엑셀 / 결재 (운영판 없음) + +--- + +## 5. 사용자 결정 사항 (2026-05-11) + +| # | 항목 | 결정 | +|---|---|---| +| 1 | URL 경로 | `/COMPANY_16/project/progress` | +| 2 | 차종/OEM 마일스톤 | 영구 제외 | +| 3 | 운영판 1:1 재작성 범위 | **그리드 + 검색폼 모두 운영판(projectMgmtWbsList3) 기준 재작성** | +| 4 | 행 클릭 다이얼로그 | P1.5 — `OrderRegistDialog` 재사용 검토. P1에서는 미연결 | +| 5 | 진척율(WBS) 컬럼 | P2에서 — 그리드 표시 컬럼엔 없으므로 P1 영향 없음 | + +--- + +## 6. 다음 단계 + +1. P1 운영판 1:1 재작성 → 사용자 동작 확인 (그리드 18셀이 실데이터로 채워지는지) +2. P1.5: 행 클릭 동작 결정 (영업관리 OrderRegistDialog 재사용 / 미연결 / 신규 다이얼로그) +3. P2: WBS관리 메뉴(`/COMPANY_16/project/wbs-template`) — PMS_WBS_* 운영DB DDL 추출 + 02-wbs.md diff --git a/docs/migration/sales/00-gap.md b/docs/migration/sales/00-gap.md index fd9ba0db..39f647bd 100644 --- a/docs/migration/sales/00-gap.md +++ b/docs/migration/sales/00-gap.md @@ -47,14 +47,15 @@ | G1 | 🔴 | **수주확정 시 프로젝트 자동생성** | `ContractMgmtService.updateOrderStatus` (라인 2987~3113) + `project.xml` (7518~7581) | [salesOrderMgmtService.ts:521](../../../backend-node/src/services/salesOrderMgmtService.ts#L521) `updateStatus`는 `CONTRACT_RESULT` UPDATE만 — 프로젝트 생성 호출 없음 | `updateStatus` 트랜잭션 내에서: contract_item 루프 → PRODUCT='0000928'(Machine)이면 quantity만큼 N회, 아니면 1회 → `project_no` 채번 (`{주문유형}-{제품구분}-{YYMMDD}-{순번3자리}`) → `project_mgmt` INSERT | | G2 | 🔴 | **직접등록 통합폼** (`estimateAndOrderRegistFormPopup`) | `ContractMgmtService.saveEstimateAndOrderInfo` (라인 2664) | endpoint 자체 부재. 주문관리 화면 "신규" 버튼이 견적 없이 주문 등록하는 흐름 미지원 | `POST /api/sales/order/direct` 신설 — `IS_DIRECT_ORDER='Y'` 강제, `contract_mgmt` UPSERT + `contract_item` UPSERT + `contract_item_serial` 다중 INSERT | | G3 | 🟠 | 견적요청등록 시 contract_item 다중 INSERT | `ContractMgmtService.saveContractMgmtInfo` (라인 544) | [salesEstimateService.ts](../../../backend-node/src/services/salesEstimateService.ts)는 헤더만 INSERT, 라인 입력 누락 | save 트랜잭션에 `contract_item` 다중 UPSERT + `contract_item_serial` 처리 추가 | -| G4 | 🟠 | 결재 자동판정 (`checkApprovalRequired`) | (별도 컨트롤러, 신규수주/가격인하 룰) | 미구현. APPROVAL_REQUIRED='N' 라벨 표시만 | 룰 분석 후 endpoint 신설. 외부 amaranth SSO는 RPS 결재 모듈 결정 후 | +| G4 | 🟡 | 결재 자동판정 (`checkApprovalRequired`) | (별도 컨트롤러, 신규수주/가격인하 룰) | 미구현. APPROVAL_REQUIRED='N' 라벨 표시만 | 룰 분석 후 endpoint 신설 (사전판정 룰만 — SSO 흐름은 G11에서 처리됨) | | G5 | 🟠 | 견적템플릿 일반/장비 분기 + PDF | `ContractMgmtService.saveEstimateTemplate/2` (라인 1501/1591) + SmartEditor `uploadPdfChunk` | 미이식. 추가견적 카운트(시연 시드)만 표시 | template1/template2 popup 라우트 + `puppeteer` 또는 `react-pdf` PDF 생성 → `attach_file_info doc_type='estimate02'` INSERT | | G6 | 🟠 | SMTP 실제 발송 | `ContractMgmtService.sendEstimateMail` (라인 1774-1968), `MailUtil.sendMailWithAttachFileUTF8` (라인 1925) | [salesEstimateService.ts:618](../../../backend-node/src/services/salesEstimateService.ts#L618)는 mail_log INSERT만 | `mailSendSimpleService`(nodemailer) 통합 + HTML 본문 생성기(`makeEstimateMailContents`) 포팅 + 첨부 결합 | | G7 | 🟡 | 주문서 수정 시 contract_item UPSERT (OBJID 유지) | `mapper.upsertContractItemWithOrder` (UPSERT 패턴) | 이식본은 단순 UPDATE — 라인 변경 시 OBJID 유지 보장 안 됨 | UPSERT(ON CONFLICT) 패턴 적용 + 삭제된 라인 처리 분기 | | G8 | 🟡 | 프로젝트 존재 시 견적·주문 삭제 방지 | `ContractMgmtService.deleteContractMngInfo` (라인 794~808) | [salesOrderMgmtService.ts](../../../backend-node/src/services/salesOrderMgmtService.ts) delete는 사전 체크 없음 | delete 전에 `project_mgmt WHERE contract_objid=$1 LIMIT 1` 체크 → 있으면 거부 | -| G9 | 🟡 | 견적요청 → 견적작성 라인 자동 복제 UI | (원본은 사용자가 contract_item에서 수동 선택) | 미구현 — 견적 작성 시 매번 라인 재입력 | "이전 라인 복제" 버튼 + contract_item → estimate_template_item 일괄 복사 | -| G10 | 🟢 | 환율 마스터 + EXCHANGE_RATE 자동 변환 | `contractBase` SQL EST_TOTAL_AMOUNT_KRW 환산식 | 환산식만 있고 환율 마스터 미구축 | 환율 테이블 신설(또는 ECOS API 동기화) | -| G11 | 🟢 | 결재 모듈 (amaranth_approval / 자체) | 외부 amaranth + APPR_STATUS 라벨 | RPS 결재 정책 미정 | vexplor `approvalController` 매핑 vs `amaranth_approval` 도입 결정 | +| G9 | ✅ | **수주복사** (헤더 + contract_item + 시리얼 통째로 복제, 새 영업번호 채번) | `ContractMgmtService.copyEstimateAndOrderInfo` (라인 2601) + 매퍼 `copyContractMgmt`/`copyContractItems`/`copyContractItemSerials`/`getNextContractNo` | **완료 (2026-05-11)** — `salesOrderMgmtService.copyOrder` + `POST /sales/order-mgmt/:id/copy` + 주문관리 그리드 "수주복사" 버튼. 검증: [06-copy-order-verify.md](./06-copy-order-verify.md) | +| ~~G10~~ | ❌ | ~~환율 마스터 + EXCHANGE_RATE 자동 변환~~ | (영업관리 GAP 아님 — Admin 도메인) | wace 영업관리 화면도 `exchange_rate`는 사용자 직접 입력. `COMM_EXCHANGE_RATE` 테이블·환율관리 화면은 wace AdminController(`4898~4993`) + `admin.xml(8191~8336)` 소속 | 영업관리 GAP에서 제외. Admin 메뉴 이식 시점에 별도로 다룸 | +| G11 | ✅ | **수주 결재상신 (Amaranth 직행)** | wace `ApprovalService.getAmaranthSsoUrl` (라인 1782~1909) + `orderMgmtList.btnApproval` (132~175) | chpark의 `amaranthApprovalClient`(HMAC-SHA256 + AES-128-CBC) 기반. `amaranth_approval` 테이블만 사용, 자체 approval 미경유 (wace 패턴 동일). target_type=`CONTRACT_ORDER`, formId=`1161`, compSeq=`1000` | **완료 (2026-05-11)** — `salesOrderMgmtService.startOrderApproval` + `POST /sales/order-mgmt/:id/amaranth-approval` + 주문관리 "결재상신" 버튼 + 결재상태 컬럼(작성중/결재중/결재완료/반려). DB ALTER: `amaranth_approval.target_objid` → VARCHAR. 검증: [07-amaranth-approval-verify.md](./07-amaranth-approval-verify.md). 백로그: 첨부파일 원챔버 업로드 | +| G11E | ✅ | **견적 결재상신 (Amaranth 직행, G11 동일 패턴)** | wace `estimateList_new.jsp:154~270, 868~916` + `ApprovalService.getAmaranthSsoUrl` CONTRACT_ESTIMATE 분기 | G11과 동일 흐름. 차이점: target_type=`CONTRACT_ESTIMATE`, target_objid=`estimate_template.objid`(최신 차수), formId=`1162`. 사전판정(`checkApprovalRequired`)은 G4 영역으로 분리 | **완료 (2026-05-11)** — `salesEstimateService.startEstimateApproval` + `POST /sales/estimate/:id/amaranth-approval` + 견적관리 "결재상신" 버튼 + 견적 list SQL `LEFT JOIN amaranth_approval` + 결재상태 4단계 라벨 + `notRequired`/`approval_required='N'` fallback. 검증: [08-estimate-approval-verify.md](./08-estimate-approval-verify.md). 백로그: 첨부파일 원챔버 업로드, 사전판정(G4) | ## 3. 코드/SQL 정합성 메모 diff --git a/docs/migration/sales/06-copy-order-verify.md b/docs/migration/sales/06-copy-order-verify.md new file mode 100644 index 00000000..049a6661 --- /dev/null +++ b/docs/migration/sales/06-copy-order-verify.md @@ -0,0 +1,114 @@ +# 영업관리 G9 — 수주복사 검증 (BEGIN/ROLLBACK) + +> 작성: 2026-05-11 / 작성자: hjjeong +> 목적: wace `copyEstimateAndOrderInfo` 1:1 이식한 vexplor_rps의 수주복사가 contract_mgmt 헤더 + contract_item 라인 + contract_item_serial 시리얼을 정확히 복제하는지 확인. + +## 원본 매퍼 출처 +`wace_plm/src/com/pms/salesmgmt/mapper/contractMgmt.xml` +- `getNextContractNo` (6118) +- `copyContractMgmt` (6127) +- `copyContractItems` (6184) +- `copyContractItemSerials` (6230) +- `getActiveItemObjIds` (6254) + +## 이식 위치 +- 서비스: `backend-node/src/services/salesOrderMgmtService.ts:copyOrder` +- 컨트롤러: `backend-node/src/controllers/salesOrderMgmtController.ts:copyOrder` +- 라우트: `POST /api/sales/order-mgmt/:id/copy` +- 프론트 API: `frontend/lib/api/salesOrderMgmt.ts:copyOrder` +- 프론트 UI: `frontend/app/(main)/COMPANY_16/sales/order/page.tsx` (수주복사 버튼 + handleCopyOrder) + +## 복사 정책 (wace 1:1) +- 새 영업번호: `{YY}C-{NNNN}` (MAX+1) +- 헤더 복사 시 갱신: `objid`, `contract_no`, `writer`, `regdate(NOW())`, `contract_result=''`, `is_direct_order='Y'` +- 라인 복사 시 갱신: `objid`, `contract_objid`, `writer`, `regdate(NOW())`, `status='ACTIVE'` +- 시리얼 복사: `objid = prefix || seq`, 나머지 컬럼 그대로 + +> 복사본은 항상 `is_direct_order='Y'`로 들어오므로 견적관리에 노출되지 않고 주문관리에만 노출됨 (wace orderMgmtList 동작). + +## 검증 SQL (수동 BEGIN/ROLLBACK) + +```sql +BEGIN; + +-- 1. 새 영업번호 채번 +SELECT TO_CHAR(NOW(),'YY') || 'C-' || LPAD(( + COALESCE(MAX(SUBSTRING(contract_no FROM 5)::integer), 0) + 1 +)::VARCHAR, 4, '0') AS new_contract_no +FROM contract_mgmt +WHERE contract_no LIKE TO_CHAR(NOW(),'YY') || 'C-%'; + +-- 2. 헤더 복사 (수동 테스트 키) +INSERT INTO contract_mgmt ( + objid, category_cd, customer_objid, product, area_cd, + customer_equip_name, customer_project_name, customer_production_no, mechanical_type, + paid_type, receipt_date, req_del_date, contract_result, + po_no, order_date, contract_currency, exchange_rate, + regdate, writer, contract_no, is_direct_order, + order_supply_price, order_vat, order_total_amount +) +SELECT + 'CM-TEST-COPY-001', category_cd, customer_objid, product, area_cd, + customer_equip_name, customer_project_name, customer_production_no, mechanical_type, + paid_type, receipt_date, req_del_date, '', + po_no, order_date, contract_currency, exchange_rate, + NOW(), 'TEST_USER', '26C-9999', 'Y', + order_supply_price, order_vat, order_total_amount +FROM contract_mgmt +WHERE objid = ''; + +-- 3. 활성 라인 복사 +INSERT INTO contract_item ( + objid, contract_objid, seq, part_objid, part_no, part_name, + quantity, due_date, customer_request, return_reason, + regdate, writer, status, + order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount +) +SELECT + 'CI-TEST-COPY-' || seq::varchar, 'CM-TEST-COPY-001', seq, part_objid, part_no, part_name, + quantity, due_date, customer_request, return_reason, + NOW(), 'TEST_USER', 'ACTIVE', + order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount +FROM contract_item +WHERE contract_objid='' AND status='ACTIVE'; + +-- 4. 검증: BEFORE/AFTER 비교 +SELECT '원본 라인' AS what, count(*) FROM contract_item WHERE contract_objid='' AND status='ACTIVE'; +SELECT '복사 라인' AS what, count(*) FROM contract_item WHERE contract_objid='CM-TEST-COPY-001' AND status='ACTIVE'; + +ROLLBACK; +``` + +## 검증 결과 (2026-05-11, 26C-0800 → 시뮬레이션) + +| 단계 | 원본 (26C-0800) | 복사본 (26C-9999) | 비고 | +|---|---|---|---| +| 새 영업번호 | — | `26C-0803` | 현재 MAX=`26C-0802` → +1 ✓ | +| 헤더 컬럼 (category_cd/customer_objid/product/paid_type/receipt_date/contract_currency 등) | 동일 | 동일 | wace 24개 컬럼 1:1 복사 확인 | +| contract_result | (빈값) | `''` | 강제 빈문자열 ✓ | +| is_direct_order | (빈값) | `Y` | 강제 'Y' ✓ | +| writer | `khy1022` | `TEST_USER` | 현재 사용자로 갱신 ✓ | +| 활성 라인 수 | 3 | 3 | seq 보존 ✓ | +| 시리얼 | — | — | (해당 행에는 시리얼 없음 — 별도 시리얼 보유 행으로 추가 검증 필요 시) | + +## API 호출 (인증 토큰 필요) + +```bash +curl -X POST 'http://localhost:8080/api/sales/order-mgmt//copy' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' + +# 응답: {"success": true, "data": {"newObjid": "CM-...", "newContractNo": "26C-0803"}, "message": "수주가 복사되었습니다."} +``` + +## UI 동작 (wace 1:1) +1. 주문관리 그리드에서 행 1개 선택 +2. "수주복사" 버튼 클릭 +3. 확인 다이얼로그: `[26C-0800] 수주를 복사하시겠습니까?` +4. 확인 → API 호출 +5. 성공 토스트: `복사가 완료되었습니다. (영업번호: 26C-0803)` +6. 목록 새로고침 → 복사본 그리드 상단에 표시 + +## 미선택 시 분기 (wace 1:1) +- 0건 선택: `복사할 행을 선택해주십시오.` +- 2건 이상 선택: (현 vexplor UI는 단일선택만 지원 — wace의 "한번에 한개의 수주만 복사 가능합니다." 분기는 불필요) diff --git a/docs/migration/sales/07-amaranth-approval-verify.md b/docs/migration/sales/07-amaranth-approval-verify.md new file mode 100644 index 00000000..3a7d0edb --- /dev/null +++ b/docs/migration/sales/07-amaranth-approval-verify.md @@ -0,0 +1,138 @@ +# 영업관리 G4/G11 — 수주 결재상신 (Amaranth 직행) 검증 + +> 작성: 2026-05-11 / 작성자: hjjeong +> 목적: wace 영업관리 결재 흐름(외부 Amaranth SSO 직행)을 vexplor_rps 주문관리에 1:1 이식. chpark의 `amaranthApprovalClient`(자바 `AmaranthApprovalApiClient` 포팅) 재사용. + +## 원본 출처 +- 프론트: `wace_plm/WebContent/WEB-INF/view/contractMgmt/orderMgmtList.jsp:132~175` (btnApproval 가드) +- 프론트: 같은 파일 `:547~576` (`fn_openAmaranthApproval` → SSO URL 호출 → window.open) +- 백엔드: `wace_plm/src/com/pms/service/ApprovalService.java:1782~1909` (`getAmaranthSsoUrl`) +- 매퍼: `wace_plm/src/com/pms/salesmgmt/mapper/contractMgmt.xml:523~530, 661~663` (ORDER_APPR_STATUS 라벨 + LEFT JOIN AMARANTH_APPROVAL) + +## 이식 위치 +- 백엔드 서비스: `backend-node/src/services/salesOrderMgmtService.ts:startOrderApproval` +- 백엔드 컨트롤러: `backend-node/src/controllers/salesOrderMgmtController.ts:startApproval` +- 라우트: `POST /api/sales/order-mgmt/:id/amaranth-approval` +- 프론트 API: `frontend/lib/api/salesOrderMgmt.ts:startApproval` +- 프론트 UI: `frontend/app/(main)/COMPANY_16/sales/order/page.tsx:handleAmaranthApproval` + "결재상신" 버튼 + `order_appr_status` 컬럼 +- 재사용: `backend-node/src/services/amaranthApprovalClient.ts:getSsoUrl` (chpark) + +## DB 스키마 변경 +- `amaranth_approval.target_objid` BIGINT → **VARCHAR(80)** (wace 운영 패턴 1:1) + - 출처: wace 매퍼 `T.OBJID::VARCHAR = AMR_ORDER.TARGET_OBJID` + - 이유: vexplor_rps `contract_mgmt.objid`가 varchar(`CM-...` prefix 형식)라 bigint cast 불가 + - 영향: 해당 테이블 데이터 0건이라 무손실. ECR/CS는 bigint값을 varchar에 INSERT해도 자동 cast → 무영향 + - 마이그레이션 파일 `approvalTableMigration.ts`도 동기화 + +## 결재 정책 (wace 1:1) +- target_type: `CONTRACT_ORDER` (영업관리 주문서) +- formId: `1161` (운영 amaranth 양식 ID) +- compSeq: `1000` (운영 회사 시퀀스) +- mod: `W` (Write) +- empSeq 출처: `user_info.emp_seq` (PersonBean.getEmpseq 동등) — 미설정 시 명시적 에러 +- approKey 분기: + - 신규: `UB_` + Date.now().toString(36).toUpperCase() + - 기존 reject/delete/create: 새 approKey + amaranth_approval UPDATE (재상신) + - 기존 inProcess/complete: 기존 approKey 재사용 (프론트에서 차단되지만 백엔드 방어) + +## 환경변수 (운영 배포 시 주입) +| 변수 | 기본값 | 비고 | +|---|---|---| +| `AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER` | (없음) | 수주 결재 전용 코드. 미설정 시 `AMARANTH_OUT_PROCESS_CODE` fallback | +| `AMARANTH_FORM_ID_CONTRACT_ORDER` | `1161` | wace 운영값 | +| `AMARANTH_COMP_SEQ` | `1000` | wace 운영값 | + +> Amaranth 외부 커넥션의 인증 정보(`baseUrl`/`groupSeq`/`callerName`/`accessToken`/`hashKey`/`aesKey`)는 chpark이 'Amaranth - 결재' 외부 커넥션 시드로 자동 주입 (별도 환경변수 불필요). + +## 가드 (프론트 + 백엔드 동시) +| 조건 | 메시지 | 처리 | +|---|---|---| +| 행 미선택 | "결재상신할 행을 선택해주십시오." | 프론트 toast | +| `has_order_data === 0` | "수주 품목을 먼저 등록해주세요." | 프론트 toast + 백엔드 400 | +| `order_amaranth_status === 'inProcess'` | "결재 진행중인 건은 상신할 수 없습니다." | 프론트 toast | +| `order_amaranth_status === 'complete'` | "결재 완료된 건은 상신할 수 없습니다." | 프론트 toast | +| 사용자 emp_seq 미설정 | "empSeq 정보가 없습니다." | 백엔드 400 | +| 원본 contract_mgmt 부재 | "주문서를 찾을 수 없습니다." | 백엔드 404 | +| SSO API resultCode != 0 | "결재 연동 오류: ..." | 백엔드 502 | + +## 검증 SQL (BEGIN/ROLLBACK) +```sql +BEGIN; +INSERT INTO amaranth_approval + (objid, target_objid, target_type, appro_key, status, form_id, comp_seq, emp_seq, writer, sso_url, regdate) +VALUES (9999999999, '1256462102', 'CONTRACT_ORDER', 'UB_TEST', 'create', '1161', '1000', '999', 'test', 'http://test', NOW()); + +SELECT T.objid, T.contract_no, + CASE WHEN AMR.status='complete' THEN '결재완료' + WHEN AMR.status='inProcess' THEN '결재중' + WHEN AMR.status='reject' THEN '반려' + WHEN AMR.status='create' THEN '작성중' + ELSE '' END AS order_appr_status + FROM contract_mgmt T + LEFT JOIN amaranth_approval AMR + ON AMR.target_objid = T.objid AND AMR.target_type='CONTRACT_ORDER' + WHERE T.objid='1256462102'; +ROLLBACK; +``` + +### 검증 결과 (2026-05-11) +| target_objid | status | order_appr_status (한글) | +|---|---|---| +| `1256462102` | create | 작성중 | +| `1256462102` | inProcess | 결재중 | +| `1256462102` | complete | 결재완료 | +| `1256462102` | reject | 반려 | +| `CM-1778464096341-756` (varchar PK) | inProcess | 결재중 ✓ | + +- VARCHAR PK 호환 확인 — ALTER 효과 정상. +- ROLLBACK 후 운영 데이터 영향 없음. + +## API 호출 +```bash +curl -X POST 'http://localhost:8080/api/sales/order-mgmt//amaranth-approval' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"approvalTitle":"주문서 결재 - 26C-0800"}' + +# 성공: {"success":true,"data":{"fullUrl":"https://...","approKey":"UB_...","status":"create"}} +# 실패: {"success":false,"message":"empSeq 정보가 없습니다. ..."} +``` + +## UI 동작 +1. 주문관리 그리드에서 행 1개 선택 +2. "결재상신" 버튼 클릭 (수주복사 옆, 하늘색 sky-600) +3. 가드 통과 → 확인 다이얼로그: "결재상신 하시겠습니까?" +4. 확인 → API 호출 → `window.open(fullUrl, "amaranthApproval", "width=1200,height=900,...")` +5. 외부 Amaranth 결재 페이지에서 사용자가 양식 작성 + 상신 +6. 목록 새로고침 → "결재상태" 컬럼이 '작성중' → '결재중' → '결재완료' 순으로 변화 + +## 미구현 (백로그) +- **첨부파일 원챔버 업로드** — wace `uploadOrderFilesToOneChamber` (영업관리 첨부 흐름 별도 작업 후 연동) +- **견적 결재** (target_type=`CONTRACT_ESTIMATE`) — 같은 패턴, 견적관리 페이지에 추가만 하면 됨 (이번 PR 범위 외 — 현재 [estimate/page.tsx:474](../../../frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx#L474) placeholder 토스트만 있음) +- **결재 콜백** — amaranth가 우리 시스템에 결재 결과를 통보하는 webhook (운영에서는 폴링 또는 amaranth_approval 수동 갱신) + +## 트러블슈팅 — Amaranth 운영 측 토큰 등록 (2026-05-11 확인) + +dev 환경에서 wace 계정(emp_seq=379) + 코드/HMAC 서명 모두 정상이지만 amaranth 서버가 다음 메시지로 거부: + +``` +인증 토큰 발급 실패: API Proxy 호출 시 유효한 레디스 값이 존재하지 않습니다. +``` + +**진단**: +- 우리 코드 흐름 정상 (`getAuthToken` → `api99u01A01` 호출까지 도달) +- amaranth 서버가 `{resultCode:비-0, resultMsg:"...레디스..."}` 응답 → 토큰을 Redis 캐시에서 찾지 못함 +- 7개 amaranth 커넥션 모두 같은 callerName(`API_gcmsAmaranth40578`)/groupSeq(`gcmsAmaranth40578`) 공유, accessToken만 도메인별로 다름 +- 'Amaranth - 결재' 커넥션 시드 시점: 2026-05-08 12:16 (chpark 시드). 마지막 테스트: 2026-05-08 16:44 + +**가능한 원인 (운영 측 조치 필요)**: +1. 'Amaranth - 결재' 토큰만 amaranth 측 Redis 캐시에 등록 안 됨 (cron 배치로 매일 호출되는 다른 7개 커넥션은 정상 동작 가능성) +2. callerName이 wace_plm 운영과 공유되어 동시 사용 시 한쪽이 무효화 +3. 결재 전용 토큰의 별도 갱신 주기 + +**대응**: +- chpark에게 5/8 시드 시 amaranth 운영 측에 결재 토큰 등록을 마저 요청했는지 확인 +- 또는 RPS ERP 담당자에게 결재 토큰 Redis 재등록 요청 +- 우회 검증: 다른 amaranth cron 배치(예: 매일 03:10 사원 동기화)가 잘 도는지 확인 → 다른 건 성공이면 결재 토큰만 별도 등록 필요 확정 + +**코드 변경 없음** — 운영 협조로 해결되는 영역. 토큰 등록 완료되면 같은 흐름이 그대로 동작. diff --git a/docs/migration/sales/08-estimate-approval-verify.md b/docs/migration/sales/08-estimate-approval-verify.md new file mode 100644 index 00000000..3bdbffcb --- /dev/null +++ b/docs/migration/sales/08-estimate-approval-verify.md @@ -0,0 +1,162 @@ +# 영업관리 G4/G11 — 견적 결재상신 (Amaranth 직행) 검증 + +> 작성: 2026-05-11 / 작성자: hjjeong +> 목적: wace 영업관리 견적 결재 흐름(외부 Amaranth SSO 직행)을 vexplor_rps 견적관리에 1:1 이식. G11 수주 결재상신과 동일 패턴, `target_type='CONTRACT_ESTIMATE'` + `target_objid=estimate_template.objid` 차이. + +## 원본 출처 +- 프론트: `wace_plm/WebContent/WEB-INF/view/contractMgmt/estimateList_new.jsp:154~270` (btnApproval 가드 + checkApprovalRequired 사전판정) +- 프론트: 같은 파일 `:868~916` (`fn_showApprovalConfirmSimple` + `fn_openAmaranthApproval` → SSO URL 호출 → window.open) +- 백엔드: `wace_plm/src/com/pms/service/ApprovalService.java:1782~1909` (`getAmaranthSsoUrl` — CONTRACT_ESTIMATE 분기는 1853~1854) +- 매퍼: `wace_plm/src/com/pms/salesmgmt/mapper/contractMgmt.xml:513~522, 656~659` (APPR_STATUS 라벨 + LEFT JOIN AMARANTH_APPROVAL CONTRACT_ESTIMATE) + +## G11(수주) 대비 차이점 + +| 항목 | 수주 (G11) | 견적 (이번 작업) | +|---|---|---| +| target_type | `CONTRACT_ORDER` | `CONTRACT_ESTIMATE` | +| target_objid | `contract_mgmt.objid` (헤더 1개) | `estimate_template.objid` (최신 차수) | +| formId | `1161` | `1162` | +| 가드 | `has_order_data === 0` | `est_objid` 없음(견적서 미작성) | +| 사전판정 | 없음 | wace는 `checkApprovalRequired` 분기 — **G4 영역**이라 이번 작업에서 제외 | + +견적은 차수마다 별도 결재. 같은 영업번호의 차수1 결재 후 차수2 만들면 차수2는 다시 신규 amaranth_approval 매핑. + +## 이식 위치 +- 백엔드 서비스: `backend-node/src/services/salesEstimateService.ts:startEstimateApproval` +- 백엔드 컨트롤러: `backend-node/src/controllers/salesEstimateController.ts:startApproval` +- 라우트: `POST /api/sales/estimate/:id/amaranth-approval` +- 프론트 API: `frontend/lib/api/salesEstimate.ts:startApproval` +- 프론트 UI: `frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx:handleAmaranthApproval` + "결재상신" 버튼 +- 견적 list SQL 보강: 같은 파일 `salesEstimateService.getList` — `LEFT JOIN amaranth_approval AMR ON ET.objid::VARCHAR=AMR.target_objid AND AMR.target_type='CONTRACT_ESTIMATE'` + APPR_STATUS/AMARANTH_STATUS CASE 분기 +- 재사용: `backend-node/src/services/amaranthApprovalClient.ts:getSsoUrl` (chpark, G11에서 도입) + +## 결재 정책 (wace 1:1) +- target_type: `CONTRACT_ESTIMATE` +- formId: `1162` (운영 amaranth 견적 양식 ID — 수주 1161과 별도) +- compSeq: `1000` +- mod: `W` +- empSeq 출처: `user_info.emp_seq` +- approKey 분기 (G11 동일): + - 신규: `UB_` + Date.now().toString(36).toUpperCase() + - 기존 reject/delete/create: 새 approKey + amaranth_approval UPDATE (재상신) + - 기존 inProcess/complete: 기존 approKey 재사용 (프론트에서 차단되지만 백엔드 방어) + +## 환경변수 (운영 배포 시 주입) +| 변수 | 기본값 | 비고 | +|---|---|---| +| `AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE` | (없음) | 견적 결재 전용 코드. 미설정 시 `AMARANTH_OUT_PROCESS_CODE` fallback | +| `AMARANTH_FORM_ID_CONTRACT_ESTIMATE` | `1162` | wace 운영값 | +| `AMARANTH_COMP_SEQ` | `1000` | wace 운영값 (수주와 공유) | + +3개 docker-compose(`deploy/onpremise`, `docker/deploy`, `docker/prod/docker-compose.backend.prod.yml`) 모두 `${VAR:-default}` 형식으로 매핑됨 — 호스트 .env에 아무것도 안 넣어도 wace 운영값 그대로 동작. + +## 가드 (프론트 + 백엔드 동시) +| 조건 | 메시지 | 처리 | +|---|---|---| +| 행 미선택 | "결재상신할 행을 선택해주십시오." | 프론트 toast | +| `est_objid` 없음 | "견적서를 먼저 작성해주세요." | 프론트 toast + 백엔드 400 | +| `amaranth_status === 'inProcess'` | "결재 진행중인 건은 상신할 수 없습니다." | 프론트 toast | +| `amaranth_status === 'complete'` | "결재 완료된 건은 상신할 수 없습니다." | 프론트 toast | +| `amaranth_status === 'notRequired'` 또는 `approval_required === 'N'` | "결재불필요로 처리된 건입니다." | 프론트 toast | +| 사용자 emp_seq 미설정 | "empSeq 정보가 없습니다." | 백엔드 400 | +| 원본 contract_mgmt 부재 | "견적을 찾을 수 없습니다." | 백엔드 404 | +| SSO API resultCode != 0 | "결재 연동 오류: ..." | 백엔드 502 | + +## 검증 SQL (BEGIN/ROLLBACK) + +```sql +BEGIN; + +-- 가짜 amaranth_approval 매핑 INSERT (CONTRACT_ESTIMATE, target_objid = est_objid) +INSERT INTO amaranth_approval + (objid, target_objid, target_type, appro_key, out_process_code, form_id, + status, emp_seq, comp_seq, dept_seq, writer, sso_url, regdate) +VALUES + (9999999998, '-452406811', 'CONTRACT_ESTIMATE', 'UB_TEST_EST', 'RPSPLM_00001', '1162', + 'create', '999', '1000', '', 'test', 'http://test', NOW()); + +-- 4단계 상태 라벨 확인 (UPDATE를 반복) +SELECT CASE + WHEN AMR.status = 'complete' THEN '결재완료' + WHEN AMR.status = 'inProcess' THEN '결재중' + WHEN AMR.status = 'reject' THEN '반려' + WHEN AMR.status = 'create' THEN '작성중' + WHEN AMR.status = 'notRequired' THEN '결재불필요' + WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요' + ELSE '' END AS appr_status, AMR.status, T.contract_no + FROM contract_mgmt T + LEFT JOIN LATERAL ( + SELECT objid FROM estimate_template + WHERE contract_objid = T.objid + ORDER BY regdate DESC LIMIT 1 + ) ET ON true + LEFT JOIN amaranth_approval AMR + ON AMR.target_objid = ET.objid::VARCHAR + AND AMR.target_type = 'CONTRACT_ESTIMATE' + WHERE T.objid = '-912974684'; + +UPDATE amaranth_approval SET status='inProcess' WHERE objid=9999999998; +-- 위 SELECT 재실행 → '결재중' +UPDATE amaranth_approval SET status='complete' WHERE objid=9999999998; +-- → '결재완료' +UPDATE amaranth_approval SET status='reject' WHERE objid=9999999998; +-- → '반려' +DELETE FROM amaranth_approval WHERE objid=9999999998; +-- → '결재불필요' (approval_required='N' fallback) + +ROLLBACK; +``` + +### 검증 결과 (2026-05-11) + +샘플 contract: `26C-0712` (contract_objid=`-912974684`, est_objid=`-452406811`, approval_required='N') + +| AMR.status | appr_status (한글) | +|---|---| +| `create` | 작성중 | +| `inProcess` | 결재중 | +| `complete` | 결재완료 | +| `reject` | 반려 | +| (AMR row 삭제, approval_required='N') | 결재불필요 | + +- AMR row가 있으면 status 우선, 없으면 approval_required fallback — wace 1:1. +- ROLLBACK 후 운영 데이터 영향 없음. +- 견적 list SQL의 LEFT JOIN AMR 동작 확인 — 차수가 늘어나도 최신 차수만 매핑. + +## API 호출 + +```bash +curl -X POST 'http://localhost:8080/api/sales/estimate//amaranth-approval' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"approvalTitle":"견적서 결재 - 26C-0712"}' + +# 성공: {"success":true,"data":{"fullUrl":"https://...","approKey":"UB_...","status":"create","estObjid":"-452406811"}} +# 실패: {"success":false,"message":"견적서를 먼저 작성해주세요."} +``` + +## UI 동작 + +1. 견적관리 그리드에서 행 1개 선택 +2. "결재상신" 버튼 클릭 (메일발송 옆, sky-600) +3. 가드 통과 → 확인 다이얼로그: "결재상신 하시겠습니까?" +4. 확인 → API 호출 → `window.open(fullUrl, "amaranthApproval", "width=1200,height=900,...")` +5. 외부 Amaranth 결재 페이지에서 사용자가 양식 작성 + 상신 +6. 목록 새로고침 → "결재상태" 컬럼이 '작성중' → '결재중' → '결재완료' 순으로 변화 + +## 미구현 (백로그) +- **첨부파일 원챔버 업로드** — wace `uploadEstimateFilesToOneChamber` (영업관리 첨부 흐름 별도 작업 후 연동) +- **사전판정 (`checkApprovalRequired`)** — G4 영역. wace는 결재상신 클릭 → 재오더/신규수주/가격인하 룰 판정 → 재오더면 "결재불필요" 자동 처리, 신규수주/가격인하면 사유 안내 후 결재상신. 이번 작업은 G11 동일 흐름(단순 SSO)만. +- **결재 콜백** — amaranth가 우리 시스템에 결재 결과를 통보하는 webhook (운영에서는 폴링 또는 amaranth_approval 수동 갱신) + +## 운영 트러블슈팅 (G11과 공유) + +dev에서 wace 계정으로 결재상신 클릭 시 amaranth가 다음 메시지로 거부 가능: + +``` +인증 토큰 발급 실패: API Proxy 호출 시 유효한 레디스 값이 존재하지 않습니다. +``` + +원인은 'Amaranth - 결재' 커넥션 accessToken이 amaranth 측 Redis 캐시에 등록 안 된 상태(7개 amaranth 커넥션 중 결재 토큰만 별도). G11 작업 시 동일 현상 확인됨. 대응 = chpark 또는 RPS ERP 담당자에게 결재 토큰 Redis 등록 요청 (자세한 진단: `07-amaranth-approval-verify.md` 트러블슈팅 섹션). + +**코드 변경 없음** — 운영 협조로 해결되는 영역. diff --git a/frontend/app/(main)/COMPANY_16/project/progress/page.tsx b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx new file mode 100644 index 00000000..781a44c2 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/project/progress/page.tsx @@ -0,0 +1,238 @@ +"use client"; + +// 진행관리 (wace projectMgmtWbsList3.jsp + projectMgmtWbsGridList 1:1 이식) +// 원본: +// - JSP: /Users/jhj/wace_plm/WebContent/WEB-INF/view/project/projectMgmtWbsList3.jsp (349줄) +// - 매퍼: wace_plm/src/com/pms/mapper/project.xml:3854 projectMgmtWbsGridList +// GAP: docs/migration/project/00-gap.md +// +// 그리드: 8그룹 18셀 평탄화 (DataGrid가 그룹 헤더 미지원 → 라벨 prefix로 표현) +// 검색폼: 11필드 (1행 6 + 2행 5) +// 행 클릭: P1.5에서 영업관리 OrderRegistDialog 재사용 검토 — 현재 미연결 + +import React, { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Search, Loader2, RotateCcw } from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { CustomerSelect } from "@/components/common/CustomerSelect"; +import { PartSelect } from "@/components/common/PartSelect"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt"; + +// wace projectMgmtWbsList3.jsp 컬럼 정의 1:1 (8그룹 → 평탄화, 그룹명은 라벨 prefix) +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "project_no", label: "프로젝트번호", width: "w-[160px]", frozen: true }, + // 프로젝트정보 그룹 + { key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" }, + { key: "product_name", label: "제품구분", width: "w-[100px]", align: "left" }, + { key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" }, + { key: "reg_date", label: "접수일", width: "w-[110px]", align: "center" }, + { key: "customer_name", label: "고객사", width: "w-[140px]" }, + { key: "free_of_charge", label: "유/무상", width: "w-[80px]", align: "center" }, + { key: "product_item_code", label: "품번", width: "w-[150px]" }, + { key: "product_item_name", label: "품명", width: "w-[180px]" }, + { key: "serial_no", label: "S/N", width: "w-[150px]" }, + { key: "contract_qty", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "req_del_date", label: "요청납기", width: "w-[110px]", align: "center" }, + // 설계 + { key: "ebom_status", label: "E-BOM", width: "w-[100px]", align: "center" }, + // 생산관리 + { key: "mbom_status", label: "M-BOM", width: "w-[100px]", align: "center" }, + // 구매 + { key: "order_date", label: "발주일", width: "w-[110px]", align: "center" }, + { key: "receiving_rate", label: "입고율", width: "w-[90px]", align: "right" }, + // 생산 + { key: "production_team_12", label: "제조1,2팀", width: "w-[100px]", align: "center" }, + { key: "production_team_3", label: "제조3팀", width: "w-[100px]", align: "center" }, + // 장비 + { key: "assembly", label: "조립", width: "w-[90px]", align: "center" }, + { key: "verification", label: "검증", width: "w-[90px]", align: "center" }, + // 출하 + { key: "shipment_date", label: "출하일", width: "w-[110px]", align: "center" }, +]; + +const CATEGORY_GROUP = "0000167"; // 주문유형 +const PRODUCT_GROUP = "0000001"; // 제품구분 + +// wace L229: 시스템년도 ±4 (운영판은 sysYear-4 ~ sysYear). RPS는 sysYear±4로 여유. +const YEAR_OPTIONS = (() => { + const cur = new Date().getFullYear(); + const arr: string[] = []; + for (let y = cur + 4; y >= cur - 4; y--) arr.push(String(y)); + return arr; +})(); + +const EMPTY_FILTER: ProgressListFilter = { + Year: "", project_nos: "", category_cd: "", customer_objid: "", product: "", + contract_start_date: "", contract_end_date: "", + area_cd: "", free_of_charge: "", + search_partObjId: "", serial_no: "", +}; + +export default function ProjectProgressPage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [projectNoOptions, setProjectNoOptions] = useState([]); + + const fetchList = useCallback(async () => { + setLoading(true); + try { + const data = await projectMgmtApi.list(filter); + setRows(data); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { setLoading(false); } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + // 프로젝트번호 셀렉트 옵션 (wace common.getCusProjectNoList 대응) + useEffect(() => { + projectMgmtApi.projectNoOptions() + .then((opts) => setProjectNoOptions(opts.map((o) => ({ code: o.value, label: o.label })))) + .catch(() => {}); + }, []); + + const handleReset = () => { + setFilter(EMPTY_FILTER); + setTimeout(() => fetchList(), 0); + }; + + return ( +
+ {/* 검색폼 — wace projectMgmtWbsList3.jsp:222-313 활성 11필드 */} +
+
+ {/* 1행 */} + + + + + setFilter({ ...filter, project_nos: v })} + placeholder="전체" + /> + + + setFilter({ ...filter, category_cd: v })} + /> + + + setFilter({ ...filter, customer_objid: v })} + /> + + + setFilter({ ...filter, product: v })} + /> + + +
+ setFilter({ ...filter, contract_start_date: e.target.value })} /> + ~ + setFilter({ ...filter, contract_end_date: e.target.value })} /> +
+
+ + {/* 2행 */} + + + + + + + + setFilter({ ...filter, search_partObjId: v })} + /> + + + setFilter({ ...filter, search_partObjId: v })} + /> + + + setFilter({ ...filter, serial_no: e.target.value })} + placeholder="S/N LIKE" + /> + + + {/* 액션 */} +
+ + +
+
+
+ + {/* 그리드 (8그룹 18셀 평탄화) */} +
+ +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx index 4ecf2c4c..45a4caed 100644 --- a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx @@ -11,7 +11,7 @@ import { import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Plus, Save, Trash2, Loader2, Search, Pencil } from "lucide-react"; +import { Plus, Save, Trash2, Loader2, Search, Pencil, Send } from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -21,6 +21,7 @@ import { PartSelect } from "@/components/common/PartSelect"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog"; import { AttachmentDialog } from "@/components/common/AttachmentDialog"; +import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog"; import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate"; // ─── 컬럼 ───────────────────────────────────────────────────── @@ -131,15 +132,8 @@ export default function SalesEstimatePage() { const [seqStartNo, setSeqStartNo] = useState(""); const [seqCount, setSeqCount] = useState(""); - // 메일 발송 다이얼로그 + // 메일 발송 다이얼로그 — wace estimateMailFormPopup.jsp 1:1 const [mailDialogOpen, setMailDialogOpen] = useState(false); - const [mailSending, setMailSending] = useState(false); - const [mailForm, setMailForm] = useState({ - toEmails: "", - ccEmails: "", - subject: "", - contents: "", - }); // 첨부파일 다이얼로그 (추가견적 클립 컬럼 클릭 시) // G5 견적작성 — 일반/장비 선택 다이얼로그 @@ -359,56 +353,47 @@ export default function SalesEstimatePage() { } }; + // ─── 결재상신 (G4/G11 동일 패턴 — 견적, target_type='CONTRACT_ESTIMATE') ───── + // wace estimateList_new.jsp:155 btnApproval + :887 fn_openAmaranthApproval 1:1. + // 단순 SSO 흐름만 — 사전판정(checkApprovalRequired) 분기는 G4 영역으로 분리. + // 가드: 행 미선택 / est_objid 없음(견적서 미작성) / inProcess·complete / 결재불필요 + const handleAmaranthApproval = async () => { + if (!selected) { toast.warning("결재상신할 행을 선택해주십시오."); return; } + if (!selected.est_objid) { toast.warning("견적서를 먼저 작성해주세요."); return; } + const amaranthStatus = String(selected.amaranth_status ?? ""); + if (amaranthStatus === "inProcess") { toast.warning("결재 진행중인 건은 상신할 수 없습니다."); return; } + if (amaranthStatus === "complete") { toast.warning("결재 완료된 건은 상신할 수 없습니다."); return; } + if (amaranthStatus === "notRequired" || selected.approval_required === "N") { + toast.warning("결재불필요로 처리된 건입니다."); return; + } + const ok = await confirm("결재상신", { description: "결재상신 하시겠습니까?" }); + if (!ok) return; + try { + const { fullUrl } = await salesEstimateApi.startApproval(selected.objid, { + approvalTitle: `견적서 결재${selected.contract_no ? " - " + selected.contract_no : ""}`, + }); + if (!fullUrl) { toast.error("결재 SSO URL을 받지 못했습니다."); return; } + window.open(fullUrl, "amaranthApproval", "width=1200,height=900,scrollbars=yes,resizable=yes"); + await fetchList(); + } catch (err: any) { + toast.error(err?.response?.data?.message ?? "결재 시스템 연동 중 오류가 발생했습니다."); + } + }; + // ─── 메일 발송 ────────────────────────────────────────────── - // 1차에서는 mail_log INSERT만 (이력만 등록 → 그리드 메일발송 컬럼 표시). - // 실제 SMTP 발송은 mailSendSimple 인프라 통합 시 추가 예정. + // 실제 SMTP는 backend-node SMTP_SEND_SWITCH='Y'일 때 sales 계정(sales@rps-korea.com)으로 발송. + // PDF 첨부: 견적관리 → "메일발송" 클릭 → EstimateMailDialog가 + // 최신 차수 template1/template2 페이지를 hidden iframe에 렌더 → + // fn_generateAndUploadPdf로 base64 추출 → backend가 estimate02 N건과 합본 첨부. const openMailDialog = () => { if (!selected) { toast.warning("메일 발송할 견적을 선택하세요."); return; } - const customer = selected.customer_name ?? ""; - const estNo = selected.estimate_no ?? selected.contract_no ?? ""; - setMailForm({ - toEmails: "", - ccEmails: "", - subject: `[${customer}] 견적서 발송 - ${estNo}`, - contents: `안녕하세요, ${customer} 담당자님.\n\n요청하신 견적서(${estNo})를 발송드립니다.\n검토 후 회신 부탁드립니다.\n\n감사합니다.`, - }); setMailDialogOpen(true); }; - const handleSendMail = async () => { - if (!selected) return; - if (!mailForm.toEmails.trim()) { - toast.error("받는 사람을 입력하세요."); - return; - } - if (!mailForm.subject.trim()) { - toast.error("제목을 입력하세요."); - return; - } - setMailSending(true); - try { - await salesEstimateApi.sendMail({ - contractObjid: selected.objid, - toEmails: mailForm.toEmails.trim(), - ccEmails: mailForm.ccEmails.trim() || undefined, - subject: mailForm.subject, - contents: mailForm.contents, - isSend: "Y", - }); - toast.success("메일 발송 이력이 등록되었습니다."); - setMailDialogOpen(false); - await fetchList(); - } catch (err: any) { - toast.error(`메일 발송 실패: ${err?.response?.data?.message ?? err.message}`); - } finally { - setMailSending(false); - } - }; - // ─── 라인 편집 ────────────────────────────────────────────── const updateItem = (idx: number, key: keyof EstimateItem, val: any) => { @@ -512,9 +497,9 @@ export default function SalesEstimatePage() { -