diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index ad66a7c2..b841af44 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -376,6 +376,8 @@ export class AuthController { userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선 userTypeName: dbUserInfo.userTypeName || "일반사용자", email: dbUserInfo.email || "", + tel: dbUserInfo.tel || "", + cellPhone: dbUserInfo.cellPhone || "", photo: dbUserInfo.photo, locale: dbUserInfo.locale || "KR", // locale 정보 추가 deptCode: dbUserInfo.deptCode, // 추가 필드 diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index 03048d6d..df939714 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -601,8 +601,17 @@ export const getFileList = async ( } if (docType) { - whereConditions.push(`doc_type = $${paramIndex}`); - queryParams.push(docType as string); + const docTypes = String(docType) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (docTypes.length === 1) { + whereConditions.push(`doc_type = $${paramIndex}`); + queryParams.push(docTypes[0]); + } else if (docTypes.length > 1) { + whereConditions.push(`doc_type = ANY($${paramIndex}::text[])`); + queryParams.push(docTypes); + } paramIndex++; } diff --git a/backend-node/src/controllers/salesEstimateController.ts b/backend-node/src/controllers/salesEstimateController.ts index a7ba00aa..4f8b7fb5 100644 --- a/backend-node/src/controllers/salesEstimateController.ts +++ b/backend-node/src/controllers/salesEstimateController.ts @@ -96,3 +96,52 @@ export async function sendMail(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: error.message }); } } + +// ────────────────────────────────────────────────────────────── +// G5 견적작성 — estimate_template +// ────────────────────────────────────────────────────────────── + +export async function saveTemplate1(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const data = await salesEstimateService.saveEstimateTemplate1(userId, req.body); + return res.json({ success: true, data, message: "견적서가 저장되었습니다." }); + } catch (error: any) { + logger.error("견적작성(일반) 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function saveTemplate2(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const data = await salesEstimateService.saveEstimateTemplate2(userId, req.body); + return res.json({ success: true, data, message: "견적서가 저장되었습니다." }); + } catch (error: any) { + logger.error("견적작성(장비) 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getTemplate(req: AuthenticatedRequest, res: Response) { + try { + const { templateObjid } = req.params; + const data = await salesEstimateService.getTemplateById(templateObjid); + if (!data) return res.status(404).json({ success: false, message: "견적서를 찾을 수 없습니다." }); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("견적작성 단건 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function listTemplates(req: AuthenticatedRequest, res: Response) { + try { + const { contractObjid } = req.params; + const data = await salesEstimateService.listTemplatesByContract(contractObjid); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("견적 차수 리스트 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/salesOrderMgmtController.ts b/backend-node/src/controllers/salesOrderMgmtController.ts index 2ca1850e..d086fbe3 100644 --- a/backend-node/src/controllers/salesOrderMgmtController.ts +++ b/backend-node/src/controllers/salesOrderMgmtController.ts @@ -52,6 +52,15 @@ export async function remove(req: AuthenticatedRequest, res: Response) { } catch (e: any) { logger.error("주문서 삭제 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); } } +export async function getFormView(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const data = await svc.getOrderFormView(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 updateStatus(req: AuthenticatedRequest, res: Response) { try { const userId = req.user!.userId; @@ -59,5 +68,16 @@ export async function updateStatus(req: AuthenticatedRequest, res: Response) { const { contract_result } = req.body; await svc.updateStatus(userId, id, contract_result); return res.json({ success: true, message: "수주 상태가 변경되었습니다." }); - } catch (e: any) { return res.status(500).json({ success: false, message: e.message }); } + } catch (e: any) { logger.error("수주 상태 변경 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); } +} + +// 수주취소: 라인별 cancel_qty 다중 UPDATE (wace saveOrderCancelQty 이식) +export async function saveCancelQty(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const { entries } = req.body as { entries: svc.CancelQtyEntry[] }; + const result = await svc.saveOrderCancelQty(userId, entries); + if (result.result === "false") return res.status(400).json({ success: false, message: result.msg }); + return res.json({ success: true, message: result.msg }); + } catch (e: any) { logger.error("수주취소 저장 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); } } diff --git a/backend-node/src/routes/salesCommonRoutes.ts b/backend-node/src/routes/salesCommonRoutes.ts index 66082601..fc74e038 100644 --- a/backend-node/src/routes/salesCommonRoutes.ts +++ b/backend-node/src/routes/salesCommonRoutes.ts @@ -33,17 +33,23 @@ router.get("/codes/:groupId", async (req: Request, res: Response) => { } }); -/** GET /api/sales/parts → [{id, item_number, item_name}] (영업관리 풀-옵션용) */ +/** GET /api/sales/parts → [{id, item_number, item_name}] (영업관리 풀-옵션용) + * 데이터 출처: wace 품목 마스터 전용 테이블 part_mng (8,176건). + * 반환 키는 기존 호환을 위해 id/item_number/item_name으로 alias. + * status는 active/release/활성만 (wace 운영 값). + */ router.get("/parts", async (_req: Request, res: Response) => { try { const pool = getPool(); - // wace 이식 데이터(company_code 빈 값/별표) 우선, COMPANY_16 데이터 추가 const result = await pool.query( - `SELECT id, item_number, item_name - FROM item_info - WHERE COALESCE(company_code, '') IN ('', '*', 'COMPANY_16') - AND (item_number IS NOT NULL OR item_name IS NOT NULL) - ORDER BY item_number NULLS LAST, item_name NULLS LAST`, + `SELECT objid::varchar AS id, + part_no AS item_number, + part_name AS item_name + FROM part_mng + WHERE LOWER(COALESCE(status, '')) IN ('active', 'release', '활성') + AND part_no IS NOT NULL AND part_no <> '' + AND part_name IS NOT NULL AND part_name <> '' + ORDER BY part_no NULLS LAST, part_name NULLS LAST`, ); res.json({ success: true, data: result.rows }); } catch (err) { diff --git a/backend-node/src/routes/salesEstimateRoutes.ts b/backend-node/src/routes/salesEstimateRoutes.ts index 2761fbe2..4fcbcda2 100644 --- a/backend-node/src/routes/salesEstimateRoutes.ts +++ b/backend-node/src/routes/salesEstimateRoutes.ts @@ -8,6 +8,13 @@ router.use(authenticateToken); router.get("/list", salesEstimateController.getList); router.get("/generate-number", salesEstimateController.generateNumber); router.post("/mail", salesEstimateController.sendMail); + +// G5 견적작성 (estimate_template) — /:id 라우트보다 위에 +router.post("/template1", salesEstimateController.saveTemplate1); +router.post("/template2", salesEstimateController.saveTemplate2); +router.get("/template/:templateObjid", salesEstimateController.getTemplate); +router.get("/templates/:contractObjid", salesEstimateController.listTemplates); + 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 a9115502..dffda35d 100644 --- a/backend-node/src/routes/salesOrderMgmtRoutes.ts +++ b/backend-node/src/routes/salesOrderMgmtRoutes.ts @@ -7,10 +7,12 @@ router.use(authenticateToken); router.get("/list", ctrl.getList); router.get("/generate-number", ctrl.generateNumber); +router.get("/:id/form-view", ctrl.getFormView); router.get("/:id", ctrl.getById); router.post("/", ctrl.create); router.put("/:id", ctrl.update); router.delete("/:id", ctrl.remove); router.patch("/:id/status", ctrl.updateStatus); +router.post("/:id/cancel-qty", ctrl.saveCancelQty); export default router; diff --git a/backend-node/src/services/salesEstimateService.ts b/backend-node/src/services/salesEstimateService.ts index 4692dd9f..bb094edc 100644 --- a/backend-node/src/services/salesEstimateService.ts +++ b/backend-node/src/services/salesEstimateService.ts @@ -27,64 +27,38 @@ export interface EstimateListFilter { due_end_date?: string; } -export interface EstimateTemplateBody { - // 헤더(estimate_template) - contract_objid?: string; // 신규면 contract_mgmt 생성 후 채워짐 - template_type: string; // '1' 일반, '2' 장비 - executor?: string; - recipient?: string; - estimate_no?: string; - contact_person?: string; - greeting_text?: string; - model_name?: string; - model_code?: string; - executor_date?: string; - note1?: string; - note2?: string; - note3?: string; - note4?: string; - categories_json?: string; - notes_content?: string; - validity_period?: string; - total_amount?: string; - total_amount_krw?: string; - manager_name?: string; - manager_contact?: string; - note_remarks?: string; - show_total_row?: string; - group1_subtotal?: string; - part_name?: string; - part_objid?: string; - // 라인 - items?: EstimateTemplateItem[]; - // contract_mgmt 신규 생성 시 필요한 컨텍스트(선택) - contract_context?: ContractContext; -} +// wace 견적요청등록(estimateRegistFormPopup.jsp) 폼 — 헤더 8 + 라인 8 +// 원본: contractMgmt.xml#saveContractMgmtInfo, insertContractItem, insertContractItemSerial +// 견적 템플릿(estimate_template)은 견적**작성**(saveEstimateTemplate / G5) 단계 데이터 — 견적요청등록과 분리. -export interface EstimateTemplateItem { - seq: number; - category?: string; - description?: string; - specification?: string; - quantity?: string; - unit?: string; - unit_price?: string; - amount?: string; - note?: string; - remark?: string; - part_objid?: string; -} - -export interface ContractContext { +export interface EstimateBody { + // 신규: contract_no 자동 채번. 수정: objid는 path param. contract_no?: string; - customer_objid?: string; - category_cd?: string; - product?: string; - area_cd?: string; - paid_type?: string; + // 헤더 8개 (필수: category_cd, area_cd, customer_objid, paid_type, receipt_date, approval_required) + category_cd: string; + area_cd: string; + customer_objid: string; + paid_type: string; // 'paid' | 'free' + receipt_date: string; contract_currency?: string; - receipt_date?: string; - req_del_date?: string; + exchange_rate?: string; + approval_required: string; // 'Y' | 'N' + // 라인 + items: EstimateItem[]; +} + +export interface EstimateItem { + objid?: string; // 수정 시 기존 라인 식별. 없으면 신규 + seq: number; + product: string; // 제품구분 (필수) + part_objid: string; // 품목 마스터 id (필수) + part_no: string; // 품번 (마스터 fallback용 raw) + part_name: string; // 품명 + serials?: string[]; // S/N 목록 + quantity?: string; // 견적수량 + due_date?: string; // 요청납기 (YYYY-MM-DD) + return_reason?: string; // 반납사유 (comm_code) + customer_request?: string; // 고객요청사항 } // ─── 목록 ───────────────────────────────────────────────────── @@ -205,7 +179,7 @@ export async function getList(filter: EstimateListFilter) { ,C.customer_name AS CUSTOMER_NAME ,C.customer_type AS CUSTOMER_TYPE ,T.PRODUCT - ,CC_PRD.code_name AS PRODUCT_NAME + ,COALESCE(CI_AGG.product_summary, CC_PRD.code_name) AS PRODUCT_NAME ,T.AREA_CD ,CC_AREA.code_name AS AREA_NAME ,T.PAID_TYPE @@ -214,6 +188,8 @@ export async function getList(filter: EstimateListFilter) { WHEN T.PAID_TYPE='free' THEN '무상' ELSE T.PAID_TYPE END AS PAID_TYPE_NAME + ,T.CONTRACT_RESULT AS CONTRACT_RESULT + ,T.APPROVAL_REQUIRED AS APPROVAL_REQUIRED ,T.CONTRACT_CURRENCY ,CC_CUR.code_name AS CONTRACT_CURRENCY_NAME ,T.EXCHANGE_RATE @@ -264,6 +240,7 @@ export async function getList(filter: EstimateListFilter) { END AS SERIAL_NO ,CI_AGG.earliest_due_date AS EARLIEST_DUE_DATE ,COALESCE(CI_AGG.other_due_date_count, 0) AS OTHER_DUE_DATE_COUNT + ,CI_AGG.return_reason_summary AS RETURN_REASON_SUMMARY /* 메일 발송 정보 (mail_log: TITLE의 [OBJID:NN]에서 OBJID 매칭) */ ,ML.mail_send_status AS MAIL_SEND_STATUS ,ML.mail_send_date AS MAIL_SEND_DATE @@ -315,17 +292,21 @@ export async function getList(filter: EstimateListFilter) { FROM estimate_template GROUP BY contract_objid ) ET_CNT ON ET_CNT.contract_objid = T.OBJID - /* 품목 집계 */ + /* 품목 집계 (제품구분/반납사유 한글명 distinct join — wace 헤더 product 폐지·라인으로 이동) */ LEFT JOIN ( SELECT CI.contract_objid, COUNT(*) AS item_count, - (array_agg(COALESCE(IT.item_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name, - (array_agg(COALESCE(IT.item_number, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no, + (array_agg(COALESCE(PM.part_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name, + (array_agg(COALESCE(PM.part_no, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no, MIN(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN CI.due_date END) AS earliest_due_date, - GREATEST(COUNT(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN 1 END) - 1, 0) AS other_due_date_count + GREATEST(COUNT(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN 1 END) - 1, 0) AS other_due_date_count, + STRING_AGG(DISTINCT CC_RR.code_name, ', ') FILTER (WHERE CC_RR.code_name IS NOT NULL) AS return_reason_summary, + STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary FROM contract_item CI - LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid + LEFT JOIN comm_code CC_RR ON CC_RR.code_id = CI.return_reason AND CC_RR.status='active' + LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active' WHERE CI.status = 'ACTIVE' GROUP BY CI.contract_objid ) CI_AGG ON CI_AGG.contract_objid = T.OBJID @@ -369,165 +350,164 @@ export async function getList(filter: EstimateListFilter) { // ─── 단건 ───────────────────────────────────────────────────── +// 견적요청등록의 단건 조회 — contract_mgmt 헤더 + contract_item 라인 + contract_item_serial +// (estimate_template는 G5 견적작성 단계 데이터 — 여기 포함 안 함) export async function getById(objid: string) { const pool = getPool(); const headerRes = await pool.query( - `SELECT et.*, cm.contract_no, cm.customer_objid, cm.category_cd, - cm.area_cd, cm.paid_type, cm.contract_currency - FROM estimate_template et - LEFT JOIN contract_mgmt cm ON cm.objid = et.contract_objid - WHERE et.objid = $1`, + `SELECT cm.*, + (CASE WHEN cm.customer_objid LIKE 'C_%' THEN substring(cm.customer_objid, 3) ELSE cm.customer_objid END) AS customer_code_norm + FROM contract_mgmt cm + WHERE cm.objid = $1`, [objid], ); if (headerRes.rowCount === 0) return null; const itemsRes = await pool.query( - `SELECT * FROM estimate_template_item - WHERE template_objid = $1 - ORDER BY seq`, + `SELECT CI.*, + COALESCE(PM.part_no, CI.part_no) AS master_part_no, + COALESCE(PM.part_name, CI.part_name) AS master_part_name + FROM contract_item CI + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid + WHERE CI.contract_objid = $1 + AND CI.status = 'ACTIVE' + ORDER BY CI.seq`, [objid], ); - return { ...headerRes.rows[0], items: itemsRes.rows }; + const itemIds: string[] = itemsRes.rows.map((r: any) => r.objid); + let serials: any[] = []; + if (itemIds.length > 0) { + const sRes = await pool.query( + `SELECT objid, item_objid, seq, serial_no + FROM contract_item_serial + WHERE item_objid = ANY($1::varchar[]) + AND status = 'ACTIVE' + ORDER BY item_objid, seq`, + [itemIds], + ); + serials = sRes.rows; + } + + const items = itemsRes.rows.map((it: any) => ({ + ...it, + serials: serials.filter((s) => s.item_objid === it.objid).map((s) => s.serial_no), + })); + return { ...headerRes.rows[0], items }; } // ─── 채번 ───────────────────────────────────────────────────── -// estimate_no 채번. 형식: EST-YYYYMMDD-NNN (3자리) +// 견적요청등록은 contract_mgmt 새 행을 만든다 → 기존 generateContractNo 재사용 (SO-YYYYMMDD-NNN). +// (wace 운영 26C-XXXX 형식은 별도 채번 룰 — 추후 통일 시 변경) export async function generateNumber(): Promise { - const pool = getPool(); - const today = new Date(); - const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`; - const prefix = `EST-${ymd}-`; - - const res = await pool.query( - `SELECT estimate_no FROM estimate_template - WHERE estimate_no LIKE $1 - ORDER BY estimate_no DESC LIMIT 1`, - [`${prefix}%`], - ); - - let seq = 1; - if (res.rowCount && res.rowCount > 0) { - const last = res.rows[0].estimate_no as string; - const lastSeq = parseInt(last.replace(prefix, ""), 10); - if (!isNaN(lastSeq)) seq = lastSeq + 1; - } - return `${prefix}${String(seq).padStart(3, "0")}`; + return generateContractNo(); } -// objid 채번: estimate_template/contract_mgmt 모두 VARCHAR PK 사용 (wace_plm 관행) -// 형식: ET-{epoch_ms}-{rand} / CM-{epoch_ms}-{rand} +// objid 채번: contract_mgmt VARCHAR PK function genVarcharObjid(prefix: string) { const ms = Date.now(); const rand = Math.floor(Math.random() * 1000).toString().padStart(3, "0"); return `${prefix}-${ms}-${rand}`; } -// ─── 생성 ───────────────────────────────────────────────────── +// ─── 라인 UPSERT 공용 (라인 전체 교체 패턴 — wace 견적요청등록은 라인을 통째로 다시 받음) ── -export async function create(userId: string, body: EstimateTemplateBody) { - const pool = getPool(); - const client = await pool.connect(); +async function upsertItems(client: any, contractObjid: string, items: EstimateItem[], userId: string) { + // 기존 라인 INACTIVE + await client.query( + `UPDATE contract_item SET status='INACTIVE', chgdate=NOW(), chg_user_id=$2 WHERE contract_objid=$1`, + [contractObjid, userId], + ); - try { - await client.query("BEGIN"); - - // 1. contract_mgmt 헤더 (없으면 신규 생성) - let contractObjid = body.contract_objid; - if (!contractObjid) { - contractObjid = genVarcharObjid("CM"); - const ctx = body.contract_context ?? {}; - const contractNo = ctx.contract_no || (await generateContractNo()); - await client.query( - `INSERT INTO contract_mgmt ( - objid, contract_no, customer_objid, category_cd, area_cd, - product, paid_type, contract_currency, - writer, regdate - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, NOW())`, - [ - contractObjid, - contractNo, - ctx.customer_objid || null, - ctx.category_cd || null, - ctx.area_cd || null, - null, // product - ctx.paid_type || null, - ctx.contract_currency || null, - userId, - ], - ); - } - - // 2. estimate_template 헤더 - const templateObjid = genVarcharObjid("ET"); - const estimateNo = body.estimate_no || (await generateNumber()); + for (const item of items) { + const itemObjid = item.objid || genVarcharObjid("CI"); + // quantity는 integer 컬럼 — 빈문자/콤마 제거 후 integer 캐스트. + const qtyRaw = (item.quantity ?? "").toString().replace(/,/g, "").trim(); + const qtyInt: number | null = qtyRaw === "" ? null : (Number.isFinite(parseInt(qtyRaw, 10)) ? parseInt(qtyRaw, 10) : null); await client.query( - `INSERT INTO estimate_template ( - objid, contract_objid, template_type, - executor, recipient, estimate_no, contact_person, greeting_text, - model_name, model_code, executor_date, - note1, note2, note3, note4, - categories_json, writer, regdate, chg_user_id, chgdate, - notes_content, validity_period, - total_amount, total_amount_krw, - manager_name, manager_contact, note_remarks, - show_total_row, group1_subtotal, part_name, part_objid + `INSERT INTO contract_item ( + objid, contract_objid, seq, product, + part_objid, part_no, part_name, + quantity, due_date, return_reason, customer_request, + regdate, writer, status ) VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, - NOW(),$17,NOW(),$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28 - )`, + $1::varchar, $2::varchar, $3::integer, $4::varchar, + $5::varchar, $6::varchar, $7::varchar, + $8::integer, $9::varchar, $10::varchar, $11::text, + NOW(), $12::varchar, 'ACTIVE' + ) + ON CONFLICT (objid) DO UPDATE SET + seq=EXCLUDED.seq, product=EXCLUDED.product, + part_objid=EXCLUDED.part_objid, part_no=EXCLUDED.part_no, part_name=EXCLUDED.part_name, + quantity=EXCLUDED.quantity, due_date=EXCLUDED.due_date, + return_reason=EXCLUDED.return_reason, customer_request=EXCLUDED.customer_request, + status='ACTIVE', chgdate=NOW(), chg_user_id=EXCLUDED.writer`, [ - templateObjid, contractObjid, body.template_type, - body.executor || null, body.recipient || null, estimateNo, - body.contact_person || null, body.greeting_text || null, - body.model_name || null, body.model_code || null, body.executor_date || null, - body.note1 || null, body.note2 || null, body.note3 || null, body.note4 || null, - body.categories_json || null, + itemObjid, contractObjid, item.seq, item.product || null, + item.part_objid || null, item.part_no || null, item.part_name || null, + qtyInt, item.due_date || null, + item.return_reason || null, item.customer_request || null, userId, - body.notes_content || null, - body.validity_period || null, - body.total_amount || null, - body.total_amount_krw || null, - body.manager_name || null, - body.manager_contact || null, - body.note_remarks || null, - body.show_total_row || "Y", - body.group1_subtotal || null, - body.part_name || null, - body.part_objid || null, ], ); - // 3. estimate_template_item 라인 (배치 INSERT) - const items = body.items ?? []; - for (const item of items) { + // 시리얼 재구성: 라인의 모든 시리얼 INACTIVE → 새로 INSERT + await client.query(`UPDATE contract_item_serial SET status='INACTIVE' WHERE item_objid=$1`, [itemObjid]); + const serials = item.serials ?? []; + for (let i = 0; i < serials.length; i++) { + const s = (serials[i] ?? "").trim(); + if (!s) continue; await client.query( - `INSERT INTO estimate_template_item ( - template_objid, seq, category, description, specification, - quantity, unit, unit_price, amount, note, remark, part_objid - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, - [ - templateObjid, - item.seq, - item.category || null, - item.description || null, - item.specification || null, - item.quantity || null, - item.unit || null, - item.unit_price || null, - item.amount || null, - item.note || null, - item.remark || null, - item.part_objid || null, - ], + `INSERT INTO contract_item_serial (objid, item_objid, seq, serial_no, regdate, writer, status) + VALUES ($1, $2, $3, $4, NOW(), $5, 'ACTIVE') + ON CONFLICT (item_objid, serial_no) DO UPDATE SET status='ACTIVE', seq=EXCLUDED.seq`, + [genVarcharObjid("CIS"), itemObjid, i + 1, s, userId], ); } + } +} + +// ─── 생성 ───────────────────────────────────────────────────── +// wace estimateRegistFormPopup 폼: 헤더(contract_mgmt) + 라인(contract_item) + 시리얼(contract_item_serial) +// 견적단계 신규 행: contract_result NULL, is_direct_order='N' (견적관리 그리드에 노출) + +export async function create(userId: string, body: EstimateBody) { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const contractObjid = genVarcharObjid("CM"); + const contractNo = body.contract_no || (await generateContractNo()); + + await client.query( + `INSERT INTO contract_mgmt ( + objid, contract_no, + category_cd, area_cd, customer_objid, paid_type, + receipt_date, contract_currency, exchange_rate, approval_required, + is_direct_order, writer, regdate + ) VALUES ( + $1::varchar, $2::varchar, + $3::varchar, $4::varchar, $5::varchar, $6::varchar, + $7::varchar, $8::varchar, $9::varchar, $10::varchar, + 'N', $11::varchar, NOW() + )`, + [ + contractObjid, contractNo, + body.category_cd || null, body.area_cd || null, body.customer_objid || null, body.paid_type || null, + body.receipt_date || null, body.contract_currency || null, + body.exchange_rate || null, body.approval_required || "N", + userId, + ], + ); + + await upsertItems(client, contractObjid, body.items ?? [], userId); await client.query("COMMIT"); - logger.info("견적 등록", { templateObjid, contractObjid, estimateNo, itemCount: items.length }); - return { objid: templateObjid, contract_objid: contractObjid, estimate_no: estimateNo }; + logger.info("견적요청 등록", { contractObjid, contractNo, items: (body.items ?? []).length }); + return { objid: contractObjid, contract_no: contractNo }; } catch (error) { await client.query("ROLLBACK"); throw error; @@ -538,61 +518,32 @@ export async function create(userId: string, body: EstimateTemplateBody) { // ─── 수정 ───────────────────────────────────────────────────── -export async function update(userId: string, objid: string, body: EstimateTemplateBody) { +export async function update(userId: string, objid: string, body: EstimateBody) { const pool = getPool(); const client = await pool.connect(); - try { await client.query("BEGIN"); await client.query( - `UPDATE estimate_template SET - template_type=$2, executor=$3, recipient=$4, estimate_no=$5, - contact_person=$6, greeting_text=$7, model_name=$8, model_code=$9, - executor_date=$10, note1=$11, note2=$12, note3=$13, note4=$14, - categories_json=$15, notes_content=$16, validity_period=$17, - total_amount=$18, total_amount_krw=$19, - manager_name=$20, manager_contact=$21, note_remarks=$22, - show_total_row=$23, group1_subtotal=$24, part_name=$25, part_objid=$26, - chg_user_id=$27, chgdate=NOW() - WHERE objid = $1`, + `UPDATE contract_mgmt SET + category_cd=$2::varchar, area_cd=$3::varchar, customer_objid=$4::varchar, paid_type=$5::varchar, + receipt_date=$6::varchar, contract_currency=$7::varchar, exchange_rate=$8::varchar, + approval_required=$9::varchar, + chg_user_id=$10::varchar + WHERE objid=$1::varchar`, [ objid, - body.template_type, - body.executor || null, body.recipient || null, body.estimate_no || null, - body.contact_person || null, body.greeting_text || null, - body.model_name || null, body.model_code || null, body.executor_date || null, - body.note1 || null, body.note2 || null, body.note3 || null, body.note4 || null, - body.categories_json || null, - body.notes_content || null, body.validity_period || null, - body.total_amount || null, body.total_amount_krw || null, - body.manager_name || null, body.manager_contact || null, body.note_remarks || null, - body.show_total_row || "Y", body.group1_subtotal || null, - body.part_name || null, body.part_objid || null, + body.category_cd || null, body.area_cd || null, body.customer_objid || null, body.paid_type || null, + body.receipt_date || null, body.contract_currency || null, body.exchange_rate || null, + body.approval_required || "N", userId, ], ); - // 라인 전체 교체 (단순 패턴; 추후 diff 방식으로 개선 가능) - await client.query(`DELETE FROM estimate_template_item WHERE template_objid = $1`, [objid]); - const items = body.items ?? []; - for (const item of items) { - await client.query( - `INSERT INTO estimate_template_item ( - template_objid, seq, category, description, specification, - quantity, unit, unit_price, amount, note, remark, part_objid - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, - [ - objid, item.seq, item.category || null, item.description || null, - item.specification || null, item.quantity || null, item.unit || null, - item.unit_price || null, item.amount || null, item.note || null, - item.remark || null, item.part_objid || null, - ], - ); - } + await upsertItems(client, objid, body.items ?? [], userId); await client.query("COMMIT"); - logger.info("견적 수정", { objid, itemCount: items.length }); + logger.info("견적요청 수정", { objid, items: (body.items ?? []).length }); } catch (error) { await client.query("ROLLBACK"); throw error; @@ -651,17 +602,23 @@ export async function sendMail(userId: string, body: EstimateMailBody) { } // ─── 삭제 ───────────────────────────────────────────────────── -// 라인 → 헤더 순. contract_mgmt는 다른 견적이 참조 중일 수 있어 삭제하지 않음. +// 시리얼 → 라인 → 헤더(contract_mgmt) 순. +// (G8 — 프로젝트 존재 시 삭제 방지는 별도 PR) export async function remove(objid: string) { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); - await client.query(`DELETE FROM estimate_template_item WHERE template_objid = $1`, [objid]); - await client.query(`DELETE FROM estimate_template WHERE objid = $1`, [objid]); + await client.query( + `UPDATE contract_item_serial SET status='INACTIVE' + WHERE item_objid IN (SELECT objid FROM contract_item WHERE contract_objid=$1)`, + [objid], + ); + await client.query(`DELETE FROM contract_item WHERE contract_objid=$1`, [objid]); + await client.query(`DELETE FROM contract_mgmt WHERE objid=$1`, [objid]); await client.query("COMMIT"); - logger.info("견적 삭제", { objid }); + logger.info("견적요청 삭제", { objid }); } catch (error) { await client.query("ROLLBACK"); throw error; @@ -669,3 +626,326 @@ export async function remove(objid: string) { client.release(); } } + +// ============================================================ +// G5 견적작성 (estimate_template / estimate_template_item) +// ============================================================ +// wace contractMgmt.xml#insertEstimateTemplate/updateEstimateTemplate (template1=일반) +// contractMgmt.xml#insertEstimateTemplate2/updateEstimateTemplate2 (template2=장비) +// contractMgmt.xml#deleteEstimateTemplateItems/insertEstimateTemplateItems +// ContractMgmtServiceImpl#saveEstimateTemplate/2 (line 1501/1591) +// +// 한 contract_mgmt → N estimate_template (template_objid 명시 없으면 항상 신규 차수) +// 라인 처리: 매 저장마다 DELETE + 전체 재INSERT (wace 패턴 그대로). + +export interface EstimateTemplateItem { + seq?: number; + category?: string; + part_objid?: string; + description?: string; + specification?: string; + quantity?: string; + unit?: string; + unit_price?: string; + amount?: string; + note?: string; + remark?: string; +} + +// Template1 (일반 견적서) +export interface EstimateTemplate1Body { + contract_objid: string; + template_objid?: string; // 명시되고 존재하면 수정, 아니면 항상 신규 차수 + executor?: string; + recipient?: string; + estimate_no?: string; + contact_person?: string; + greeting_text?: string; + model_name?: string; + model_code?: string; + executor_date?: string; + note1?: string; + note2?: string; + note3?: string; + note4?: string; + note_remarks?: string; + total_amount?: string; + total_amount_krw?: string; + manager_name?: string; + manager_contact?: string; + show_total_row?: "Y" | "N"; + items: EstimateTemplateItem[]; +} + +// Template2 (장비 견적서) — categories_json 1개 + items 자동 1행 +export interface EstimateTemplate2Body { + contract_objid: string; + template_objid?: string; + executor_date?: string; + recipient?: string; + part_name?: string; + part_objid?: string; + notes_content?: string; + validity_period?: string; + categories_json: string; + group1_subtotal?: string; + total_amount?: string; + total_amount_krw?: string; +} + +// 라인 재구축 헬퍼 (wace deleteEstimateTemplateItems + insertEstimateTemplateItems) +async function rebuildTemplateItems(client: any, templateObjid: string, items: EstimateTemplateItem[]) { + await client.query(`DELETE FROM estimate_template_item WHERE template_objid=$1`, [templateObjid]); + let seq = 1; + for (const item of items ?? []) { + const desc = (item.description ?? "").trim(); + if (desc === "") continue; // wace: WHERE COALESCE(item->>'description','') != '' + await client.query( + `INSERT INTO estimate_template_item ( + template_objid, seq, category, part_objid, + description, specification, quantity, unit, unit_price, amount, + note, remark + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + [ + templateObjid, + item.seq ?? seq++, + item.category ?? null, + item.part_objid ?? null, + desc, + item.specification ?? null, + item.quantity ?? null, + item.unit ?? null, + item.unit_price ?? null, + item.amount ?? null, + item.note ?? null, + item.remark ?? null, + ], + ); + } +} + +async function existsTemplate(client: any, templateObjid: string): Promise { + const r = await client.query(`SELECT 1 FROM estimate_template WHERE objid=$1 LIMIT 1`, [templateObjid]); + return (r.rowCount ?? 0) > 0; +} + +// 일반 견적서 저장 (wace saveEstimateTemplate) +export async function saveEstimateTemplate1(userId: string, body: EstimateTemplate1Body) { + if (!body.contract_objid) throw new Error("contract_objid is required"); + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + let templateObjid = body.template_objid ?? ""; + const isUpdate = templateObjid !== "" && templateObjid !== "-1" && (await existsTemplate(client, templateObjid)); + + if (isUpdate) { + await client.query( + `UPDATE estimate_template SET + executor=$2, recipient=$3, estimate_no=$4, contact_person=$5, + greeting_text=$6, model_name=$7, model_code=$8, executor_date=$9, + note1=$10, note2=$11, note3=$12, note4=$13, note_remarks=$14, + total_amount=$15, total_amount_krw=$16, + manager_name=$17, manager_contact=$18, show_total_row=$19, + chg_user_id=$20, chgdate=NOW() + WHERE objid=$1`, + [ + templateObjid, + body.executor ?? null, body.recipient ?? null, body.estimate_no ?? null, body.contact_person ?? null, + body.greeting_text ?? null, body.model_name ?? null, body.model_code ?? null, body.executor_date ?? null, + body.note1 ?? null, body.note2 ?? null, body.note3 ?? null, body.note4 ?? null, body.note_remarks ?? null, + body.total_amount ?? null, body.total_amount_krw ?? null, + body.manager_name ?? null, body.manager_contact ?? null, body.show_total_row ?? "Y", + userId, + ], + ); + } else { + templateObjid = genVarcharObjid("ET"); + await client.query( + `INSERT INTO estimate_template ( + objid, contract_objid, template_type, + executor, recipient, estimate_no, contact_person, greeting_text, + model_name, model_code, executor_date, + note1, note2, note3, note4, note_remarks, + total_amount, total_amount_krw, + manager_name, manager_contact, show_total_row, + writer, regdate, chg_user_id, chgdate + ) VALUES ( + $1, $2, '1', + $3, $4, $5, $6, $7, + $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17, + $18, $19, $20, + $21, NOW(), $21, NOW() + )`, + [ + templateObjid, body.contract_objid, + body.executor ?? null, body.recipient ?? null, body.estimate_no ?? null, body.contact_person ?? null, body.greeting_text ?? null, + body.model_name ?? null, body.model_code ?? null, body.executor_date ?? null, + body.note1 ?? null, body.note2 ?? null, body.note3 ?? null, body.note4 ?? null, body.note_remarks ?? null, + body.total_amount ?? null, body.total_amount_krw ?? null, + body.manager_name ?? null, body.manager_contact ?? null, body.show_total_row ?? "Y", + userId, + ], + ); + } + + await rebuildTemplateItems(client, templateObjid, body.items ?? []); + + await client.query("COMMIT"); + logger.info("견적작성(일반) 저장", { templateObjid, contractObjid: body.contract_objid, isUpdate, items: (body.items ?? []).length }); + return { templateObjid, isUpdate }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +// 장비 견적서 저장 (wace saveEstimateTemplate2) +// 헤더 + categories_json + 장비 1행 자동 라인 INSERT (cnc_machine 수량 추출은 클라이언트 책임) +export async function saveEstimateTemplate2(userId: string, body: EstimateTemplate2Body) { + if (!body.contract_objid && !body.template_objid) throw new Error("contract_objid or template_objid is required"); + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + let templateObjid = body.template_objid ?? ""; + let contractObjid = body.contract_objid; + const isUpdate = templateObjid !== "" && templateObjid !== "-1" && (await existsTemplate(client, templateObjid)); + + if (isUpdate) { + if (!contractObjid) { + const r = await client.query(`SELECT contract_objid FROM estimate_template WHERE objid=$1`, [templateObjid]); + contractObjid = r.rows[0]?.contract_objid; + } + await client.query( + `UPDATE estimate_template SET + executor_date=$2, recipient=$3, part_name=$4, part_objid=$5, + notes_content=$6, validity_period=$7, categories_json=$8, + group1_subtotal=$9, total_amount=$10, total_amount_krw=$11, + chg_user_id=$12, chgdate=NOW() + WHERE objid=$1`, + [ + templateObjid, + body.executor_date ?? null, body.recipient ?? null, body.part_name ?? null, body.part_objid ?? null, + body.notes_content ?? null, body.validity_period ?? null, body.categories_json ?? null, + body.group1_subtotal ?? null, body.total_amount ?? null, body.total_amount_krw ?? null, + userId, + ], + ); + } else { + templateObjid = genVarcharObjid("ET"); + await client.query( + `INSERT INTO estimate_template ( + objid, contract_objid, template_type, + executor_date, recipient, part_name, part_objid, + notes_content, validity_period, categories_json, + group1_subtotal, total_amount, total_amount_krw, + writer, regdate, chg_user_id, chgdate + ) VALUES ( + $1, $2, '2', + $3, $4, $5, $6, + $7, $8, $9, + $10, $11, $12, + $13, NOW(), $13, NOW() + )`, + [ + templateObjid, contractObjid, + body.executor_date ?? null, body.recipient ?? null, body.part_name ?? null, body.part_objid ?? null, + body.notes_content ?? null, body.validity_period ?? null, body.categories_json ?? null, + body.group1_subtotal ?? null, body.total_amount ?? null, body.total_amount_krw ?? null, + userId, + ], + ); + } + + // 장비 견적서는 part_name + total_amount + categories_json의 cnc_machine 수량으로 1행 라인 생성 + // (wace saveEstimateTemplate2 line 1647~) + await client.query(`DELETE FROM estimate_template_item WHERE template_objid=$1`, [templateObjid]); + const partName = (body.part_name ?? "").trim(); + const amountStr = (body.total_amount ?? "").toString().replace(/[^0-9.]/g, ""); + if (partName !== "" && amountStr !== "") { + let qty = 1; + if (body.categories_json) { + try { + const cats = JSON.parse(body.categories_json) as Array; + const cnc = cats.find(c => c?.category === "cnc_machine"); + const firstItem = cnc?.items?.[0]; + const qtyRaw = firstItem?.quantity; + if (qtyRaw != null) { + const numOnly = String(qtyRaw).split("\n")[0].replace(/[^0-9]/g, ""); + if (numOnly) qty = parseInt(numOnly, 10); + } + } catch { + // categories_json 파싱 실패 시 수량 1로 폴백 + } + } + await client.query( + `INSERT INTO estimate_template_item ( + template_objid, seq, category, part_objid, + description, specification, quantity, unit, unit_price, amount, + note, remark + ) VALUES ($1, 1, 'cnc_machine', $2, $3, NULL, $4, NULL, NULL, $5, NULL, NULL)`, + [templateObjid, body.part_objid ?? null, partName, String(qty), amountStr], + ); + } + + await client.query("COMMIT"); + logger.info("견적작성(장비) 저장", { templateObjid, contractObjid, isUpdate }); + return { templateObjid, isUpdate }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +// 단건 조회 (wace getEstimateTemplateByObjId + getEstimateTemplateItemsByTemplateObjId) +export async function getTemplateById(templateObjid: string) { + const pool = getPool(); + const headerRes = await pool.query( + `SELECT ET.*, + TO_CHAR(ET.regdate, 'YYYY-MM-DD HH24:MI') AS regdate_str, + TO_CHAR(ET.chgdate, 'YYYY-MM-DD HH24:MI') AS chgdate_str, + CM.exchange_rate, + CM.contract_currency, + CC_CUR.code_name AS contract_currency_name + FROM estimate_template ET + LEFT JOIN contract_mgmt CM ON CM.objid = ET.contract_objid + LEFT JOIN comm_code CC_CUR ON CC_CUR.code_id = CM.contract_currency AND CC_CUR.status='active' + WHERE ET.objid=$1`, + [templateObjid], + ); + if (headerRes.rowCount === 0) return null; + const header = headerRes.rows[0]; + + const itemsRes = await pool.query( + `SELECT * FROM estimate_template_item WHERE template_objid=$1 ORDER BY seq`, + [templateObjid], + ); + + return { ...header, items: itemsRes.rows }; +} + +// 견적 차수 리스트 (wace estimateList.jsp fn_showEstimateList — contract_objid별 차수들) +export async function listTemplatesByContract(contractObjid: string) { + const pool = getPool(); + const res = await pool.query( + `SELECT objid, template_type, estimate_no, + recipient, total_amount, total_amount_krw, + writer, + TO_CHAR(regdate, 'YYYY-MM-DD HH24:MI') AS regdate, + TO_CHAR(chgdate, 'YYYY-MM-DD HH24:MI') AS chgdate + FROM estimate_template + WHERE contract_objid=$1 + ORDER BY regdate DESC`, + [contractObjid], + ); + return res.rows; +} diff --git a/backend-node/src/services/salesOrderMgmtService.ts b/backend-node/src/services/salesOrderMgmtService.ts index 8e5ba5af..e6d84cc3 100644 --- a/backend-node/src/services/salesOrderMgmtService.ts +++ b/backend-node/src/services/salesOrderMgmtService.ts @@ -47,7 +47,7 @@ export interface OrderItem { } export interface OrderBody { - // contract_mgmt 헤더 + // contract_mgmt 헤더 (wace estimateAndOrderRegistFormPopup 직접등록 통합폼 — G2) objid?: string; // 신규면 자동 생성 contract_no?: string; category_cd?: string; @@ -57,15 +57,17 @@ export interface OrderBody { paid_type?: string; contract_currency?: string; exchange_rate?: string; - receipt_date?: string; - contract_date?: string; // 발주일 + receipt_date?: string; // 접수일 * + order_date?: string; // 발주일 * (wace G2 필수) req_del_date?: string; // 요청납기 po_no?: string; // 발주번호 - contract_result?: string; // 수주상태 (예: WAITING/CONFIRMED/CANCELLED) + contract_result?: string; // 수주상태 + approval_required?: string; // 결재여부 'Y'|'N' pm_user_id?: string; customer_request?: string; shipping_method?: string; incoterms?: string; + is_direct_order?: string; // 'Y'면 직접등록 (견적관리 노출 X, 주문관리만 노출) // 라인 items: OrderItem[]; } @@ -127,7 +129,7 @@ export async function getList(filter: OrderListFilter) { ,T.CUSTOMER_OBJID ,C.customer_name AS CUSTOMER_NAME ,T.PRODUCT - ,CC_PRD.code_name AS PRODUCT_NAME + ,COALESCE(CI_AGG.product_summary, CC_PRD.code_name) AS PRODUCT_NAME ,T.AREA_CD ,CC_AREA.code_name AS AREA_NAME ,T.PAID_TYPE @@ -182,6 +184,7 @@ export async function getList(filter: OrderListFilter) { ,NULL::text AS ORDER_APPR_STATUS ,NULL::text AS AMARANTH_STATUS ,0 AS CU01_CNT + ,T.IS_DIRECT_ORDER AS IS_DIRECT_ORDER FROM contract_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 @@ -197,16 +200,18 @@ export async function getList(filter: OrderListFilter) { SELECT CI.contract_objid, COUNT(*) AS item_count, - (array_agg(COALESCE(IT.item_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name, - (array_agg(COALESCE(IT.item_number, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no, + (array_agg(COALESCE(PM.part_name, CI.part_name) ORDER BY CI.seq))[1] AS first_part_name, + (array_agg(COALESCE(PM.part_no, CI.part_no) ORDER BY CI.seq))[1] AS first_part_no, MIN(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN CI.due_date END) AS earliest_due_date, GREATEST(COUNT(CASE WHEN CI.due_date IS NOT NULL AND CI.due_date != '' THEN 1 END) - 1, 0) AS other_due_date_count, COALESCE(SUM(CAST(REPLACE(NULLIF(CI.order_quantity, ''), ',', '') AS NUMERIC)), 0) AS order_quantity_sum, COALESCE(SUM(CASE WHEN CI.cancel_qty IS NOT NULL AND CI.cancel_qty != '' AND CI.cancel_qty != '0' THEN CAST(CI.cancel_qty AS NUMERIC) END), 0) AS cancel_qty_sum, - COUNT(CASE WHEN CI.order_quantity IS NOT NULL AND CI.order_quantity != '' AND CI.order_quantity != '0' THEN 1 END) AS has_order_data + COUNT(CASE WHEN CI.order_quantity IS NOT NULL AND CI.order_quantity != '' AND CI.order_quantity != '0' THEN 1 END) AS has_order_data, + STRING_AGG(DISTINCT CC_PRDI.code_name, ', ') FILTER (WHERE CC_PRDI.code_name IS NOT NULL) AS product_summary FROM contract_item CI - LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid + LEFT JOIN comm_code CC_PRDI ON CC_PRDI.code_id = CI.product AND CC_PRDI.status='active' WHERE CI.status='ACTIVE' GROUP BY CI.contract_objid ) CI_AGG ON CI_AGG.contract_objid = T.OBJID @@ -244,9 +249,9 @@ export async function getById(objid: string) { if (headerRes.rowCount === 0) return null; const itemsRes = await pool.query( - `SELECT CI.*, IT.item_name AS master_item_name + `SELECT CI.*, PM.part_name AS master_item_name FROM contract_item CI - LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE' ORDER BY CI.seq`, [objid], ); @@ -270,13 +275,89 @@ export async function getById(objid: string) { return { ...headerRes.rows[0], items }; } +// ─── 주문서 뷰 (wace orderFormView 대응) ────────────────────── +// wace 패턴: getOrderFormInfo(헤더 + 거래처) + getOrderFormItems(라인) 두 쿼리. + +export async function getOrderFormView(objid: string) { + const pool = getPool(); + const headerSql = ` + SELECT + T.objid AS objid, + T.contract_no AS contract_no, + T.po_no AS po_no, + T.order_date AS order_date, + T.customer_objid AS customer_objid, + CC_CAT.code_name AS category_name, + CC_CUR.code_name AS currency_name, + T.contract_currency AS currency_code, + T.exchange_rate AS exchange_rate, + T.order_supply_price AS order_supply_price, + T.order_vat AS order_vat, + T.order_total_amount AS order_total_amount, + CASE + WHEN T.paid_type = 'paid' THEN '부가세별도' + WHEN T.paid_type = 'free' THEN '부가세미포함' + ELSE '' + END AS vat_note, + T.customer_request AS customer_request, + C.customer_name AS client_nm, + C.business_number AS client_bus_reg_no, + COALESCE(C.ceo_name, C.representative_name) AS client_ceo_nm, + C.address AS client_addr, + C.biz_condition AS client_bus_type, + C.biz_item AS client_bus_item, + COALESCE(C.tel, C.phone, C.contact_phone) AS client_tel_no, + COALESCE(C.fax_no, C.fax) AS client_fax_no, + COALESCE(C.email, C.charge_email) AS client_email, + TRIM(BOTH FROM COALESCE(U_WR.dept_name, '') || ' ' || COALESCE(U_WR.user_name, T.writer)) AS writer_name, + U_WR.tel AS writer_contact, + TO_CHAR(T.regdate, 'YYYY-MM-DD HH24:MI:SS') AS reg_datetime + FROM contract_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 + LEFT JOIN user_info U_WR ON U_WR.user_id = T.writer + LEFT JOIN comm_code CC_CAT ON CC_CAT.code_id = T.category_cd AND CC_CAT.status='active' + LEFT JOIN comm_code CC_CUR ON CC_CUR.code_id = T.contract_currency AND CC_CUR.status='active' + WHERE T.objid = $1 + `; + const headerRes = await pool.query(headerSql, [objid]); + if (headerRes.rowCount === 0) return null; + + const itemsSql = ` + SELECT + CI.seq, + COALESCE(PM.part_no, CI.part_no) AS part_no, + COALESCE(PM.part_name, CI.part_name) AS part_name, + IT.size AS spec, + CC_UNIT.code_name AS unit_name, + IT.unit AS unit_code, + CI.due_date AS due_date, + CI.order_quantity AS order_quantity, + CI.order_unit_price AS order_unit_price, + CI.order_supply_price AS order_supply_price, + CI.order_vat AS order_vat, + CI.order_total_amount AS order_total_amount + FROM contract_item CI + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid + LEFT JOIN comm_code CC_UNIT ON CC_UNIT.code_id = IT.unit AND CC_UNIT.status='active' + WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE' + ORDER BY CI.seq + `; + const itemsRes = await pool.query(itemsSql, [objid]); + + logger.info("주문서 뷰 조회", { objid, itemCount: itemsRes.rowCount }); + return { info: headerRes.rows[0], items: itemsRes.rows }; +} + // ─── 영업번호 채번 ───────────────────────────────────────────── +// 영업번호 채번 — wace 운영 패턴: {YY}C-{NNNN} +// YY = 년도 뒷 2자리 / C = 고정 / NNNN = 같은 prefix 안에서 MAX(NNNN)+1, 4자리 zero-pad +// 운영 데이터 90건 모두 이 형식 (예: 26C-0801, 26C-0800, ...) export async function generateContractNo(): Promise { const pool = getPool(); - const today = new Date(); - const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`; - const prefix = `SO-${ymd}-`; + const yy = String(new Date().getFullYear()).slice(-2); + const prefix = `${yy}C-`; const res = await pool.query( `SELECT contract_no FROM contract_mgmt WHERE contract_no LIKE $1 @@ -288,7 +369,7 @@ export async function generateContractNo(): Promise { const lastSeq = parseInt(last.replace(prefix, ""), 10); if (!isNaN(lastSeq)) seq = lastSeq + 1; } - return `${prefix}${String(seq).padStart(3, "0")}`; + return `${prefix}${String(seq).padStart(4, "0")}`; } // ─── 생성/수정 공용 ─────────────────────────────────────────── @@ -357,25 +438,29 @@ export async function create(userId: string, body: OrderBody) { return s + (isNaN(v) ? 0 : v); }, 0); + // wace G2 패턴: 주문관리 등록은 직접등록 통합폼 — is_direct_order='Y' (견적관리에 노출 X) + const isDirectOrder = body.is_direct_order || "Y"; + await client.query( `INSERT INTO contract_mgmt ( objid, contract_no, category_cd, customer_objid, product, area_cd, paid_type, - contract_currency, exchange_rate, receipt_date, contract_date, req_del_date, - po_no, contract_result, pm_user_id, customer_request, shipping_method, incoterms, + contract_currency, exchange_rate, receipt_date, order_date, req_del_date, + po_no, contract_result, approval_required, pm_user_id, customer_request, + shipping_method, incoterms, is_direct_order, order_supply_price, order_vat, order_total_amount, writer, regdate ) VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, - $19,$20,$21,$22,NOW() + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, + $21,$22,$23,$24,NOW() )`, [ objid, contractNo, body.category_cd || null, body.customer_objid || null, body.product || null, body.area_cd || null, body.paid_type || null, body.contract_currency || null, body.exchange_rate || null, - body.receipt_date || null, body.contract_date || null, body.req_del_date || null, - body.po_no || null, body.contract_result || null, + body.receipt_date || null, body.order_date || null, body.req_del_date || null, + body.po_no || null, body.contract_result || null, body.approval_required || "N", body.pm_user_id || null, body.customer_request || null, - body.shipping_method || null, body.incoterms || null, + body.shipping_method || null, body.incoterms || null, isDirectOrder, String(sum("order_supply_price")), String(sum("order_vat")), String(sum("order_total_amount")), userId, ], @@ -406,18 +491,18 @@ export async function update(userId: string, objid: string, body: OrderBody) { await client.query( `UPDATE contract_mgmt SET category_cd=$2, customer_objid=$3, product=$4, area_cd=$5, paid_type=$6, - contract_currency=$7, exchange_rate=$8, receipt_date=$9, contract_date=$10, - req_del_date=$11, po_no=$12, contract_result=$13, pm_user_id=$14, - customer_request=$15, shipping_method=$16, incoterms=$17, - order_supply_price=$18, order_vat=$19, order_total_amount=$20, - chg_user_id=$21 + contract_currency=$7, exchange_rate=$8, receipt_date=$9, order_date=$10, + req_del_date=$11, po_no=$12, contract_result=$13, approval_required=$14, pm_user_id=$15, + customer_request=$16, shipping_method=$17, incoterms=$18, + order_supply_price=$19, order_vat=$20, order_total_amount=$21, + chg_user_id=$22 WHERE objid=$1`, [ objid, body.category_cd || null, body.customer_objid || null, body.product || null, body.area_cd || null, body.paid_type || null, body.contract_currency || null, body.exchange_rate || null, - body.receipt_date || null, body.contract_date || null, body.req_del_date || null, - body.po_no || null, body.contract_result || null, + body.receipt_date || null, body.order_date || null, body.req_del_date || null, + body.po_no || null, body.contract_result || null, body.approval_required || "N", body.pm_user_id || null, body.customer_request || null, body.shipping_method || null, body.incoterms || null, String(sum("order_supply_price")), String(sum("order_vat")), String(sum("order_total_amount")), @@ -442,13 +527,295 @@ export async function remove(objid: string) { logger.info("주문서 삭제", { objid }); } +// ─── 수주확정 → 프로젝트 자동생성 (wace ContractMgmtService.updateOrderStatus 이식) ── +// 원본: wace_plm/src/com/pms/salesmgmt/service/ContractMgmtService.java:2987~3113 +// + wace_plm/src/com/pms/mapper/project.xml:7415~7618 (createProject) +// 트리거: contract_result='0000964'(수주) 또는 '0000968'(수주FCST) +// 룰: project_no = {주문유형}-{제품구분}-{YYMMDD}-{NNN} +// 주문유형(category code_name): 오버홀=O 개조=M 개발=D 견적=Q 수리=R 판매=S 그외=T +// 제품구분(product code_name): Machine=MC A/S=AS D/S=DS B/S=BS C/T=CT A/C=AC W/M=WM 기타=기타 그외=replace('/', '') +// 순번: 같은 prefix-prefix-YYMMDD-% 안에서 MAX(NNN)+1, 없으면 001 +// Machine(0000928): hasProject=false면 quantity 만큼 N회 INSERT (각 quantity=1) + +const PROJECT_TRIGGER_RESULTS = ["0000964", "0000968"] as const; +const PRODUCT_MACHINE = "0000928"; + +async function generateProjectNo(client: any, categoryCd: string | null, productCd: string | null): Promise { + const sql = ` + WITH meta AS ( + SELECT + CASE CC_CAT.code_name + WHEN '오버홀' THEN 'O' WHEN '개조' THEN 'M' WHEN '개발' THEN 'D' + WHEN '견적' THEN 'Q' WHEN '수리' THEN 'R' WHEN '판매' THEN 'S' + ELSE 'T' + END AS cat_abbr, + CASE CC_PRD.code_name + WHEN 'Machine' THEN 'MC' WHEN 'A/S' THEN 'AS' WHEN 'D/S' THEN 'DS' + WHEN 'B/S' THEN 'BS' WHEN 'C/T' THEN 'CT' WHEN 'A/C' THEN 'AC' + WHEN 'W/M' THEN 'WM' WHEN '기타' THEN '기타' + ELSE REPLACE(COALESCE(CC_PRD.code_name, ''), '/', '') + END AS prd_abbr, + TO_CHAR(CURRENT_DATE, 'YYMMDD') AS ymd + FROM (SELECT 1) X + LEFT JOIN comm_code CC_CAT ON CC_CAT.code_id = $1 AND CC_CAT.status='active' + LEFT JOIN comm_code CC_PRD ON CC_PRD.code_id = $2 AND CC_PRD.status='active' + ) + SELECT + m.cat_abbr || '-' || m.prd_abbr || '-' || m.ymd || '-' || + LPAD( + COALESCE(( + SELECT MAX(SUBSTRING(project_no FROM '\\d{3}$')::int) + 1 + FROM project_mgmt + WHERE project_no LIKE m.cat_abbr || '-' || m.prd_abbr || '-' || m.ymd || '-%' + ), 1)::text, 3, '0' + ) AS project_no + FROM meta m + `; + const res = await client.query(sql, [categoryCd, productCd]); + return res.rows[0]?.project_no || ""; +} + +async function insertProjectFromContract( + client: any, + contractObjid: string, + item: any, + productCd: string | null, + quantity: string, + projectNo: string, + userId: string, +) { + const newObjid = genObjid("PJ"); + const sql = ` + INSERT INTO project_mgmt ( + objid, contract_objid, category_cd, customer_objid, product, + customer_project_name, status_cd, due_date, location, setup, + facility, facility_qty, facility_type, facility_depth, production_no, + bus_cal_cd, category1_cd, chg_user_id, plan_date, complete_date, + result_cd, project_no, pm_user_id, contract_price, contract_price_currency, + contract_currency, regdate, writer, contract_no, customer_equip_name, + req_del_date, contract_del_date, contract_company, contract_date, po_no, + manufacture_plant, contract_result, project_name, spec_user_id, spec_plan_date, + spec_comp_date, spec_result_cd, est_plan_date, est_user_id, est_comp_date, + est_result_cd, area_cd, mechanical_type, overhaul_order, is_temp, + part_objid, part_no, part_name, quantity, contract_item_objid + ) + SELECT + $1::varchar, $2::varchar, T.category_cd, T.customer_objid, COALESCE($3::varchar, T.product), + T.customer_project_name, T.status_cd, $4::varchar, T.location, T.setup, + T.facility, T.facility_qty, T.facility_type, T.facility_depth, T.production_no, + T.bus_cal_cd, T.category1_cd, $5::varchar, T.plan_date, T.complete_date, + T.result_cd, $6::varchar, T.pm_user_id, NULL, NULL, + T.contract_currency, NOW(), T.writer, T.contract_no, T.customer_equip_name, + T.req_del_date, T.contract_del_date, T.contract_company, T.contract_date, T.po_no, + T.manufacture_plant, T.contract_result, NULL, T.spec_user_id, T.spec_plan_date, + T.spec_comp_date, T.spec_result_cd, T.est_plan_date, T.est_user_id, T.est_comp_date, + T.est_result_cd, T.area_cd, T.mechanical_type, NULL, '1', + $7::varchar, $8::varchar, $9::varchar, $10::varchar, $11::varchar + FROM contract_mgmt T WHERE T.objid = $2::varchar + `; + await client.query(sql, [ + newObjid, + contractObjid, + productCd, + item.due_date || null, + userId, + projectNo, + item.part_objid || null, + item.part_no || null, + item.part_name || null, + quantity, + item.objid || null, + ]); + logger.info("[수주확정] 프로젝트 생성", { contractObjid, partObjid: item.part_objid, projectNo, quantity }); +} + +async function updateProjectFromContract( + client: any, + contractObjid: string, + item: any, + isMachine: boolean, + quantity: string, + productCd: string | null, + userId: string, +) { + // wace ModifyProjectByContract: SET DUE_DATE/QUANTITY/PRODUCT 등 (Machine은 quantity 미변경) + const setParts: string[] = []; + const params: any[] = []; + let idx = 1; + if (item.due_date) { setParts.push(`due_date=$${idx++}`); params.push(item.due_date); } + if (!isMachine) { setParts.push(`quantity=$${idx++}`); params.push(quantity); } + if (productCd) { setParts.push(`product=$${idx++}`); params.push(productCd); } + setParts.push(`chg_user_id=$${idx++}`); params.push(userId); + + // contract_item_objid 우선 매칭, 없으면 part_objid fallback (wace choose 패턴) + const sql = `UPDATE project_mgmt SET ${setParts.join(", ")} + WHERE contract_objid = $${idx++} + AND (contract_item_objid = $${idx++} + OR (contract_item_objid IS NULL AND part_objid = $${idx++}))`; + params.push(contractObjid, item.objid, item.part_objid); + await client.query(sql, params); + logger.info("[수주확정] 프로젝트 업데이트", { contractObjid, partObjid: item.part_objid, isMachine }); +} + +async function createProjectsFromContract(client: any, contractObjid: string, userId: string) { + // 1) 기존 프로젝트 존재 여부 + const existsRes = await client.query( + `SELECT objid FROM project_mgmt WHERE contract_objid = $1 LIMIT 1`, + [contractObjid], + ); + const hasProject = (existsRes.rowCount ?? 0) > 0; + + // 2) 헤더 기본 정보 + const headerRes = await client.query( + `SELECT category_cd, product FROM contract_mgmt WHERE objid = $1`, + [contractObjid], + ); + if (headerRes.rowCount === 0) return; + const header = headerRes.rows[0]; + + // 3) 라인 조회 (item_info 마스터 우선 fallback — wace getContractItems 패턴) + const itemsRes = await client.query( + `SELECT + CI.objid, CI.part_objid, + COALESCE(PM.part_no, CI.part_no) AS part_no, + COALESCE(PM.part_name, CI.part_name) AS part_name, + CI.quantity, CI.order_quantity, CI.due_date, CI.product + FROM contract_item CI + LEFT JOIN part_mng PM ON PM.objid::varchar = CI.part_objid + WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE' + ORDER BY CI.seq`, + [contractObjid], + ); + + if (itemsRes.rowCount === 0) { + logger.warn("[수주확정] contract_item 없음 — 프로젝트 생성 스킵", { contractObjid }); + return; + } + + let createdCount = 0; + for (const item of itemsRes.rows) { + const itemProduct: string | null = item.product || header.product || null; + const isMachine = itemProduct === PRODUCT_MACHINE; + + // 수량: order_quantity 우선, 없으면 quantity, 둘 다 없으면 1 + const rawQty = String(item.order_quantity ?? item.quantity ?? "1").replace(/[^0-9.]/g, ""); + let qtyNum = parseFloat(rawQty); + if (!Number.isFinite(qtyNum) || qtyNum <= 0) qtyNum = 1; + const itemQuantity = Math.floor(qtyNum); + + if (hasProject) { + // 존재하면 1회 UPDATE만 (Machine이면 quantity 미변경) + const updQty = isMachine ? "1" : String(itemQuantity); + await updateProjectFromContract(client, contractObjid, item, isMachine, updQty, itemProduct, userId); + } else { + // 신규: Machine이면 quantity만큼 반복(각 1), 그외 1회(원래 수량) + const loopCount = isMachine ? itemQuantity : 1; + const insertQty = isMachine ? "1" : String(itemQuantity); + for (let q = 0; q < loopCount; q++) { + const projectNo = await generateProjectNo(client, header.category_cd, itemProduct); + await insertProjectFromContract(client, contractObjid, item, itemProduct, insertQty, projectNo, userId); + createdCount++; + } + } + } + + logger.info("[수주확정] 프로젝트 처리 완료", { + contractObjid, hasProject, items: itemsRes.rowCount, created: createdCount, + }); +} + // ─── 수주 상태 변경 ──────────────────────────────────────────── +// ─── 수주취소 (라인별 cancel_qty 입력) — wace saveOrderCancelQty 이식 ────── +// 원본: ContractMgmtService.java:3932~4011, contractMgmt.xml:5292~5299 +// 룰: +// - cancel_qty 비었거나 0 → 빈값으로 초기화 (UPDATE cancel_qty='') +// - 음수 → 에러 ("수주취소 수량은 0 이상이어야 합니다") +// - order_qty 이상 → 에러 + rollback ("수주취소 수량은 수주수량(N)보다 적어야 합니다") — 전체 취소 불가 +// - 정상 → UPDATE cancel_qty=값 +// 응답: { result: 'true'|'false', msg } + +export interface CancelQtyEntry { + itemObjId: string; + cancelQty: string | number; + orderQty: string | number; +} + +export async function saveOrderCancelQty(userId: string, entries: CancelQtyEntry[]): + Promise<{ result: "true" | "false"; msg: string }> +{ + if (!entries || entries.length === 0) { + return { result: "false", msg: "취소 수량 정보가 없습니다." }; + } + + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + for (const e of entries) { + const cancelStr = String(e.cancelQty ?? "").trim(); + const orderStr = String(e.orderQty ?? "").trim(); + + // 빈값 또는 0 → 초기화 + if (cancelStr === "" || cancelStr === "0") { + await client.query( + `UPDATE contract_item SET cancel_qty='', chgdate=NOW(), chg_user_id=$2 WHERE objid=$1`, + [e.itemObjId, userId], + ); + continue; + } + + const cancelInt = parseInt(cancelStr, 10); + const orderInt = parseInt(orderStr, 10); + + if (Number.isNaN(cancelInt) || cancelInt < 0) { + await client.query("ROLLBACK"); + return { result: "false", msg: "수주취소 수량은 0 이상이어야 합니다." }; + } + if (cancelInt >= orderInt) { + await client.query("ROLLBACK"); + return { result: "false", msg: `수주취소 수량은 수주수량(${orderInt})보다 적어야 합니다.` }; + } + + await client.query( + `UPDATE contract_item SET cancel_qty=$2, chgdate=NOW(), chg_user_id=$3 WHERE objid=$1`, + [e.itemObjId, cancelStr, userId], + ); + } + + await client.query("COMMIT"); + logger.info("수주취소 수량 저장", { count: entries.length }); + return { result: "true", msg: "수주취소 수량이 저장되었습니다." }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + export async function updateStatus(userId: string, objid: string, contractResult: string) { const pool = getPool(); - await pool.query( - `UPDATE contract_mgmt SET contract_result=$2, chg_user_id=$3 WHERE objid=$1`, - [objid, contractResult, userId], - ); - logger.info("주문 상태 변경", { objid, contractResult }); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + await client.query( + `UPDATE contract_mgmt SET contract_result=$2, chg_user_id=$3 WHERE objid=$1`, + [objid, contractResult, userId], + ); + + if (PROJECT_TRIGGER_RESULTS.includes(contractResult as any)) { + await createProjectsFromContract(client, objid, userId); + } + + await client.query("COMMIT"); + logger.info("주문 상태 변경", { objid, contractResult }); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } } diff --git a/backend-node/src/services/salesSaleService.ts b/backend-node/src/services/salesSaleService.ts index fd425b11..14217df5 100644 --- a/backend-node/src/services/salesSaleService.ts +++ b/backend-node/src/services/salesSaleService.ts @@ -169,11 +169,12 @@ export async function getSaleList(filter: SaleListFilter) { ,CM.customer_request ,T.sales_deadline_date ,T.regdate - /* 출하지시상태/생산상태/분할S/N/거래명세서/주문서첨부 — 1차 placeholder */ + /* 출하지시상태/생산상태/분할S/N/거래명세서 — 1차 placeholder */ ,NULL::text AS production_status ,NULL::text AS split_serial_no ,'N'::text AS has_transaction_statement - ,0 AS cu01_cnt + /* 주문서첨부 카운트 — contract_mgmt.objid 기반 (wace 동일) */ + ,COALESCE(AF.cu01_cnt, 0) AS cu01_cnt FROM project_mgmt T LEFT JOIN contract_mgmt CM ON CM.objid = T.contract_objid LEFT JOIN contract_item CI ON CI.objid = T.contract_item_objid AND CI.status = 'ACTIVE' @@ -202,6 +203,14 @@ export async function getSaleList(filter: SaleListFilter) { AND CIS.serial_no IS NOT NULL AND CIS.serial_no != '' GROUP BY CIS.item_objid ) CIS_AGG ON CIS_AGG.item_objid = T.contract_item_objid + /* 주문서첨부 카운트 (contract_mgmt.objid 기반, doc_type FTC_ORDER/ORDER — wace 동일) */ + LEFT JOIN ( + SELECT target_objid, + COUNT(*) FILTER (WHERE doc_type IN ('FTC_ORDER','ORDER')) AS cu01_cnt + FROM attach_file_info + WHERE UPPER(status) = 'ACTIVE' + GROUP BY target_objid + ) AF ON AF.target_objid = T.contract_objid ${where} ORDER BY T.regdate DESC NULLS LAST, T.project_no DESC `; @@ -297,16 +306,37 @@ export async function getRevenueList(filter: SaleListFilter) { /* 분할S/N · 거래명세서 — 1차 placeholder */ ,NULL::text AS split_serial_no ,'N'::text AS has_transaction_statement + /* V1 컬럼 보강 (wace 일치) — 접수일/유무상/요청납기/고객사요청사항/수주상태/주문서첨부/담당자/인도조건 */ + ,CM.receipt_date AS receipt_date + ,CM.paid_type AS payment_type + ,CASE WHEN CM.paid_type='paid' THEN '유상' WHEN CM.paid_type='free' THEN '무상' ELSE CM.paid_type END AS payment_type_name + ,COALESCE(NULLIF(CI.due_date, ''), NULLIF(T.due_date, ''), NULLIF(CM.due_date, '')) AS request_date + ,CM.customer_request AS customer_request + ,T.contract_result AS order_status + ,CC_RES.code_name AS order_status_name + ,U_MGR.user_name AS manager_name + ,SR.incoterms AS incoterms + ,COALESCE(AF.cu01_cnt, 0) AS cu01_cnt FROM project_mgmt T LEFT JOIN contract_mgmt CM ON CM.objid = T.contract_objid + LEFT JOIN contract_item CI ON CI.objid = T.contract_item_objid AND CI.status='ACTIVE' 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 LEFT JOIN sales_registration SR ON SR.project_no = T.project_no + LEFT JOIN user_info U_MGR ON U_MGR.user_id = SR.manager_user_id LEFT JOIN comm_code CC_CAT ON CC_CAT.code_id = T.category_cd AND CC_CAT.status='active' LEFT JOIN comm_code CC_AREA ON CC_AREA.code_id = T.area_cd AND CC_AREA.status='active' LEFT JOIN comm_code CC_PRD ON CC_PRD.code_id = T.product AND CC_PRD.status='active' + LEFT JOIN comm_code CC_RES ON CC_RES.code_id = T.contract_result AND CC_RES.status='active' LEFT JOIN comm_code CC_CUR ON CC_CUR.code_id = T.contract_currency AND CC_CUR.status='active' LEFT JOIN comm_code CC_CUR_S ON CC_CUR_S.code_id = SR.sales_currency AND CC_CUR_S.status='active' + /* 주문서첨부 카운트 — contract_mgmt.objid 기반 (wace 동일) */ + LEFT JOIN ( + SELECT target_objid, + COUNT(*) FILTER (WHERE doc_type IN ('FTC_ORDER','ORDER')) AS cu01_cnt + FROM attach_file_info WHERE UPPER(status)='ACTIVE' + GROUP BY target_objid + ) AF ON AF.target_objid = T.contract_objid ${where} ORDER BY T.regdate DESC NULLS LAST, T.project_no DESC `; diff --git a/docs/migration/sales/00-gap.md b/docs/migration/sales/00-gap.md new file mode 100644 index 00000000..fd9ba0db --- /dev/null +++ b/docs/migration/sales/00-gap.md @@ -0,0 +1,118 @@ +# 영업관리 이식 GAP 분석 (원본 wace_plm 대비) + +> 작성: 2026-05-08 / 작성자: hjjeong +> 목적: vexplor_rps에 이식된 영업관리 4개 메뉴가 wace_plm 원본 흐름과 어디서 어긋나는지 정리하고, 다음 PR 우선순위를 합의하기 위한 단일 문서. +> 참고: [01-estimate.md](./01-estimate.md), [02-order.md](./02-order.md), [feedback_wace_jsp_columns](../../../../.claude/projects/-Users-jhj-vexplor-rps/memory/feedback_wace_jsp_columns.md) + +## 0. 한 문장 요약 + +견적/주문 list와 SQL은 잘 이식됐지만 **상태 전이 트리거**(수주확정 → 프로젝트 자동생성)와 **직접등록 통합폼**, **결재 자동판정**, **PDF·SMTP 실작업**이 통째로 빠져 있어, 사용자가 영업 흐름을 끝까지 돌릴 수 없는 상태. + +## 0.1 이식 원칙 (모든 GAP 작업 공통) + +> **JSP/Java/매퍼XML 안의 주석 블록(`/* */`, ``, `//`)은 비활성 옛 로직 보존 영역이다 — 절대 이식 대상이 아니다. 활성 코드만, 한 줄 한 줄 직접 따라가서 그대로 이식한다.** + +- **운영 화면이 진실의 기준**: waceplm.esgrin.com 운영 화면에 실제 보이는 항목/동작이 활성. 코드만 보면 활성/비활성 구분이 흐려짐. +- **컬럼 정의(`var columns = [...]`)**: `/* 주석처리된 컬럼 - 필요시 활성화 */` 블록 이하는 무시. +- **검색 폼(`#plmSearchZon`)**: `` 블록 이하는 무시. +- **서비스 메서드**: 주석된 옛 SQL/분기 무시. 호출 그래프(controller → service → mapper)를 한 줄씩 따라가서 활성 경로만 추출. +- **매퍼 XML**: `` 블록 안의 SQL fragment는 무시. ` - -
- - setForm({ - ...form, - contract_context: { ...form.contract_context!, customer_objid: v }, - })} - /> -
-
- - setForm({ - ...form, - contract_context: { ...form.contract_context!, contract_currency: v }, - })} /> -
-
- - setForm({ - ...form, - contract_context: { ...form.contract_context!, receipt_date: e.target.value }, - })} /> -
-
- - -
-
- - setForm({ - ...form, - contract_context: { ...form.contract_context!, category_cd: v }, - })} /> -
- - - )} - - {/* 견적 템플릿 */} -
- 견적 템플릿 -
+ {/* 견적요청 기본정보 — wace estimateRegistFormPopup.jsp 1행/2행 (8개) */} +
+ 견적요청 기본정보 +
- - +
+
+ + setForm({ ...form, category_cd: v })} /> +
+
+ + setForm({ ...form, area_cd: v })} /> +
+
+ + setForm({ ...form, customer_objid: v })} /> +
+
+ +
- - setForm({ ...form, estimate_no: e.target.value })} /> + + setForm({ ...form, receipt_date: e.target.value })} />
- - setForm({ ...form, validity_period: e.target.value })} - placeholder="예: 견적일로부터 30일" /> + + setForm({ ...form, contract_currency: v })} />
- - setForm({ ...form, executor: e.target.value })} /> + + setForm({ ...form, exchange_rate: e.target.value })} />
-
- - setForm({ ...form, recipient: e.target.value })} /> +
+ +
+ + +
-
- - setForm({ ...form, manager_name: e.target.value })} /> -
-
- - setForm({ ...form, manager_contact: e.target.value })} /> -
-
- - setForm({ ...form, model_name: e.target.value })} /> -
-
- - setForm({ ...form, model_code: e.target.value })} /> -
-
-
- -