from pathlib import Path
import json, hashlib, shutil, sys
from copy import deepcopy
from docx import Document
from docx.shared import Cm, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.style import WD_STYLE_TYPE
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.opc.constants import RELATIONSHIP_TYPE as RT
from PIL import Image

ROOT = Path(__file__).resolve().parents[1]
REF = ROOT / 'work/reference_thesis.docx'
OUT = ROOT / 'TourApp_Appendix_A_B.docx'

# Number, heading, prose, screenshot key, source; prose is authored in Thai.
A = [
('A.1', 'ฮาร์ดแวร์และซอฟต์แวร์ที่ใช้', [
'ระบบจัดกลุ่มนักท่องเที่ยวอัตโนมัติ (Automated Tourism Clustering System) แบ่งเป็นแอปนักท่องเที่ยวที่พัฒนาด้วย Flutter บริการ REST ที่พัฒนาด้วย Dart ฐานข้อมูล MariaDB และเว็บบริษัทนำเที่ยวกับผู้ดูแลระบบที่พัฒนาด้วย React เครื่องที่ใช้ตรวจสภาพแวดล้อมเป็น Windows 11 Home Single Language รุ่น 10.0.26200 แบบ 64 บิต หน่วยประมวลผล AMD Ryzen 5 7520U และหน่วยความจำที่ระบบรายงานประมาณ 15.3 GB เครื่องติดตั้งใหม่ควรมีพื้นที่ว่างเพียงพอสำหรับ Android SDK อิมเมจ Emulator และไฟล์แพ็กเกจ พร้อมเชื่อมต่ออินเทอร์เน็ตขณะติดตั้ง',
'Windows เป็นระบบปฏิบัติการสำหรับเปิดเครื่องมือ Flutter ใช้สร้างและรันแอป ส่วน Dart ใช้รันบริการ Android SDK และ Emulator จำลองอุปกรณ์ทดสอบ Visual Studio Code ใช้เปิดโครงการและเทอร์มินัล XAMPP ในเครื่องอ้างอิงจัดเตรียม MariaDB และโปรแกรม mysql ส่วน Node.js กับ npm ใช้ติดตั้งและเรียก Vite สำหรับ Portal เว็บเบราว์เซอร์ใช้เปิดหน้าของบริษัทและผู้ดูแล',
'รุ่นที่ตรวจจากคำสั่งบนเครื่องนี้ ได้แก่ Flutter 3.35.1 ช่องทาง stable, Dart 3.9.0, Node.js 24.19.0, npm 11.17.0, MariaDB 10.4.32 และ Android SDK Platform Tools 37.0.1 โดย adb รายงานเวอร์ชัน 1.0.41 มี Android Virtual Device ชื่อ Pixel_6 ส่วนรุ่น Android system image ต้องตรวจจาก Device Manager ของเครื่องติดตั้ง ไม่ควรอนุมานจากชื่ออุปกรณ์',
'ไฟล์ pubspec.yaml กำหนด Dart SDK ^3.9.0 และแอปรุ่น 1.0.0+1 ส่วน Portal กำหนด React ^19.1.0, React Router ^7.6.3, Vite ^7.1.7 และ TypeScript ~5.8.3 ค่าดังกล่าวเป็นข้อกำหนดของแพ็กเกจ มิใช่การยืนยันว่าติดตั้งรุ่นล่าสุด ควรเก็บไฟล์ล็อกและติดตั้งตามรุ่นที่โครงการใช้งาน'], 'versions', 'pubspec.yaml; portal/package.json; read-only version commands'),
('A.2', 'การเตรียมโครงการและแพ็กเกจ', [
'นำสำเนาโครงการมาไว้ในโฟลเดอร์ที่ผู้ติดตั้งมีสิทธิ์อ่านและเขียน เปิด Visual Studio Code เลือก File > Open Folder แล้วเลือก travelin_app โครงสร้างหลักประกอบด้วย lib สำหรับแอป Flutter, server สำหรับบริการ Dart, database สำหรับโครงสร้างและการปรับปรุงฐานข้อมูล และ portal สำหรับเว็บบริษัทนำเที่ยวกับผู้ดูแลระบบ โครงการนี้ไม่ใช่ระบบ PHP จึงไม่ต้องนำไฟล์ไปวางใน htdocs เพื่อเปิดแอป',
'เปิดเทอร์มินัลที่ travelin_app แล้วเรียก flutter pub get เพื่อติดตั้งแพ็กเกจ Dart และ Flutter จากนั้นเปลี่ยนเข้าโฟลเดอร์ portal แล้วเรียก npm ci เมื่อมี package-lock.json หากยังไม่มีไฟล์ล็อกให้ใช้ npm install ตรวจว่ามีแพ็กเกจครบก่อนเริ่มระบบ และกลับมายังโฟลเดอร์ travelin_app สำหรับการเปิดบริการ',
'ใช้ flutter doctor เพื่อตรวจรายการเครื่องมือและใช้ flutter doctor --android-licenses ในเครื่องที่ยังไม่ได้ยอมรับสิทธิ์ใช้งาน Android SDK ตรวจเครือข่ายและพื้นที่จัดเก็บเมื่อดาวน์โหลดไม่สำเร็จ ไม่ต้องติดตั้ง Apache หรือ PHP เพื่อให้ Dart API และ Vite ทำงาน'], 'project_folder', 'start_tourapp.bat; pubspec.yaml; portal/package-lock.json'),
('A.3', 'การเตรียมฐานข้อมูลสำหรับการติดตั้งใหม่', [
'ขั้นตอนนี้ใช้กับเครื่องใหม่หรือฐานข้อมูลทดสอบว่างเท่านั้น ให้ติดตั้ง MariaDB แล้วสร้างฐานข้อมูลชื่อ tourist_grouping_db โดยใช้ชุดอักขระ utf8mb4 และจัดเตรียมบัญชีเชื่อมต่อที่มีสิทธิ์ตามงาน ฐานข้อมูลของเครื่องพัฒนาใช้พอร์ต 3306 และตำแหน่ง C:\\xampp\\mysql\\data ตามที่ตัวเปิดระบบตรวจไว้',
'หากเครื่องใหม่ใช้แนวทางเดียวกับตัวเปิดโครงการ ให้ติดตั้ง XAMPP ที่ C:\\xampp โดยเลือกส่วนประกอบ MySQL ซึ่งจัดเตรียม MariaDB ตามชุดโปรแกรมนั้น ตรวจว่า C:\\xampp\\mysql_start.bat และ C:\\xampp\\mysql\\bin\\mysql.exe กับ mysqld.exe มีอยู่ก่อนใช้งาน เลือกใช้คอนโซล mysql_start.bat แบบคงอยู่ตาม ก.7 และไม่เปิดบริการฐานข้อมูลซ้ำจาก Control Panel เมื่อติดตั้งชุดอื่นต้องปรับการเริ่มและเส้นทางให้ตรงกันก่อนใช้ตัวเปิดโครงการ',
'ไฟล์ database/tourist_grouping_db_complete.sql ที่มีอยู่เป็นไฟล์สำรองซึ่งมีทั้งโครงสร้าง ข้อมูล และคำสั่ง DROP TABLE จึงไม่ควรนำเข้าลงฐานข้อมูลที่มีข้อมูลใช้งานอยู่ การติดตั้งใหม่ควรใช้ไฟล์ส่งออกเฉพาะโครงสร้างที่ผู้ดูแลจัดเตรียมจากรุ่นเดียวกับโค้ด เช่นการส่งออกด้วย mysqldump --no-data แล้วตรวจว่าไม่มีแถวข้อมูลธุรกิจหรือข้อมูลส่วนบุคคล ก่อนนำเข้าไฟล์โครงสร้างในฐานข้อมูลว่าง',
'บนเครื่องติดตั้งใหม่ที่มี MariaDB พร้อมแล้ว เปิด Command Prompt และเรียกโปรแกรม mysql ดังตัวอย่าง โดยเปลี่ยน DB_USER เป็นชื่อบัญชีฐานข้อมูลที่มีสิทธิ์ และเปลี่ยนตำแหน่งโปรแกรมตามเครื่องติดตั้ง ตัวเลือก --password ให้โปรแกรมถามรหัสผ่านโดยไม่เขียนค่าลับในคำสั่ง',
'[คำสั่ง]C:\\xampp\\mysql\\bin\\mysql.exe --user=DB_USER --password',
'ภายใน mysql สร้างฐานข้อมูลว่างด้วยคำสั่งต่อไปนี้ จากนั้นเลือกฐานข้อมูลและนำเข้าไฟล์ schema-only ฉบับปัจจุบันที่ผู้ดูแลตรวจแล้ว เปลี่ยน C:/install/schema.sql เป็นเส้นทางไฟล์ที่ได้รับ ขั้นตอนนี้ไม่ใช้กับฐานข้อมูลธุรกิจเดิม',
'[คำสั่ง]CREATE DATABASE tourist_grouping_db\n  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\nUSE tourist_grouping_db;\nSOURCE C:/install/schema.sql;',
'การจัดเตรียมไฟล์จากฐานข้อมูลต้นทางที่ได้รับอนุญาตใช้คำสั่งต่อไปนี้ใน Command Prompt โดยเปลี่ยน DB_USER และเส้นทางไฟล์ตามงาน เครื่องหมาย ^ ต่อคำสั่งหลายบรรทัด คำสั่งถามรหัสผ่านและส่งออกเฉพาะโครงสร้าง ไม่ต้องใส่ค่าลับในเอกสาร',
'[คำสั่ง]C:\\xampp\\mysql\\bin\\mysqldump.exe --user=DB_USER --password ^\n  --no-data --result-file=C:/install/schema.sql tourist_grouping_db',
'โครงการที่ตรวจยังไม่มีไฟล์ติดตั้ง schema-only ฉบับสมบูรณ์ที่ยืนยันกับโค้ดปัจจุบัน ผู้ดูแลต้องจัดเตรียมและตรวจไฟล์นี้ก่อนเริ่มนำเข้า เส้นทางตัวอย่างข้างต้นจึงไม่ใช่การยืนยันว่ามีไฟล์ติดตั้งพร้อมใช้แล้ว',
'หากติดตั้งจากโครงสร้างรุ่นเดิม ให้ตรวจสคริปต์ทีละไฟล์ตามรุ่นที่นำเข้า เริ่มจาก database/travelin_automated_clustering.sql แล้วจึง travelin_matching_v2.sql และ travelin_agency_admin_foundation.sql ตามลำดับที่ส่วนหัวของไฟล์กำหนด travelin_runtime_features.sql สร้างตารางแชตและคะแนนโหวตที่ travelin_voting_lifecycle.sql ใช้ ส่วน travelin_destination_polls.sql ต้องมาก่อน travelin_destination_poll_lifecycle.sql ตรวจเพิ่มเติม travelin_matching_waiting.sql, travelin_admin_user_management.sql, travelin_travel_documents.sql, travelin_slip_verification.sql, travelin_journey_features.sql, travelin_journey_safety.sql, travelin_trip_hub.sql และ travelin_notifications.sql ตามตารางและคอลัมน์ที่มี ไม่รันไฟล์ demo, reset, cleanup หรือ backup รวมทั้งหมด สคริปต์บางไฟล์มี backfill และ UPDATE จึงต้องอ่านและตรวจบนฐานข้อมูลทดสอบก่อน ไม่รับรองว่าไฟล์ทั้งหมดรันซ้ำได้',
'ตรวจตารางหลัก users, travel_preferences, tour_groups, group_members, agencies, bids และ payments รวมถึงตารางโหวต เอกสาร และการเดินทางตามคุณลักษณะที่เปิดใช้ การตรวจด้วย SHOW TABLES หรือ SHOW COLUMNS เป็นการตรวจโครงสร้าง ส่วนบัญชีเริ่มต้นและหมวดหมู่ท่องเที่ยวต้องจัดเตรียมเป็นข้อมูลทดสอบที่ไม่เปิดเผยข้อมูลจริง'], 'database_schema', 'database SQL file classification; start_tourapp.bat'),
('A.4', 'การตั้งค่าบริการ Dart และผู้ให้บริการภายนอก', [
'บริการเริ่มจาก server/gemini_proxy.dart และใช้พอร์ตเริ่มต้น 8787 กำหนด DB_HOST, DB_PORT, DB_NAME, DB_USER และ DB_PASSWORD ผ่าน environment ของบริการ หรือไฟล์ตั้งค่าเฉพาะเครื่องตามรูปแบบ server/database_key.example.txt โดยใช้ค่า host และบัญชีของเครื่องติดตั้งเอง เก็บค่าลับไว้นอกเอกสารและไม่ส่งไปยังแอป Flutter หรือ Portal',
'การแปลข้อความความต้องการเดินทางใช้ GEMINI_API_KEY ฝั่งบริการ โดยโค้ดอ่าน environment ก่อนและรองรับไฟล์เฉพาะเครื่อง server/.env หรือ server/gemini_key.txt คีย์ใช้เพื่อแปลงข้อความเป็นข้อมูลปลายทาง งบประมาณ ช่วงเดินทาง และความสนใจ การจัดสมาชิกกลุ่มใช้ตรรกะจับคู่ของระบบ หลังตั้งค่าให้เริ่มด้วย dart run server/gemini_proxy.dart จากโฟลเดอร์ travelin_app',
'การตรวจสลิปจริงรองรับ TRAVELIN_SLIP_PROVIDER=slipok พร้อม SLIPOK_API_KEY และ SLIPOK_BRANCH_ID ส่วน TRAVELIN_PAYMENT_RECEIVER เป็นข้อมูลผู้รับเงินที่ตั้งค่าฝั่งบริการ โหมด demo ต้องกำหนด TRAVELIN_SLIP_PROVIDER=demo อย่างชัดเจนและใช้ได้เมื่อไม่ใช่ production โค้ดไม่เปลี่ยนการตั้งค่าที่ขาดให้เป็นการชำระสำเร็จอัตโนมัติ การมีคีย์หรือเปิดหน้าชำระเงินได้ยังไม่ยืนยันผลตรวจสลิปจริง',
'ตัวเปิดระบบโหลด config/local_secrets.bat เฉพาะในหน้าต่างบริการ และเปิด TRAVELIN_ENABLE_DEVELOPMENT_ACCOUNTS=1 ซึ่งอนุญาตนโยบายบัญชีพัฒนาของระบบ ตัวเปิดนี้จึงเป็นเครื่องมือพัฒนา การติดตั้งใช้งานจริงต้องเริ่มบริการด้วยการตั้งค่าที่ตรวจแล้วและไม่เปิดตัวเลือกบัญชีพัฒนา การเปิดบัญชีทดสอบเป็นทางเลือกเฉพาะสภาพแวดล้อมที่จัดเตรียมไว้ บัญชีผู้ดูแลเริ่มต้นรองรับ TRAVELIN_ADMIN_USERNAME, TRAVELIN_ADMIN_EMAIL, TRAVELIN_ADMIN_PASSWORD และ TRAVELIN_ADMIN_DISPLAY_NAME ผู้ติดตั้งต้องกำหนดค่าของตนเอง โดยไม่ใส่ชื่อบัญชีหรือรหัสผ่านทดสอบในเอกสาร'], 'backend_config', 'server/gemini_proxy.dart; server/slipok_verifier.dart; start_tourapp.bat'),
('A.5', 'การเตรียม Android และเปิดแอปนักท่องเที่ยว', [
'ติดตั้ง Android SDK, Platform Tools, Emulator และ system image ที่รองรับ จากนั้นสร้าง AVD ผ่าน Android Studio Device Manager เครื่องอ้างอิงมีอุปกรณ์ชื่อ Pixel_6 เปิดอุปกรณ์และรอจนบูตเสร็จ ตรวจด้วย adb devices ให้สถานะเป็น device แล้วตรวจรายการอุปกรณ์ที่ Flutter พบด้วย flutter devices',
'แอป Android ใช้ URL เริ่มต้น http://127.0.0.1:8787 เชื่อมพอร์ตของอุปกรณ์กลับมายังคอมพิวเตอร์ก่อนเรียก Flutter ดังตัวอย่าง เปลี่ยน DEVICE_ID เป็นรหัสที่ adb รายงาน ไม่ใช้ชื่อ Pixel_6 แทนรหัสดังกล่าว ตัว start_android_test.bat --launcher จัดการ reverse ตรวจ /api/health และเรียก Flutter ให้ตามลำดับ',
'[คำสั่ง]adb -s DEVICE_ID reverse tcp:8787 tcp:8787\nflutter run -d DEVICE_ID',
'หากต้องกำหนดปลายทาง API ต่างจากค่าเริ่มต้น ให้เพิ่ม --dart-define ในคำสั่ง Flutter ดังตัวอย่าง เปลี่ยน API_URL เป็นที่อยู่ที่อุปกรณ์เข้าถึงได้ ค่า localhost ของอุปกรณ์จริงไม่หมายถึงเครื่องพัฒนา ตรวจ URL และการเชื่อมต่อก่อนส่งข้อมูล',
'[คำสั่ง]flutter run -d DEVICE_ID ^\n  --dart-define=TRAVELIN_API_BASE_URL=API_URL'], 'android_setup', 'lib/core/api_config.dart; start_android_test.bat'),
('A.6', 'การเตรียมและเปิดเว็บ Portal', [
'เปิดเทอร์มินัลที่โฟลเดอร์ portal ติดตั้งแพ็กเกจแล้วเรียก npm run dev -- --host 127.0.0.1 --port 4173 ระบบเว็บใช้ Vite และส่งคำขอ /api ผ่าน proxy ไปยัง http://127.0.0.1:8787 ตาม vite.config.ts หากเปลี่ยนปลายทางให้กำหนด VITE_API_BASE_URL ของสภาพแวดล้อมเว็บให้ถูกต้อง',
'บริษัทนำเที่ยวเข้าสู่ http://127.0.0.1:4173/agency/login และสมัครที่ /agency/register เมื่อเข้าสู่ระบบแล้วดู /agency/dashboard และ /agency/pending ได้ บัญชีที่อนุมัติแล้วจึงเข้าถึง /agency/marketplace, /agency/bids, /agency/travelers และ /agency/journeys ส่วนรายละเอียดกลุ่มและข้อเสนอใช้รหัสรายการในเส้นทาง',
'ผู้ดูแลเข้าสู่ http://127.0.0.1:4173/admin/login แล้วใช้งาน /admin/agencies, /admin/users และ /admin/groups หน้าปลายทางเริ่มต้นของผู้ดูแลคือรายชื่อบริษัทนำเที่ยว ไม่มีเส้นทาง /admin/dashboard หรือหน้าแก้สิทธิ์แบบแยกใน App.tsx การเปิดหน้าล็อกอินได้เป็นเพียงความพร้อมของเว็บ ต้องตรวจบริการ API และสิทธิ์บัญชีประกอบ'], 'portal_login', 'portal/src/App.tsx; portal/vite.config.ts; portal/src/lib/api.ts'),
('A.7', 'การเปิดระบบด้วย start_tourapp bat', [
'ดับเบิลคลิก start_tourapp.bat หรือเรียกไฟล์นี้จากโฟลเดอร์ travelin_app ตัวเปิดระบบตรวจ C:\\xampp\\mysql_start.bat, mysql.exe และ mysqld.exe ก่อน จากนั้นตรวจเจ้าของพอร์ต 3306 ตำแหน่งโปรแกรมฐานข้อมูล และความสามารถในการอ่านตาราง users ใน tourist_grouping_db หากพบโปรแกรมหรือเส้นทางที่ขัดกันจะหยุดและไม่เปิดฐานข้อมูลซ้ำ',
'หากยังไม่มี MariaDB ที่คาดไว้ ตัวเปิดระบบจะเปิดหน้าต่างคอนโซลแบบคงอยู่ด้วย mysql_start.bat และรอความพร้อมประมาณ 60 วินาที ต้องคงหน้าต่างฐานข้อมูลนี้ไว้ขณะใช้งาน วิธีอ้างอิงของโครงการนี้คือคอนโซลดังกล่าว ไม่ใช้ปุ่ม MySQL Start ใน XAMPP Control Panel มาเปิดซ้ำกับคอนโซลที่ทำงานอยู่',
'เมื่อฐานข้อมูลพร้อม ตัวเปิดระบบค้นหา Dart, Flutter, Node, npm, adb และ Emulator ตรวจ API จาก /api/health โดยต้องได้ ok และ databaseConfigured เป็นจริง จากนั้นเปิดบริการ Dart ในหน้าต่างแยก เปิด Portal หากจำเป็น และเปิดเบราว์เซอร์หน้า /agency/login ต่อด้วยการเลือกอุปกรณ์ Android ที่พร้อมหรือเปิด AVD ตัวแรก แล้วเรียก start_android_test.bat --launcher',
'ข้อความสุดท้ายแยก Backend READY, Portal READY หรือ NOT STARTED และ User App STARTING หรือ NOT STARTED การแสดง STARTING หมายถึงเริ่มเรียก Flutter แล้ว ยังต้องรอให้แอปแสดงบนอุปกรณ์ หากบริการใดไม่พร้อมให้ตรวจหน้าต่างของบริการนั้นและแก้เครื่องมือหรือการตั้งค่า ไม่เปิดหลายสำเนาเพื่อทับพอร์ตเดิม'], 'launcher', 'start_tourapp.bat actual control flow'),
('A.8', 'การตรวจความพร้อมหลังติดตั้ง', [
'ตรวจ API ด้วยการเปิด http://127.0.0.1:8787/api/health และพิจารณาค่า ok กับ databaseConfigured โดย databaseConfigured ยืนยันว่าบริการมีค่าตั้งฐานข้อมูล มิใช่หลักฐานว่าทุก query เชื่อมต่อสำเร็จ ตัวเปิดระบบตรวจการอ่านตาราง users แยกด้วย mysql.exe การตรวจอ่านข้อมูลจริงหรือเข้าสู่ระบบด้วยบัญชีที่ได้รับอนุญาตจึงเป็นหลักฐานความสามารถเชื่อมฐานข้อมูล จากนั้นเปิดหน้าล็อกอิน Portal และหน้าแรกของ Flutter ตรวจภาษาไทยและข้อความผิดพลาดตามสิทธิ์',
'ความพร้อมของหน้าจอและ health check ไม่เท่ากับการทดสอบกระบวนการครบทุกขั้น การยืนยัน matching, การโหวต, การชำระเงิน, การตรวจสลิปจริง และการจบทริปต้องมีข้อมูลและเงื่อนไขของแต่ละสถานะ พร้อมบันทึกผลทดสอบแยกต่างหาก หากยังไม่มีบัญชีหรือข้อมูลสำหรับสถานะใดให้ระบุว่ายังไม่ตรวจ ไม่ตีความว่าผ่านจากภาพหน้าแรก',
'เมื่อพบปัญหาให้แยกตรวจพอร์ต API, สถานะฐานข้อมูล, URL ของ Android และ proxy ของ Portal ก่อน ตรวจคำอธิบายข้อผิดพลาด Gemini หรือ SlipOK ตามที่ระบบแสดงโดยไม่เผยแพร่คีย์ เก็บ log ที่ตัดข้อมูลส่วนบุคคลแล้วเพื่อให้ผู้พัฒนาตรวจต่อได้'], 'readiness', 'start_tourapp.bat; API health checks; safe installation verification'),
]

AGENCY = [
('B.2.1','การเข้าสู่ระบบบริษัทนำเที่ยว',[
'เปิดเว็บ Portal ที่ /agency/login กรอกชื่อผู้ใช้หรืออีเมลและรหัสผ่านของบริษัท แล้วกดเข้าสู่ระบบ บัญชีบริษัทแยกจากบัญชีนักท่องเที่ยวและผู้ดูแล เมื่อเข้าสู่ระบบระบบตรวจข้อมูลบริษัทและสถานะก่อนแสดงเมนูที่ใช้ได้',
'บริษัทที่ยังรอตรวจสอบสามารถเข้าสู่ระบบเพื่อติดตามสถานะได้ แต่ยังส่งข้อเสนอไม่ได้ หากระบบแจ้ง session หมดอายุให้เข้าสู่ระบบใหม่ และไม่ใช้บัญชีของบริษัทอื่นเพื่ออ่านข้อมูลหรือส่งแพ็กเกจ'], 'agency_login','portal/src/pages/AgencyAuthPages.tsx; portal/src/App.tsx','agency'),
('B.2.2','การสมัครบริษัทและแนบใบอนุญาต',[
'เลือกสมัครบริษัทนำเที่ยวที่ /agency/register กรอกชื่อบริษัท ผู้ติดต่อ อีเมล โทรศัพท์ ที่อยู่ เลขที่ใบอนุญาต ชื่อผู้ใช้ รหัสผ่านและยืนยันรหัสผ่าน แนบใบอนุญาตเป็น PDF, JPG หรือ PNG ขนาดไม่เกิน 5 MB ตามแบบฟอร์ม รหัสผ่านของบริษัทกำหนดอย่างน้อย 8 ตัว',
'ตรวจข้อมูลแล้วส่งใบสมัคร เมื่อสำเร็จระบบพากลับไปเข้าสู่ระบบเพื่อตรวจสถานะการอนุมัติ การส่งใบสมัครไม่ทำให้บัญชีผ่านตรวจทันที เอกสารสำหรับการสาธิตควรระบุ DEMO และไม่ใช้ใบอนุญาตหรือข้อมูลส่วนบุคคลของผู้อื่น'], 'agency_register','portal/src/pages/AgencyAuthPages.tsx','agency'),
('B.2.3','การตรวจสถานะอนุมัติและข้อมูลบริษัท',[
'หลังเข้าสู่ระบบเปิด ภาพรวม หรือ สถานะบัญชี ตรวจชื่อบริษัท เลขใบอนุญาต ผู้ประสานงานและสถานะ approved, pending, rejected หรือ suspended หากมีหมายเหตุจากผู้ดูแลให้อ่านเพื่อทราบสาเหตุหรือสิ่งที่ต้องแก้ไข',
'เมื่อ approved จึงเปิด Lead Marketplace และหน้าส่งข้อเสนอได้ หน้าปัจจุบันแสดงข้อมูลบริษัทจากบัญชี แต่ไม่มีเส้นทางแก้โปรไฟล์บริษัทแยกใน Portal หากข้อมูลไม่ถูกต้องให้ประสานผู้ดูแลตามวิธีที่หน่วยงานกำหนด ไม่ถือว่าบันทึกแก้ไขได้จากหน้าภาพรวม'], 'agency_dashboard','portal/src/pages/AgencyDashboardPages.tsx; portal/src/App.tsx','agency'),
('B.2.4','การค้นหากลุ่มใน Lead Marketplace',[
'บริษัทที่อนุมัติแล้วเปิด Lead Marketplace เพื่อดูเฉพาะกลุ่มที่ระบบพร้อมรับข้อเสนอ ตรวจปลายทาง จำนวนสมาชิก ช่วงเดินทางและงบประมาณ แล้วใช้ตัวกรองที่แสดงเพื่อหากลุ่มที่บริษัทสามารถจัดบริการได้',
'รายการนี้ใช้สำหรับวิเคราะห์โอกาสเสนอราคา ไม่ใช่การเพิ่มสมาชิกหรือสร้างกลุ่มแทนนักท่องเที่ยว บริษัทไม่ควรสรุปว่าทุกกลุ่มเปิดรับข้อเสนอจากรายการข้อมูลทริปทั่วไป ต้องตรวจสถานะและรายละเอียดใน Lead Marketplace ก่อน'], 'agency_leads','portal/src/pages/AgencyMarketplacePages.tsx','agency'),
('B.2.5','การอ่านรายละเอียดกลุ่มและความต้องการรวม',[
'เปิดรายละเอียด lead ที่สนใจ ตรวจปลายทางสุดท้าย ช่วงเดินทาง งบเฉลี่ย ช่วงงบและขนาดกลุ่ม ใช้สรุปความสนใจ ผลสำรวจเมืองและสถานที่ของกลุ่มเพื่อเตรียมแพ็กเกจให้ตรงกับข้อมูลที่ระบบสรุปแล้ว',
'ข้อมูลสำหรับเสนอราคาเป็นข้อมูลรวมของกลุ่ม บริษัทไม่ควรขอหรือเปิดเผยว่าใครลงคะแนนให้ตัวเลือกใด และไม่ใช้เอกสารส่วนตัวของนักท่องเที่ยวประกอบภาพเสนอราคา หากหน้าจอแสดงรายละเอียดความต้องการที่ไม่ระบุตัวตน ให้ใช้เพื่อจัดบริการโดยไม่พยายามเชื่อมกลับไปหาบุคคล'], 'agency_lead_detail','portal/src/pages/AgencyMarketplacePages.tsx LeadAggregateSummary','agency'),
('B.2.6','การเตรียมราคาและโปรแกรม PDF',[
'ก่อนส่งข้อเสนอ เตรียมชื่อแพ็กเกจ รายละเอียดโปรแกรม สิ่งที่รวมและเงื่อนไข ราคาต่อคนเป็นจำนวนที่มากกว่า 0 และโปรแกรมทัวร์ PDF ไม่เกิน 5 MB ให้ปลายทางและกิจกรรมสัมพันธ์กับผลเมืองและความสนใจของกลุ่ม',
'สำหรับการสาธิตให้ตั้งชื่อไฟล์และเนื้อหาว่า DEMO อย่างชัดเจน และใช้ข้อมูลตัวอย่างที่ไม่อ้างการขายจริง การเตรียม PDF ในเครื่องยังไม่ใช่การส่งข้อเสนอ ต้องเลือกไฟล์และยืนยันจากแบบฟอร์ม Portal ส่วนหน้าเสนอราคา Flutter ที่มีในโค้ดเป็น UI สาธิตซึ่งไม่อัปโหลดไฟล์จริง'], 'agency_program','portal/src/pages/AgencyMarketplacePages.tsx AgencyBidSubmitPage; lib/pages/agency_bid_page.dart','agency'),
('B.2.7','การอัปโหลดและส่งข้อเสนอ',[
'จาก lead เลือก ส่งข้อเสนอให้กลุ่มนี้ กรอกชื่อแพ็กเกจ ราคาต่อคนและรายละเอียด เลือกไฟล์ PDF ที่เตรียมแล้วตรวจข้อมูลอีกครั้ง กด ยืนยันส่งข้อเสนอ ระบบผูกข้อเสนอกับกลุ่มและบริษัทที่เข้าสู่ระบบโดยอัตโนมัติ',
'เมื่อบันทึกสำเร็จระบบเปลี่ยนไปหน้ารายละเอียดข้อเสนอ หากแจ้งไฟล์หรือราคาไม่ถูกต้องให้แก้ก่อนส่งใหม่ การแสดงชื่อไฟล์ในแบบฟอร์มเป็นเพียงการเลือกไฟล์ ต้องรอผลการส่งสำเร็จเพื่อยืนยันว่าข้อเสนอถูกเก็บแล้ว'], 'agency_submit','portal/src/pages/AgencyMarketplacePages.tsx submitBid','agency'),
('B.2.8','การติดตามข้อเสนอและสถานะ',[
'เปิด ข้อเสนอของฉัน เพื่อดูแพ็กเกจ กลุ่ม ราคาและสถานะของบริษัท เปิดรายละเอียดแต่ละรายการเพื่อตรวจข้อมูลที่ส่งและโปรแกรมที่แนบ เมื่อต้องเปลี่ยนข้อมูลหรือยกเลิกใช้เฉพาะปุ่มที่ระบบเปิดให้ในสถานะนั้น',
'ข้อเสนอของกลุ่มที่จบหรือยุบแล้วแสดงในประวัติข้อเสนอและไม่สามารถแก้ไขหรือยกเลิกตามหน้าจอ ประวัตินี้เป็นฝั่งบริษัทสำหรับติดตามการเสนอราคา ไม่ใช่หน้าประวัติทริปแยกของนักท่องเที่ยว'], 'agency_bids','portal/src/pages/AgencyBidPages.tsx','agency'),
('B.2.9','การอ่านผลโหวตและข้อเสนอที่ชนะ',[
'ตรวจสถานะรอบโหวตและผลรวมในรายละเอียดข้อเสนอ รอจนบริการประกาศผู้ชนะก่อนถือว่าบริษัทได้รับทริป หากคะแนนยังไม่ครบหรือเสมอให้ยึดสถานะรอบที่ระบบแจ้ง',
'บริษัทอ่านจำนวนคะแนนรวมและผลของข้อเสนอได้ตามสิทธิ์ ไม่ควรอ้างว่าสามารถดูคะแนนรายนักท่องเที่ยวหรือระบุสมาชิกที่เลือกบริษัทใด เมื่อข้อเสนอเป็นผู้ชนะที่ประกาศแล้วจึงดำเนินงานกับสมาชิกและข้อมูลทริปตามหน้าที่ระบบเปิดให้'], 'agency_vote_results','portal/src/pages/AgencyBidPages.tsx; server/agency_admin_api.dart','agency'),
('B.2.10','การตรวจเอกสารนักท่องเที่ยวของทริปที่ชนะ',[
'เปิดเมนูนักท่องเที่ยวของบริษัทเพื่อตรวจรายการเอกสารเฉพาะทริปที่มีสิทธิ์ เปิดรายละเอียดการส่งเอกสารผ่านช่องทาง private อ่านข้อมูลที่จำเป็น ตรวจไฟล์และสถานะ แล้วบันทึกผลตรวจตามตัวเลือก เช่นผ่านตรวจหรือขอแก้ไข พร้อมหมายเหตุที่ชัดเจน',
'สิทธิ์ดูเอกสารหลังได้รับทริปแตกต่างจากช่วงเสนอราคา ซึ่งไม่เปิดเอกสารส่วนตัว บริษัทต้องไม่บันทึกภาพ Passport ข้อมูลติดต่อฉุกเฉินหรือไฟล์ส่วนตัวลงเอกสารสาธารณะ และไม่ตรวจรายการของกลุ่มที่บริษัทไม่ได้เป็นผู้ชนะ'], 'agency_documents','portal/src/pages/AgencyTravelerPages.tsx; PrivateDocumentPreview.tsx','agency'),
('B.2.11','การจัดการนัดหมายและสถานะทริป',[
'เปิด ทริปและความพร้อม เพื่อดูกลุ่มที่บริษัทชนะแล้ว ตรวจ payment เอกสารและความพร้อมของสมาชิก กรอกวันเวลา จุดนัดหมาย ผู้ประสานงาน เบอร์ติดต่อและหมายเหตุ แล้วกด บันทึกข้อมูลนัดหมาย เพื่อให้ข้อมูลปรากฏใน Trip Hub ของนักท่องเที่ยว',
'เลือกสถานะกำลังเตรียมตัว พร้อมเดินทาง กำลังเดินทาง หรือจบทริป พร้อมความต้องการ Visa/ETA และสถานะเอกสารที่เกี่ยวข้อง แล้วกด อัปเดตสถานะ บันทึกตามการดำเนินงานจริงและเงื่อนไขที่บริการตรวจ ไม่เปลี่ยนเป็นจบทริปเพียงเพื่อให้ผู้ใช้กดรีวิวในการใช้งานจริง'], 'agency_journey','portal/src/pages/AgencyJourneyPages.tsx','agency'),
('B.2.12','คะแนนบริษัทและการออกจากระบบ',[
'คะแนนบริษัทจากทริปที่ตรวจสอบแล้วแสดงในหน้าข้อมูลบริษัทของแอปนักท่องเที่ยวเมื่อมีรีวิว ใน Portal ที่ตรวจยังไม่มีหน้า dashboard คะแนนบริษัทแยก จึงไม่อธิบายเมนูคะแนนที่ไม่ได้มีจริง บริษัทติดตามข้อเสนอและประวัติที่ Portal เปิดให้ได้ตามบทบาท',
'เมื่อเสร็จงานเลือกออกจากระบบจากเมนูบัญชี และตรวจว่าเว็บกลับไปหน้าที่ต้องเข้าสู่ระบบ การออกจากระบบไม่ยกเลิกข้อเสนอหรือทริปที่บันทึกไว้ หากจะเปลี่ยนรายการต้องทำผ่านหน้ารายการนั้นตามสถานะและสิทธิ์'], 'agency_logout','portal/src/components/PortalShell.tsx; lib/pages/agency_profile_page.dart','agency'),
]

B = [
('B.1.1','หน้าแรกและการเลือกเมนู',[
'เมื่อเปิดแอป Travelin ระบบแสดงหน้าแรกสำหรับอ่านข้อมูลทริปและเริ่มระบุความต้องการเดินทาง ผู้ใช้เลือกเมนูความต้องการเดินทางหรือเริ่มจับคู่จากส่วนที่แสดงบนหน้าจอ แล้วไปยังแบบฟอร์มก่อนส่งข้อมูล หน้าแรกอาจแสดงข้อมูลจากรายการปลายทางประกอบการตัดสินใจ จึงควรแยกข้อมูลแนะนำออกจากกลุ่มที่ตนได้รับจริง',
'ตรวจสถานะบัญชีและสถานะการจับคู่ที่แสดงก่อนดำเนินการ การสมัครและเข้าสู่ระบบทำให้ระบบผูก preference กับบัญชีผู้ใช้ เมื่อยังเป็นผู้เยี่ยมชมสามารถดูหน้าแรกได้ แต่การส่งข้อมูลและเข้าหน้าสมาชิกจะขอให้เข้าสู่ระบบ'], 'home','lib/pages/home_page.dart; lib/main.dart','tourist'),
('B.1.2','การสมัครสมาชิกนักท่องเที่ยว',[
'จากหน้าเข้าสู่ระบบเลือกสมัครสมาชิก กรอกชื่อ นามสกุล อีเมล เบอร์โทรศัพท์ รหัสผ่าน และยืนยันรหัสผ่านให้ตรงกัน แบบฟอร์มตรวจรูปแบบอีเมล เบอร์โทรศัพท์ และรหัสผ่านอย่างน้อย 6 ตัวตามหน้าจอปัจจุบัน จากนั้นกด สร้างบัญชีและเริ่มใช้งาน',
'รอผลตอบกลับก่อนออกจากหน้า เมื่อสร้างบัญชีสำเร็จระบบแจ้งว่าบันทึกบัญชีแล้ว หากอีเมลหรือข้อมูลไม่ผ่านการตรวจให้แก้เฉพาะช่องที่ระบบแจ้ง ไม่กรอกข้อมูลตัวอย่างที่ปรากฏในภาพเป็นข้อมูลบัญชีของตน และไม่ถ่ายภาพรหัสผ่านที่เปิดเผยอยู่'], 'register','lib/pages/register_page.dart','tourist'),
('B.1.3','การเข้าสู่ระบบ',[
'เปิดหน้าเข้าสู่ระบบ กรอกอีเมลหรือเบอร์โทรศัพท์และรหัสผ่านของบัญชีนักท่องเที่ยว แล้วกดปุ่มเข้าสู่ระบบ ระบบส่งคำขอไปยังบริการและเก็บ session เพื่อใช้งานหน้าสมาชิก หากใช้บัญชีที่บันทึกไว้ให้ตรวจชื่อบัญชีก่อนเลือก',
'เมื่อเข้าสู่ระบบสำเร็จสามารถเปิดโปรไฟล์ ส่งความต้องการเดินทาง และดูสถานะกลุ่มตามบัญชีได้ หากแจ้งข้อมูลไม่ถูกต้องหรือ session หมดอายุให้ตรวจบัญชีและเข้าสู่ระบบใหม่ ปุ่ม Google, Apple และ Facebook ในรุ่นนี้แจ้งให้ใช้อีเมลหรือโทรศัพท์ที่สมัครก่อน ส่วน ลืมรหัสผ่าน ยังไม่เปิดการเปลี่ยนรหัสผ่านผ่านอีเมลและแจ้งให้ติดต่อผู้ดูแล'], 'login','lib/pages/login_page.dart; services/auth_service.dart','tourist'),
('B.1.4','การดูและแก้ไขโปรไฟล์',[
'เปิด โปรไฟล์ของฉัน เพื่อตรวจชื่อ นามสกุล อีเมล และเบอร์โทรศัพท์ แก้ข้อมูลติดต่อที่ต้องการแล้วกด บันทึก หรือ บันทึกการเปลี่ยนแปลง รอข้อความยืนยันจากระบบก่อนกลับหน้าหลัก',
'หน้าฝั่งสมาชิกยังเชื่อมไปยังเอกสารการเดินทาง Trip Hub และสถานะกลุ่มตามสิทธิ์ การบันทึกโปรไฟล์ไม่ได้เปลี่ยน preference ที่ส่งเข้าการจับคู่โดยอัตโนมัติ หากต้องเปลี่ยนแผนเดินทางควรดำเนินการจากแบบฟอร์มหรือยกเลิกสถานะเดิมตามที่ระบบอนุญาต'], 'profile','lib/pages/profile_page.dart','tourist'),
('B.1.5','การเขียนความต้องการเดินทางด้วยข้อความ',[
'เลือกหน้าระบุความต้องการและกรอกข้อความภาษาธรรมชาติที่บอกปลายทาง งบประมาณ ช่วงเดินทาง และกิจกรรมที่สนใจ เช่น ต้องการไปญี่ปุ่นในเดือนที่วางแผนไว้ ชอบอาหารและถ่ายภาพ แล้วกำหนดตัวเลือกในแบบฟอร์มให้ตรงกับข้อความ',
'ข้อความช่วยให้บริการวิเคราะห์เจตนา แต่ผู้ใช้ต้องตรวจข้อมูลในตัวควบคุมก่อนส่งเสมอ หน้าปัจจุบันยังมีประเทศ เมืองหรือภูมิภาค งบประมาณ เดือนเดินทาง และหมวดความสนใจแยกให้ระบุ จึงไม่ควรถือว่าข้อความเพียงอย่างเดียวแทนค่าทุกช่องที่ระบบจะใช้'], 'preference_text','lib/widgets/ai_preference_form.dart; lib/pages/smart_search_page.dart','tourist'),
('B.1.6','การระบุและทบทวนข้อมูลก่อนจับคู่',[
'เลือกประเทศปลายทาง ระบุภูมิภาคหรือเมืองเพิ่มเติมเมื่อมีความต้องการเฉพาะ หรือเลือกยืดหยุ่นภายในประเทศ จากนั้นเลือกเดือนเดินทาง ปรับงบประมาณต่อคน และเลือกความสนใจอย่างน้อยหนึ่งรายการ ตรวจค่าที่เห็นในแบบฟอร์มให้ตรงกับแผนของตนก่อนกดส่ง',
'ปลายทางเป็นเงื่อนไขหลักของการจับคู่ จากนั้นระบบพิจารณาช่วงเดินทาง งบประมาณ และความสนใจ การเลือกความสนใจหลายรายการควรสะท้อนกิจกรรมที่ต้องการจริง ผลการรวมกลุ่มและคะแนนที่แสดงภายหลังขึ้นอยู่กับข้อมูลสมาชิกและตรรกะจับคู่ ไม่ใช่คะแนนสุ่มจากข้อความ'], 'preference_fields','lib/widgets/ai_preference_form.dart; lib/pages/preferences_page.dart; matching/matching_engine.dart','tourist'),
('B.1.7','การส่งข้อมูลและบทบาทของ Gemini',[
'เมื่อข้อมูลครบให้กดปุ่มส่งเพื่อเริ่มจับคู่ ผู้ใช้ต้องเข้าสู่ระบบและมี session ที่ใช้งานได้ ระบบวิเคราะห์ข้อความผ่านบริการ Gemini เมื่อเชื่อมต่อได้ และมีตัววิเคราะห์สำรองตามโค้ดปัจจุบัน ก่อนส่งข้อมูล preference ที่ระบุในแบบฟอร์มให้บริการจัดเก็บและจับคู่',
'หลังส่งสำเร็จแอปเปลี่ยนไปยัง สถานะการจับคู่ ภาพของแบบฟอร์มเป็นหลักฐานการกรอกข้อมูลเท่านั้น ไม่ใช่หลักฐานว่า Gemini ตอบกลับหรือสร้างกลุ่มสำเร็จ หากเกิดข้อผิดพลาดให้อ่านข้อความบนหน้าจอและตรวจค่าก่อนลองใหม่ ไม่ส่งข้อมูลซ้ำระหว่างปุ่มแสดงกำลังทำงาน'], 'preference_submit','lib/pages/smart_search_page.dart; lib/view_models/matching_view_model.dart','tourist'),
('B.1.8','การเข้าสู่ระบบก่อนทำรายการสมาชิก',[
'หากผู้เยี่ยมชมกดส่งความต้องการหรือเปิดหน้าที่ต้องเป็นสมาชิก ระบบแสดงคำขอเข้าสู่ระบบ เลือกเข้าสู่ระบบหรือสมัครสมาชิก แล้วกลับมาดำเนินการตามแบบฟอร์มที่ต้องการ',
'การดูหน้าข้อมูลได้ไม่หมายถึงมีสิทธิ์ส่ง preference สนทนา โหวต หรือชำระเงิน เมื่อ session หมดอายุระบบอาจขอเข้าสู่ระบบใหม่ก่อนทำรายการ เพื่อให้ข้อมูลอยู่ภายใต้บัญชีที่ถูกต้อง'], 'guest','lib/pages/smart_search_page.dart; lib/core/auth_guard.dart; lib/main.dart','tourist'),
('B.1.9','สถานะรอจับคู่และกรอบเวลา 24 ชั่วโมง',[
'หลังบันทึกความต้องการ หากยังไม่มีสมาชิกที่เหมาะสมเพียงพอ หน้าสถานะจะแสดง กำลังค้นหาเพื่อนร่วมทริป พร้อมข้อมูลความต้องการและเวลาคงเหลือ ค่าเริ่มต้นของบริการกำหนดการรอ 24 ชั่วโมง แต่เครื่องทดสอบอาจตั้งค่าระยะเวลาต่างกันได้ จึงควรยึดเวลาที่แสดงบนหน้าจอ',
'ผู้ใช้ไม่ต้องค้นหากลุ่มแล้วกด Join ในกระบวนการหลัก ระบบประเมินสมาชิกที่รออยู่เมื่อมีข้อมูลที่เกี่ยวข้องเข้ามา หากต้องการหยุดให้ใช้ ยกเลิกการจับคู่ แล้วตรวจผลตอบกลับ การปิดแอปเพียงอย่างเดียวไม่ใช่การยกเลิกข้อมูลที่บันทึกบนบริการ'], 'waiting','lib/widgets/matching_status_view.dart; server/matching_waiting_policy.dart','tourist'),
('B.1.10','การรอต่อและเลือกจุดหมายทางเลือก',[
'เมื่อครบเวลารอและยังไม่พบกลุ่ม หน้าจออาจแสดงจุดหมายทางเลือกตามที่บริการเสนอ ผู้ใช้ตรวจข้อมูลแล้วเลือกยอมรับทางเลือก หรือกด รอต่อ เพื่อเริ่มรอบรอใหม่ หากสถานะยังไม่อัปเดตใช้ ตรวจสอบสถานะล่าสุด',
'การยอมรับทางเลือกเป็นการบันทึกการเปลี่ยนจุดหมายโดยผู้ใช้แล้วให้ระบบจับคู่ใหม่ ไม่ใช่การรวมผู้ที่เลือกคนละประเทศเข้าด้วยกันทันที หากไม่ต้องการเปลี่ยนปลายทางให้คงข้อมูลเดิมหรือยกเลิกตามตัวเลือกที่ปรากฏ'], 'waiting_alternative','lib/widgets/matching_status_view.dart; lib/pages/groups_page.dart','tourist'),
('B.1.11','ผลจับกลุ่มและข้อมูลกลุ่มของฉัน',[
'เมื่อจับคู่สำเร็จ หน้าสถานะแสดงกลุ่มที่ได้รับและปุ่มดูกลุ่มของฉัน ตรวจชื่อกลุ่ม ปลายทาง และข้อมูลสมาชิกหรือคะแนนที่ระบบแสดง แล้วเปิดกลุ่มเพื่อดำเนินการวางแผนร่วมกัน',
'คะแนนความเหมาะสมรายสมาชิกเป็นข้อมูลของผลจับคู่ ส่วนคะแนนรวมของกลุ่มมีความหมายต่างกัน ไม่ควรใช้ตัวเลขเดียวสรุปแทนสมาชิกทุกคน การได้กลุ่มเป็นผลจากการบันทึกและประเมิน preference โดยระบบ ไม่ต้องกดเข้าร่วมกลุ่มเพิ่มในโฟลว์อัตโนมัติ'], 'matched','lib/widgets/matching_status_view.dart; lib/pages/groups_page.dart; lib/models/matching_models.dart','tourist'),
('B.1.12','การสนทนาภายในกลุ่ม',[
'เปิดกลุ่มของฉันแล้วเลือก Group Chat หรือเมนูสนทนา อ่านข้อความก่อนหน้า พิมพ์ข้อความในช่องส่ง แล้วกดส่งเพื่อพูดคุยเรื่องช่วงเดินทาง กิจกรรมและความพร้อมกับสมาชิก การเข้าห้องสนทนาต้องมีสิทธิ์สมาชิกของกลุ่มปัจจุบัน',
'ห้องสนทนายังแสดงบัตรสถานะที่เชื่อมกับโหวต ข้อเสนอ ชำระเงินและการเดินทางตามขั้นตอนของกลุ่ม ควรใช้เมนูของกระบวนการนั้นเพื่อบันทึกผลอย่างเป็นทางการ ข้อความในแชตไม่แทนคะแนนโหวตหรือหลักฐานการชำระเงิน'], 'chat','lib/pages/group_messages_page.dart; lib/services/group_service.dart','tourist'),
('B.1.13','การลงคะแนนเลือกเมืองหรือภูมิภาค',[
'สมาชิกเปิด Group Chat แล้วกด โหวตแผนเที่ยว ตรวจตัวเลือกเมืองหรือภูมิภาคที่บริการจัดไว้ภายในปลายทางของกลุ่ม เลือกตัวเลือกแล้วกด บันทึกคะแนน ตามข้อจำกัดของรอบ อ่านจำนวนผู้ลงคะแนนและกำหนดเวลาที่แสดง',
'รอบเลือกเมืองเป็นขั้นตอนกำหนดเมืองของกลุ่มภายในปลายทางที่จับคู่แล้ว ผลสำรวจประเทศที่มีในข้อมูลเดิมไม่ทำให้ประเทศปลายทางเปลี่ยนอัตโนมัติ การเปลี่ยนปลายทางต้องอาศัยขั้นตอนที่ระบบกำหนดและการยอมรับของผู้ใช้'], 'city_poll','lib/pages/destination_poll_page.dart; server/destination_poll_service.dart','tourist'),
('B.1.14','การเลือกสถานที่และกิจกรรมตามเมืองที่ชนะ',[
'เมื่อรอบเมืองสรุปผลแล้ว เปิดรอบสถานที่หรือกิจกรรมจาก โหวตแผนเที่ยว ระบบกรองตัวเลือกตามเมืองของกลุ่ม เลือกรายการที่สนใจแล้วกด บันทึกคะแนน หากหน้าจอแสดง รอผลเมืองของกลุ่ม ให้กลับไปตรวจรอบเมืองก่อน ยังไม่ควรตีความว่ารายการสถานที่หายเป็นข้อผิดพลาด',
'ผลเมืองที่ประกาศแล้วเป็นฐานสำหรับรายการสถานที่ของรอบถัดไป หากไม่มีตัวเลือกที่เชื่อมกับเมืองนั้น ระบบแสดงข้อความแจ้งแทน การบันทึกความสนใจสถานที่ไม่ได้เป็นการจองบริการหรือยืนยันค่าใช้จ่ายของบริษัทนำเที่ยว'], 'places_poll','lib/pages/destination_poll_page.dart visibleOptions; server/destination_poll_service.dart','tourist'),
('B.1.15','การตรวจผลและประวัติรอบโหวตปลายทาง',[
'อ่านสถานะรอบ เช่น เปิดรับคะแนน รอคะแนนเพิ่มเติม รอบเสมอ หรือสรุปผล ตรวจเมืองที่กลุ่มสรุปและอันดับของสถานที่ในผลลัพธ์ เมื่อมีประวัติรอบก่อนระบบจะแสดงข้อมูลประกอบเพื่ออธิบายการลงคะแนนรอบใหม่',
'ผลที่ยังอยู่ระหว่างรอหรือเสมอไม่ใช่ปลายทางสุดท้าย ให้รอผลที่บริการระบุว่าประกาศแล้ว คะแนนและการเปิดรอบถัดไปขึ้นอยู่กับเงื่อนไขของบริการ เมื่อกลับ Group Chat ระบบโหลดข้อมูลผลล่าสุด ผู้ใช้ไม่ควรบันทึกผลจากหน้าที่ยังไม่สรุปเป็นแผนสุดท้ายของกลุ่ม'], 'poll_results','lib/pages/destination_poll_page.dart resultIsFinal and tieHistoryWidgets','tourist'),
('B.1.16','การดูข้อเสนอจากหลายบริษัท',[
'เมื่อกลุ่มพร้อมรับแพ็กเกจ เปิด ดูข้อเสนอและลงคะแนน เพื่อดูข้อเสนอที่บริษัทนำเที่ยวส่งให้กลุ่ม เปรียบเทียบชื่อแพ็กเกจ บริษัท รายละเอียดโปรแกรม และราคาต่อคนจากหลายรายการก่อนตัดสินใจ',
'ข้อเสนอที่พบมาจากข้อมูลของกลุ่มและบริษัทที่มีสิทธิ์เสนอราคา จำนวนข้อเสนออาจต่างกันตามข้อมูลในระบบ การมีหลายบริษัทในรายการไม่ได้หมายความว่ามีผู้ชนะแล้ว และราคาแสดงควรตรวจร่วมกับสิ่งที่รวมและไม่รวมในรายละเอียดโปรแกรม'], 'offers','lib/pages/voting_page.dart; lib/services/group_service.dart offersWithVoting','tourist'),
('B.1.17','การอ่านรายละเอียดแพ็กเกจและข้อมูลบริษัท',[
'เปิดรายละเอียดข้อเสนอที่สนใจ อ่านโปรแกรม ราคาและเงื่อนไข แล้วใช้ ดูข้อมูลบริษัทและใบอนุญาต เพื่อตรวจข้อมูลบริษัทที่เกี่ยวข้อง โปรแกรม PDF ที่แนบต้องเปิดผ่านช่องทางที่ตรวจสิทธิ์ของระบบ',
'หน้าข้อมูลบริษัทแสดงใบอนุญาต ข้อมูลติดต่อ และคะแนนรวมเมื่อมีรีวิวจากทริปที่ตรวจสอบแล้ว หากยังไม่มีคะแนนให้ใช้ข้อมูลที่มีประกอบการพิจารณา ไม่ตีความว่าเป็นคะแนนศูนย์ ระบบอาจแสดงเฉพาะคะแนนรวมเมื่อยังไม่มีความยินยอมให้เผยแพร่ความคิดเห็น'], 'package_detail','lib/pages/voting_page.dart; lib/pages/agency_profile_page.dart','tourist'),
('B.1.18','การลงคะแนนเลือกบริษัทนำเที่ยว',[
'ในหน้าลงคะแนนเลือกข้อเสนอของบริษัทที่ต้องการ แล้วกด บันทึกคะแนนของฉัน สมาชิกหนึ่งคนมีหนึ่งคะแนนและเปลี่ยนได้ก่อนหมดเวลาตามหน้าจอ ตรวจข้อความบันทึกสำเร็จและรายการที่แสดงว่าเป็นคะแนนของคุณ',
'เมื่อรอบปิด คะแนนถูกล็อกและผู้ใช้ไม่สามารถเปลี่ยนตามปกติ หากผลเสมอหรือจำนวนผู้ลงคะแนนยังไม่ผ่านเงื่อนไข ให้รอการดำเนินรอบตามสถานะที่ระบบแสดง การพูดคุยในแชตหรือเลือกดูรายละเอียดไม่ใช่การลงคะแนนที่บันทึกแล้ว'], 'agency_vote','lib/pages/voting_page.dart; server/voting_policy.dart','tourist'),
('B.1.19','การประกาศผู้ชนะและเปิดชำระเงิน',[
'ตรวจผลโหวตจากหน้ากลุ่มหรือข้อเสนอ เมื่อระบบประกาศข้อเสนอที่เลือกอย่างเป็นทางการแล้วจึงเปิดขั้นตอนชำระเงิน ตรวจบริษัท ชื่อโปรแกรมและราคาของข้อเสนอที่ชนะอีกครั้งก่อนดำเนินการ',
'หน้าชำระเงินตรวจว่าข้อเสนอมีสถานะ selected หากยังไม่มีผู้ชนะหรือข้อเสนอไม่ตรงกับสิทธิ์ ระบบแจ้งว่ายังชำระเงินไม่ได้ การเห็นบริษัทคะแนนสูงระหว่างรอบยังไม่เท่ากับการประกาศผู้ชนะขั้นสุดท้าย'], 'winner','lib/pages/payment_page.dart didChangeDependencies; voting_page.dart','tourist'),
('B.1.20','การเลือกช่องทางและเตรียมหลักฐานชำระเงิน',[
'เปิด สรุปยอดและชำระเงิน ตรวจยอดต่อคนและข้อมูลผู้รับเงินที่ระบบแสดง เลือกช่องทางที่รองรับ เช่น พร้อมเพย์หรือโอนธนาคาร แล้วดำเนินการชำระตามข้อมูลที่ยืนยันจากระบบ จากนั้นเลือกภาพหลักฐานจากอุปกรณ์ให้ตรงกับรายการ',
'ภาพ QR หรือสลิปสำหรับการสาธิตต้องระบุว่าเป็น Demo และไม่ใช้เป็นหลักฐานว่าโอนเงินจริง คู่มือนี้ไม่แสดงเลขบัญชีหรือสลิปของบุคคล ผู้ใช้ควรตรวจผู้รับ ยอดเงินและวันเวลาของรายการจริงก่อนส่งหลักฐาน และไม่กดชำระซ้ำเพียงเพราะสถานะตรวจสอบยังไม่เสร็จ'], 'payment','lib/pages/payment_page.dart; server/gemini_proxy.dart payment receiver','tourist'),
('B.1.21','การส่งสลิปและติดตามผลตรวจสอบ',[
'เลือกภาพหลักฐานแล้วกดส่ง ระบบแจ้ง ส่งหลักฐานแล้ว สถานะ รอตรวจสอบ และเริ่มขั้นตอนตรวจสลิป ตรวจผลจากหน้าสถานะซึ่งอาจเป็น waiting_verify, paid หรือ verification_failed หากไม่ผ่านให้อ่านเหตุผลและแก้หลักฐานตามที่ระบบอนุญาต',
'โหมด Demo ทดสอบลำดับหน้าจอและสถานะ ส่วน SlipOK ต้องตั้งคีย์และ branch ฝั่งบริการพร้อมข้อมูลผู้รับเงินจริง การที่สถานะแสดงสำเร็จใน Demo ไม่รับรองการตรวจสลิปกับผู้ให้บริการจริง และการตั้งค่าครบไม่เท่ากับตรวจสลิปผ่าน ต้องพิจารณาผลตรวจที่บริการบันทึกของรายการจริง'], 'slip_status','lib/pages/payment_page.dart _submit and _startVerification; server/slipok_verifier.dart','tourist'),
('B.1.22','การใช้ Trip Hub และอ่านข้อมูลนัดหมาย',[
'เปิด ทริปของฉัน หรือ เปิด Trip Hub จากโปรไฟล์ เพื่อดูบริษัทที่เลือก โปรแกรม ราคา สมาชิก การชำระเงิน เอกสาร รายการเตรียมตัว จุดนัดหมายและผู้ติดต่อ อ่านข้อมูลแล้วใช้ อ่านโปรแกรมแล้ว และ ดูข้อมูลจุดนัดหมายแล้ว เมื่อรับทราบจริง',
'Trip Hub รวบรวมความพร้อมจากหลายส่วน ไม่ได้ทำให้ payment หรือเอกสารผ่านโดยการกดรับทราบ หากบริษัทเปลี่ยนข้อมูลนัดหมายให้ตรวจข้อมูลล่าสุดก่อนเดินทาง และใช้ช่องทางติดต่อที่แสดงเมื่อจำเป็น'], 'trip_hub','lib/pages/trip_hub_page.dart; lib/services/trip_hub_service.dart','tourist'),
('B.1.23','การจัดเตรียมและส่งเอกสารการเดินทาง',[
'เปิด เอกสารการเดินทาง ตรวจความต้องการของทริป ระบุข้อมูล Passport และวันหมดอายุเมื่อจำเป็น เลือกไฟล์เอกสาร กรอกผู้ติดต่อฉุกเฉิน และแนบประกันการเดินทางได้ตามช่องที่แสดง จากนั้นกด ส่งเอกสารอย่างปลอดภัย แล้วตรวจไฟล์และสถานะที่บันทึกแล้ว',
'เอกสารส่วนตัวเปิดผ่านช่องทางที่ตรวจสิทธิ์ ผู้ใช้สามารถตรวจไฟล์ที่ส่งแล้วและลบตามสิทธิ์ที่ระบบอนุญาต หากบริษัทขอแก้ไขให้อ่านหมายเหตุและส่งข้อมูลที่ถูกต้อง ใช้เอกสารจริงเฉพาะกระบวนการใช้งานที่จำเป็น ไม่เผยแพร่ Passport หรือข้อมูลฉุกเฉินในภาพคู่มือ'], 'travel_docs','lib/pages/travel_documents_page.dart; server/travel_document_service.dart','tourist'),
('B.1.24','สถานะการเดินทางและการจบทริป',[
'ติดตามสถานะกำลังเตรียมตัว พร้อมเดินทาง กำลังเดินทาง และเดินทางเสร็จสิ้นจากหน้าทริปหรือบัตรใน Group Chat ความพร้อมขึ้นอยู่กับเงื่อนไข payment เอกสารและข้อมูลนัดหมายของทริป บริษัทผู้ชนะดูแลข้อมูลและสถานะใน Portal ตามสิทธิ์',
'เมื่อทริปเสร็จแล้วระบบเปิดการให้คะแนน ในโค้ดมีปุ่ม จำลองว่าทริปเสร็จแล้ว สำหรับการสาธิต ซึ่งต้องแยกจากการยืนยันว่ามีการเดินทางจริง ผู้ใช้ควรตรวจสถานะที่บริการบันทึกและไม่ใช้ปุ่มสาธิตเป็นหลักฐานผลการทดสอบทริปจริง'], 'journey','lib/pages/group_messages_page.dart; lib/pages/trip_hub_page.dart; portal/pages/AgencyJourneyPages.tsx','tourist'),
('B.1.25','การให้คะแนนและตรวจข้อมูลหลังจบทริป',[
'หลังสถานะทริปสิ้นสุดเลือก ให้คะแนนทริปนี้ หน้าต่าง ทริปนี้เป็นอย่างไรบ้าง แสดงดาวคะแนนทริปและบริษัทนำเที่ยวแยกกัน เลือกคะแนนแต่ละส่วนตั้งแต่ 1 ถึง 5 ดาวและตรวจตัวเลขที่แสดง กรอกความคิดเห็นได้ไม่เกิน 500 ตัวอักษร เลื่อนหน้าต่างเมื่อคีย์บอร์ดบังปุ่ม แล้วกด ส่งคะแนน หรือปิดหน้าต่างหากยังไม่ต้องการส่ง หากระบบอนุญาตให้แก้ไขใช้ปุ่มแก้ไขและบันทึกการแก้ไข ตรวจว่าคะแนนของตนแสดงหลังส่งสำเร็จ',
'ข้อมูลคะแนนและทริปที่เสร็จปรากฏตามสิทธิ์ในหน้าปัจจุบัน ส่วนโค้ดแอปที่ตรวจยังไม่มีเส้นทางประวัติทริปแยกโดยเฉพาะ จึงไม่ควรอ้างว่ามีหน้าประวัติที่เรียกได้ทุกครั้ง หลังให้คะแนนระบบแจ้งว่ากลุ่มจะยุบอัตโนมัติภายในหนึ่งชั่วโมง การเข้าถึงย้อนหลังต้องตรวจความสามารถของรุ่นที่ใช้งานจริง'], 'rating_history','lib/pages/group_messages_page.dart rating sheet; lib/main.dart routes','tourist'),
('B.1.26','การออกจากกลุ่ม สลับบัญชีและออกจากระบบ',[
'หากกลุ่มยังอยู่ในสถานะที่อนุญาตให้ออก เปิดเมนูออกจากกลุ่มและอ่านเงื่อนไขก่อนยืนยัน เมื่อออกสำเร็จจะสูญเสียสิทธิ์เข้าห้องสนทนาและกระบวนการของกลุ่มนั้นตามข้อกำหนด ผู้ใช้สามารถเริ่ม preference ใหม่เมื่อสถานะเดิมสิ้นสุดหรือยกเลิกแล้ว',
'เมื่อต้องการเปลี่ยนบัญชีให้เปิดโปรไฟล์แล้วเลือก สลับบัญชีที่บันทึก ตรวจชื่อบัญชีปลายทางให้ถูกต้อง หรือเลือก ออกจากระบบ เพื่อยุติ session ในแอป การออกจากระบบไม่ใช่การออกจากกลุ่มหรือยกเลิก preference ข้อมูลที่บริการบันทึกยังคงผูกกับบัญชีเดิม'], 'switch_logout','lib/pages/profile_page.dart; lib/pages/groups_page.dart','tourist'),
]

B += AGENCY

B += [
('B.3.1','การเข้าสู่ระบบผู้ดูแล',[
'เปิด /admin/login ใน Portal กรอกชื่อผู้ใช้หรืออีเมลและรหัสผ่านของบัญชีผู้ดูแลที่หน่วยงานจัดเตรียม แล้วเข้าสู่ระบบ ระบบพาไปหน้ารายการบริษัทนำเที่ยว ผู้ดูแลไม่ใช้บัญชีบริษัทหรือนักท่องเที่ยวแทนบัญชีบทบาทนี้',
'เมื่อพบข้อผิดพลาดให้ตรวจข้อมูลบัญชีและการเชื่อมต่อบริการ หาก session หมดอายุให้เข้าสู่ระบบใหม่ บัญชีผู้ดูแลเริ่มต้นต้องกำหนดในฝั่งบริการตามภาคผนวก ก คู่มือไม่แสดงรหัสผ่านหรือข้อมูลลับของบัญชีใด'], 'admin_login','portal/src/pages/AdminPages.tsx AdminLoginPage; portal/src/App.tsx','admin'),
('B.3.2','หน้าหลักผู้ดูแลและจำนวนรายการ',[
'หลังเข้าสู่ระบบใช้เมนูบริษัทนำเที่ยว ผู้ใช้งาน และกลุ่มท่องเที่ยวเพื่อเปิดข้อมูลตามหน้าที่ หน้าหลักผู้ดูแลปัจจุบันคือ /admin/agencies ซึ่งแสดงจำนวนบริษัทที่โหลดและรายการตามสถานะที่เลือก',
'จำนวนบริษัท บัญชีผู้ใช้หรือกลุ่มที่แสดงเป็นจำนวนรายการของข้อมูลและตัวกรองที่เปิดอยู่ ไม่ใช่สถิติภาพรวมทุกช่วงเวลา ไม่มีหน้า dashboard กราฟหรือเส้นทาง /admin/dashboard แยกใน Portal รุ่นที่ตรวจ หากต้องทำรายงานสถิติควรอธิบายช่วงข้อมูลและตัวกรองให้ชัดเจน'], 'admin_overview','portal/src/App.tsx; portal/src/pages/AdminPages.tsx','admin'),
('B.3.3','การค้นหาและดูข้อมูลผู้ใช้งาน',[
'เปิดเมนู ผู้ใช้งาน กรอกคำค้นชื่อ ชื่อผู้ใช้ อีเมลหรือโทรศัพท์ และเลือกกรองทุกสถานะ ใช้งานอยู่ ถูกระงับหรือไม่ใช้งาน กดค้นหาแล้วเปิด ดูรายละเอียด เพื่อตรวจข้อมูลบัญชี กลุ่มปัจจุบันและสถานะ matching',
'หน้ารายละเอียดแสดงประวัติสถานะเมื่อมีข้อมูล ผู้ดูแลควรตรวจชื่อบัญชีและเหตุผลก่อนทำรายการ หน้านี้ไม่แสดง Passport สลิปหรือเอกสารส่วนตัว จึงไม่ใช้การจัดการบัญชีเป็นช่องทางเข้าถึงเอกสารเหล่านั้น'], 'admin_users','portal/src/pages/AdminPages.tsx AdminUsersPage and AdminUserDetailPage','admin'),
('B.3.4','การระงับและเปิดใช้งานบัญชีนักท่องเที่ยว',[
'จากรายละเอียดผู้ใช้เลือก ระงับบัญชี หรือ เปิดใช้งานบัญชี ตามสถานะปัจจุบัน เมื่อระงับต้องกรอกเหตุผล อ่านผลกระทบในหน้าต่างยืนยันแล้วกดยืนยัน รอผลตอบกลับและตรวจสถานะกับประวัติอีกครั้ง',
'การระงับตัด session และปิดการทำรายการของผู้ใช้ตามฝั่งบริการ จึงต้องตรวจบัญชีเป้าหมายและเหตุผลก่อนยืนยัน การเปลี่ยนสถานะเป็นการจัดการสิทธิ์ใช้งานบัญชี ไม่ใช่หน้าเปลี่ยนบทบาทเป็นบริษัทหรือผู้ดูแล ไม่มีการสอนแก้สิทธิ์แบบกำหนดเองที่ไม่ปรากฏในหน้าจอ'], 'admin_user_status','portal/src/pages/AdminPages.tsx updateStatus and ConfirmDialog','admin'),
('B.3.5','การตรวจบริษัทและใบอนุญาต',[
'เปิด บริษัทนำเที่ยว เลือกสถานะที่ต้องการ แล้วกด ตรวจสอบ ในแถวบริษัท ตรวจข้อมูลผู้ติดต่อ อีเมล โทรศัพท์ ที่อยู่และเลขใบอนุญาต เปิด ดูใบอนุญาต เพื่อดูไฟล์ผ่านช่องทางที่ตรวจสิทธิ์ของผู้ดูแล',
'หากไม่มีไฟล์ใบอนุญาตระบบแจ้งว่าไม่สามารถอนุมัติได้ ตรวจข้อมูลและเอกสารให้สัมพันธ์กันก่อนเลือกผล พิจารณาจากหลักฐานจริงของบริษัท ไม่ใช้ไฟล์ DEMO เป็นใบอนุญาตผ่านตรวจในการใช้งานจริง'], 'admin_license','portal/src/pages/AdminPages.tsx AdminAgencyDetailPage; PrivateDocumentPreview.tsx','admin'),
('B.3.6','การอนุมัติ ปฏิเสธและระงับบริษัท',[
'จากหน้ารายละเอียดบริษัทเลือก อนุมัติ ปฏิเสธ หรือ ระงับ กรอกหมายเหตุสำหรับบริษัทเมื่อมี อ่านผลกระทบในหน้าต่างยืนยัน แล้วกดยืนยันรายการ เมื่อบริการบันทึกสำเร็จตรวจสถานะและหมายเหตุล่าสุดอีกครั้ง',
'การอนุมัติเปิดสิทธิ์ Lead Marketplace และการส่งข้อเสนอ ส่วนการปฏิเสธหรือระงับปิดความสามารถขายตามสถานะ บัญชีบริษัทอาจยังเห็นหน้าติดตามสถานะได้ การเปลี่ยนสถานะจึงไม่ใช่การลบข้อมูลบริษัท และไม่ควรยืนยันจากเพียงสีของปุ่มก่อนรับผลจากบริการ'], 'admin_agency_status','portal/src/pages/AdminPages.tsx actionCopy and setStatus','admin'),
('B.3.7','การตรวจกลุ่มและสถานะกระบวนการ',[
'เปิดเมนู กลุ่มท่องเที่ยว ใช้คำค้นและตัวกรองสถานะหรือประเภท แล้วดูรายละเอียดกลุ่ม ตรวจปลายทาง เดือนเดินทาง จำนวนสมาชิก ประเภท Automatic หรือ Manual สถานะกลุ่ม Voting, Journey และกำหนดเวลาที่มี',
'หน้ากลุ่มมีเครื่องมือสำหรับการพัฒนาบางรายการ เช่นนำสมาชิกออกหรือรีเซ็ตกลุ่มทดสอบซึ่งต้องผ่านเงื่อนไขและหน้าต่างยืนยัน เครื่องมือดังกล่าวใช้เฉพาะงานพัฒนาที่ได้รับอนุญาต ผู้ดูแลควรใช้ข้อมูลสถานะเพื่อประสานปัญหาตามหน้าที่ และไม่ใช้เครื่องมือรีเซ็ตกับกลุ่มธุรกิจ'], 'admin_groups','portal/src/pages/AdminGroupPages.tsx','admin'),
('B.3.8','การใช้งานตามบทบาทและออกจากระบบ',[
'ผู้ดูแลใช้สิทธิ์ตรวจบัญชีและบริษัทผ่าน session ของตน ส่วนบริษัทใช้สิทธิ์เสนอราคาและดูแลทริปที่ชนะ นักท่องเที่ยวใช้สิทธิ์ preference กลุ่มและการเดินทางของตน Portal ตรวจ session และสถานะอนุมัติก่อนเปิดเส้นทางที่ป้องกันไว้',
'เมื่อเสร็จงานเลือกออกจากระบบและตรวจว่าต้องเข้าสู่ระบบก่อนกลับมาหน้าผู้ดูแล ไม่มีหน้าจัดการตารางสิทธิ์แบบอิสระหรือหน้าสถิติแยกในเส้นทางปัจจุบัน คู่มือจึงอธิบายสิทธิ์จากบทบาทและสถานะที่ระบบมีจริง'], 'admin_logout','portal/src/components/PortalShell.tsx; portal/src/App.tsx','admin'),
]

def font(run, size=16, bold=False):
    run.font.name='TH SarabunPSK'; run.font.size=Pt(size); run.bold=bold; run.font.color.rgb=RGBColor(0,0,0)
    for attr in ('ascii','hAnsi','eastAsia','cs'):
        run._element.get_or_add_rPr().rFonts.set(qn('w:'+attr),'TH SarabunPSK')
    language=OxmlElement('w:lang');language.set(qn('w:val'),'th-TH');language.set(qn('w:eastAsia'),'th-TH');run._element.rPr.append(language)

def para(doc,text,style='Normal',align=None,bold=False,size=16):
    p=doc.add_paragraph(style=style); font(p.add_run(text),size,bold)
    p.alignment=align if align is not None else WD_ALIGN_PARAGRAPH.JUSTIFY
    f=p.paragraph_format; f.line_spacing=1.15; f.space_after=Pt(6); f.widow_control=True
    if style=='Normal': f.first_line_indent=Cm(1.27)
    return p

def divider(doc,thai,title):
    if len(doc.paragraphs): doc.add_page_break()
    spacer=doc.add_paragraph();spacer.add_run(' ').font.size=Pt(1)
    spacer.paragraph_format.line_spacing=Pt(180);spacer.paragraph_format.space_before=Pt(0);spacer.paragraph_format.space_after=Pt(0)
    p=para(doc,'ภาคผนวก '+thai,'Title',WD_ALIGN_PARAGRAPH.CENTER,True,18)
    p.paragraph_format.space_before=Pt(0); p.paragraph_format.space_after=Pt(8)
    para(doc,title,'Title',WD_ALIGN_PARAGRAPH.CENTER,True,18)
    doc.add_page_break()

def build(checkpoint):
    reference=Document(str(REF))
    # A clean OPC container avoids inherited reference payloads and broken legacy parts.
    # Typography/styles and page geometry remain taken from the retained thesis.
    doc=Document()
    styles=doc.styles.element
    for child in list(styles):styles.remove(child)
    for child in reference.styles.element:styles.append(deepcopy(child))
    source_section=reference.sections[-1]
    section=doc.sections[0]
    for name in ('page_width','page_height','top_margin','bottom_margin','left_margin','right_margin','header_distance','footer_distance'):setattr(section,name,getattr(source_section,name))
    for name in ('Normal','Caption','Heading 1','Heading 2','Heading 3','Title'):
        if name not in doc.styles: doc.styles.add_style(name,WD_STYLE_TYPE.PARAGRAPH)
        style=doc.styles[name]; style.font.name='TH SarabunPSK';style.font.size=Pt(16 if name in ('Normal','Caption') else 18);style.font.color.rgb=RGBColor(0,0,0)
    for part in (section.header,section.footer):
        for p in part.paragraphs: p.clear()
    h=section.header.paragraphs[0];h.alignment=WD_ALIGN_PARAGRAPH.RIGHT
    fld=OxmlElement('w:fldSimple');fld.set(qn('w:instr'),'PAGE');h._p.append(fld)
    for name in ('author','last_modified_by','subject','keywords','comments','category','content_status','identifier','language','version'):setattr(doc.core_properties,name,'')
    doc.core_properties.title='ภาคผนวก ระบบจัดกลุ่มนักท่องเที่ยวอัตโนมัติ'
    manifest_path=ROOT/'work/image_manifest.json'
    images=json.loads(manifest_path.read_text('utf-8')) if manifest_path.exists() else {}
    missing=[];index=[];counts={'A':0,'tourist':0,'agency':0,'admin':0}
    divider(doc,'ก','ขั้นตอนการติดตั้งระบบ')
    para(doc,'ภาคผนวกนี้อธิบายการเตรียมสภาพแวดล้อมและเปิดระบบ TourApp บนเครื่องพัฒนา เพื่อให้ผู้ติดตั้งเชื่อมต่อฐานข้อมูล บริการ Dart แอปนักท่องเที่ยว และเว็บ Portal ได้ตามโครงสร้างของโครงการ ขั้นตอนฐานข้อมูลใช้สำหรับการติดตั้งใหม่เท่านั้น')
    a_selected=A[:1] if checkpoint==1 else A
    counters={'ก':0,'ข':0}
    def item(entry,thai,role):
        num,title,texts,key,source=entry
        display_num=num.replace('A.','ก.').replace('B.','ข.')
        hp=para(doc,display_num+' '+title,'Heading 2',WD_ALIGN_PARAGRAPH.LEFT,True)
        hp.paragraph_format.keep_with_next=True;hp.paragraph_format.space_before=Pt(12)
        record=images.get(key)
        if record and (ROOT/record['path']).exists():
            path=ROOT/record['path'];w,h=Image.open(path).size
            maxw=15.5;maxh=11 if h>w else 8.4
            width=min(maxw,maxh*w/h)
            ip=doc.add_paragraph();ip.alignment=WD_ALIGN_PARAGRAPH.CENTER;ip.paragraph_format.keep_with_next=True
            ip.add_run().add_picture(str(path),width=Cm(width));ip.paragraph_format.space_after=Pt(3)
            counters[thai]+=1;caption=f'ภาพประกอบที่ {thai}-{counters[thai]} {title}'
            cp=para(doc,caption,'Caption',WD_ALIGN_PARAGRAPH.CENTER);cp.paragraph_format.keep_with_next=True
            counts[role]+=1;index.append((caption,record['path'],record.get('origin',''),source))
        else:
            pending_p=para(doc,'[รอภาพหน้าจอจริง: '+title+']','Caption',WD_ALIGN_PARAGRAPH.CENTER)
            pending_p.paragraph_format.keep_with_next=True
            missing.append((display_num,title,key,role))
        for t in texts:
            if t.startswith('[คำสั่ง]'):
                p=para(doc,t[len('[คำสั่ง]'):],align=WD_ALIGN_PARAGRAPH.LEFT,size=11)
                p.paragraph_format.first_line_indent=Cm(0);p.paragraph_format.left_indent=Cm(0.5);p.paragraph_format.line_spacing=1.05
                for run in p.runs:
                    run.font.name='Consolas';run.font.size=Pt(11)
                    for attr in ('ascii','hAnsi','eastAsia','cs'):run._element.rPr.rFonts.set(qn('w:'+attr),'Consolas')
            else:para(doc,t)
    for entry in a_selected:item(entry,'ก','A')
    if checkpoint>=3:
        divider(doc,'ข','คู่มือการใช้งานระบบ')
        para(doc,'คู่มือแบ่งตามบทบาทนักท่องเที่ยว บริษัทนำเที่ยว และผู้ดูแลระบบ หน้าจอและสิทธิ์ที่ใช้ขึ้นอยู่กับสถานะบัญชีและทริป ผู้ใช้งานควรอ่านผลตอบกลับของระบบหลังทำรายการ เพื่อทราบว่าข้อมูลถูกบันทึกหรือยังต้องแก้ไขขั้นตอนใด')
        for role,heading,threshold in [('tourist','ข.1 คู่มือสำหรับนักท่องเที่ยว',3),('agency','ข.2 คู่มือสำหรับบริษัทนำเที่ยว',4),('admin','ข.3 คู่มือสำหรับผู้ดูแลระบบ',5)]:
            if checkpoint<threshold:continue
            para(doc,heading,'Heading 1',WD_ALIGN_PARAGRAPH.LEFT,True,18)
            for entry in B:
                if entry[-1]==role:item(entry[:-1],'ข',role)
    doc.save(str(OUT))
    cp=ROOT/'checkpoints';cp.mkdir(exist_ok=True)
    shutil.copy2(OUT,cp/f'checkpoint_{checkpoint:02d}.docx')
    summary={'checkpoint':checkpoint,'headings_A':len(a_selected),'headings_B':sum(1 for x in B if checkpoint>=({'tourist':3,'agency':4,'admin':5}[x[-1]])),'images':counts,'pending_images':len(missing),'source_sha256':hashlib.sha256(REF.read_bytes()).hexdigest()}
    (ROOT/'work/build_summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2),encoding='utf-8')
    pending='# รายการภาพที่ยังต้องเก็บ\n\nภาพที่ขาดไม่แทนด้วยภาพจำลอง ภาพเก่าที่เป็นโฟลว์ค้นหาและเข้าร่วมกลุ่มไม่ได้ใช้แทนระบบจับกลุ่มอัตโนมัติ\n\n'+'\n'.join(f'- {n} {t} ({r}; key `{k}`)' for n,t,k,r in missing)
    pending+='\n\n## งานที่ต้องเตรียมก่อนประกอบเล่มฉบับสมบูรณ์\n\n- เตรียมและตรวจไฟล์ schema-only สำหรับการติดตั้งใหม่ให้ตรงกับรุ่นโค้ด ยังไม่มี clean installer ที่ยืนยันแล้วในโครงการ ไม่รัน dump ธุรกิจ\n- จัด session และข้อมูลทดสอบที่ได้รับอนุญาตสำหรับ Matching, Poll, Bid, Payment, Trip Hub และ Portal หลังเข้าสู่ระบบ ก่อนเก็บภาพตามรายการข้างต้น\n- ภาพ Payment ต้องแยก Demo ออกจากผล SlipOK จริง ไม่เรียกผู้ให้บริการหรือส่งสลิปเพื่อสร้างภาพประกอบโดยไม่มีขอบเขตทดสอบที่ได้รับอนุญาต\n- หน้า trip history, admin dashboard แยก, role editor และ agency score dashboard ที่ไม่มีจริงไม่ถือเป็นภาพที่ต้องสร้าง สามารถทบทวนเมื่อระบบเพิ่มคุณลักษณะเหล่านี้\n'
    (ROOT/'appendix_pending.md').write_text(pending+'\n',encoding='utf-8')
    lines=['# ดัชนีภาพภาคผนวก','', 'เส้นทาง screenshots อ้างจาก C:\\Users\\Asus\\Desktop\\TourAppProject\\travelin_app\\docs\\thesis_appendices ส่วนเส้นทาง audit, lib, portal, server และไฟล์โครงการอ้างจาก C:\\Users\\Asus\\Desktop\\TourAppProject\\travelin_app','', '| คำบรรยาย | ไฟล์ในชุดงาน | ที่มาภาพ | UI จริง หรือ Fixture | ใส่ใน Word แล้ว | โค้ดอ้างอิง |','|---|---|---|---|---|---|']+[f'| {c} | {p} | {o} | UI จริงจากภาพเดิม | แล้ว | {s} |' for c,p,o,s in index]
    lines += ['', 'จำนวนภาพที่ใส่ใน Word: '+str(len(index)), 'ภาพใหม่: 0; ภาพใช้ซ้ำจากหลักฐานเดิม: '+str(len(index)), 'Fixture: 0; ไม่ใช้ภาพจำลองหรือภาพของโครงการเพื่อน']
    (ROOT/'appendix_image_index.md').write_text('\n'.join(lines)+'\n',encoding='utf-8')
    print(json.dumps(summary,ensure_ascii=False))

if __name__=='__main__':build(int(sys.argv[1]))
