SumoSunday

ซีรีส์ Apps Script ด้วย AI · ตอนที่ 2 จาก 3

ชีตที่ตาเห็นว่าปกติ แต่สคริปต์อ่านผิด 10 จุด: จัดชีตก่อนให้ AI เขียน Apps Script

21 ต.ค. 2569 · อ่าน 9 นาที

AI ที่เขียน Apps Script ให้มองไม่เห็นชีตของคุณ มันเขียนโค้ดสำหรับตารางเรียบร้อยที่ 1 แถวคือ 1 รายการ แต่ชีตที่ใช้กันจริงมีเซลล์ผสาน หัวตาราง 2 ชั้น วันที่ พ.ศ. ที่พิมพ์เป็นข้อความ และเบอร์โทรที่เลข 0 หาย ตอนที่ 2 ของซีรีส์ Apps Script ด้วย AI ตรวจชีตร้านขนมสมมติด้วยสคริปต์จริง เจอ 10 จุด พร้อมวิธีแก้และสคริปต์ตรวจชีตให้เอาไปใช้ฟรี

ตอนที่ 2 ของซีรีส์ "Apps Script ด้วย AI" ต่อจากตอนที่แล้วที่เราทำเมนูของตัวเองใน Google ชีต

ชีตในตอนที่แล้วเรียบร้อยมาก แถวแรกเป็นหัวตาราง และ 1 แถวคือ 1 ออเดอร์ แต่ชีตที่ใช้กันจริงหน้าตาไม่ค่อยเป็นแบบนั้น และปัญหาคือ AI ที่เขียนสคริปต์ให้มองไม่เห็นชีตของคุณ มันจะเขียนโค้ดสำหรับตารางเรียบร้อยเสมอ ถ้าชีตจริงไม่ใช่ สคริปต์จะอ่านผิดโดยไม่มี error ให้เห็น

ตอนนี้เราเอาชีตแบบที่เจอบ่อยมาตรวจด้วยสคริปต์ แล้วดูว่าต้องแก้อะไรก่อนให้ AI เขียนโค้ด

ข้อมูล ณ ตุลาคม 2569 ผลในบทความนี้มาจากการลองจริงในบัญชี Google ของเรา ไฟล์ตัวอย่างตั้งค่าภาษา (Locale) เป็น Thailand ร้านขนมบ้านเนยเป็นร้านสมมติ และเบอร์โทรเป็นเลขสมมติ

ชีตแบบที่เจอบ่อย

ออเดอร์ชุดเดียวกับตอนที่แล้ว แต่จดแบบที่คนจดกันจริง

  • หัวรายงาน "ออเดอร์ร้านขนมบ้านเนย เดือนตุลาคม 2569" ผสานเซลล์ยาวทั้งแถว
  • หัวตาราง 2 ชั้น เช่น "ข้อมูลลูกค้า" คลุม "ชื่อ" กับ "เบอร์"
  • วันที่สั่งผสานเซลล์คลุมหลายออเดอร์ของวันเดียวกัน
  • วันที่บางแถวพิมพ์ว่า "13/10/2569" บางแถว "14 ต.ค. 69"
  • มีแถว "รวม 12–13 ต.ค." อยู่กลางตาราง ตามด้วยแถวว่าง
  • ช่องจ่ายเงินมีทั้ง "จ่ายแล้ว" "✓" "โอนแล้ว" "ยัง" และช่องว่าง

คนอ่านชีตนี้เข้าใจหมด เพราะตาเราเห็นภาพรวม ส่วนสคริปต์อ่านทีละช่องเป็นตาราง แถว × คอลัมน์

ไฟล์ตัวอย่าง: ดาวน์โหลด "ชีตตัวอย่าง Apps Script ตอน 2: ชีตออเดอร์แบบที่เจอบ่อย" ได้ที่หน้าเทมเพลต (สมัครสมาชิกฟรีก่อนดาวน์โหลด) ในไฟล์มีแท็บ "ออเดอร์-แบบที่เจอบ่อย" ที่เราตรวจในตอนนี้ และแท็บ "ออเดอร์" แบบเรียบร้อยไว้เทียบ ไฟล์เป็น Excel ต้องเปลี่ยนเป็น Google ชีตก่อนใช้ ดังนี้

  1. อัปไฟล์ขึ้น Google Drive แล้วเปิดไฟล์
  2. กด File › Save as Google Sheets ระบบจะเปิดไฟล์ใหม่ที่เป็น Google ชีต ใช้ไฟล์ใหม่นี้ในทุกขั้นต่อจากนี้
  3. กด File › Settings แล้วตั้งช่อง Locale เป็น Thailand ตามที่เราใช้ทดสอบ

ผลตรวจจริง: สคริปต์อ่านผิด 10 จุด แบ่งได้ 8 แบบ

เราใช้สคริปต์ตรวจชีต (โค้ดอยู่ท้ายบทความ) บอกมันว่าหัวตารางอยู่แถว 3 ผลที่ได้คือ "เจอ 10 เรื่องที่ทำให้สคริปต์อ่านผิด" จัดกลุ่มได้ 8 แบบ

1. เซลล์ผสาน

เจอ 9 จุด ทั้งหัวรายงาน หัวตาราง และวันที่ ถ้าคลิกช่อง A5 ที่อยู่ในก้อนวันที่ผสาน ชีตจะเลือกทั้งก้อนและโชว์วันที่ให้เห็น แต่สคริปต์อ่านทีละช่อง เราลองให้สคริปต์อ่าน 3 ช่องในก้อนนี้ ได้วันที่แค่ช่องบนสุด อีก 2 ช่องเป็นค่าว่าง ออเดอร์ A-1002 กับ A-1003 จึงกลายเป็นออเดอร์ที่ไม่มีวันที่

แก้: เลือกช่วงที่ผสาน กดเมนู Format › Merge cells › Unmerge แล้วเติมวันที่ให้ครบทุกแถว

2. หัวตารางไม่ได้อยู่แถว 1 และมี 2 ชั้น

สคริปต์ส่วนใหญ่ที่ AI เขียนจะถือว่าแถว 1 คือหัวตาราง ถ้าไม่บอก มันจะเอาหัวรายงานไปเป็นชื่อคอลัมน์ ผลตรวจยังเจอว่าคอลัมน์ A กับ B ไม่มีชื่อ เพราะชื่ออยู่ในเซลล์ผสานแถว 2–3

แก้: หัวตาราง 1 แถว อยู่แถว 1 ชื่อสั้นและไม่ซ้ำกัน หัวรายงานย้ายไปไว้ชื่อแท็บแทน

3. แถวว่างคั่นกลางข้อมูล

สคริปต์หลายแบบอ่านจนเจอแถวว่างแรกแล้วหยุด ออเดอร์หลังแถวว่างจะหายไปจากผลลัพธ์

แก้: ลบแถวว่างออก

4. แถวรวมยอดอยู่กลางตาราง

แถว "รวม 12–13 ต.ค." ยอด 1,620 จะถูกนับเป็นออเดอร์หนึ่ง พอสคริปต์รวมยอดทั้งเดือน ยอดของ 2 วันนั้นจะถูกนับซ้ำ

แก้: ไม่ใส่ยอดรวมในตารางข้อมูล ทำยอดรวมในแท็บรายงานแยก

5. คอลัมน์เดียวมีข้อมูลหลายชนิด

คอลัมน์จำนวนมีตัวเลข 6 ช่อง และข้อความ "3 กล่อง" 1 ช่อง สคริปต์เอา "3 กล่อง" ไปคูณราคาไม่ได้

แก้: 1 คอลัมน์ 1 ชนิด จำนวนใส่แค่ตัวเลข หน่วยไปอยู่ในชื่อหัวตาราง เช่น "จำนวน (กล่อง)"

6. วันที่ที่พิมพ์เป็นข้อความ

เจอ 3 แถว ทั้ง "13/10/2569" และ "14 ต.ค. 69" สคริปต์เรียงหรือนับวันจากข้อความไม่ได้ เพราะมันไม่รู้ว่าเป็นวันที่

เราลองพิมพ์ 15/10/2569 ลงช่องว่างในแท็บที่สะอาดของไฟล์ตัวอย่าง (ตั้งภาษาเป็น Thailand) ผลคือชีตเก็บเป็นข้อความ (ชิดซ้าย) ชีตไม่ได้แปลงปี พ.ศ. ที่เราพิมพ์ให้

แล้วลองพิมพ์ปี ค.ศ. แบบ วัน/เดือน/ปี 2 ช่อง ได้ผลไม่เหมือนกัน

  • 15/10/2026 ชีตยังเก็บเป็นข้อความ (ชิดซ้าย)
  • 12/10/2026 ชีตรับเป็นวันที่ (ชิดขวา) และสูตร =MONTH() ได้ 10 คือ 12 ตุลาคม ตรงตามที่พิมพ์

พิมพ์รูปแบบเดียวกัน แต่ช่องหนึ่งเป็นวันที่ อีกช่องเป็นข้อความ และชีตไม่เตือนอะไรเลย ถ้าไม่สังเกตว่าช่องชิดซ้ายหรือชิดขวา จะไม่รู้เลยว่ามีช่องที่สคริปต์นับวันไม่ได้

แก้: พิมพ์วันที่แบบ ปี-เดือน-วัน เช่น 2026-10-15 เราลองแล้ว ชีตรับเป็นวันที่ (ชิดขวา) สูตร =MONTH() ได้ 10 และ =DAY() ได้ 15 ตรงตามที่พิมพ์ ถ้าอยากเห็นเป็น วว/ดด/ปปปป หรือ พ.ศ. ค่อยจัดรูปแบบตอนแสดงผล

7. เบอร์โทรที่เลข 0 หาย

เราพิมพ์ 0800000110 ลงช่องว่าง กด Enter แล้วช่องนั้นเหลือ 800000110 เพราะชีตเห็นว่าเป็นตัวเลข ในชีตตัวอย่างมี 3 แถวที่เป็นแบบนี้ และอีก 4 แถวพิมพ์เบอร์เป็นข้อความหลายแบบ มีทั้งขีดและวรรค

แก้: เลือกทั้งคอลัมน์ แล้วกดเมนู Format › Number › Plain text ก่อนพิมพ์ แล้วพิมพ์เบอร์แบบเดียวกันทุกแถว

8. วรรคที่มองไม่เห็น

"คุณมะลิ" ในแถว 11 มีวรรคต่อท้าย ตาเราไม่เห็น แต่สคริปต์เห็นเป็นคนละชื่อกับ "คุณมะลิ" ในแถวแรก ถ้าสรุปยอดตามลูกค้า คุณมะลิจะถูกแยกเป็น 2 คน

แก้: เลือกข้อมูล แล้วกดเมนู Data › Data cleanup › Trim whitespace

สิ่งที่ตัวตรวจจับไม่ได้ และวิธีกันไว้ก่อน

ช่องจ่ายเงินที่มีคำ 4 แบบ ("จ่ายแล้ว" "✓" "โอนแล้ว" "ยัง") ไม่อยู่ในผลตรวจ เพราะทุกช่องเป็นข้อความเหมือนกัน สคริปต์ไม่รู้ว่า "✓" กับ "โอนแล้ว" แปลว่าจ่ายแล้วเหมือนกัน คนต้องเป็นคนตัดสิน

แก้: ใช้ช่องติ๊ก (Insert › Checkbox) หรือรายการให้เลือก (Data › Data validation) จะได้มีแค่ค่าที่ตกลงกันไว้

ตัวตรวจในตอนนี้เดาจากรูปแบบที่รู้จักเท่านั้น ผ่านแล้วไม่ได้แปลว่าชีตไม่มีปัญหาเลย

หลักเดียวที่แก้ได้เกือบทุกข้อ: แยกแท็บข้อมูลออกจากแท็บรายงาน

แยกแท็บข้อมูลให้เครื่องอ่าน ออกจากแท็บรายงานให้คนอ่าน

แท็บข้อมูล (ให้สคริปต์และ AI อ่าน)แท็บรายงาน (ให้คนอ่าน)
หัวตาราง 1 แถว อยู่แถว 1หัวรายงาน สีสัน จัดหน้าได้เต็มที่
1 แถว = 1 รายการ ไม่ผสานเซลล์ผสานเซลล์ได้
ไม่มีแถวว่าง ไม่มีแถวรวมยอดรวมอยู่ที่นี่
1 คอลัมน์ 1 ชนิด วันที่เป็นวันที่ เบอร์เป็นข้อความดึงข้อมูลมาด้วยสูตร หรือให้สคริปต์เขียนให้

คนที่จดออเดอร์จะชอบแท็บรายงานมากกว่า ไม่เป็นไร ให้จดในแท็บข้อมูล แล้วให้แท็บรายงานดึงข้อมูลไปแสดงเอง

เราตรวจแท็บออเดอร์แบบเรียบร้อยด้วยสคริปต์เดียวกัน ผลคือ "ไม่เจอปัญหาที่ตรวจได้ ✓" แล้วลองพิมพ์เบอร์โทรกับวันที่ พ.ศ. เพิ่มลงไปในแท็บนั้น ตรวจอีกรอบ มันเจอทั้ง 2 ช่องทันที

สคริปต์ตรวจชีตสำหรับนำไปใช้กับชีตของคุณ

สคริปต์นี้อ่านอย่างเดียว ไม่แก้ข้อมูล และเขียนให้ขอสิทธิ์แค่ไฟล์ที่ใช้ (@OnlyCurrentDoc จากตอนที่แล้ว)

  1. ทำสำเนาไฟล์ของคุณก่อน เปิดสำเนา กดเมนู Extensions › Apps Script วางโค้ดข้างล่าง แล้วกดบันทึก
  2. กลับไปหน้าชีต รีเฟรชหนึ่งครั้ง แล้วกดเมนู 🔍 ตรวจชีต › ตรวจแท็บนี้
  3. ครั้งแรกจะขอสิทธิ์ อ่านตามวิธีในตอนที่แล้ว อนุญาตแล้วกดเมนูอีกครั้ง
  4. พิมพ์เลขแถวที่เป็นหัวตาราง ถ้าไม่แน่ใจใส่ 1

ถ้าไฟล์นั้นมีสคริปต์ที่มี onOpen อยู่แล้ว (เช่น เมนูร้านขนมจากตอนที่แล้ว) ให้ใช้กับสำเนาอีกไฟล์ เพราะ onOpen 2 ตัวในโปรเจกต์เดียวจะชนกัน

โค้ด JavaScript
/** @OnlyCurrentDoc */
// ตรวจชีตก่อนให้ AI เขียนสคริปต์ (อ่านอย่างเดียว ไม่แก้ข้อมูล)

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('🔍 ตรวจชีต')
    .addItem('ตรวจแท็บนี้', 'checkThisSheet')
    .addToUi();
}

// ชื่อเดือนต้องมีตัวเลขติดหน้าหรือหลัง — ไม่งั้นชื่อเล่นอย่าง "มีนา" "ตุลา" จะถูกเหมาว่าเป็นวันที่
const THAI_MONTH = /\d\s*(ม\.ค|ก\.พ|มี\.ค|เม\.ย|พ\.ค|มิ\.ย|ก\.ค|ส\.ค|ก\.ย|ต\.ค|พ\.ย|ธ\.ค|มกรา|กุมภา|มีนา|เมษา|พฤษภา|มิถุนา|กรกฎา|สิงหา|กันยา|ตุลา|พฤศจิกา|ธันวา)|(ม\.ค|ก\.พ|มี\.ค|เม\.ย|พ\.ค|มิ\.ย|ก\.ค|ส\.ค|ก\.ย|ต\.ค|พ\.ย|ธ\.ค|มกรา|กุมภา|มีนา|เมษา|พฤษภา|มิถุนา|กรกฎา|สิงหา|กันยา|ตุลา|พฤศจิกา|ธันวา)\.?\s*\d/;
const DATE_LIKE = /^\d{1,2}[\/\-.]\d{1,2}[\/\-.]\d{2,4}$/;

function checkThisSheet() {
  const ui = SpreadsheetApp.getUi();
  const sheet = SpreadsheetApp.getActiveSheet();
  const answer = ui.prompt('ตรวจแท็บ "' + sheet.getName() + '"',
    'หัวตาราง (ชื่อคอลัมน์) อยู่แถวที่เท่าไร? ถ้าไม่แน่ใจ ใส่ 1', ui.ButtonSet.OK_CANCEL);
  if (answer.getSelectedButton() !== ui.Button.OK) return;
  const headerRow = parseInt(answer.getResponseText(), 10) || 1;

  const range = sheet.getDataRange();
  const values = range.getValues();
  const problems = [];

  // 1) เซลล์ผสาน — สคริปต์เห็นค่าเฉพาะช่องซ้ายบน ช่องอื่นเป็นค่าว่าง
  const merged = range.getMergedRanges().map(function (r) { return r.getA1Notation(); });
  if (merged.length) {
    problems.push('มีเซลล์ผสาน ' + merged.length + ' จุด: ' + merged.join(', ') +
      ' → สคริปต์อ่านค่าได้แค่ช่องแรกของแต่ละจุด ช่องที่เหลือกลายเป็นค่าว่าง');
  }
  if (headerRow > 1) {
    problems.push('หัวตารางอยู่แถว ' + headerRow + ' (ไม่ใช่แถว 1) → ต้องบอก AI ทุกครั้ง หรือย้ายหัวตารางขึ้นแถว 1');
  }

  const headers = values[headerRow - 1] || [];
  const rows = values.slice(headerRow);
  const label = function (c) {
    return columnLetter_(c + 1) + (headers[c] ? ' "' + String(headers[c]).trim() + '"' : '');
  };

  // 2) แถวว่างคั่น และแถวรวมยอดกลางตาราง
  const blankRows = [];
  const totalRows = [];
  rows.forEach(function (row, i) {
    const rowNo = headerRow + 1 + i;
    if (row.every(function (v) { return v === ''; })) blankRows.push(rowNo);
    else if (row.some(function (v) { return typeof v === 'string' && /^(รวม|ยอดรวม|total)/i.test(v.trim()); })) totalRows.push(rowNo);
  });
  if (blankRows.length) problems.push('แถวว่างคั่นกลางข้อมูล: แถว ' + blankRows.join(', ') + ' → สคริปต์หลายตัวหยุดอ่านตรงแถวว่างแรก');
  if (totalRows.length) problems.push('แถวรวมยอดอยู่กลางข้อมูล: แถว ' + totalRows.join(', ') + ' → สคริปต์จะนับแถวรวมเป็นรายการหนึ่ง ยอดเลยซ้ำ');

  // 3) ตรวจทีละคอลัมน์
  for (let c = 0; c < headers.length; c++) {
    const kinds = { วันที่: 0, ตัวเลข: 0, ข้อความ: 0, 'ถูก/ผิด': 0 };
    let blanks = 0;
    const phoneLost0 = [];
    const textDates = [];
    const buddhistYear = [];
    const spaces = [];
    rows.forEach(function (row, i) {
      const v = row[c];
      const rowNo = headerRow + 1 + i;
      if (totalRows.indexOf(rowNo) !== -1) return; // แถวรวมยอดรายงานไปแล้วข้างบน
      if (v === '') { blanks++; return; }
      if (v instanceof Date) {
        kinds['วันที่']++;
        if (v.getFullYear() > 2400) buddhistYear.push(rowNo);
      } else if (typeof v === 'number') {
        kinds['ตัวเลข']++;
        if (Number.isInteger(v) && /^[689]\d{7,8}$/.test(String(v))) phoneLost0.push(rowNo);
      } else if (typeof v === 'boolean') {
        kinds['ถูก/ผิด']++;
      } else {
        kinds['ข้อความ']++;
        const s = String(v);
        if (DATE_LIKE.test(s.trim()) || THAI_MONTH.test(s)) textDates.push(rowNo);
        if (s !== s.trim()) spaces.push(rowNo);
      }
    });
    const found = Object.keys(kinds).filter(function (k) { return kinds[k] > 0; });
    if (found.length > 1) {
      problems.push('คอลัมน์ ' + label(c) + ' มีข้อมูลหลายชนิดปนกัน (' +
        found.map(function (k) { return k + ' ' + kinds[k]; }).join(' · ') + ') → ให้ 1 คอลัมน์มีชนิดเดียว');
    }
    if (phoneLost0.length) problems.push('คอลัมน์ ' + label(c) + ' แถว ' + phoneLost0.join(', ') + ' น่าจะเป็นเบอร์โทรที่เลข 0 หาย → จัดรูปแบบคอลัมน์เป็น "ข้อความธรรมดา" แล้วพิมพ์ใหม่');
    if (textDates.length) problems.push('คอลัมน์ ' + label(c) + ' แถว ' + textDates.join(', ') + ' เป็นวันที่ที่พิมพ์เป็นข้อความ → สคริปต์เรียง/นับวันไม่ได้');
    if (buddhistYear.length) problems.push('คอลัมน์ ' + label(c) + ' แถว ' + buddhistYear.join(', ') + ' เป็นวันที่ปี ค.ศ. เกิน 2400 (น่าจะพิมพ์ พ.ศ. ลงไป) → คำนวณวันผิด 543 ปี');
    if (spaces.length) problems.push('คอลัมน์ ' + label(c) + ' แถว ' + spaces.join(', ') + ' มีวรรคหน้า/หลังข้อความ → "คุณมะลิ" กับ "คุณมะลิ␣" ถูกนับเป็นคนละคน');
    if (blanks > 0 && blanks < rows.length) Logger.log('คอลัมน์ ' + label(c) + ' มีช่องว่าง ' + blanks + ' ช่อง');
  }

  const title = problems.length ? 'เจอ ' + problems.length + ' เรื่องที่ทำให้สคริปต์อ่านผิด' : 'ไม่เจอปัญหาที่ตรวจได้ ✓';
  problems.forEach(function (p) { Logger.log('• ' + p); });
  ui.alert(title, problems.map(function (p, i) { return (i + 1) + ') ' + p; }).join('\n\n') ||
    'ชีตนี้พร้อมให้ AI เขียนสคริปต์แล้ว (ตรวจเฉพาะเรื่องที่สคริปต์นี้รู้จัก)', ui.ButtonSet.OK);
}

function columnLetter_(n) {
  let s = '';
  while (n > 0) { const m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = Math.floor((n - 1) / 26); }
  return s;
}

ตัวตรวจดู 8 เรื่องในบทความนี้ บางเรื่องดูจากรูปแบบของข้อมูล เช่น เมื่อเจอตัวเลข 8–9 หลักที่ขึ้นต้นด้วย 6 8 หรือ 9 ตัวตรวจจะแจ้งว่าเป็นเบอร์โทรที่เลข 0 หาย ถ้าคอลัมน์นั้นเป็นตัวเลขอย่างอื่นจริง ก็ข้ามข้อนั้นไปได้

ตอนถัดไป: สั่ง AI อย่างไรให้ได้สคริปต์ที่ตรงกับชีตจริง?

ชีตพร้อมแล้ว ขั้นต่อไปคือบอก AI ว่าชีตหน้าตาเป็นยังไง ตอนถัดไป เราลองสั่ง AI เขียนสคริปต์สรุปวันลาด้วย prompt 2 แบบ แบบสั้นได้โค้ดสำหรับชีตที่ไม่มีอยู่จริง ส่วนแบบเต็มได้ผลตรงกับที่คิดมือทุกแถว

อาทิตย์ละสกิล เดี๋ยวก็เก่ง