// ═══════════════════════════════════════════════════════════════════════════
// ONE-PAGE · ตัวอ่าน "ไฟล์จริงของแต่ละฝ่าย" (วิศวะ / เซอร์วิส / ฝ่ายขาย)
//
// ทำไมไม่ทำเป็นเทมเพลตให้กรอก: ทั้ง 3 ฝ่ายมีไฟล์ที่อัปเดตอยู่แล้ว และตัวเลข
// ในไฟล์ตรงกับ One-Page แทบทุกช่อง (ตรวจกับไฟล์จริง ส.ค. 2026 แล้ว)
//
// ★★ หัวใจ: "แผนที่คอลัมน์" (map) เก็บใน DB ไม่ได้ฝังในโค้ด
//    → วันที่เขาเปลี่ยนชื่อคอลัมน์/ชีต ผู้ใช้แก้เองในเว็บได้ ไม่ต้องแก้โค้ด
//    → จับคอลัมน์ไม่ได้ = หยุด ไม่บันทึกครึ่ง ๆ กลาง ๆ (ห้ามเดาเงียบ)
//
// ทุก identifier ขึ้นต้น OPSRC/opsrc กัน global collision (in-browser Babel)
// ═══════════════════════════════════════════════════════════════════════════
(function () {

  /* ── utils ───────────────────────────────────────────────────────────── */
  function opsrcNum(v) {
    if (v == null || v === '') return null;
    if (typeof v === 'number') return isFinite(v) ? v : null;
    const n = Number(String(v).replace(/[,\s฿]/g, ''));
    return isNaN(n) ? null : n;
  }
  function opsrcTxt(v) { return v == null ? '' : String(v).trim(); }
  function opsrcNorm(s) { return String(s == null ? '' : s).toLowerCase().replace(/[\s._\-()]/g, ''); }
  function opsrcIsDate(v) { return v instanceof Date && !isNaN(v.getTime()); }

  // อ่านชีตเป็น AoA (ค่าดิบ ไม่แปลงเป็นข้อความ เพื่อให้วันที่/ตัวเลขคงชนิด)
  function opsrcAoa(ws) {
    if (!ws || !window.XLSX) return [];
    return window.XLSX.utils.sheet_to_json(ws, { header: 1, defval: null, raw: true, blankrows: true }) || [];
  }

  /* ── นิยามแหล่งข้อมูล 3 ฝ่าย ──────────────────────────────────────────
     def  = ชื่อคอลัมน์ที่ "เจอในไฟล์ปัจจุบัน" ใช้เป็นค่าเดาตั้งต้นเท่านั้น
     req  = จับไม่ได้แล้วต้องหยุด                                           */
  const OPSRC_DEFS = {
    sales: {
      key: 'sales', label: 'ฝ่ายขาย', icon: '📈',
      fileHint: 'โครงการที่กำลังดำเนินการ SALE.xlsx',
      what: 'Backlog โครงการสำรอง + งบที่คาดว่าจะได้รับ',
      sheets: [{ key: 'main', label: 'ชีตรายโครงการ', def: 'Sheet1', hint: '1 แถว = 1 โครงการที่เสนอ' }],
      fields: [
        { k: 'company', sheet: 'main', label: 'บริษัท', def: 'บริษัท', req: true, type: 'text' },
        { k: 'ptype', sheet: 'main', label: 'ประเภทผลิตภัณฑ์', def: 'type', req: true, type: 'text' },
        { k: 'amount', sheet: 'main', label: 'มูลค่า (บาท)', def: 'มูลค่าผลิตภัณฑ์', req: true, type: 'num' },
        { k: 'budget', sheet: 'main', label: 'ประเภทงบประมาณ', def: 'ประเภทงบประมาณ', req: true, type: 'text' },
        { k: 'fy', sheet: 'main', label: 'ปีงบประมาณ', def: 'ปีงบประมาณ', req: true, type: 'text' },
      ],
    },
    service: {
      key: 'service', label: 'ฝ่ายเซอร์วิส', icon: '🔧',
      fileHint: 'After Sales Service.xlsx',
      what: 'โครงการในระบบ · จังหวัด · เปิด/ปิดใช้งาน · หมดประกัน',
      sheets: [{ key: 'main', label: 'ชีตโครงการทั้งหมด', def: 'โครงการทั้งหมด', hint: '1 แถว = 1 โครงการที่ติดตั้งแล้ว' }],
      fields: [
        { k: 'contract', sheet: 'main', label: 'เลขที่สัญญา', def: 'Contract No', req: true, type: 'text' },
        { k: 'province', sheet: 'main', label: 'จังหวัด', def: 'จังหวัด', req: true, type: 'text' },
        { k: 'active', sheet: 'main', label: 'สถานะเปิด/ปิดใช้งาน', def: 'สถานะ', req: true, type: 'text' },
        { k: 'warranty', sheet: 'main', label: 'สถานะประกัน', def: 'Status', req: false, type: 'text' },
        { k: 'warrantyEnd', sheet: 'main', label: 'วันที่หมดระยะประกัน', def: 'วันที่หมดระยะประกัน', req: false, type: 'date' },
      ],
    },
    eng: {
      key: 'eng', label: 'ฝ่ายวิศวะ', icon: '🏗',
      fileHint: 'Project Control 67-68-69-xxxxx.xlsx',
      what: 'ลงนามรายบริษัท · มูลค่าสัญญา · ต้นทุน · กำไรขั้นต้น · ส่งมอบ',
      perYear: true,   // ชีตแยกตามปีงบ — ชื่อชีตลงท้ายด้วยเลขปี 2 หลัก
      sheets: [
        { key: 'mainall', label: 'ชีตทะเบียนโครงการ (Main all)', def: 'Main all{YY}', hint: 'ใช้หา "งานขาย" รายบริษัท' },
        { key: 'pc', label: 'ชีตต้นทุน (Project Control)', def: 'Project Control-{YY}', hint: 'ใช้หา COST / Gross Profit' },
        { key: 'summary', label: 'ชีตสรุป (Summary)', def: 'Summary{YY}', hint: 'ใช้หา "มูลค่าสัญญาที่ส่งมอบงานแล้ว"' },
      ],
      fields: [
        { k: 'contractNo', sheet: 'mainall', label: 'เลขที่สัญญา', def: 'Contract No.', req: true, type: 'text' },
        { k: 'salesCo', sheet: 'mainall', label: 'บริษัทผู้ขาย', def: 'งานขาย', req: true, type: 'text' },
        { k: 'signedAmt', sheet: 'mainall', label: 'มูลค่าสัญญาที่เซ็น (รวม VAT)', def: 'มูลค่าสัญญาที่เซ็น (รวมVAT)', req: true, type: 'num' },
        { k: 'cancelled', sheet: 'mainall', label: 'ยกเลิกโครงการ', def: 'ยกเลิกโครงการ', req: false, type: 'text' },
        { k: 'pcContractNo', sheet: 'pc', label: 'เลขที่สัญญา (ในชีตต้นทุน)', def: 'Contract No.', req: true, type: 'text' },
        { k: 'pcContract', sheet: 'pc', label: 'มูลค่าสัญญา (Contract Amount)', def: 'Contract Amount', req: true, type: 'num' },
        { k: 'pcCost', sheet: 'pc', label: 'ต้นทุนรวม (COST)', def: 'COST', req: true, type: 'num' },
        { k: 'pcGross', sheet: 'pc', label: 'กำไรขั้นต้น (Gross Profit)', def: 'Gross Profit', req: true, type: 'num' },
        { k: 'pcReceived', sheet: 'pc', label: 'รับเงินแล้ว (Received)', def: 'Received', req: false, type: 'num' },
      ],
      // ชีต Summary เป็นตาราง "ป้าย | ค่า" ไม่ใช่หัวคอลัมน์ → จับด้วยข้อความในคอลัมน์แรก
      kvRows: [
        { k: 'delivered', sheet: 'summary', label: 'มูลค่าสัญญาที่ส่งมอบงานแล้ว', def: 'มูลค่าสัญญาที่ส่งมอบงานแล้ว', req: true, unit: 'ลบ.' },
        { k: 'signed', sheet: 'summary', label: 'มูลค่าสัญญาที่ลงนามแล้ว', def: 'มูลค่าสัญญาที่ลงนามแล้ว', req: false, unit: 'ลบ.' },
      ],
    },
  };

  /* ── เดาว่าไฟล์ที่อัปเป็นของฝ่ายไหน ─────────────────────────────────── */
  function opsrcDetect(wb) {
    const names = (wb.SheetNames || []).map(opsrcNorm);
    const has = t => names.some(n => n.indexOf(t) >= 0);
    if (has('mainall') || has('projectcontrol')) return 'eng';
    if (has('โครงการทั้งหมด') || names.some(n => n.indexOf('aftersales') >= 0)) return 'service';
    // ฝ่ายขาย: ชีตธรรมดา → ดูจากหัวคอลัมน์
    for (const sn of (wb.SheetNames || [])) {
      const aoa = opsrcAoa(wb.Sheets[sn]);
      for (let i = 0; i < Math.min(aoa.length, 12); i++) {
        const row = (aoa[i] || []).map(opsrcNorm);
        if (row.indexOf('มูลค่าผลิตภัณฑ์') >= 0 || row.indexOf('ประเภทงบประมาณ') >= 0) return 'sales';
      }
    }
    return null;
  }

  /* ── หาแถวหัวตาราง + ตำแหน่งคอลัมน์ตามแผนที่ ────────────────────────
     ไม่ยึดตำแหน่ง: สแกนหาแถวที่มีชื่อคอลัมน์ที่ต้องการมากที่สุด          */
  function opsrcFindHeader(aoa, wanted) {
    const want = wanted.map(opsrcNorm).filter(Boolean);
    let best = -1, bestHit = 0;
    for (let i = 0; i < Math.min(aoa.length, 30); i++) {
      const row = (aoa[i] || []).map(opsrcNorm);
      let hit = 0;
      want.forEach(w => { if (w && row.indexOf(w) >= 0) hit++; });
      if (hit > bestHit) { bestHit = hit; best = i; }
    }
    return { row: best, hit: bestHit };
  }
  function opsrcHeaderList(aoa, hi) {
    return (aoa[hi] || []).map(c => opsrcTxt(c).replace(/\n/g, ' '));
  }

  // เลือกชีตตามแผนที่ (ชื่อเป๊ะ → ชื่อใกล้เคียง → null)
  function opsrcPickSheet(wb, want) {
    if (!want) return null;
    if (wb.Sheets[want]) return want;
    const w = opsrcNorm(want);
    const names = wb.SheetNames || [];
    let hit = names.find(sn => opsrcNorm(sn) === w);
    if (hit) return hit;
    // ชื่อชีตในไฟล์ "ครอบ" ชื่อที่ต้องการ (เช่น want 'mainall68' → ชีต 'Main all68 (ใหม่)')
    hit = names.find(sn => opsrcNorm(sn).indexOf(w) >= 0);
    if (hit) return hit;
    // ★ ทางกลับ (ชื่อชีตสั้นกว่าที่ต้องการ) ต้องยาวพอ ไม่งั้นชีตชื่อ "MA" จะไปแมตช์
    //   "Main all67" ได้ (mainall67 ขึ้นต้นด้วย ma) แล้วอ่านผิดชีตแบบเงียบ ๆ
    const cand = names.filter(sn => {
      const n = opsrcNorm(sn);
      return n.length >= 5 && w.indexOf(n) >= 0;
    }).sort((a, b) => opsrcNorm(b).length - opsrcNorm(a).length);
    return cand[0] || null;
  }

  /* ── plan: ดูว่าไฟล์นี้จับคู่ได้ครบไหม (ก่อนบันทึกจริง) ──────────────
     คืน { ok, sheets, headers, cols, missing[], notes[] }                 */
  function opsrcPlan(wb, srcKey, map, ctx) {
    const D = OPSRC_DEFS[srcKey];
    if (!D) return { ok: false, missing: [{ label: 'ไม่รู้จักแหล่งข้อมูล' }] };
    map = map || {};
    let years = (D.perYear ? ((ctx && ctx.years) || []) : [null]);
    const skipped = [];
    if (D.perYear && years.length) {
      // ★ ปีงบที่ไฟล์นี้ไม่มีชีตของมันเลยสักใบ = ไฟล์คนละปี → ข้ามปีนั้นไป
      //   (ไม่ใช่ error ของไฟล์) แล้วบอกให้รู้ว่าอ่านปีไหนบ้าง
      // ต้องเจอชีต "ครบทุกใบ" ของปีนั้น ถึงจะอ่านปีนั้นได้
      //   เจอไม่ครบ (เช่นมี Main all70 แต่ยังไม่มี Project Control-70) = ข้ามปีนั้น
      //   ไม่ใช่บล็อกทั้งไฟล์ — ปีที่เหลือยังอ่านได้ตามปกติ
      const keep = years.filter(yy => D.sheets.every(sd => {
        const want = (map.sheets && map.sheets[sd.key + ':' + yy]) || sd.def.replace('{YY}', yy);
        return !!opsrcPickSheet(wb, want);
      }));
      years.forEach(yy => { if (keep.indexOf(yy) < 0) skipped.push(yy); });
      years = keep;
    }
    const out = { srcKey: srcKey, ok: true, sheets: {}, headers: {}, cols: {}, missing: [], notes: [], years: years, skippedYears: skipped };
    if (D.perYear && !years.length) {
      out.ok = false;
      out.missing.push({ kind: 'year', label: 'ไฟล์นี้ไม่มีชีตครบของปีงบที่เลือกเลยสักปี (' + (ctx && ctx.years || []).map(y => '25' + y).join(', ') + ') — น่าจะเป็นไฟล์คนละปี' });
      return out;
    }

    D.sheets.forEach(sd => {
      years.forEach(yy => {
        const slot = yy == null ? sd.key : (sd.key + ':' + yy);
        const wantRaw = (map.sheets && map.sheets[slot]) || (yy == null ? sd.def : sd.def.replace('{YY}', yy));
        const found = opsrcPickSheet(wb, wantRaw);
        out.sheets[slot] = found;
        if (!found) {
          out.ok = false;
          out.missing.push({ kind: 'sheet', slot: slot, label: sd.label + (yy != null ? ' ปี ' + yy : ''), want: wantRaw });
          return;
        }
        const aoa = opsrcAoa(wb.Sheets[found]);
        const fieldsHere = (D.fields || []).filter(f => f.sheet === sd.key);
        if (fieldsHere.length) {
          const wanted = fieldsHere.map(f => (map.cols && map.cols[f.k]) || f.def);
          const hd = opsrcFindHeader(aoa, wanted);
          if (hd.row < 0) {
            out.ok = false;
            out.missing.push({ kind: 'header', slot: slot, label: 'หาแถวหัวตารางในชีต "' + found + '" ไม่เจอ' });
            return;
          }
          const heads = opsrcHeaderList(aoa, hd.row);
          out.headers[slot] = heads;
          const nmap = heads.map(opsrcNorm);
          fieldsHere.forEach(f => {
            const want = (map.cols && map.cols[f.k]) || f.def;
            let ci = nmap.indexOf(opsrcNorm(want));
            if (ci < 0) ci = nmap.findIndex(h => h && (h.indexOf(opsrcNorm(want)) >= 0 || opsrcNorm(want).indexOf(h) >= 0));
            out.cols[f.k + (yy != null ? ':' + yy : '')] = { sheet: found, headerRow: hd.row, col: ci, want: want };
            if (ci < 0 && f.req) {
              out.ok = false;
              out.missing.push({ kind: 'col', field: f.k, slot: slot, label: f.label + (yy != null ? ' (ปี ' + yy + ')' : ''), want: want });
            }
          });
        }
        // ชีตแบบ "ป้าย | ค่า"
        (D.kvRows || []).filter(r => r.sheet === sd.key).forEach(r => {
          const want = (map.cols && map.cols[r.k]) || r.def;
          let found2 = -1;
          for (let i = 0; i < Math.min(aoa.length, 40); i++) {
            const lab = opsrcNorm((aoa[i] || [])[0]);
            if (lab && (lab === opsrcNorm(want) || lab.indexOf(opsrcNorm(want)) >= 0)) { found2 = i; break; }
          }
          out.cols[r.k + (yy != null ? ':' + yy : '')] = { sheet: found, row: found2, want: want };
          if (found2 < 0 && r.req) {
            out.ok = false;
            out.missing.push({ kind: 'kv', field: r.k, slot: slot, label: r.label + (yy != null ? ' (ปี ' + yy + ')' : ''), want: want });
          }
        });
      });
    });
    return out;
  }

  /* ── อ่านแถวข้อมูลของชีตหนึ่ง ตาม plan ───────────────────────────── */
  function opsrcRows(wb, plan, srcKey, sheetKey, yy) {
    const D = OPSRC_DEFS[srcKey];
    const slot = yy == null ? sheetKey : (sheetKey + ':' + yy);
    const sn = plan.sheets[slot];
    if (!sn) return [];
    const aoa = opsrcAoa(wb.Sheets[sn]);
    const fieldsHere = (D.fields || []).filter(f => f.sheet === sheetKey);
    if (!fieldsHere.length) return [];
    const anyCol = plan.cols[fieldsHere[0].k + (yy != null ? ':' + yy : '')];
    if (!anyCol || anyCol.headerRow == null) return [];
    const out = [];
    for (let i = anyCol.headerRow + 1; i < aoa.length; i++) {
      const raw = aoa[i] || [];
      const o = {};
      let any = false;
      fieldsHere.forEach(f => {
        const c = plan.cols[f.k + (yy != null ? ':' + yy : '')];
        const v = (c && c.col >= 0) ? raw[c.col] : null;
        if (f.type === 'num') o[f.k] = opsrcNum(v);
        else if (f.type === 'date') o[f.k] = opsrcIsDate(v) ? v : null;
        else o[f.k] = opsrcTxt(v);
        if (o[f.k] != null && o[f.k] !== '') any = true;
      });
      if (any) out.push(o);
    }
    return out;
  }
  function opsrcKv(wb, plan, key, yy) {
    const c = plan.cols[key + (yy != null ? ':' + yy : '')];
    if (!c || c.row == null || c.row < 0) return null;
    const aoa = opsrcAoa(wb.Sheets[c.sheet]);
    const row = aoa[c.row] || [];
    for (let i = 1; i < row.length; i++) {
      const n = opsrcNum(row[i]);
      if (n != null) return n;
    }
    return null;
  }

  /* ── extract: จากไฟล์ → payload ที่หน้า One-Page ใช้ ────────────────── */
  const OPSRC_BACKLOG_FY_FROM = 2571;   // ปีงบตั้งแต่นี้ = Backlog (สต๊อก) · ต่ำกว่า = งบที่คาดว่าจะได้รับ

  function opsrcGroup(rows, keyOf, amtOf) {
    const g = {};
    rows.forEach(r => {
      const k = keyOf(r); if (!k) return;
      if (!g[k]) g[k] = { key: k, projects: 0, amountM: 0 };
      g[k].projects++;
      g[k].amountM += (amtOf(r) || 0) / 1e6;
    });
    return Object.keys(g).map(k => g[k]).sort((a, b) => b.amountM - a.amountM);
  }

  function opsrcExtract(wb, srcKey, plan, ctx) {
    ctx = ctx || {};
    if (srcKey === 'sales') {
      const rows = opsrcRows(wb, plan, 'sales', 'main', null).filter(r => r.company);
      const fyFrom = ctx.backlogFyFrom || OPSRC_BACKLOG_FY_FROM;
      const fyNum = r => { const n = opsrcNum(String(r.fy).replace(/[^\d]/g, '')); return n || 0; };
      const backlog = rows.filter(r => fyNum(r) >= fyFrom);
      const budget = rows.filter(r => fyNum(r) > 0 && fyNum(r) < fyFrom);
      const budRows = [];
      const bg = {};
      budget.forEach(r => {
        const fy = String(fyNum(r)), src = r.budget || '(ไม่ระบุแหล่งงบ)';
        const k = fy + '|' + src;
        if (!bg[k]) bg[k] = { fy: fy, source: src, projects: 0, amountM: 0 };
        bg[k].projects++; bg[k].amountM += (r.amount || 0) / 1e6;
      });
      const fyCount = {};
      budget.forEach(r => { const fy = String(fyNum(r)); fyCount[fy] = (fyCount[fy] || 0) + 1; });
      Object.keys(bg).sort().reverse().forEach(k => {
        const b = bg[k];
        budRows.push({ fy: b.fy, projects: fyCount[b.fy] || b.projects, source: b.source, amountM: Math.round(b.amountM * 100) / 100 });
      });
      return {
        payload: {
          backlogByCompany: opsrcGroup(backlog, r => r.company, r => r.amount).map(g => ({ company: g.key, projects: g.projects, amountM: Math.round(g.amountM * 100) / 100 })),
          backlogByType: opsrcGroup(backlog, r => r.ptype, r => r.amount).map(g => ({ type: g.key, projects: g.projects, amountM: Math.round(g.amountM * 100) / 100 })),
          budget: budRows,
        },
        stats: { rows: rows.length, backlogN: backlog.length, budgetN: budget.length,
          backlogM: backlog.reduce((s, r) => s + (r.amount || 0), 0) / 1e6,
          budgetM: budget.reduce((s, r) => s + (r.amount || 0), 0) / 1e6, fyFrom: fyFrom },
      };
    }

    if (srcKey === 'service') {
      const rows = opsrcRows(wb, plan, 'service', 'main', null).filter(r => r.contract || r.province);
      const prov = new Set(); rows.forEach(r => { if (r.province) prov.add(r.province.replace(/^จ\.?\s*/, '').trim()); });
      // ★ กับดักภาษาไทย: "เปิดใช้งาน" มีคำว่า "ปิด" อยู่ข้างใน (เ+ปิด)
      //   และ "inactive" มีคำว่า "active" อยู่ข้างใน → ต้องยึดจาก "ต้นข้อความ" เท่านั้น
      let on = 0, off = 0, unknownStatus = 0;
      rows.forEach(r => {
        const t = (r.active || '').trim();
        if (/^เปิด/.test(t) || /^active/i.test(t)) on++;
        else if (/^ปิด/.test(t) || /^in\s*active/i.test(t)) off++;
        else if (t) unknownStatus++;
      });
      // หมดประกัน: ใช้คอลัมน์สถานะประกันก่อน · ไม่มีค่อยคำนวณจากวันหมดประกัน
      let expired = 0, hasWarrantyCol = false;
      rows.forEach(r => { if (r.warranty) hasWarrantyCol = true; });
      if (hasWarrantyCol) rows.forEach(r => { if (/หมดประกัน|expired/i.test(r.warranty || '')) expired++; });
      else {
        const asOf = ctx.asOf ? new Date(ctx.asOf + 'T23:59:59') : new Date();
        rows.forEach(r => { if (opsrcIsDate(r.warrantyEnd) && r.warrantyEnd <= asOf) expired++; });
      }
      return {
        payload: { inSystem: rows.length, provinces: prov.size, active: on, inactive: off, warrantyExpired: expired },
        stats: { rows: rows.length, provinces: prov.size, active: on, inactive: off, warrantyExpired: expired,
          unknownStatus: unknownStatus,
          warrantyFrom: hasWarrantyCol ? 'คอลัมน์สถานะประกัน' : 'คำนวณจากวันที่หมดระยะประกัน' },
      };
    }

    if (srcKey === 'eng') {
      const years = plan.years || [];
      const byCo = {};
      let contractM = 0, costM = 0, grossM = 0, receivedM = 0, deliveredM = 0, signedM = 0;
      let mainN = 0, pcN = 0;
      years.forEach(yy => {
        opsrcRows(wb, plan, 'eng', 'mainall', yy)
          .filter(r => r.contractNo && !opsrcTxt(r.cancelled) && (opsrcTxt(r.salesCo) || r.signedAmt))
          .forEach(r => {
            mainN++;
            const co = r.salesCo || '(ไม่ระบุบริษัท)';
            if (!byCo[co]) byCo[co] = { company: co, projects: 0, amountM: 0 };
            byCo[co].projects++; byCo[co].amountM += (r.signedAmt || 0) / 1e6;
          });
        // ★ ต้องมีเลขที่สัญญา — ไม่งั้นจะนับ "แถวรวม" ท้ายตารางซ้ำ ทำให้ยอดเบิ้ล 2 เท่า
        opsrcRows(wb, plan, 'eng', 'pc', yy)
          .filter(r => opsrcTxt(r.pcContractNo))
          .forEach(r => {
            pcN++;
            contractM += (r.pcContract || 0) / 1e6;
            costM += (r.pcCost || 0) / 1e6;
            grossM += (r.pcGross || 0) / 1e6;
            receivedM += (r.pcReceived || 0) / 1e6;
          });
        const d = opsrcKv(wb, plan, 'delivered', yy); if (d != null) deliveredM += d;
        const s = opsrcKv(wb, plan, 'signed', yy); if (s != null) signedM += s;
      });
      const r2 = n => Math.round(n * 100) / 100;
      const signedRows = Object.keys(byCo).map(k => byCo[k])
        .map(g => ({ company: g.company, projects: g.projects, amountM: r2(g.amountM) }))
        .sort((a, b) => b.amountM - a.amountM);
      return {
        payload: {
          signedByCompany: signedRows,
          contractM: r2(contractM), costM: r2(costM), grossM: r2(grossM),
          receivedM: r2(receivedM), deliveredM: r2(deliveredM), signedTotalM: r2(signedM),
          years: years.slice(),
        },
        stats: { years: years.slice(), mainRows: mainN, pcRows: pcN, contractM: r2(contractM), costM: r2(costM),
          grossM: r2(grossM), deliveredM: r2(deliveredM),
          costPct: contractM ? r2(costM / contractM * 100) : null },
      };
    }
    return { payload: {}, stats: {} };
  }

  /* ── ไฟล์ Project Control ปีเก่า → ยอดสะสม (โครงการติดตั้ง + มูลค่า) ──
     ชีตชื่อ "Main all63/64/65/66..." อ่านทุกชีตที่เจอ                     */
  function opsrcCumulative(wb) {
    const out = { years: {}, n: 0, valueM: 0, sheets: [] };
    (wb.SheetNames || []).forEach(sn => {
      const m = String(sn).match(/main\s*all\s*(\d{2})/i);
      if (!m) return;
      const yy = m[1];
      const aoa = opsrcAoa(wb.Sheets[sn]);
      let hd = opsrcFindHeader(aoa, ['Contract No.', 'มูลค่าสัญญาที่เซ็น (รวมVAT)', 'มูลค่าสัญญาที่เซ็น', 'ยกเลิกโครงการ']);
      if (hd.row < 0) hd = { row: 0, hit: 0 };   // ไฟล์เก่าบางปีชื่อคอลัมน์ต่าง — หัวตารางอยู่แถวแรก
      const heads = opsrcHeaderList(aoa, hd.row).map(opsrcNorm);
      // ★ ไฟล์ปีเก่าตั้งชื่อคอลัมน์ไม่เหมือนกัน (เช่นปี 64 ใช้ "Contract No. By Wari")
      //   → จับแบบขึ้นต้นด้วย ไม่ใช่ตรงเป๊ะ
      const ci = heads.findIndex(h => h.indexOf('contractno') === 0);
      let vi = heads.findIndex(h => h.indexOf(opsrcNorm('มูลค่าสัญญาที่เซ็น')) === 0 && h.indexOf('vat') >= 0);
      if (vi < 0) vi = heads.findIndex(h => h.indexOf(opsrcNorm('มูลค่าสัญญาที่เซ็น')) === 0);
      const ki = heads.findIndex(h => h.indexOf(opsrcNorm('ยกเลิกโครงการ')) >= 0);
      if (ci < 0) return;
      let n = 0, v = 0;
      for (let i = hd.row + 1; i < aoa.length; i++) {
        const r = aoa[i] || [];
        if (!opsrcTxt(r[ci])) continue;
        if (ki >= 0 && opsrcTxt(r[ki])) continue;
        n++; if (vi >= 0) v += (opsrcNum(r[vi]) || 0);
      }
      out.years[yy] = { n: n, valueM: Math.round(v / 1e6 * 100) / 100 };
      out.sheets.push(sn);
      out.n += n; out.valueM += v / 1e6;
    });
    out.valueM = Math.round(out.valueM * 100) / 100;
    return out;
  }

  /* ── ตรวจความสมเหตุสมผล เทียบงวดก่อน ────────────────────────────────
     กันเคส "ชื่อคอลัมน์เหมือนเดิม แต่ความหมาย/หน่วยเปลี่ยน"              */
  function opsrcSanity(srcKey, stats, prevStats) {
    const w = [];
    const pctChg = (a, b) => (b ? (a - b) / Math.abs(b) * 100 : null);
    if (srcKey === 'sales') {
      if (!stats.rows) w.push('อ่านได้ 0 แถว — ตรวจว่าเลือกชีตถูกไหม');
      if (prevStats && prevStats.rows) {
        const c = pctChg(stats.rows, prevStats.rows);
        if (Math.abs(c) > 40) w.push('จำนวนแถวเปลี่ยนจากงวดก่อน ' + c.toFixed(0) + '% (' + prevStats.rows + ' → ' + stats.rows + ') — ผิดปกติ ตรวจไฟล์อีกครั้ง');
        const m = pctChg(stats.backlogM + stats.budgetM, (prevStats.backlogM || 0) + (prevStats.budgetM || 0));
        if (m != null && Math.abs(m) > 30) w.push('มูลค่ารวมเปลี่ยนจากงวดก่อน ' + m.toFixed(0) + '% — อาจเปลี่ยนหน่วย (บาท ↔ ล้านบาท)');
      }
      if (stats.backlogN === 0) w.push('ไม่พบโครงการปีงบ ' + stats.fyFrom + ' ขึ้นไป → Backlog จะว่าง (ตรวจว่าปีงบในไฟล์เปลี่ยนไปหรือยัง)');
      if (stats.budgetN === 0) w.push('ไม่พบโครงการปีงบต่ำกว่า ' + stats.fyFrom + ' → งบที่คาดว่าจะได้รับจะว่าง');
    }
    if (srcKey === 'service') {
      if (!stats.rows) w.push('อ่านได้ 0 แถว — ตรวจว่าเลือกชีตถูกไหม');
      const sum = (stats.active || 0) + (stats.inactive || 0);
      if (stats.rows && sum < stats.rows * 0.8) w.push('อ่านสถานะเปิด/ปิดใช้งานได้แค่ ' + sum + ' จาก ' + stats.rows + ' แถว — คอลัมน์สถานะอาจเปลี่ยนคำ');
      if (stats.provinces > 77) w.push('จำนวนจังหวัด ' + stats.provinces + ' เกิน 77 — คอลัมน์จังหวัดน่าจะจับผิด');
      if (stats.unknownStatus > stats.rows * 0.05) w.push('มี ' + stats.unknownStatus + ' แถวที่อ่านสถานะเปิด/ปิดใช้งานไม่ออก — คำในคอลัมน์อาจเปลี่ยน');
      if (prevStats && prevStats.rows) {
        const c = pctChg(stats.rows, prevStats.rows);
        if (Math.abs(c) > 40) w.push('จำนวนโครงการเปลี่ยนจากงวดก่อน ' + c.toFixed(0) + '% — ผิดปกติ');
      }
    }
    if (srcKey === 'eng') {
      if (!stats.pcRows) w.push('อ่านชีตต้นทุนได้ 0 แถว — ตรวจว่าเลือกชีต Project Control ถูกปีไหม');
      if (!stats.mainRows) w.push('อ่านชีตทะเบียนได้ 0 แถว — ตรวจชีต Main all');
      if (stats.costPct != null && (stats.costPct < 40 || stats.costPct > 100)) {
        w.push('ต้นทุนคิดเป็น ' + stats.costPct + '% ของมูลค่าสัญญา ซึ่งอยู่นอกช่วงปกติ (40–100%) — คอลัมน์ COST อาจจับผิด');
      }
      if (stats.deliveredM && stats.contractM && stats.deliveredM > stats.contractM * 1.05) {
        w.push('มูลค่าส่งมอบมากกว่ามูลค่าสัญญา — ตรวจบรรทัด "มูลค่าสัญญาที่ส่งมอบงานแล้ว" ในชีต Summary');
      }
      if (prevStats && prevStats.contractM) {
        const c = pctChg(stats.contractM, prevStats.contractM);
        if (Math.abs(c) > 30) w.push('มูลค่าสัญญาเปลี่ยนจากงวดก่อน ' + c.toFixed(0) + '% — ผิดปกติ');
      }
    }
    return w;
  }

  window.OPSRC = {
    DEFS: OPSRC_DEFS,
    BACKLOG_FY_FROM: OPSRC_BACKLOG_FY_FROM,
    detect: opsrcDetect,
    plan: opsrcPlan,
    extract: opsrcExtract,
    cumulative: opsrcCumulative,
    sanity: opsrcSanity,
    aoa: opsrcAoa,
    num: opsrcNum,
    norm: opsrcNorm,
    pickSheet: opsrcPickSheet,
    headerList: opsrcHeaderList,
    findHeader: opsrcFindHeader,
  };
})();
