// ============================================================
// 영업관리 > 견적관리 (wace_plm 도메인 이식)
// 메인 테이블: estimate_template + estimate_template_item
// 헤더 컨텍스트: 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 { sendMailUTF8 } from "../utils/mailUtil";
import { AppError } from "../middleware/errorHandler";
import * as amaranth from "./amaranthApprovalClient";
import { generateContractNo, assertNoProjectExists } from "./salesOrderMgmtService";

// ─── 타입 ─────────────────────────────────────────────────────

export interface EstimateListFilter {
  category_cd?: string;
  customer_objid?: string;
  search_partObjId?: string;
  search_partNo?: string;
  search_partName?: string;
  search_serialNo?: string;
  appr_status?: string;
  receipt_start_date?: string;
  receipt_end_date?: string;
  product?: string;
  area_cd?: string;
  paid_type?: string;
  due_start_date?: string;
  due_end_date?: string;
}

// wace 견적요청등록(estimateRegistFormPopup.jsp) 폼 — 헤더 8 + 라인 8
// 원본: contractMgmt.xml#saveContractMgmtInfo, insertContractItem, insertContractItemSerial
// 견적 템플릿(estimate_template)은 견적**작성**(saveEstimateTemplate / G5) 단계 데이터 — 견적요청등록과 분리.

export interface EstimateBody {
  // 신규: contract_no 자동 채번. 수정: objid는 path param.
  contract_no?: 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;
  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;  // 고객요청사항
}

// ─── 목록 ─────────────────────────────────────────────────────
// wace_plm contractMgmt.xml#estimateGridList + contractBase 를 단순화하여 이식.
// 차이점:
//   - 거래처 마스터: wace_plm SUPPLY_MNG/CLIENT_MNG → vexplor_rps customer_mng
//     (contract_mgmt.customer_objid에 customer_mng.id::text 또는 'C_'prefix 형식 모두 수용)
//   - 사용자 마스터: wace_plm USER_INFO → vexplor_rps user_info (동일)
//   - 품목 마스터: wace_plm PART_MNG → vexplor_rps item_info (id varchar)
//   - CODE_NAME(...) 함수: 없으면 NULL 반환되는 형태로 LEFT JOIN 또는 raw 코드 노출
//   - 결재(APPROVAL/AMARANTH_APPROVAL) / 첨부(ATTACH_FILE_INFO) / 메일(MAIL_LOG) 집계:
//     RPS에서 별도 모듈 결정 후 추가 (1차에서는 NULL/0)

export async function getList(filter: EstimateListFilter) {
  const pool = getPool();
  const conditions: string[] = [];
  const params: any[] = [];
  let idx = 1;

  if (filter.customer_objid) {
    conditions.push(`T.CUSTOMER_OBJID = $${idx++}`);
    params.push(filter.customer_objid);
  }
  if (filter.category_cd) {
    conditions.push(`T.CATEGORY_CD = $${idx++}`);
    params.push(filter.category_cd);
  }
  if (filter.area_cd) {
    conditions.push(`T.AREA_CD = $${idx++}`);
    params.push(filter.area_cd);
  }
  if (filter.paid_type) {
    conditions.push(`T.PAID_TYPE = $${idx++}`);
    params.push(filter.paid_type);
  }
  if (filter.product) {
    conditions.push(`T.PRODUCT = $${idx++}`);
    params.push(filter.product);
  }
  if (filter.search_partObjId) {
    conditions.push(`EXISTS (
      SELECT 1 FROM contract_item CI
      WHERE CI.contract_objid = T.OBJID
        AND CI.status = 'ACTIVE'
        AND CI.part_objid = $${idx++}
    )`);
    params.push(filter.search_partObjId);
  }
  if (filter.search_partNo) {
    conditions.push(`EXISTS (
      SELECT 1 FROM contract_item CI
      WHERE CI.contract_objid = T.OBJID
        AND CI.status = 'ACTIVE'
        AND UPPER(CI.part_no) LIKE UPPER($${idx++})
    )`);
    params.push(`%${filter.search_partNo}%`);
  }
  if (filter.search_partName) {
    conditions.push(`EXISTS (
      SELECT 1 FROM contract_item CI
      WHERE CI.contract_objid = T.OBJID
        AND CI.status = 'ACTIVE'
        AND UPPER(CI.part_name) LIKE UPPER($${idx++})
    )`);
    params.push(`%${filter.search_partName}%`);
  }
  if (filter.search_serialNo) {
    conditions.push(`EXISTS (
      SELECT 1 FROM contract_item CI
      JOIN contract_item_serial CIS ON CI.objid = CIS.item_objid AND CIS.status = 'ACTIVE'
      WHERE CI.contract_objid = T.OBJID
        AND CI.status = 'ACTIVE'
        AND UPPER(CIS.serial_no) LIKE UPPER($${idx++})
    )`);
    params.push(`%${filter.search_serialNo}%`);
  }
  if (filter.receipt_start_date) {
    conditions.push(`T.RECEIPT_DATE >= $${idx++}`);
    params.push(filter.receipt_start_date);
  }
  if (filter.receipt_end_date) {
    conditions.push(`T.RECEIPT_DATE <= $${idx++}`);
    params.push(filter.receipt_end_date);
  }
  if (filter.due_start_date) {
    conditions.push(`(SELECT MIN(CI.due_date) FROM contract_item CI
                      WHERE CI.contract_objid = T.OBJID AND CI.status='ACTIVE'
                        AND CI.due_date IS NOT NULL AND CI.due_date != '') >= $${idx++}`);
    params.push(filter.due_start_date);
  }
  if (filter.due_end_date) {
    conditions.push(`(SELECT MIN(CI.due_date) FROM contract_item CI
                      WHERE CI.contract_objid = T.OBJID AND CI.status='ACTIVE'
                        AND CI.due_date IS NOT NULL AND CI.due_date != '') <= $${idx++}`);
    params.push(filter.due_end_date);
  }
  // 견적관리는 통합 직발(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++}`);
    params.push(filter.appr_status);
  }

  const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";

  const sql = `
    SELECT
       T.OBJID
      ,T.CONTRACT_NO
      ,T.CATEGORY_CD
      ,CC_CAT.code_name        AS CATEGORY_NAME
      ,T.CUSTOMER_OBJID
      ,C.customer_name        AS CUSTOMER_NAME
      ,C.customer_type        AS CUSTOMER_TYPE
      ,T.PRODUCT
      ,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
      ,CASE
         WHEN T.PAID_TYPE='paid' THEN '유상'
         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
      ,T.RECEIPT_DATE
      ,T.REGDATE
      ,T.WRITER
      ,U_WR.user_name         AS WRITER_NAME
      ,T.PM_USER_ID
      ,U_PM.user_name         AS PM_USER_NAME
      /* 최신 견적 템플릿 1건 */
      ,ET.objid               AS EST_OBJID
      ,ET.estimate_no
      ,ET.template_type
      ,ET.total_amount        AS EST_TOTAL_AMOUNT
      /* 원화환산공급가액 = total_amount * exchange_rate (둘 다 숫자형 문자열일 때만) */
      ,CASE
         WHEN T.EXCHANGE_RATE IS NOT NULL AND T.EXCHANGE_RATE != ''
              AND REPLACE(T.EXCHANGE_RATE, ',', '') ~ '^[0-9]+\\.{0,1}[0-9]*$'
              AND CAST(REPLACE(T.EXCHANGE_RATE, ',', '') AS NUMERIC) != 0
         THEN ROUND(
                CAST(REPLACE(COALESCE(ET.total_amount, '0'), ',', '') AS NUMERIC)
                * CAST(REPLACE(T.EXCHANGE_RATE, ',', '') AS NUMERIC), 2)
         ELSE CAST(REPLACE(COALESCE(ET.total_amount_krw, '0'), ',', '') AS NUMERIC)
       END                    AS EST_TOTAL_AMOUNT_KRW
      ,ET.manager_name
      ,ET.manager_contact
      /* 견적 라인 수량 합계 */
      ,COALESCE(ETI.estimate_quantity, 0) AS ESTIMATE_QUANTITY
      /* 견적 건수 (= 견적현황) */
      ,COALESCE(ET_CNT.est_status, 0)     AS EST_STATUS
      /* 품목 요약 (CONTRACT_ITEM 집계) */
      ,CASE
         WHEN COALESCE(CI_AGG.item_count, 0) = 1 THEN CI_AGG.first_part_name
         WHEN COALESCE(CI_AGG.item_count, 0) > 1 THEN CI_AGG.first_part_name || ' 외 ' || (CI_AGG.item_count - 1) || '건'
         ELSE ''
       END                    AS ITEM_SUMMARY
      /* 품번 요약 */
      ,CASE
         WHEN COALESCE(CI_AGG.item_count, 0) = 1 THEN CI_AGG.first_part_no
         WHEN COALESCE(CI_AGG.item_count, 0) > 1 THEN CI_AGG.first_part_no || ' 외 ' || (CI_AGG.item_count - 1) || '건'
         ELSE T.PART_NO
       END                    AS PART_NO
      /* S/N 요약 (CONTRACT_ITEM_SERIAL 집계) */
      ,CASE
         WHEN COALESCE(SER_AGG.serial_count, 0) = 1 THEN SER_AGG.first_serial_no
         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
      ,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
      /* 결재상태 (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
      ,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
    FROM contract_mgmt T
    /* 거래처 (vexplor_rps customer_mng — id::text와 매칭. 'C_' prefix는 substr로 처리) */
    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
    /* 작성자/PM */
    LEFT JOIN user_info U_WR ON U_WR.user_id = T.WRITER
    LEFT JOIN user_info U_PM ON U_PM.user_id = T.PM_USER_ID
    /* 공통코드 한글명 (wace 도입한 comm_code lookup) */
    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_CUR  ON CC_CUR.code_id  = T.CONTRACT_CURRENCY AND CC_CUR.status='active'
    /* 최신 견적 템플릿 (CONTRACT_OBJID당 1건, 최신 regdate) */
    LEFT JOIN LATERAL (
      SELECT objid, estimate_no, template_type, total_amount, total_amount_krw,
             manager_name, manager_contact, regdate
      FROM estimate_template
      WHERE contract_objid = T.OBJID
      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
      FROM estimate_template_item
      WHERE template_objid = ET.objid
    ) ETI ON true
    /* 견적 건수 */
    LEFT JOIN (
      SELECT contract_objid, COUNT(*) AS est_status
      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(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,
        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 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
    /* 시리얼 집계 */
    LEFT JOIN (
      SELECT CI.contract_objid,
             (array_agg(CIS.serial_no ORDER BY CI.seq, CIS.seq))[1] AS first_serial_no,
             COUNT(*) AS serial_count
      FROM contract_item CI
      JOIN contract_item_serial CIS ON CI.objid = CIS.item_objid AND CIS.status = 'ACTIVE'
      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
    /* 메일 발송 (mail_log title 안에 [OBJID:nn] 포함된 항목 중 최신) */
    LEFT JOIN LATERAL (
      SELECT is_send AS mail_send_status,
             TO_CHAR(log_time, 'YYYY-MM-DD HH24:MI') AS mail_send_date
      FROM mail_log
      WHERE mail_type = 'CONTRACT_ESTIMATE'
        AND title LIKE '%[OBJID:' || T.OBJID || ']%'
      ORDER BY log_time DESC
      LIMIT 1
    ) ML ON true
    /* 첨부 카운트 (추가견적 estimate02 / 주문서 FTC_ORDER·ORDER) — wace 패턴 */
    LEFT JOIN (
      SELECT target_objid,
             COUNT(*) FILTER (WHERE doc_type = 'estimate02')          AS add_est_cnt,
             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.OBJID
    ${where}
    ORDER BY T.regdate DESC
  `;

  const res = await pool.query(sql, params);
  logger.info("견적 목록 조회", { count: res.rowCount, filter });
  return res.rows;
}

// ─── 단건 ─────────────────────────────────────────────────────

// 견적요청등록의 단건 조회 — 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 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 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],
  );

  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 };
}

// ─── 채번 ─────────────────────────────────────────────────────
// 견적요청등록은 contract_mgmt 새 행을 만든다 → 기존 generateContractNo 재사용 (SO-YYYYMMDD-NNN).
// (wace 운영 26C-XXXX 형식은 별도 채번 룰 — 추후 통일 시 변경)

export async function generateNumber(): Promise<string> {
  return generateContractNo();
}

// 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 견적요청등록은 라인을 통째로 다시 받음) ──

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],
  );

  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 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::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`,
      [
        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,
      ],
    );

    // 시리얼 재구성: 라인의 모든 시리얼 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 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("견적요청 등록", { contractObjid, contractNo, items: (body.items ?? []).length });
    return { objid: contractObjid, contract_no: contractNo };
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

// ─── 수정 ─────────────────────────────────────────────────────

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 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.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, objid, body.items ?? [], userId);

    await client.query("COMMIT");
    logger.info("견적요청 수정", { objid, items: (body.items ?? []).length });
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

// ─── 메일 발송 (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;
  toEmails: string;          // ; 또는 , 로 구분
  ccEmails?: string;
  subject: string;
  contents: string;
  /** 프론트에서 hidden iframe으로 template1/2 페이지 렌더 후 html2canvas+jsPDF로 생성한 base64 (data URL 또는 raw base64) */
  pdfBase64?: string;
  /** 'Y'면 견적 PDF 생성 스킵하고 estimate02만 첨부 */
  useAddEstOnly?: "Y" | "N";
}

// ─── 메일 발송 helper SQL ─────────────────────────────────────

/** wace getContractInfoForMail — 메일 다이얼로그 자동채움용 (제목/수신/참조) */
export async function getContractInfoForMail(contractObjid: string) {
  const pool = getPool();
  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;
}

/** 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;
}

// ─── 메일 발송 본체 ───────────────────────────────────────────

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 → <br> 변환)
  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",
  });

  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<Buffer> {
  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은 줄바꿈으로 보이게 <br> 변환)
  const escaped = text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
  return `<html><body><div style="font-family:'Malgun Gothic','맑은 고딕',Arial,sans-serif;font-size:13px;line-height:1.6;white-space:pre-wrap;">${escaped.replace(/\n/g, "<br>")}</div></body></html>`;
}

// ─── 삭제 ─────────────────────────────────────────────────────
// 시리얼 → 라인 → 헤더(contract_mgmt) 순.
// 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)`,
      [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 });
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    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<boolean> {
  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<any>;
          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 };
}

// ─── 결재상신 ────────────────────────────────────────────────
//
// 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();
  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;
}
