ซีรีส์ 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 ชีตก่อนใช้ ดังนี้
- อัปไฟล์ขึ้น Google Drive แล้วเปิดไฟล์
- กด File › Save as Google Sheets ระบบจะเปิดไฟล์ใหม่ที่เป็น Google ชีต ใช้ไฟล์ใหม่นี้ในทุกขั้นต่อจากนี้
- กด 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 จากตอนที่แล้ว)
- ทำสำเนาไฟล์ของคุณก่อน เปิดสำเนา กดเมนู Extensions › Apps Script วางโค้ดข้างล่าง แล้วกดบันทึก
- กลับไปหน้าชีต รีเฟรชหนึ่งครั้ง แล้วกดเมนู 🔍 ตรวจชีต › ตรวจแท็บนี้
- ครั้งแรกจะขอสิทธิ์ อ่านตามวิธีในตอนที่แล้ว อนุญาตแล้วกดเมนูอีกครั้ง
- พิมพ์เลขแถวที่เป็นหัวตาราง ถ้าไม่แน่ใจใส่ 1
ถ้าไฟล์นั้นมีสคริปต์ที่มี onOpen อยู่แล้ว (เช่น เมนูร้านขนมจากตอนที่แล้ว) ให้ใช้กับสำเนาอีกไฟล์ เพราะ onOpen 2 ตัวในโปรเจกต์เดียวจะชนกัน
/** @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 แบบ แบบสั้นได้โค้ดสำหรับชีตที่ไม่มีอยู่จริง ส่วนแบบเต็มได้ผลตรงกับที่คิดมือทุกแถว
อาทิตย์ละสกิล เดี๋ยวก็เก่ง