/* leasit_engine.jsx — engine สำหรับแท็บ "ลีซอิท" ในหน้า #debt
   - parseLeasitWorkbook(workbook) → {loans, prepaid, actual, refund}
   - litComputeTotals(loanId, prepaid, actual, refund)
   - litBuildExportWorkbook(loans, prepaid, actual, refund) → XLSX workbook
     (ชีตสรุปรวม + ชีตต่อสัญญา; ทุก Int.amount เป็นสูตร =Principal*Rate*Days/365
      เพื่อให้บัญชีตรวจการคำนวณดอกได้)

   ทุก identifier prefix LIT_ / lit*  (กัน global-scope name collision)
*/
'use strict';

/* ── Date helpers ────────────────────────────────────────────────────── */
// Excel serial → ISO YYYY-MM-DD (epoch 1899-12-30; 1900 leap bug แก้แล้ว)
function litSerialToISO(n) {
  var num = Number(n);
  if (!isFinite(num) || num <= 0) return '';
  // กัน date serial บั๊ก: ค่า > 100000 ก็ไม่ใช่วันที่
  if (num > 100000) return '';
  var ms = Math.round((num - 25569) * 86400000);
  var d = new Date(ms);
  if (isNaN(d.getTime())) return '';
  return d.toISOString().slice(0, 10);
}

// "DD/MM/YYYY" (Thai or CE) → ISO; ปี > 2400 → ลบ 543
function litDateStrToISO(s) {
  if (!s) return '';
  var str = String(s).trim();
  if (!str) return '';
  // ISO อยู่แล้ว
  if (/^\d{4}-\d{2}-\d{2}/.test(str)) return str.slice(0, 10);
  var m = str.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})/);
  if (!m) return '';
  var d = parseInt(m[1], 10);
  var mo = parseInt(m[2], 10);
  var y = parseInt(m[3], 10);
  if (y < 100) y += 2500; // ฉ้วย: เปลี่ยน 2-digit เป็น พ.ศ.
  if (y > 2400) y -= 543;
  if (!d || !mo || !y || mo < 1 || mo > 12 || d < 1 || d > 31) return '';
  var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
  return y + '-' + pad(mo) + '-' + pad(d);
}

// ★ เซลล์เดียวอาจมีหลายวัน — "11/7/2025,17/07/2025" หรือ "27/01/2026, 23/02/2026"
//   (การเงินกรอกวันจ่ายหลายงวดรวมช่องเดียวในชีต "สรุปรวม")
function litSplitDates(v) {
  if (v == null || v === '') return [];
  if (v instanceof Date || typeof v === 'number') {
    var one = litToISO(v);
    return one ? [one] : [];
  }
  var out = [];
  var parts = String(v).match(/\d{1,4}[\/\-]\d{1,2}[\/\-]\d{2,4}/g) || [];
  for (var i = 0; i < parts.length; i++) {
    var iso = litDateStrToISO(parts[i]);
    if (iso && out.indexOf(iso) < 0) out.push(iso);
  }
  out.sort();
  return out;
}

// ★ หา column ของ "งวดจ่าย" ในชีตสรุปรวม จากหัวตาราง (ไม่ hardcode ตำแหน่ง)
//   รองรับเพิ่มงวด 3, 4, … ในไฟล์ได้เองโดยไม่ต้องแก้โค้ด
//   คืน { inst:[{n,date,doc,amt}], payDate, total, outstanding }
function litFindSummaryPayCols(saoa) {
  var inst = {}, payDate = -1, total = -1, outstanding = -1;
  var maxScan = Math.min(saoa.length, 8);
  for (var r = 0; r < maxScan; r++) {
    var row = saoa[r] || [];
    for (var c = 0; c < row.length; c++) {
      var t = litTrim(row[c]);
      if (!t) continue;
      var m;
      if ((m = t.match(/^วันที่จ่ายงวด\s*(\d+)/))) {
        inst[m[1]] = inst[m[1]] || { n: +m[1], date: -1, doc: -1, amt: -1 };
        inst[m[1]].date = c;
      } else if ((m = t.match(/^เลขที่เอกสารงวด\s*(\d+)/))) {
        inst[m[1]] = inst[m[1]] || { n: +m[1], date: -1, doc: -1, amt: -1 };
        inst[m[1]].doc = c;
      } else if ((m = t.match(/^จ่ายงวด\s*(\d+)/))) {
        inst[m[1]] = inst[m[1]] || { n: +m[1], date: -1, doc: -1, amt: -1 };
        inst[m[1]].amt = c;
      } else if (t === 'วันที่จ่ายเงิน' && payDate < 0) {
        payDate = c;
      } else if (t === 'ยอดจ่าย' && total < 0) {
        total = c;
      } else if (t === 'ยอดคงเหลือ' && outstanding < 0) {
        outstanding = c;
      }
    }
  }
  var list = [];
  for (var k in inst) { if (inst[k].date >= 0) list.push(inst[k]); }
  list.sort(function (a, b) { return a.n - b.n; });
  // fallback ตำแหน่งเดิม ถ้าหา header ไม่เจอ (ไฟล์รูปแบบเก่า)
  if (!list.length) {
    list = [{ n: 1, date: 17, doc: 18, amt: 22 }, { n: 2, date: 19, doc: 20, amt: 23 }];
  }
  if (payDate < 0) payDate = 21;
  if (total < 0) total = 24;
  if (outstanding < 0) outstanding = 25;
  return { inst: list, payDate: payDate, total: total, outstanding: outstanding };
}

// ค่าใดๆ → ISO (Date, number=serial, string)
function litToISO(v) {
  if (v == null || v === '' || v === 0) return '';
  if (v instanceof Date) {
    if (isNaN(v.getTime())) return '';
    return v.toISOString().slice(0, 10);
  }
  if (typeof v === 'number') return litSerialToISO(v);
  return litDateStrToISO(v);
}

// ISO → days between (inclusive +1, ตรงกับสูตร DATEDIF+1 ในต้นฉบับ)
function litDaysBetween(startISO, endISO) {
  if (!startISO || !endISO) return 0;
  var a = new Date(startISO).getTime();
  var b = new Date(endISO).getTime();
  if (!isFinite(a) || !isFinite(b)) return 0;
  return Math.round((b - a) / 86400000) + 1;
}

function litNum(v) {
  if (v == null || v === '') return 0;
  if (typeof v === 'number') return v;
  var s = String(v).replace(/,/g, '').trim();
  if (!s) return 0;
  // วงเล็บ = ติดลบ
  if (/^\(.*\)$/.test(s)) s = '-' + s.slice(1, -1);
  var n = parseFloat(s);
  return isFinite(n) ? n : 0;
}

function litTrim(v) {
  if (v == null) return '';
  return String(v).trim();
}

/* ── Header parsing (rows 1-9) — label-driven ─────────────────────────── */
// หาค่าใน "ช่องถัดจาก label" — เดิน row + col ที่ label match แล้วเอาค่าจาก col+offset
function litFindBesideLabel(aoa, label, opts) {
  opts = opts || {};
  var maxRow = opts.maxRow || 12;
  var offset = opts.offset || 1; // ค่ามักอยู่ช่องถัดไป
  var rx = label instanceof RegExp ? label : new RegExp(label);
  for (var r = 0; r < Math.min(maxRow, aoa.length); r++) {
    var row = aoa[r] || [];
    for (var c = 0; c < row.length; c++) {
      var cell = row[c];
      if (cell != null && rx.test(String(cell))) {
        // ลองช่องถัดไป (อาจเป็น merged → next non-null in same row)
        for (var d = c + 1; d <= c + 4 && d < row.length; d++) {
          var v = row[d];
          if (v != null && v !== '') return v;
        }
        return row[c + offset];
      }
    }
  }
  return null;
}

/* ── Schedule block parsing ──────────────────────────────────────────── */
// หาแถว header ของ schedule block
//   layout ใหม่: Type | Month | Year | เริ่ม | สิ้นสุด | Principal | …
//   layout เก่า:        Month | Year | เริ่ม | สิ้นสุด | Principal | …  (ไม่มี Type → ทุกแถว = Prepaid)
// คืน { row: r, hasType: bool } หรือ null ถ้าหาไม่เจอ
function litFindScheduleHeader(aoa, startRow) {
  for (var r = startRow || 0; r < aoa.length; r++) {
    var row = aoa[r] || [];
    var c0 = litTrim(row[0]).toLowerCase();
    var c1 = litTrim(row[1]).toLowerCase();
    var c2 = litTrim(row[2]).toLowerCase();
    var c3 = litTrim(row[3]).toLowerCase();
    // layout ใหม่ (with Type) — col 0 = "Type" / "Prepaid" / "Actual" (variant ในบางชีต)
    //   หัวคอลัมน์ที่ค้ำคือ col 1=Month + col 2=Year
    if ((c0 === 'type' || c0 === 'prepaid' || c0 === 'actual')
        && (c1 === 'month' || c1 === 'เดือน')
        && (c2 === 'year' || c2 === 'ปี')) {
      return { row: r, hasType: true };
    }
    // layout เก่า (no Type) — col 0 = Month/เดือน, col 1 = Year/ปี
    if ((c0 === 'month' || c0 === 'เดือน') && (c1 === 'year' || c1 === 'ปี')
        && (c2 === 'เริ่ม' || c2 === 'start' || c2 === 'beginning')
        && (c3 === 'สิ้นสุด' || c3 === 'end')) {
      return { row: r, hasType: false };
    }
  }
  return null;
}

// อ่านแถว schedule ตั้งแต่ headerRow+1
//   hasType=true:  col 0 = Type (Prepaid/Actual) — กรองตาม expectType
//   hasType=false: ไม่มี Type — ทุกแถวที่มีเดือน/ปี/ยอด = ของ expectType (Prepaid implicit)
function litReadScheduleRows(aoa, headerRow, expectType, hasType) {
  var rows = [];
  var seq = 0;
  var endRow = headerRow + 1;
  // offset ของแต่ละ field — old layout shift ซ้าย 1 ช่อง
  var off = hasType ? 0 : -1;
  var cMonth = 1 + off, cYear = 2 + off, cStart = 3 + off, cEnd = 4 + off;
  var cPrincipal = 5 + off, cRate = 6 + off, cDays = 7 + off, cInt = 8 + off;
  var cInst = 9 + off, cPaid = 10 + off, cOut = 11 + off;
  for (var r = headerRow + 1; r < aoa.length; r++) {
    var row = aoa[r] || [];
    if (hasType) {
      var t = litTrim(row[0]);
      if (!t) {
        if (rows.length > 0) { endRow = r; break; }
        continue;
      }
      if (expectType && t !== expectType) {
        endRow = r;
        break;
      }
    } else {
      // no-Type layout: หยุดเมื่อ Month + Year ว่างทั้งคู่ (= แถวจบ)
      var mEmpty = !litTrim(row[cMonth]);
      var yEmpty = !litNum(row[cYear]);
      // และยอด Principal ว่าง = ไม่ใช่ data row
      var pEmpty = !litNum(row[cPrincipal]);
      if (mEmpty && yEmpty && pEmpty) {
        if (rows.length > 0) { endRow = r; break; }
        continue;
      }
    }
    seq++;
    var dateStart = litToISO(row[cStart]);
    var dateEnd = litToISO(row[cEnd]);
    var rawDays = litNum(row[cDays]);
    var days = (rawDays > 0 && rawDays <= 366) ? Math.round(rawDays) : litDaysBetween(dateStart, dateEnd);
    rows.push({
      seq: seq,
      month: litTrim(row[cMonth]),
      year: litNum(row[cYear]) || null,
      dateStart: dateStart,
      dateEnd: dateEnd,
      days: days,
      principal: litNum(row[cPrincipal]),
      intRate: litNum(row[cRate]),
      intAmount: litNum(row[cInt]),
      installment: litNum(row[cInst]),
      principalPaid: litNum(row[cPaid]),
      outstanding: litNum(row[cOut])
    });
    endRow = r + 1;
  }
  return { rows: rows, endRow: endRow };
}

/* ── Refund block parsing — free-text rows in col 3 + amount in col 5 ── */
// เริ่มจาก startRow (มัก = หลังแถว "ส่วนต่าง") กวาดทุกแถวจนจบ aoa
function litReadRefundRows(aoa, startRow) {
  var rows = [];
  for (var r = startRow; r < aoa.length; r++) {
    var row = aoa[r] || [];
    var text = litTrim(row[2]);
    var amt = litNum(row[4]);
    if (!text || amt === 0) continue;
    // ตัดแถว "รวม" / "ส่วนต่าง" / "ยอดดอกเบี้ย..." / sub-total
    if (/^(รวม|ส่วนต่าง|Total|ยอดดอกเบี้ย)/i.test(text)) continue;
    // กำหนดประเภท + วันที่ + ref จากข้อความ
    var refundType = 'Transfer';
    if (/RV/i.test(text)) refundType = 'RV';
    // ดึงวันที่จากข้อความ DD/MM/YYYY
    var refundDate = '';
    var dm = text.match(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})/);
    if (dm) refundDate = litDateStrToISO(dm[0]);
    // ดึง ref doc (RV20XXXX-XXX)
    var refDoc = '';
    var rm = text.match(/RV\s*\d{4}[-\s]*\d{2,4}/i);
    if (rm) refDoc = rm[0].replace(/\s+/g, '');
    rows.push({
      refundDate: refundDate,
      refundType: refundType,
      amount: amt,
      refDoc: refDoc,
      note: text
    });
  }
  return rows;
}

/* ── parseLeasitWorkbook — main entry ────────────────────────────────── */
// คืน {loans, prepaid, actual, refund, registry, errors[]}
function parseLeasitWorkbook(wb) {
  if (!wb || !wb.SheetNames) return { loans: [], prepaid: [], actual: [], refund: [], errors: ['ไม่ใช่ workbook'] };
  var XLSX = window.XLSX;
  if (!XLSX) return { loans: [], prepaid: [], actual: [], refund: [], errors: ['ไม่พบ XLSX'] };

  var errors = [];
  var loans = [];
  var prepaid = [];
  var actual = [];
  var refund = [];

  // 1) ทะเบียนโครงการ (เผื่อ enrich)
  var registry = {};
  if (wb.SheetNames.indexOf('รายชื่อโครงการ') >= 0) {
    var rws = wb.Sheets['รายชื่อโครงการ'];
    var raoa = XLSX.utils.sheet_to_json(rws, { header: 1, raw: true, defval: null });
    for (var ri = 2; ri < raoa.length; ri++) {
      var rrow = raoa[ri] || [];
      var lid = litNum(rrow[1]);
      if (lid > 0) {
        registry[lid] = {
          jobNo: litTrim(rrow[2]).split(/[-\s]/)[0] || litTrim(rrow[2]),
          siteName: litTrim(rrow[2]),
          productType: litTrim(rrow[3]),
          contractStart: litToISO(rrow[9]),
          contractEnd: litToISO(rrow[10])
        };
      }
    }
  }

  // 1b) ★ สรุปรวม sheet — authoritative ticket type + วันที่จ่ายเงิน (refDocs) per loanId
  //     col 4 (E) "ประเภท" = PRE/POS/NON
  //     col 17 (R) วันที่จ่ายงวด 1 · col 18 (S) เลขที่เอกสารงวด 1
  //     col 19 (T) วันที่จ่ายงวด 2 · col 20 (U) เลขที่เอกสารงวด 2
  var summaryTicket = {};      // {loanId: 'PRE'|'POS'|'NON'}
  var summaryJob = {};         // {loanId: 'ENC156' | 'PP055' …} — JOB code (col C)
  var summaryRepayDocs = {};   // {loanId: [{date:ISO, doc:'RV…', amount, seq}, …]}
  var summaryPaidTotal = {};   // {loanId: ยอดจ่ายรวมจากคอลัมน์ "ยอดจ่าย"}
  if (wb.SheetNames.indexOf('สรุปรวม') >= 0) {
    var sws = wb.Sheets['สรุปรวม'];
    var saoa = XLSX.utils.sheet_to_json(sws, { header: 1, raw: true, defval: null });
    // ★ อ่านตำแหน่งคอลัมน์งวดจ่ายจากหัวตาราง — เพิ่มงวดในไฟล์ได้เองไม่ต้องแก้โค้ด
    var sumCols = litFindSummaryPayCols(saoa);
    for (var si = 6; si < saoa.length; si++) {
      var srow = saoa[si] || [];
      var slid = litNum(srow[0]);
      if (slid <= 0) continue;
      var stype = litTrim(srow[4]).toUpperCase();
      if (stype === 'PRE' || stype === 'POS' || stype === 'NON') {
        summaryTicket[slid] = stype;
      }
      // ★ col C (index 2) = JOB NO — รหัสโครงการ (PP055/ENC155/TTI063/…)
      //    ใช้เฉพาะที่ "ดูเหมือนรหัส" (3-6 ตัวอักษร+ตัวเลข) — ไม่ใช่ชื่อบ้าน
      var sjob = litTrim(srow[2]);
      if (sjob && /^[A-Z]{2,5}\d{2,4}$/i.test(sjob)) {
        summaryJob[slid] = sjob.toUpperCase();
      }
      // ★ การจ่ายคืนเงินต้นรายงวด — ไม่จำกัดจำนวนงวด
      //   1) คอลัมน์ "วันที่จ่ายงวด N / เลขที่เอกสารงวด N / จ่ายงวด N"
      //   2) ถ้าไม่มี → คอลัมน์ "วันที่จ่ายเงิน" ซึ่ง "อาจมีหลายวันในช่องเดียว"
      //      (เจอจริง 14 สัญญา เช่น "27/01/2026, 23/02/2026") → แตกเป็นหลายงวด
      var docs = [];
      for (var gi = 0; gi < sumCols.inst.length; gi++) {
        var ic = sumCols.inst[gi];
        var gd = litToISO(srow[ic.date]);
        if (!gd) continue;
        docs.push({
          date: gd,
          doc: ic.doc >= 0 ? litTrim(srow[ic.doc]) : '',
          amount: ic.amt >= 0 ? litNum(srow[ic.amt]) : 0,
          seq: ic.n
        });
      }
      var paidTot = litNum(srow[sumCols.total]);
      if (paidTot > 0) summaryPaidTotal[slid] = paidTot;
      if (!docs.length) {
        var multiD = litSplitDates(srow[sumCols.payDate]);
        for (var mi = 0; mi < multiD.length; mi++) {
          docs.push({ date: multiD[mi], doc: '', amount: 0, seq: mi + 1 });
        }
      }
      if (docs.length) summaryRepayDocs[slid] = docs;
    }
  }

  // ★ กันชีตซ้ำ loanId — {loanId: ชื่อชีตที่จองไว้แล้ว}
  var seenLoanIds = {};

  // ★ pre-pass: อ่านเฉพาะ "ลำดับที่" ของทุกชีต (10 แถวแรก — เร็ว)
  //   ใช้ตัดสินว่าถ้ามีหลายชีตอ้างเลขเดียวกัน ชีตไหนคือเจ้าของตัวจริง
  //   เจอจริง: ชีต 85 ถูกก๊อปมาจากชีต 86 แล้วลืมแก้ "ลำดับที่" → ทั้งคู่อ้างเลข 86
  var claimersById = {};   // {loanId: [ชื่อชีต, …]}
  for (var pi = 0; pi < wb.SheetNames.length; pi++) {
    var pname = wb.SheetNames[pi];
    if (!/^\d+$/.test(pname)) continue;
    try {
      var paoa = XLSX.utils.sheet_to_json(wb.Sheets[pname], {
        header: 1, raw: true, defval: null,
        range: { s: { r: 0, c: 0 }, e: { r: 9, c: 24 } }
      });
      var plid = litNum(litFindBesideLabel(paoa, /ลำดับที่/, { maxRow: 9 })) || litNum(pname);
      if (plid) {
        claimersById[plid] = claimersById[plid] || [];
        claimersById[plid].push(pname);
      }
    } catch (ePeek) { /* ชีตอ่านไม่ได้ — ปล่อยให้ loop หลักแจ้ง error */ }
  }

  // 2) เดินทุกชีตที่ชื่อเป็นเลขล้วน (= per-loan sheet)
  for (var i = 0; i < wb.SheetNames.length; i++) {
    var name = wb.SheetNames[i];
    if (!/^\d+$/.test(name)) continue;
    try {
      var ws = wb.Sheets[name];
      var aoa = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
      if (!aoa.length) continue;

      // 2a) Header block (rows 1-9)
      var contractNo = litTrim(litFindBesideLabel(aoa, /เลขสัญญาเงินกู้/));
      var projectName = litTrim(litFindBesideLabel(aoa, /ชื่อโครงการ/));
      var dateReceived = litToISO(litFindBesideLabel(aoa, /วันที่รับเงิน/));
      var dateDue = litToISO(litFindBesideLabel(aoa, /^วันที่ครบกำหนด$/));
      // ครบกำหนด(โรล) — label มีวงเล็บ ใช้ regex หลวมๆ
      var dateDueRoll = litToISO(litFindBesideLabel(aoa, /วันที่ครบกำหนด.*โรล/));
      var dateRepaid = litToISO(litFindBesideLabel(aoa, /วันที่คืนเงิน/));
      var principalChequeNo = litTrim(litFindBesideLabel(aoa, /เลขที่เช็คเงินต้น/));
      var principal = litNum(litFindBesideLabel(aoa, /จำนวนเงินกู้/));
      var interestRate = litNum(litFindBesideLabel(aoa, /อัตราดอกเบี้ย/));
      var termDays = litNum(litFindBesideLabel(aoa, /ระยะเวลากู้/));
      var ticketType = litTrim(litFindBesideLabel(aoa, /ประเภทตั๋ว/));

      // loanId — หา label "ลำดับที่" ก่อน; fallback = ชื่อชีต
      var loanId = litNum(litFindBesideLabel(aoa, /ลำดับที่/, { maxRow: 9 })) || litNum(name);
      if (!loanId) {
        errors.push('ชีต ' + name + ': หา loanId ไม่ได้');
        continue;
      }
      // ★ 2 ชีตอ้าง "ลำดับที่" เดียวกัน = แถวซ้ำในตาราง + ยอดจ่ายคืนเบิ้ล
      //   (เจอจริง: ชีตถูกก๊อปมาแล้วลืมแก้ลำดับที่/เลขสัญญา)
      //   เจ้าของตัวจริง = ชีตที่ "ชื่อชีตตรงกับเลขนั้น" · อีกชีตใช้เลขชีตตัวเอง
      var claimers = claimersById[loanId] || [];
      if (claimers.length > 1 && litNum(name) !== loanId) {
        var ownerSheet = '';
        for (var ci = 0; ci < claimers.length; ci++) {
          if (litNum(claimers[ci]) === loanId) { ownerSheet = claimers[ci]; break; }
        }
        if (ownerSheet && litNum(name)) {
          errors.push('ชีต ' + name + ': ระบุ "ลำดับที่ ' + loanId + '" ซ้ำกับชีต ' + ownerSheet +
            ' → ใช้เลขชีต ' + litNum(name) + ' แทน (ควรแก้ลำดับที่/เลขสัญญาในไฟล์ต้นทาง)');
          loanId = litNum(name);
        }
      }
      // backstop: ยังชนอยู่ (ชื่อชีตก็ถูกจองแล้ว) → ข้าม ไม่ให้แถวซ้ำหลุดเข้าระบบ
      if (seenLoanIds[loanId]) {
        var byName = litNum(name);
        if (byName && !seenLoanIds[byName]) {
          errors.push('ชีต ' + name + ': ระบุ "ลำดับที่ ' + loanId + '" ซ้ำกับชีต ' +
            seenLoanIds[loanId] + ' → ใช้เลขชีต ' + byName + ' แทน (ควรแก้ในไฟล์ต้นทาง)');
          loanId = byName;
        } else {
          errors.push('ชีต ' + name + ': ลำดับที่ ' + loanId + ' ซ้ำกับชีต ' +
            seenLoanIds[loanId] + ' — ข้ามชีตนี้ (แก้ไฟล์ต้นทางแล้วนำเข้าใหม่)');
          continue;
        }
      }
      seenLoanIds[loanId] = name;

      // 2b) Prepaid block
      var hdr1 = litFindScheduleHeader(aoa, 0);
      if (!hdr1) {
        errors.push('ชีต ' + name + ': ไม่พบ header row Type|Month|Year');
        continue;
      }
      var rpre = litReadScheduleRows(aoa, hdr1.row, 'Prepaid', hdr1.hasType);

      // 2c) Actual block — header ถัดไป (เฉพาะ layout ใหม่ที่มี Type → มี Actual block แยก)
      var racta = { rows: [], endRow: rpre.endRow };
      if (hdr1.hasType) {
        var hdr2 = litFindScheduleHeader(aoa, rpre.endRow);
        if (hdr2) racta = litReadScheduleRows(aoa, hdr2.row, 'Actual', hdr2.hasType);
      }

      // 2d) Summary block + refund — refund เริ่มหลังแถว "ส่วนต่าง"
      //   layout ใหม่: สรุป → ยอดล่วงหน้า → ยอดเกิดจริง → ส่วนต่าง → (refunds)
      //   layout เก่า: ไม่มี Actual/สรุป → refund (ถ้ามี) อยู่ท้ายชีต
      var refundStart = racta.endRow;
      for (var r2 = racta.endRow; r2 < aoa.length; r2++) {
        var row2 = aoa[r2] || [];
        var lbl = litTrim(row2[2]);
        if (/^ส่วนต่าง/.test(lbl)) { refundStart = r2 + 1; break; }
      }
      var refunds = litReadRefundRows(aoa, refundStart);

      // 2e) project_id link — ลำดับความสำคัญ: สรุปรวม col C (authoritative) > registry > regex projectName
      //     ★ summaryJob เป็น code (ENC155/PP055) ที่ผู้ใช้กรอกเองในชีต สรุปรวม
      var reg = registry[loanId] || {};
      var jobNo = summaryJob[loanId] || '';
      if (!jobNo && reg.jobNo && /^[A-Z]{2,5}\d{2,4}$/i.test(reg.jobNo)) {
        jobNo = reg.jobNo.toUpperCase();
      }
      // fallback: ลองดึง code จาก projectName (เช่น "PP054 …")
      if (!jobNo) {
        var jm = projectName.match(/^([A-Z]{2,5}\d{2,4})/i);
        if (jm) jobNo = jm[1].toUpperCase();
      }

      // 2f) คำนวณ totals — ★ refund เฉพาะ "ดอกเบี้ย" (kind!=='principal')
      //     เงินต้นจ่ายคืน track แยก (ไม่นับเป็น refund ดอก)
      var totPre = 0; for (var ip = 0; ip < rpre.rows.length; ip++) totPre += rpre.rows[ip].intAmount;
      var totAct = 0; for (var ia = 0; ia < racta.rows.length; ia++) totAct += racta.rows[ia].intAmount;
      var variance = totPre - totAct;
      var totRef = 0;
      for (var ire = 0; ire < refunds.length; ire++) {
        if (refunds[ire].kind === 'principal') continue;  // ★ ข้ามเงินต้น
        totRef += refunds[ire].amount;
      }
      var refundOutstanding = variance - totRef;

      // 2g) สถานะ — ปิดหนี้ถ้ามีวันคืนเงิน
      var status = dateRepaid ? 'Close' : 'Active';

      // 2h) ★ ticket type: ใช้ summary เป็นหลัก (authoritative)
      //     ชีตเดี่ยวเป็น fallback (กรณีไม่มี summary entry)
      var resolvedTicket = summaryTicket[loanId] || ticketType.toUpperCase();
      if (resolvedTicket !== 'PRE' && resolvedTicket !== 'POS' && resolvedTicket !== 'NON') {
        resolvedTicket = 'POS';   // default ถ้าระบุไม่ชัด
      }

      loans.push({
        loanId: loanId,
        contractNo: contractNo,
        jobNo: jobNo,
        projectName: projectName,
        ticketType: resolvedTicket,
        productType: reg.productType || '',
        loanType: resolvedTicket === 'NON' ? 'NON' : resolvedTicket === 'POS' ? 'POS' : 'PRE',
        interestRate: interestRate,
        principal: principal,
        dateReceived: dateReceived,
        dateDue: dateDue,
        dateDueRoll: dateDueRoll || '',
        termDays: termDays,
        principalChequeNo: principalChequeNo,
        dateRepaid: dateRepaid || '',
        status: status,
        totalPrepaidInterest: totPre,
        totalActualInterest: totAct,
        variance: variance,
        totalRefunded: totRef,
        refundOutstanding: refundOutstanding
      });

      // child rows — แนบ loanId
      for (var pj = 0; pj < rpre.rows.length; pj++) {
        rpre.rows[pj].loanId = loanId;
        prepaid.push(rpre.rows[pj]);
      }
      // ★ เก็บ principal repayment events จาก actual rows ที่ principalPaid > 0
      //   declining principal → จาก actual block ของ source Excel (loan 49 = 2,461,003.95 + 233,996.05)
      // ★ NON (Term Loan) → ข้าม! เพราะเป็น amortization rayalเดือน ทุกแถวมี principalPaid ตาม schedule
      //   ไม่ใช่ "partial payment" แบบ Pre/Post-financing — เก็บไว้จะกลายเป็น refund มหาศาล
      var principalEvents = [];
      var isTermLoan = resolvedTicket === 'NON';
      for (var aj = 0; aj < racta.rows.length; aj++) {
        racta.rows[aj].loanId = loanId;
        actual.push(racta.rows[aj]);
        if (isTermLoan) continue;  // ★ NON: ข้าม emit refund
        var pp = Number(racta.rows[aj].principalPaid) || 0;
        if (pp > 0.01) {
          principalEvents.push({
            refundDate: racta.rows[aj].dateEnd || racta.rows[aj].paymentDate || '',
            amount: pp,
            kind: 'principal',
            refundType: 'Transfer',
            note: 'จากตารางคำนวณ (Actual block)',
            refDoc: '',
            loanId: loanId
          });
        }
      }
      // ★ layout เก่า (ไม่มีคอลัมน์ Type) มีบล็อกเดียว อ่านเป็น Prepaid
      //   แต่คอลัมน์ "Principal paid" ของการจ่ายคืนอยู่ในบล็อกนั้น
      //   ถ้าไม่ดึงตรงนี้ สัญญา layout เก่าทุกใบจะไม่มีประวัติจ่ายคืนเงินต้นเลย
      if (!isTermLoan && !hdr1.hasType) {
        for (var pk = 0; pk < rpre.rows.length; pk++) {
          var ppOld = Number(rpre.rows[pk].principalPaid) || 0;
          if (ppOld > 0.01) {
            principalEvents.push({
              refundDate: rpre.rows[pk].dateEnd || '',
              amount: ppOld,
              kind: 'principal',
              refundType: 'Transfer',
              note: 'จากตารางคำนวณ (คอลัมน์ Principal paid)',
              refDoc: '',
              loanId: loanId
            });
          }
        }
      }
      // แนบ refDoc จาก สรุปรวม เรียงตามวันที่ (chronological)
      if (summaryRepayDocs[loanId] && principalEvents.length) {
        var sdocs = summaryRepayDocs[loanId].slice().sort(function (a, b) {
          return String(a.date).localeCompare(String(b.date));
        });
        principalEvents.sort(function (a, b) {
          return String(a.refundDate).localeCompare(String(b.refundDate));
        });
        for (var ei = 0; ei < principalEvents.length && ei < sdocs.length; ei++) {
          if (sdocs[ei].doc) principalEvents[ei].refDoc = sdocs[ei].doc;
          if (sdocs[ei].date) principalEvents[ei].refundDate = sdocs[ei].date;
        }
        // ★ สรุปรวมมีงวดจ่ายมากกว่าที่ชีตบันทึกไว้ → เติมงวดที่ขาด
        //   (เจอจริง: ชีตลงแค่งวดแรก แต่สรุปรวมมี 2 วัน — งวดหลังหายทั้งใบ)
        if (sdocs.length > principalEvents.length) {
          var sumSoFar = 0;
          for (var q = 0; q < principalEvents.length; q++) {
            sumSoFar += Number(principalEvents[q].amount) || 0;
          }
          var totPaidS = Number(summaryPaidTotal[loanId]) || 0;
          for (var q2 = principalEvents.length; q2 < sdocs.length; q2++) {
            var amtQ = Number(sdocs[q2].amount) || 0;
            var guessed = false;
            if (amtQ <= 0 && totPaidS > sumSoFar + 1) {
              amtQ = totPaidS - sumSoFar;   // ส่วนที่เหลือของ "ยอดจ่าย" รวม
              guessed = true;
            }
            if (amtQ > 0.01) {
              principalEvents.push({
                refundDate: sdocs[q2].date,
                amount: amtQ,
                kind: 'principal', refundType: 'Transfer',
                note: guessed ? 'จากสรุปรวม (ส่วนที่เหลือของยอดจ่าย · ตรวจยอดจริง)'
                              : 'จากสรุปรวม (งวดที่ชีตยังไม่บันทึก)',
                refDoc: sdocs[q2].doc || '',
                loanId: loanId
              });
              sumSoFar += amtQ;
            }
          }
        }
      }
      // ★ LAST RESORT: ถ้าปิดสัญญาแล้ว แต่ summary ไม่มีวันจ่ายงวด 1/2 (loan งวดเดียว) →
      //    สร้าง refund ที่ dateRepaid + amount = principal เต็ม
      //    ★ ข้าม NON (Term Loan)
      if (!isTermLoan && principalEvents.length === 0 && status === 'Close' && dateRepaid &&
          !(summaryRepayDocs[loanId] && summaryRepayDocs[loanId].length > 0)) {
        // ยอดที่จ่ายจริงจากคอลัมน์ "ยอดจ่าย" ในสรุปรวม — ไม่มีค่อยใช้เงินต้นเต็ม
        var lastAmt = Number(summaryPaidTotal[loanId]) || 0;
        principalEvents.push({
          refundDate: dateRepaid,
          amount: lastAmt > 0 ? lastAmt : principal,
          kind: 'principal', refundType: 'Transfer',
          note: lastAmt > 0 ? 'จากวันคืนเงิน + ยอดจ่ายในสรุปรวม' : 'จากวันคืนเงิน (ปิดงวดเดียว)',
          refDoc: '',
          loanId: loanId
        });
      }
      // ★ FALLBACK: ถ้า actual block ไม่มี principalPaid > 0 (เช่น loan 49 ที่ detail
      //    sheet ว่าง) แต่ summary มีวันที่จ่าย → สร้าง refund entries จาก summary
      //    ใช้ยอดจริงจาก col 22/23 (จ่ายงวด 1/2) ถ้ามี · ถ้าไม่มี = แบ่งเท่ากัน
      //    ★ ข้าม NON
      //    ★ ไม่ผูกกับ status === 'Close' อีกแล้ว — สัญญาที่จ่ายคืนบางส่วน
      //       (ยัง Active) ก็ต้องบันทึกงวดที่จ่ายไปแล้ว ไม่งั้นดอกเบี้ยเกิดจริงคำนวณผิด
      if (!isTermLoan && principalEvents.length === 0 && summaryRepayDocs[loanId] &&
          summaryRepayDocs[loanId].length > 0) {
        var sd = summaryRepayDocs[loanId];
        // ถ้ามียอดจริงครบทุกงวด → ใช้ตามจริง, ไม่งั้น fallback แบ่งเท่ากัน
        var hasAllAmounts = sd.every(function (x) { return Number(x.amount) > 0; });
        // ฐานยอด = "ยอดจ่าย" จริงจากสรุปรวม (ถ้ามี) ไม่ใช่เงินต้นเต็ม
        var basePaid = Number(summaryPaidTotal[loanId]) || 0;
        var fallbackPer = (basePaid > 0 ? basePaid : principal) / sd.length;
        for (var sdi = 0; sdi < sd.length; sdi++) {
          var amt = hasAllAmounts ? Number(sd[sdi].amount) : fallbackPer;
          principalEvents.push({
            refundDate: sd[sdi].date,
            amount: amt,
            kind: 'principal', refundType: 'Transfer',
            note: hasAllAmounts ? 'จากสรุปรวม (ยอดจริงจากตาราง)'
              : (sd.length === 1 ? 'จากสรุปรวม (ยอดจ่ายรวม)' : 'จากสรุปรวม (แบ่งเท่ากัน · ตรวจยอดจริง)'),
            refDoc: sd[sdi].doc || '',
            loanId: loanId
          });
        }
      }
      // ★ คำนวณยอดเงินต้นที่จ่ายคืนรวม + ปรับ status ถ้า partial repayment
      var totalPrincipalRepaid = 0;
      var maxRepayDate = '';
      for (var pe = 0; pe < principalEvents.length; pe++) {
        totalPrincipalRepaid += Number(principalEvents[pe].amount) || 0;
        if (principalEvents[pe].refundDate > maxRepayDate) maxRepayDate = principalEvents[pe].refundDate;
        refund.push(principalEvents[pe]);
      }
      // ปรับ status: ถ้า partial (paid < principal) → Active ไม่ใช่ Close
      if (totalPrincipalRepaid > 0 && totalPrincipalRepaid < principal - 1) {
        status = 'Active';
        loans[loans.length - 1].status = 'Active';
        loans[loans.length - 1].dateRepaid = '';
      } else if (totalPrincipalRepaid >= principal - 1 && maxRepayDate) {
        // ★ ปิดสัญญา → dateRepaid = วันสุดท้ายที่จ่ายครบ (ไม่ใช่วันแรก)
        loans[loans.length - 1].dateRepaid = maxRepayDate;
      }
      // ส่ง principalRepaid ไปกับ loan object สำหรับ litLoanToDebtRow
      loans[loans.length - 1].principalRepaid = totalPrincipalRepaid;
      for (var rj = 0; rj < refunds.length; rj++) {
        refunds[rj].loanId = loanId;
        refund.push(refunds[rj]);
      }
    } catch (e) {
      errors.push('ชีต ' + name + ': ' + (e.message || e));
    }
  }

  return { loans: loans, prepaid: prepaid, actual: actual, refund: refund, registry: registry, errors: errors };
}

/* ── compute totals (recompute หลัง edit) ─────────────────────────────── */
function litComputeTotals(loanId, prepaid, actual, refund) {
  var totPre = 0, totAct = 0, totRef = 0, totPrincipalRepaid = 0;
  for (var i = 0; i < prepaid.length; i++) if (prepaid[i].loanId === loanId) totPre += litNum(prepaid[i].intAmount);
  for (var j = 0; j < actual.length; j++) if (actual[j].loanId === loanId) totAct += litNum(actual[j].intAmount);
  for (var k = 0; k < refund.length; k++) {
    if (refund[k].loanId !== loanId) continue;
    if (refund[k].kind === 'principal') totPrincipalRepaid += litNum(refund[k].amount);
    else totRef += litNum(refund[k].amount);
  }
  return {
    totalPrepaidInterest: totPre,
    totalActualInterest: totAct,
    variance: totPre - totAct,
    totalRefunded: totRef,                          // ★ interest only
    totalPrincipalRepaid: totPrincipalRepaid,       // ★ track แยก
    refundOutstanding: totPre - totAct - totRef
  };
}

/* ── debtMaster row ↔ Leasit ─────────────────────────────────────────── */
// แปลง loan object → debtMaster row (id namespaced 'lit_<loanId>')
function litLoanToDebtRow(loan, existingId) {
  return {
    id: existingId || ('lit_' + loan.loanId),
    debtCategory: 'LIT',
    contractNo: loan.contractNo,
    borrowerName: 'บจก. ลีซอิท',
    status: loan.status,            // 'Active' / 'Close'
    facilityType: 'Loan',
    loanType: loan.loanType,        // 'PRE' / 'NON'
    principalAmount: loan.principal,
    interestRate: loan.interestRate,
    balance: loan.status === 'Active' ? (loan.principal - (loan.principalRepaid || 0)) : 0,
    currency: 'THB',
    projectCode: loan.jobNo,
    projectName: loan.projectName,
    // Leasit-specific (เก็บไว้ใน debtMaster row, ไม่กระทบฟิลด์เดิม)
    leasitSource: true,
    leasitLoanId: loan.loanId,
    leasitTicketType: loan.ticketType,
    leasitProductType: loan.productType,
    leasitDateReceived: loan.dateReceived,
    leasitDateDue: loan.dateDue,
    leasitDateDueRoll: loan.dateDueRoll,
    leasitDateRepaid: loan.dateRepaid,
    leasitTermDays: loan.termDays,
    leasitPrincipalChequeNo: loan.principalChequeNo,
    leasitTotalPrepaid: loan.totalPrepaidInterest,
    leasitTotalActual: loan.totalActualInterest,
    leasitVariance: loan.variance,
    leasitRefunded: loan.totalRefunded,
    leasitRefundOutstanding: loan.refundOutstanding,
    leasitPrincipalRepaid: loan.principalRepaid || 0,
    // วันครบกำหนดสำหรับ maturity alert ในหน้า #debt_ledger
    maturityDate: loan.dateDueRoll || loan.dateDue || ''
  };
}

/* ── Cell styles (xlsx-js-style supports `s` property) ────────────────── */
var LIT_STYLE_HEADER = {
  font: { name: 'TH SarabunPSK', bold: true, sz: 11, color: { rgb: 'FFFFFFFF' } },
  fill: { patternType: 'solid', fgColor: { rgb: 'FF2A6FDB' } },
  alignment: { horizontal: 'center', vertical: 'center', wrapText: true },
  border: {
    top: { style: 'thin', color: { rgb: 'FF1A4490' } },
    bottom: { style: 'thin', color: { rgb: 'FF1A4490' } },
    left: { style: 'thin', color: { rgb: 'FF1A4490' } },
    right: { style: 'thin', color: { rgb: 'FF1A4490' } }
  }
};
var LIT_STYLE_TITLE = {
  font: { name: 'TH SarabunPSK', bold: true, sz: 14, color: { rgb: 'FF1A4490' } },
  alignment: { horizontal: 'left', vertical: 'center' }
};
var LIT_STYLE_LABEL = {
  font: { name: 'TH SarabunPSK', bold: true, sz: 10, color: { rgb: 'FF1A4490' } },
  alignment: { horizontal: 'left', vertical: 'center' }
};
var LIT_STYLE_TOTAL = {
  font: { name: 'TH SarabunPSK', bold: true, sz: 11, color: { rgb: 'FF1A4490' } },
  fill: { patternType: 'solid', fgColor: { rgb: 'FFE8F0FA' } },
  alignment: { horizontal: 'right', vertical: 'center' },
  numFmt: '#,##0.00',   // ★ ใช้กับช่องยอดเงินเท่านั้น (ยอดรวม/สรุป/รับคืน)
  border: {
    top: { style: 'medium', color: { rgb: 'FF2A6FDB' } },
    bottom: { style: 'double', color: { rgb: 'FF2A6FDB' } }
  }
};
var LIT_STYLE_GRAND = {
  font: { name: 'TH SarabunPSK', bold: true, sz: 12, color: { rgb: 'FFFFFFFF' } },
  fill: { patternType: 'solid', fgColor: { rgb: 'FF1A4490' } },
  alignment: { horizontal: 'right', vertical: 'center' },
  border: { top: { style: 'double', color: { rgb: 'FF1A4490' } } }
};
var LIT_STYLE_CELL = {
  font: { name: 'TH SarabunPSK', sz: 10 },
  alignment: { vertical: 'center' },
  border: {
    top: { style: 'thin', color: { rgb: 'FFE0E7F0' } },
    bottom: { style: 'thin', color: { rgb: 'FFE0E7F0' } },
    left: { style: 'thin', color: { rgb: 'FFE0E7F0' } },
    right: { style: 'thin', color: { rgb: 'FFE0E7F0' } }
  }
};
var LIT_STYLE_MONEY = Object.assign({}, LIT_STYLE_CELL, {
  alignment: { vertical: 'center', horizontal: 'right' },
  numFmt: '#,##0.00'
});
var LIT_STYLE_DATE = Object.assign({}, LIT_STYLE_CELL, {
  alignment: { vertical: 'center', horizontal: 'center' },
  numFmt: 'dd/mm/yyyy'
});
var LIT_STYLE_SUMMARY_LABEL = {
  font: { name: 'TH SarabunPSK', bold: true, sz: 10, color: { rgb: 'FF1A4490' } },
  fill: { patternType: 'solid', fgColor: { rgb: 'FFF4F8FE' } },
  alignment: { horizontal: 'right', vertical: 'center' },
  border: { top: { style: 'thin', color: { rgb: 'FF2A6FDB' } } }
};

// helper: ใส่ style ลง cell (ใช้กับ ws[cellRef])
function litStyleCell(ws, cellRef, style, isNumber, isDate) {
  var c = ws[cellRef];
  if (!c) ws[cellRef] = c = { v: '', t: 's' };
  c.s = style;
  if (isNumber && c.v != null && c.v !== '') { c.t = 'n'; if (style.numFmt) c.z = style.numFmt; }
  if (isDate && c.v && (c.v instanceof Date)) { c.t = 'd'; if (style.numFmt) c.z = style.numFmt; }
}
// ★ loanId จาก DB อาจเป็น "82" (string) ส่วนใน memory เป็น 82 (number) → เทียบให้ตรงกัน
function litSameLoan(a, b) {
  if (a === b) return true;
  if (a == null || b == null) return false;
  return String(a) === String(b) || litNum(a) === litNum(b);
}
function litColLetter(n) {
  var s = '';
  while (n >= 0) { s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26) - 1; }
  return s;
}

/* ── Export Excel workbook (มีสูตรคำนวณดอกเบี้ย + style สวย) ────────── */
// คืน workbook object (ใช้กับ XLSX.writeFile) — บัญชีตรวจสูตร =F*G*H/365 ได้
function litBuildExportWorkbook(loans, prepaid, actual, refund) {
  var XLSX = window.XLSX;
  if (!XLSX) return null;
  var wb = XLSX.utils.book_new();

  // ★ ค่าที่อ่านกลับจากฐานข้อมูลเป็น "ข้อความ"/null ได้ (เช่น "2774500.0000")
  //   ถ้าเรียก .toFixed() ตรง ๆ จะพัง → ปุ่ม Export กดแล้วเงียบ ไม่มีไฟล์ออก
  //   จึงแปลงเป็นตัวเลขให้เรียบร้อยก่อนใช้งานทุกครั้ง
  loans = (loans || []).map(function (L) {
    return {
      loanId: litNum(L.loanId) || L.loanId,
      contractNo: L.contractNo || '',
      jobNo: L.jobNo || '',
      projectName: L.projectName || '',
      ticketType: L.ticketType || '',
      loanType: L.loanType || '',
      interestRate: litNum(L.interestRate),
      principal: litNum(L.principal),
      dateReceived: L.dateReceived || '',
      dateDue: L.dateDue || '',
      dateDueRoll: L.dateDueRoll || '',
      termDays: litNum(L.termDays),
      principalChequeNo: L.principalChequeNo || '',
      dateRepaid: L.dateRepaid || '',
      status: L.status || '',
      totalPrepaidInterest: litNum(L.totalPrepaidInterest),
      totalActualInterest: litNum(L.totalActualInterest),
      variance: litNum(L.variance),
      totalRefunded: litNum(L.totalRefunded),
      refundOutstanding: litNum(L.refundOutstanding)
    };
  });

  // ── ชีต "สรุปรวม" — master summary (มีสูตร SUM ข้ามชีต) ─────────────
  var sumHeader = [
    'ลำดับที่', 'เลขที่สัญญา', 'JOB NO', 'ชื่อโครงการ', 'ประเภท', 'สถานะ',
    'อัตราดอกเบี้ย', 'วันที่รับเงิน', 'วันที่ครบกำหนด', 'ระยะเวลา(วัน)',
    'จำนวนเงินกู้',
    'รวมดอกจ่ายล่วงหน้า', 'รวมดอกเกิดจริง', 'ส่วนต่าง',
    'รับคืนแล้ว', 'ค้างรับคืน',
    'วันที่คืนเงิน'
  ];
  var sumRows = [sumHeader];
  // เรียงตาม loanId
  var sortedLoans = loans.slice().sort(function (a, b) { return litNum(a.loanId) - litNum(b.loanId); });
  for (var i = 0; i < sortedLoans.length; i++) {
    var L = sortedLoans[i];
    var sheetName = String(L.loanId);
    var safeRef = "'" + sheetName + "'!";
    sumRows.push([
      L.loanId,
      L.contractNo,
      L.jobNo,
      L.projectName,
      L.loanType,
      L.status,
      L.interestRate,
      L.dateReceived ? new Date(L.dateReceived) : '',
      L.dateDue ? new Date(L.dateDue) : '',
      L.termDays,
      L.principal,
      // สูตรลิงก์เข้าชีตต่อสัญญา (cells = สรุปบล็อก)
      { f: safeRef + 'E34' },  // รวมดอกจ่ายล่วงหน้า
      { f: safeRef + 'E35' },  // รวมดอกเกิดจริง
      { f: safeRef + 'E36' },  // ส่วนต่าง
      L.totalRefunded,         // value (refund รวม)
      { f: safeRef + 'E36-' + litNum(L.totalRefunded).toFixed(2) }, // ค้างรับคืน
      L.dateRepaid ? new Date(L.dateRepaid) : ''
    ]);
  }
  // grand total row
  var lastRow = sumRows.length;
  sumRows.push([
    'รวมทั้งสิ้น', '', '', '', '', '', '', '', '', '',
    { f: 'SUM(K2:K' + lastRow + ')' },
    { f: 'SUM(L2:L' + lastRow + ')' },
    { f: 'SUM(M2:M' + lastRow + ')' },
    { f: 'SUM(N2:N' + lastRow + ')' },
    { f: 'SUM(O2:O' + lastRow + ')' },
    { f: 'SUM(P2:P' + lastRow + ')' },
    ''
  ]);
  var wsSum = XLSX.utils.aoa_to_sheet(sumRows);
  wsSum['!cols'] = [
    { wch: 8 }, { wch: 22 }, { wch: 10 }, { wch: 30 }, { wch: 8 }, { wch: 8 },
    { wch: 10 }, { wch: 12 }, { wch: 12 }, { wch: 10 }, { wch: 14 },
    { wch: 16 }, { wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 14 }, { wch: 12 }
  ];
  // ★ apply styles to summary sheet
  // Header row (row 0) — บนสุด สีฟ้า bold
  var sumNCols = sumHeader.length;
  for (var ch = 0; ch < sumNCols; ch++) {
    litStyleCell(wsSum, litColLetter(ch) + '1', LIT_STYLE_HEADER);
  }
  // Data rows (rows 1..lastRow-1)
  var moneyColsSum = [10, 11, 12, 13, 14, 15];  // K=10 (จำนวนเงินกู้), L-P money
  var dateColsSum = [7, 8, 16];  // H, I, Q
  for (var dr = 1; dr < lastRow; dr++) {
    for (var dc = 0; dc < sumNCols; dc++) {
      var isMoney = moneyColsSum.indexOf(dc) >= 0;
      var isDate = dateColsSum.indexOf(dc) >= 0;
      var style = isMoney ? LIT_STYLE_MONEY : (isDate ? LIT_STYLE_DATE : LIT_STYLE_CELL);
      litStyleCell(wsSum, litColLetter(dc) + (dr + 1), style, isMoney, isDate);
    }
  }
  // Grand total row — สีฟ้าเข้ม
  for (var gc = 0; gc < sumNCols; gc++) {
    litStyleCell(wsSum, litColLetter(gc) + (lastRow + 1), LIT_STYLE_GRAND, moneyColsSum.indexOf(gc) >= 0);
  }
  // ★ freeze ส่วนหัว
  wsSum['!freeze'] = { xSplit: 0, ySplit: 1 };
  XLSX.utils.book_append_sheet(wb, wsSum, 'สรุปรวม');

  // ── ชีตต่อสัญญา — มีสูตร =F*G*H/365 ในคอลัมน์ Int.amount ───────────
  for (var li = 0; li < sortedLoans.length; li++) {
    var loan = sortedLoans[li];
    var rows = [];

    // R1-9: header block
    rows.push(['บริษัท วอเทอร์ป๊อก จำกัด']);
    rows.push(['ตารางสรุปดอกเบี้ยรายโครงการ']);
    rows.push(['เลขสัญญาเงินกู้', loan.contractNo, '', '', 'ประเภทตั๋ว', loan.ticketType || '']);
    rows.push(['ชื่อโครงการ', loan.projectName]);
    rows.push([
      'วันที่รับเงิน', loan.dateReceived ? new Date(loan.dateReceived) : '',
      '', 'วันที่ครบกำหนด', loan.dateDue ? new Date(loan.dateDue) : '',
      '', 'วันที่ครบกำหนด(โรล)', loan.dateDueRoll ? new Date(loan.dateDueRoll) : '',
      '', '', 'ลำดับที่', loan.loanId
    ]);
    rows.push([
      'วันที่คืนเงิน', loan.dateRepaid ? new Date(loan.dateRepaid) : '',
      '', 'เลขที่เช็คเงินต้น', loan.principalChequeNo || ''
    ]);
    rows.push(['จำนวนเงินกู้', loan.principal]);
    rows.push(['อัตราดอกเบี้ย/ปี', loan.interestRate, '', '', '', '', '', '', '', '', 'เลขที่ตั๋ว', loan.contractNo]);
    rows.push(['ระยะเวลากู้', loan.termDays, '', '', '', '', '', { f: 'SUM(I11:I100)' }, { f: 'SUM(J11:J100)' }]);

    // R10: schedule header
    rows.push(['Type', 'Month', 'Year', 'เริ่ม', 'สิ้นสุด', 'Principal', 'Int. rate', 'Days', 'Int. amount', 'Installment', 'Principal paid', 'Outstanding']);

    // R11..: Prepaid rows — Int.amount เป็นสูตร =F*G*H/365
    var preRows = prepaid.filter(function (p) { return litSameLoan(p.loanId, loan.loanId); });
    preRows.sort(function (a, b) { return (a.seq || 0) - (b.seq || 0); });
    var preStartRow = 11; // 1-indexed
    for (var pi = 0; pi < preRows.length; pi++) {
      var p = preRows[pi];
      var r = preStartRow + pi;
      rows.push([
        'Prepaid', p.month, p.year,
        p.dateStart ? new Date(p.dateStart) : '',
        p.dateEnd ? new Date(p.dateEnd) : '',
        p.principal, p.intRate,
        { f: 'DATEDIF(D' + r + ',E' + r + ',"d")+1' },
        { f: 'F' + r + '*G' + r + '*H' + r + '/365' },
        { f: 'I' + r },
        0,
        ''  // Outstanding ปล่อยว่าง (สูตร running ไม่ critical ต่อ recon)
      ]);
    }
    // เติมแถวว่างให้ครบช่วง Prepaid block (กัน sub-total chunk shift)
    var preEndRow = preStartRow + preRows.length - 1;
    // 2 blank rows ก่อน subtotal
    rows.push([]); rows.push([]); rows.push([]);
    var preSubtotalRow = rows.length + 1; // 1-indexed
    // ★ ไม่มีแถวเลย → อย่าสร้าง SUM ช่วงกลับหัว (SUM(I11:I10)) ที่กินตัวเองเป็น circular ref
    rows.push(['', '', '', '', '', '', '', '',
      preRows.length ? { f: 'SUM(I' + preStartRow + ':I' + preEndRow + ')' } : 0]);

    // marker "ดอกเบี้ยที่เกิดขึ้นจริง"
    rows.push(['', 'ดอกเบี้ยที่เกิดขึ้นจริง']);

    // Actual header
    rows.push(['Type', 'Month', 'Year', 'เริ่ม', 'สิ้นสุด', 'Principal', 'Int. rate', 'Days', 'Int. amount', 'Installment', 'Principal paid', 'Outstanding']);
    var actRows = actual.filter(function (a) { return litSameLoan(a.loanId, loan.loanId); });
    actRows.sort(function (a, b) { return (a.seq || 0) - (b.seq || 0); });
    var actStartRow = rows.length + 1;
    for (var ai = 0; ai < actRows.length; ai++) {
      var a = actRows[ai];
      var ar = actStartRow + ai;
      rows.push([
        'Actual', a.month, a.year,
        a.dateStart ? new Date(a.dateStart) : '',
        a.dateEnd ? new Date(a.dateEnd) : '',
        a.principal, a.intRate,
        { f: 'DATEDIF(D' + ar + ',E' + ar + ',"d")+1' },
        { f: 'F' + ar + '*G' + ar + '*H' + ar + '/365' },
        { f: 'I' + ar },
        0,
        ''
      ]);
    }
    var actEndRow = actStartRow + actRows.length - 1;
    var actSubtotalRow = rows.length + 1;
    rows.push(['', '', '', '', '', '', '', '',
      actRows.length ? { f: 'SUM(I' + actStartRow + ':I' + actEndRow + ')' } : 0]);

    // สรุป block
    rows.push([]);
    var sumStartRow = rows.length + 1;
    rows.push(['', 'สรุป', 'ยอดดอกเบี้ยจ่ายล่วงหน้า', '', { f: 'I' + preSubtotalRow }]);
    rows.push(['', '', 'ยอดดอกเบี้ยที่เกิดขึ้นจริง', '', { f: 'I' + actSubtotalRow }]);
    rows.push(['', '', 'ส่วนต่าง', '', { f: 'E' + sumStartRow + '-E' + (sumStartRow + 1) }]);
    rows.push([]);

    // refund block — list refund rows
    var refRows = refund.filter(function (rf) { return litSameLoan(rf.loanId, loan.loanId); });
    refRows.sort(function (a, b) { return (a.refundDate || '').localeCompare(b.refundDate || ''); });
    var refStart = rows.length + 1;
    for (var rfi = 0; rfi < refRows.length; rfi++) {
      var rf = refRows[rfi];
      var lbl = rf.refundType === 'RV' ? 'คืนมาแล้วตอน RV' : ('โอนคืน วันที่ ' + (rf.refundDate || ''));
      if (rf.refDoc) lbl += ' (' + rf.refDoc + ')';
      rows.push(['', '', lbl, '', rf.amount]);
    }
    var refEnd = rows.length;
    rows.push(['', '', 'รวมรับคืนแล้ว', '', refRows.length ? { f: 'SUM(E' + refStart + ':E' + refEnd + ')' } : 0]);
    rows.push(['', '', 'ค้างรับคืน', '', { f: 'E' + (sumStartRow + 2) + '-E' + (rows.length) }]);

    var ws = XLSX.utils.aoa_to_sheet(rows);
    ws['!cols'] = [
      { wch: 18 }, { wch: 12 }, { wch: 8 }, { wch: 11 }, { wch: 11 },
      { wch: 12 }, { wch: 10 }, { wch: 8 }, { wch: 14 }, { wch: 14 },
      { wch: 14 }, { wch: 14 }
    ];
    // ★ apply styles ต่อสัญญา
    // Title rows 1-2 (header text)
    litStyleCell(ws, 'A1', LIT_STYLE_TITLE);
    litStyleCell(ws, 'A2', LIT_STYLE_TITLE);
    // Header block rows 3-9 (labels left col + values)
    for (var hr = 3; hr <= 9; hr++) {
      litStyleCell(ws, 'A' + hr, LIT_STYLE_LABEL);
      litStyleCell(ws, 'D' + hr, LIT_STYLE_LABEL);
      litStyleCell(ws, 'G' + hr, LIT_STYLE_LABEL);
      litStyleCell(ws, 'K' + hr, LIT_STYLE_LABEL);
    }
    // Schedule header row 10 — สีฟ้าเข้ม
    for (var sh = 0; sh < 12; sh++) {
      litStyleCell(ws, litColLetter(sh) + '10', LIT_STYLE_HEADER);
    }
    // Body rows: Prepaid + Actual sections
    // Money cols: F(5)Principal, G(6)rate, I(8)Int.amount, J(9)Installment, K(10)Paid, L(11)Outstanding
    // Date cols: D(3)เริ่ม, E(4)สิ้นสุด
    // ★ ลงสไตล์เฉพาะ "บล็อกตาราง" เท่านั้น — เดิมไล่ตั้งแต่แถว 11 ถึงท้ายชีต
    //   ทำให้บล็อกสรุป/รับคืนโดนรูปแบบวันที่ของคอลัมน์ E ไปด้วย
    //   → ยอดเงิน 550,000 ถูกแสดงเป็นวันที่ 5/11/3405
    var totalRows = rows.length;
    var moneyCols = [5, 6, 8, 9, 10, 11];
    var dateCols = [3, 4];
    var bodyRanges = [];
    if (preRows.length) bodyRanges.push([preStartRow, preEndRow]);
    if (actRows.length) bodyRanges.push([actStartRow, actEndRow]);
    for (var bi = 0; bi < bodyRanges.length; bi++) {
      for (var br = bodyRanges[bi][0]; br <= bodyRanges[bi][1]; br++) {
        for (var bc = 0; bc < 12; bc++) {
          var isM = moneyCols.indexOf(bc) >= 0;
          var isD = dateCols.indexOf(bc) >= 0;
          var st = isM ? LIT_STYLE_MONEY : (isD ? LIT_STYLE_DATE : LIT_STYLE_CELL);
          litStyleCell(ws, litColLetter(bc) + br, st, isM, isD);
        }
      }
    }
    // แถวยอดรวมของแต่ละบล็อก (คอลัมน์ I = Int. amount)
    litStyleCell(ws, 'I' + preSubtotalRow, LIT_STYLE_TOTAL, true);
    litStyleCell(ws, 'I' + actSubtotalRow, LIT_STYLE_TOTAL, true);
    // สรุป section (sumStartRow+0..+2) + refund + final
    if (sumStartRow) {
      // ★ litColLetter(4) = 'E' — ช่องยอดเงินของบล็อกสรุปอยู่คอลัมน์ E
      //   เดิมวน 1..5 แล้วเช็ค sc===5 (= คอลัมน์ F ที่ว่าง) → ยอดสรุปไม่ได้รูปแบบตัวเลข
      for (var sr = sumStartRow; sr <= sumStartRow + 2; sr++) {
        for (var sc = 1; sc <= 4; sc++) {
          litStyleCell(ws, litColLetter(sc) + sr, sc === 4 ? LIT_STYLE_TOTAL : LIT_STYLE_SUMMARY_LABEL, sc === 4);
        }
      }
    }
    // ★ บล็อกรับคืน/จ่ายคืน — คอลัมน์ E เป็น "จำนวนเงิน" ไม่ใช่วันที่
    for (var rr = refStart; rr <= totalRows; rr++) {
      litStyleCell(ws, 'C' + rr, LIT_STYLE_SUMMARY_LABEL);
      litStyleCell(ws, 'E' + rr, LIT_STYLE_TOTAL, true);
    }
    // freeze หัวตาราง
    ws['!freeze'] = { xSplit: 0, ySplit: 10 };
    XLSX.utils.book_append_sheet(wb, ws, String(loan.loanId));
  }

  return wb;
}

/* ── Manual data entry helpers — สำหรับ form CRUD ─────────────────────── */
// Generate ตารางดอกเบี้ยรายเดือนแบบ split (เริ่ม → สิ้นเดือน → ต้นเดือนถัดไป → สิ้นสุด)
// ใช้สูตร principal × rate × days / 365 ตรงกับต้นทาง.
// คืน array {seq, month, year, dateStart, dateEnd, days, principal, intRate, intAmount, ...}
function litGenerateMonthlySchedule(principal, rate, startISO, endISO) {
  if (!startISO || !endISO || !principal || !rate) return [];
  var monthName = ['', 'มกราคม', 'กุมภาพันธ์', 'มีนาคม', 'เมษายน', 'พฤษภาคม', 'มิถุนายน',
    'กรกฎาคม', 'สิงหาคม', 'กันยายน', 'ตุลาคม', 'พฤศจิกายน', 'ธันวาคม'];
  function pad2(n) { return n < 10 ? '0' + n : '' + n; }
  // คำนวณวันที่จาก {y,m,d} เป็น ISO "YYYY-MM-DD" — เลี่ยง Date.toISOString() ที่จะ shift TZ
  function ymdToISO(y, m, d) {
    // normalize: ถ้า m หรือ d เกินขอบเขต ใช้ Date คำนวณแล้วดึง local components
    if (d < 1 || d > 28) {
      var t = new Date(y, m - 1, d);
      return t.getFullYear() + '-' + pad2(t.getMonth() + 1) + '-' + pad2(t.getDate());
    }
    return y + '-' + pad2(m) + '-' + pad2(d);
  }
  function isoToYMD(iso) {
    var p = String(iso).slice(0, 10).split('-');
    return { y: +p[0], m: +p[1], d: +p[2] };
  }
  function lastDayOf(y, m) {
    // last day of month m (1-12) of year y
    return new Date(y, m, 0).getDate();
  }
  function cmpISO(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
  function addDays(iso, n) {
    var p = isoToYMD(iso);
    var dt = new Date(p.y, p.m - 1, p.d + n);
    return dt.getFullYear() + '-' + pad2(dt.getMonth() + 1) + '-' + pad2(dt.getDate());
  }

  if (cmpISO(startISO, endISO) >= 0) return [];

  var rows = [];
  var seq = 0;
  var cursorISO = startISO.slice(0, 10);
  while (cmpISO(cursorISO, endISO) < 0) {
    var cur = isoToYMD(cursorISO);
    var checkpointISO;
    if (cur.d <= 20) {
      checkpointISO = ymdToISO(cur.y, cur.m, 20);
    } else {
      checkpointISO = ymdToISO(cur.y, cur.m, lastDayOf(cur.y, cur.m));
    }
    var segEndISO = cmpISO(checkpointISO, endISO) < 0 ? checkpointISO : endISO;
    // mid-segment: inclusive ทั้ง 2 ขอบ → +1 (e.g. Dec 4-20 = 17 days)
    // last segment (ติด endISO = วันครบกำหนด): exclusive end → ไม่ +1 (วันครบกำหนดไม่นับ)
    var isLast = segEndISO === endISO;
    var rawDiff = Math.round((new Date(segEndISO).getTime() - new Date(cursorISO).getTime()) / 86400000);
    var days = isLast ? rawDiff : rawDiff + 1;
    if (days <= 0) break;
    var intAmount = principal * rate * days / 365;
    seq++;
    rows.push({
      seq: seq,
      month: monthName[cur.m],
      year: cur.y,
      dateStart: cursorISO,
      dateEnd: segEndISO,
      days: days,
      principal: principal,
      intRate: rate,
      intAmount: intAmount,
      installment: intAmount,
      principalPaid: 0,
      outstanding: principal,
      chequeNo: '',
      paymentDate: ''
    });
    cursorISO = addDays(segEndISO, 1);
  }
  return rows;
}

// คำนวณ termDays จากวันที่ (= end - start, ไม่ +1; วันครบกำหนดเป็นวันคืนเงิน ไม่นับ)
function litCalcTermDays(startISO, endISO) {
  if (!startISO || !endISO) return 0;
  var a = new Date(startISO).getTime();
  var b = new Date(endISO).getTime();
  if (!isFinite(a) || !isFinite(b)) return 0;
  return Math.max(0, Math.round((b - a) / 86400000));
}

// สร้าง loanId ถัดไป (สูงสุด + 1) จาก debtMaster rows ที่เป็น leasit
function litNextLoanId(debtMasterRows) {
  var maxId = 0;
  (debtMasterRows || []).forEach(function (r) {
    if (r.leasitSource && r.leasitLoanId > maxId) maxId = r.leasitLoanId;
  });
  return maxId + 1;
}

// สร้าง row ว่างสำหรับฟอร์มเพิ่มใหม่
function litBlankLoanDraft(debtMasterRows) {
  return {
    leasitLoanId: litNextLoanId(debtMasterRows),
    contractNo: '',
    projectCode: '',
    projectName: '',
    leasitTicketType: 'PRE',
    principalAmount: 0,
    interestRate: 0.15,
    leasitDateReceived: '',
    leasitDateDue: '',
    leasitDateDueRoll: '',
    leasitDateRepaid: '',
    leasitTermDays: 0,
    leasitPrincipalChequeNo: '',
    leasitInterestChequeNo: '',
    status: 'Active',
    note: ''
  };
}

// ค้นหาโครงการจาก data.projects — return top N matches
// match: substring on Contract No. / Site Name / ชื่อโครงการ / code / siteName / jobNo
function litSearchProjects(projects, query, limit) {
  limit = limit || 8;
  if (!Array.isArray(projects)) return [];
  var q = String(query || '').trim().toLowerCase();
  if (!q) return projects.slice(0, limit).map(litProjectToOption);
  var matches = [];
  for (var i = 0; i < projects.length && matches.length < limit * 3; i++) {
    var p = projects[i];
    var code = String(p['Contract No.'] || p.code || p.jobNo || '').toLowerCase();
    var name = String(p['Site Name'] || p['ชื่อโครงการ'] || p.siteName || p.name || p.projectName || '').toLowerCase();
    if (code.indexOf(q) >= 0 || name.indexOf(q) >= 0) {
      matches.push(litProjectToOption(p));
    }
  }
  return matches.slice(0, limit);
}
function litProjectToOption(p) {
  return {
    code: String(p['Contract No.'] || p.code || p.jobNo || '').trim(),
    name: String(p['Site Name'] || p['ชื่อโครงการ'] || p.siteName || p.name || p.projectName || '').trim(),
    _raw: p
  };
}

// คำนวณ Actual schedule จากวันรับ → วันคืนเงิน (ใช้ตอนปิดสัญญา)
// = monthly split เดียวกับ prepaid แต่ตัดที่ dateRepaid
function litGenerateActualFromClose(principal, rate, startISO, repaidISO) {
  if (!startISO || !repaidISO) return [];
  return litGenerateMonthlySchedule(principal, rate, startISO, repaidISO);
}

// ★ Declining principal — คำนวณ Actual โดยลด principal ทุกครั้งที่จ่ายคืน
//   repayments = [{date:ISO, amount:n}, ...] · จะ sort ภายใน
//   ผลลัพธ์: schedule ราย segment ที่มี principal ลดลงตามจริง + principalPaid + outstanding ต่อแถว
function litGenerateActualDeclining(principal, rate, startISO, endISO, repayments) {
  if (!startISO || !endISO || !principal || !rate) return [];
  function pad2(n) { return n < 10 ? '0' + n : '' + n; }
  function addDays(iso, n) {
    var p = String(iso).slice(0, 10).split('-');
    var dt = new Date(+p[0], +p[1] - 1, +p[2] + n);
    return dt.getFullYear() + '-' + pad2(dt.getMonth() + 1) + '-' + pad2(dt.getDate());
  }
  // ไม่มี repayments → fallback monthly schedule ปกติ
  var reps = (repayments || [])
    .filter(function (r) { return r && r.date && Number(r.amount) > 0; })
    .map(function (r) { return { date: String(r.date).slice(0, 10), amount: Number(r.amount) || 0 }; })
    .sort(function (a, b) { return a.date.localeCompare(b.date); });
  if (!reps.length) return litGenerateMonthlySchedule(principal, rate, startISO, endISO);

  var allSegs = [];
  var currentStart = startISO.slice(0, 10);
  var currentPrincipal = principal;
  for (var i = 0; i < reps.length; i++) {
    var rep = reps[i];
    if (rep.date <= currentStart) continue;
    if (rep.date > endISO) break;
    // สร้าง schedule ตั้งแต่ currentStart → rep.date ด้วย currentPrincipal
    var segRows = litGenerateMonthlySchedule(currentPrincipal, rate, currentStart, rep.date);
    // แท็ก outstanding ต่อแถว
    for (var sj = 0; sj < segRows.length; sj++) {
      segRows[sj].principal = currentPrincipal;
      segRows[sj].outstanding = currentPrincipal;
      segRows[sj].principalPaid = 0;
    }
    if (segRows.length) {
      // แถวสุดท้ายของ segment = จุดจ่ายเงินต้น
      segRows[segRows.length - 1].principalPaid = rep.amount;
      segRows[segRows.length - 1].outstanding = Math.max(0, currentPrincipal - rep.amount);
    }
    allSegs = allSegs.concat(segRows);
    currentPrincipal = Math.max(0, currentPrincipal - rep.amount);
    currentStart = addDays(rep.date, 1);
  }
  // segment สุดท้ายถ้า principal > 0 + ยังไม่ถึง endISO
  if (currentPrincipal > 0.01 && currentStart < endISO) {
    var finalSegs = litGenerateMonthlySchedule(currentPrincipal, rate, currentStart, endISO);
    for (var fj = 0; fj < finalSegs.length; fj++) {
      finalSegs[fj].principal = currentPrincipal;
      finalSegs[fj].outstanding = currentPrincipal;
      finalSegs[fj].principalPaid = 0;
    }
    allSegs = allSegs.concat(finalSegs);
  }
  // re-number seq
  for (var k = 0; k < allSegs.length; k++) allSegs[k].seq = k + 1;
  return allSegs;
}

// expose
window.LeasitEngine = {
  parseLeasitWorkbook: parseLeasitWorkbook,
  litComputeTotals: litComputeTotals,
  litLoanToDebtRow: litLoanToDebtRow,
  litBuildExportWorkbook: litBuildExportWorkbook,
  litToISO: litToISO,
  litNum: litNum,
  litDaysBetween: litDaysBetween,
  litGenerateMonthlySchedule: litGenerateMonthlySchedule,
  litGenerateActualFromClose: litGenerateActualFromClose,
  litGenerateActualDeclining: litGenerateActualDeclining,
  litCalcTermDays: litCalcTermDays,
  litNextLoanId: litNextLoanId,
  litBlankLoanDraft: litBlankLoanDraft,
  litSearchProjects: litSearchProjects
};
