// Figures for the Software Design work product: high-level diagrams and low-fidelity wireframes. // Each figure is an SVG drawn here; scripts/sdlc-delivery/render-figures.mjs rasterises them to // PNG with the document font (scripts/sdlc-seed/figures/*.png), which build.mjs copies next to // the Markdown that references them. const FONT = "'BRN Thai', 'Leelawadee UI', sans-serif"; const BLUE = '#0797d5'; const BLUE_BG = '#e8f5fb'; const INK = '#222'; const GREY = '#8e8e8e'; const LINE = '#595959'; const WF_FILL = '#f2f2f2'; const WF_DARK = '#d9d9d9'; const esc = (s) => String(s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); function text(x, y, value, { size = 15, weight = 400, anchor = 'middle', fill = INK, lh = 1.3 } = {}) { const lines = Array.isArray(value) ? value : [value]; const top = y - ((lines.length - 1) * size * lh) / 2; return lines.map((l, i) => `${esc(l)}`).join(''); } function rect(x, y, w, h, { fill = '#fff', stroke = LINE, sw = 1.5, rx = 6, dash = '' } = {}) { return ``; } /** A labelled box: title centred, optional smaller subtitle lines. */ function box(x, y, w, h, title, sub = [], opts = {}) { const { fill = '#fff', stroke = LINE, titleSize = 15, subSize = 12.5, dash = '', rx = 6 } = opts; const subs = Array.isArray(sub) ? sub : [sub]; const total = subs.length ? titleSize * 1.4 + subs.length * subSize * 1.35 : 0; const cy = y + h / 2; const titleY = subs.length ? cy - total / 2 + titleSize * 0.7 : cy; const body = subs.length ? text(x + w / 2, titleY + titleSize * 0.75 + (subs.length * subSize * 1.35) / 2, subs, { size: subSize, fill: '#444', lh: 1.35 }) : ''; return rect(x, y, w, h, { fill, stroke, rx, dash }) + text(x + w / 2, titleY, title, { size: titleSize, weight: 700 }) + body; } /** A container with its title in the top-left corner. */ function frame(x, y, w, h, title, { fill = BLUE_BG, stroke = BLUE, dash = '', bottom = false } = {}) { return rect(x, y, w, h, { fill, stroke, sw: 2, rx: 10, dash }) + text(x + 14, bottom ? y + h - 18 : y + 20, title, { size: 15, weight: 700, anchor: 'start', fill: stroke === BLUE ? '#05618a' : INK }); } function cylinder(x, y, w, h, title, sub = []) { const ry = 12; return `` + `` + text(x + w / 2, y + h / 2 + 6, [title, ...sub].length > 1 ? [title, ...sub] : title, { size: 13.5 }); } const defs = ` `; /** Straight or elbowed connector with an optional label. `points` is [[x,y], ...]. */ function link(points, { label = '', head = 'arr', tail = '', dash = '', lx, ly, size = 12.5 } = {}) { const d = points.map(([x, y], i) => `${i ? 'L' : 'M'}${x},${y}`).join(' '); const path = ``; if (!label) return path; const mid = Math.floor((points.length - 1) / 2); const x = lx ?? (points[mid][0] + points[mid + 1][0]) / 2; const y = ly ?? (points[mid][1] + points[mid + 1][1]) / 2; const lines = Array.isArray(label) ? label : [label]; const width = Math.max(...lines.map((l) => l.length)) * size * 0.56 + 12; const height = lines.length * size * 1.3 + 6; return path + `` + text(x, y, lines, { size, fill: '#333' }); } function actor(x, y, label) { return `` + text(x, y + 50, label, { size: 14, weight: 700 }); } function usecase(cx, cy, label, w = 230, h = 50) { return `` + text(cx, cy, label, { size: 13.5 }); } const svg = (w, h, body) => `${defs}${body}`; // ─── High-level diagrams ────────────────────────────────────────────────────── function architecture() { const b = []; b.push(frame(20, 40, 190, 300, 'ผู้ใช้งาน')); b.push(actor(70, 140, 'Owner'), actor(160, 140, 'Admin'), actor(70, 270, 'Staff'), actor(160, 270, 'Viewer')); b.push(frame(250, 40, 220, 300, 'Client Tier')); b.push(box(270, 80, 180, 110, 'Web Browser', ['Chrome / Edge / Firefox', 'Desktop · Tablet · Mobile', 'หน้าจอ Responsive'])); b.push(box(270, 215, 180, 95, 'Socket.IO Client', ['รับการแจ้งเตือน', 'แบบ Real-time'])); b.push(frame(510, 20, 420, 420, 'Web Tier — Apache + PHP 8')); b.push(box(530, 55, 380, 58, 'Page Controllers', 'app//*.php — หน้าจอและฟอร์ม')); b.push(box(530, 125, 380, 58, 'API Engines', 'app/*/api/engine/*.php — รับคำสั่งจากหน้าจอ (JSON)')); b.push(box(530, 195, 380, 70, 'db_auth (ประตูตรวจสอบทุกคำขอ)', ['Session · CSRF · OTP · Role Guard', 'ขอบเขตบริษัท/คลัง (Tenant Scope)'], { fill: '#fff8e5' })); b.push(box(530, 277, 380, 70, 'Manager / Service Classes', ['ตรรกะธุรกิจ: สต๊อก ขาย จัดซื้อ การเงิน บัญชี', 'ทำงานภายใน Database Transaction'])); b.push(box(530, 359, 380, 60, 'Report / ETL Managers', 'รายงาน แดชบอร์ด และตารางสรุปยอด')); b.push(frame(970, 40, 210, 300, 'Data Tier — MariaDB')); b.push(cylinder(995, 80, 160, 105, 'wms', ['ผู้ใช้ / บริษัท / สิทธิ์'])); b.push(cylinder(995, 205, 160, 115, 'wms2', ['คลัง · ขาย · จัดซื้อ', 'การเงิน · บัญชี'])); b.push(frame(510, 480, 420, 190, 'Service Tier — Node.js (pm2)', { bottom: true })); b.push(box(530, 495, 185, 130, 'server.js', ['Socket.IO', 'ส่งการแจ้งเตือน', 'ไปยังผู้ใช้ที่เกี่ยวข้อง'])); b.push(box(725, 495, 185, 130, 'scheduler.js', ['สรุปยอดสต๊อก/GL', 'แจ้งเตือนตามเวลา', 'Asia/Bangkok'])); b.push(frame(970, 480, 210, 190, 'External', { fill: '#f5f5f5', stroke: GREY })); b.push(box(985, 515, 180, 65, 'Backup', ['Git Backup Remote', 'mysqldump รายวัน'])); b.push(box(985, 595, 180, 60, 'SMTP Server', 'อีเมล Onboarding / แจ้งเตือน')); b.push(link([[210, 190], [250, 190]])); b.push(link([[450, 135], [530, 135]], { label: 'HTTPS (TLS)', ly: 120 })); b.push(link([[910, 300], [950, 300], [950, 190], [995, 190]], { label: 'PDO', lx: 950, ly: 245 })); b.push(link([[620, 440], [620, 495]], { label: 'Emit + Secret', lx: 620, ly: 465 })); b.push(link([[820, 495], [820, 440]], { label: 'เรียก app/cron', lx: 820, ly: 465 })); b.push(link([[530, 590], [360, 590], [360, 310]], { label: 'WebSocket', lx: 420, ly: 590 })); b.push(link([[910, 400], [950, 400], [950, 625], [985, 625]], { label: 'SMTP', lx: 950, ly: 470 })); b.push(link([[1075, 320], [1075, 515]], { label: 'สำรองข้อมูล', lx: 1075, ly: 420, dash: '5 4' })); return svg(1200, 690, b.join('')); } function useCase() { const b = []; b.push(rect(270, 20, 680, 800, { fill: '#fafcfd', stroke: BLUE, sw: 2, rx: 12 })); b.push(text(610, 44, 'ระบบ BRN WMS', { size: 16, weight: 700, fill: '#05618a' })); const A = { Viewer: [100, 150], Staff: [100, 400], Admin: [100, 610], Owner: [100, 765] }; // Generalisation: each role inherits the use cases of the role above it. b.push(link([[100, 352], [100, 214]], { head: 'tri' }), link([[100, 562], [100, 464]], { head: 'tri' }), link([[100, 718], [100, 674]], { head: 'tri' })); for (const [name, [x, y]] of Object.entries(A)) b.push(actor(x, y, name)); const UC = { login: [460, 95, 'เข้าสู่ระบบ / กู้คืนรหัสผ่าน'], report: [460, 165, 'ดูแดชบอร์ดและรายงาน'], stock: [460, 255, 'รับ / จ่าย / โอนสินค้า'], sales: [460, 320, 'จัดการเอกสารขาย'], purchase: [460, 385, 'จัดการเอกสารจัดซื้อ'], finance: [460, 450, 'รับชำระ / จ่ายชำระ'], gl: [460, 515, 'สมุดรายวันและบัญชีแยกประเภท'], master: [460, 595, 'จัดการข้อมูลหลัก'], users: [460, 660, 'จัดการผู้ใช้และสิทธิ์'], company: [460, 765, 'ตั้งค่าบริษัทและ SMTP'], etl: [790, 205, 'สรุปยอดสต๊อก / GL ตามเวลา'], alert: [790, 280, 'แจ้งเตือนสินค้าใกล้หมด / ค้างชำระ'], notify: [790, 385, 'แจ้งเตือนแบบ Real-time'], email: [790, 660, 'ส่งอีเมล'], }; for (const [cx, cy, label] of Object.values(UC)) b.push(usecase(cx, cy, label)); const assoc = (actorKey, ucKey) => { const [ax, ay] = A[actorKey]; const [ux, uy] = UC[ucKey]; return link([[ax + 28, ay - 10], [ux - 115, uy]], { head: '' }); }; b.push(assoc('Viewer', 'login'), assoc('Viewer', 'report')); for (const k of ['stock', 'sales', 'purchase', 'finance', 'gl']) b.push(assoc('Staff', k)); b.push(assoc('Admin', 'master'), assoc('Admin', 'users'), assoc('Owner', 'company')); b.push(actor(1090, 260, 'System Scheduler'), actor(1090, 680, 'SMTP Service')); b.push(link([[1060, 240], [905, 205]], { head: '' }), link([[1060, 250], [905, 280]], { head: '' }), link([[1060, 660], [905, 660]], { head: '' })); b.push(link([[575, 660], [675, 660]], { dash: '6 4', head: 'open', label: '«include»', ly: 645 })); b.push(link([[575, 95], [930, 95], [930, 660], [905, 660]], { dash: '6 4', head: 'open', label: '«include»', lx: 755, ly: 95 })); b.push(link([[575, 320], [700, 370]], { dash: '6 4', head: 'open', label: '«include»', lx: 640, ly: 330 })); return svg(1200, 850, b.join('')); } function components() { const b = []; const comp = (x, y, w, h, id, name, fill = '#fff') => { const icon = ``; return rect(x, y, w, h, { fill, rx: 4 }) + icon + text(x + w / 2 - 8, y + h / 2 - 9, `«component» ${id}`, { size: 12, fill: GREY }) + text(x + w / 2 - 8, y + h / 2 + 12, name, { size: 14, weight: 700 }); }; b.push(frame(20, 20, 1160, 120, 'ส่วนกลางที่ทุกโมดูลเรียกใช้ (Cross-cutting)')); b.push(comp(45, 55, 350, 70, 'UN01', 'Identity & Access', '#fff'), comp(425, 55, 350, 70, 'UN12', 'Security & Tenant Scope'), comp(805, 55, 350, 70, 'UN02', 'Company Settings')); const W = 255, H = 80, X = [45, 330, 615, 900]; b.push(frame(20, 165, 1160, 360, 'โมดูลธุรกิจ (Manager / Service Classes)', { fill: '#fff', stroke: BLUE })); b.push(comp(X[0], 205, W, H, 'UN03', 'Master Data'), comp(X[1], 205, W, H, 'UN04', 'Inventory Control'), comp(X[2], 205, W, H, 'UN05', 'Sales'), comp(X[3], 205, W, H, 'UN06', 'Purchasing')); b.push(comp(X[0], 420, W, H, 'UN10', 'Document Control'), comp(X[1], 420, W, H, 'UN07', 'Finance'), comp(X[2], 420, W, H, 'UN08', 'Accounting / GL'), comp(X[3], 420, W, H, 'UN09', 'Reports & Dashboard')); b.push(frame(20, 550, 1160, 130, 'บริการและโครงสร้างพื้นฐาน', { fill: '#f5f5f5', stroke: GREY })); b.push(comp(45, 585, 350, 75, 'UN11', 'Notification & Scheduler'), comp(425, 585, 350, 75, 'UN13', 'Deployment & Backup')); b.push(cylinder(840, 575, 290, 95, 'MariaDB wms / wms2')); const dep = (pts, label) => link(pts, { dash: '6 4', head: 'open', label, size: 12 }); b.push(dep([[X[1], 245], [X[0] + W, 245]], 'ใช้ข้อมูลหลัก')); b.push(dep([[X[2], 230], [X[1] + W, 230]], 'ตัดสต๊อก')); b.push(dep([[X[3] + 60, 205], [X[3] + 60, 180], [X[1] + W - 60, 180], [X[1] + W - 60, 205]], 'รับสินค้าเข้า')); b.push(dep([[X[2] + 128, 285], [X[2] + 128, 420]], 'บันทึก GL')); b.push(dep([[X[3] + 60, 285], [X[3] + 60, 360], [X[2] + 200, 360], [X[2] + 200, 420]], '')); b.push(dep([[X[1] + W, 460], [X[2], 460]], 'บันทึก GL')); b.push(dep([[X[3] + 180, 420], [X[3] + 180, 330], [X[1] + 200, 330], [X[1] + 200, 285]], 'อ่านยอดสต๊อก')); b.push(dep([[X[2] + 60, 285], [X[2] + 60, 390], [X[0] + 200, 390], [X[0] + 200, 420]], 'ออกเลขที่เอกสาร')); b.push(dep([[X[1], 480], [X[0] + W, 480]], '')); b.push(dep([[220, 585], [220, 540], [X[3] + 128, 540], [X[3] + 128, 500]], 'ตารางสรุปยอด')); return svg(1200, 700, b.join('')); } function deployment() { const b = []; const node = (x, y, w, h, title) => `` + text(x + 14, y + 36, title, { size: 15, weight: 700, anchor: 'start' }); b.push(node(20, 70, 220, 250, '«device» Client')); b.push(box(40, 125, 180, 80, 'Web Browser', ['Desktop / Tablet', 'Mobile'])); b.push(box(40, 220, 180, 80, 'เครื่องสแกนบาร์โค้ด', ['(ทำงานเป็นคีย์บอร์ด)'])); b.push(node(290, 20, 600, 620, '«execution environment» Production Server — Linux + Docker Compose')); b.push(box(320, 80, 250, 150, '«container» php', ['Apache + PHP 8', 'แอปพลิเคชัน wms-app', 'config.php สร้างจาก .env', 'พอร์ต 80 / 443 (TLS)'], { fill: BLUE_BG, stroke: BLUE })); b.push(box(610, 80, 250, 150, '«container» node', ['pm2', 'server.js (Socket.IO :3000)', 'scheduler.js', '(ETL และแจ้งเตือน)'], { fill: BLUE_BG, stroke: BLUE })); b.push(box(320, 290, 250, 150, '«container» db', ['MariaDB', 'ฐานข้อมูล wms และ wms2', 'Volume ข้อมูลถาวร'], { fill: BLUE_BG, stroke: BLUE })); b.push(box(610, 290, 250, 150, 'ไฟล์ตั้งค่า', ['.env (ไม่อยู่ใน Git)', 'docker/init-env.sh', 'docker-compose.yml'], { fill: '#fff8e5' })); b.push(box(320, 500, 540, 110, 'ทางเลือกติดตั้งแบบ Manual (LAMP)', ['Apache + PHP 8 + MariaDB บนเครื่องเดียว', 'สร้างฐานข้อมูลด้วย setup.php และรัน Node.js ผ่าน pm2'], { dash: '6 4' })); b.push(node(950, 40, 230, 150, '«external» SMTP')); b.push(box(965, 80, 200, 90, ' ', ['ส่งอีเมลของแต่ละบริษัท'], { stroke: 'none', fill: 'none' })); b.push(node(950, 230, 230, 170, '«external» Git Server')); b.push(box(965, 275, 200, 100, ' ', ['Repository หลัก (origin)', 'และ Backup Remote'], { stroke: 'none', fill: 'none' })); b.push(node(950, 440, 230, 170, '«storage» Backup')); b.push(box(965, 485, 200, 100, ' ', ['mysqldump รายวัน 02:00 น.', 'เก็บนอกเครื่องแม่ข่าย'], { stroke: 'none', fill: 'none' })); b.push(link([[220, 165], [320, 165]], { label: 'HTTPS', ly: 150 })); b.push(link([[240, 110], [270, 110], [270, 12], [735, 12], [735, 80]], { label: 'WebSocket', lx: 500, ly: 12 })); b.push(link([[570, 150], [610, 150]], { label: 'Emit', ly: 135, size: 11.5 })); b.push(link([[445, 230], [445, 290]], { label: 'PDO', lx: 445, ly: 260 })); b.push(link([[860, 110], [950, 110]], { label: 'SMTP', ly: 95 })); b.push(link([[860, 330], [950, 330]], { label: 'git push', ly: 315, dash: '5 4' })); b.push(link([[445, 440], [445, 470], [920, 470], [920, 525], [950, 525]], { label: 'สำรองข้อมูล', lx: 700, ly: 470, dash: '5 4' })); return svg(1200, 660, b.join('')); } // ─── Wireframes ────────────────────────────────────────────────────────────── const MENU = ['แดชบอร์ด', 'ข้อมูลหลัก', 'คลังสินค้า', 'ขาย', 'จัดซื้อ', 'การเงิน', 'บัญชี', 'รายงาน', 'ตั้งค่า']; /** Browser window + sidebar + top bar; returns the SVG and the content area. */ function shell(title, active, content) { const W = 1200, H = 760; const b = []; b.push(rect(10, 10, W - 20, H - 20, { fill: '#fff', stroke: '#9a9a9a', rx: 10 })); b.push(rect(10, 10, W - 20, 36, { fill: WF_DARK, stroke: '#9a9a9a', rx: 10 }), rect(10, 30, W - 20, 16, { fill: WF_DARK, stroke: 'none', rx: 0 })); b.push(...[30, 50, 70].map((x) => ``)); b.push(rect(100, 18, 520, 20, { fill: '#fff', stroke: '#b5b5b5', rx: 10 }), text(115, 28, 'https://wms.brn.co.th/app/…', { size: 12, anchor: 'start', fill: GREY })); b.push(rect(10, 46, 200, H - 56, { fill: '#e9e9e9', stroke: 'none', rx: 0 })); b.push(rect(28, 62, 120, 30, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }), text(88, 77, 'โลโก้บริษัท', { size: 12, fill: GREY })); MENU.forEach((m, i) => { const y = 116 + i * 40; if (m === active) b.push(rect(18, y - 15, 184, 30, { fill: '#fff', stroke: BLUE, rx: 4 })); b.push(rect(32, y - 7, 14, 14, { fill: m === active ? BLUE : '#bdbdbd', stroke: 'none', rx: 3 }), text(56, y, m, { size: 14, anchor: 'start', weight: m === active ? 700 : 400 })); }); b.push(rect(210, 46, W - 220, 50, { fill: '#fff', stroke: WF_DARK, rx: 0 })); b.push(text(232, 71, title, { size: 18, weight: 700, anchor: 'start' })); b.push(rect(W - 330, 58, 150, 26, { fill: WF_FILL, stroke: '#b5b5b5', rx: 4 }), text(W - 255, 71, 'บริษัท ▾', { size: 12.5 })); b.push(``, text(W - 90, 71, 'ผู้ใช้ ▾', { size: 12.5 })); b.push(content); return svg(W, H, b.join('')); } const btn = (x, y, w, label, primary = false) => rect(x, y, w, 32, { fill: primary ? BLUE : '#fff', stroke: primary ? BLUE : '#9a9a9a', rx: 5 }) + text(x + w / 2, y + 16, label, { size: 13.2, fill: primary ? '#fff' : INK, weight: primary ? 700 : 400 }); const field = (x, y, w, label, hint = '') => text(x, y, label, { size: 12.5, anchor: 'start', fill: '#444' }) + rect(x, y + 10, w, 32, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }) + (hint ? text(x + 10, y + 26, hint, { size: 12, anchor: 'start', fill: '#aaa' }) : ''); function grid(x, y, cols, rowCount, { rowH = 34, headFill = WF_DARK } = {}) { const w = cols.reduce((a, c) => a + c[1], 0); const b = [rect(x, y, w, rowH, { fill: headFill, stroke: '#b5b5b5', rx: 0 })]; let cx = x; for (const [label, cw, align] of cols) { b.push(text(align === 'r' ? cx + cw - 10 : cx + 10, y + rowH / 2, label, { size: 12.5, weight: 700, anchor: align === 'r' ? 'end' : 'start' })); cx += cw; } for (let r = 1; r <= rowCount; r += 1) { b.push(rect(x, y + r * rowH, w, rowH, { fill: r % 2 ? '#fff' : '#fafafa', stroke: '#d0d0d0', rx: 0 })); let px = x; for (const [, cw, align] of cols) { const bw = Math.min(cw - 20, 40 + ((r * 37 + cw) % 60)); b.push(rect(align === 'r' ? px + cw - 10 - bw : px + 10, y + r * rowH + rowH / 2 - 5, bw, 10, { fill: WF_DARK, stroke: 'none', rx: 3 })); px += cw; } } return b.join(''); } const tile = (x, y, w, label) => rect(x, y, w, 82, { fill: '#fff', stroke: '#b5b5b5', rx: 6 }) + text(x + 16, y + 24, label, { size: 13.2, anchor: 'start', fill: '#555' }) + rect(x + 16, y + 44, 80, 22, { fill: WF_DARK, stroke: 'none', rx: 4 }) + rect(x + w - 50, y + 22, 32, 32, { fill: WF_FILL, stroke: '#c8c8c8', rx: 6 }); function wfLogin() { const b = []; b.push(rect(10, 10, 1180, 740, { fill: '#f4f6f8', stroke: '#9a9a9a', rx: 10 })); b.push(rect(400, 110, 400, 520, { fill: '#fff', stroke: '#b5b5b5', rx: 10 })); b.push(rect(530, 140, 140, 50, { fill: WF_FILL, stroke: '#b5b5b5', rx: 6 }), text(600, 165, 'โลโก้', { size: 13.2, fill: GREY })); b.push(text(600, 225, 'เข้าสู่ระบบ BRN WMS', { size: 20, weight: 700 })); b.push(field(440, 265, 320, 'ชื่อผู้ใช้ / อีเมล', 'username'), field(440, 335, 320, 'รหัสผ่าน', '••••••••')); b.push(rect(440, 405, 16, 16, { fill: '#fff', stroke: '#9a9a9a', rx: 3 }), text(465, 413, 'จดจำการเข้าสู่ระบบ', { size: 12.5, anchor: 'start' })); b.push(btn(440, 440, 320, 'เข้าสู่ระบบ', true)); b.push(text(600, 500, 'ลืมรหัสผ่าน? · ลงทะเบียนบริษัทใหม่', { size: 13.2, fill: BLUE })); b.push(rect(440, 530, 320, 70, { fill: '#fff8e5', stroke: '#e0c070', rx: 6, dash: '5 4' }), text(600, 565, ['ขั้นตอน OTP (ถ้าเปิดใช้งาน):', 'กรอกรหัส 6 หลักที่ส่งทางอีเมล'], { size: 12.5 })); return svg(1200, 760, b.join('')); } function wfDashboard() { const c = []; ['สินค้าทั้งหมด', 'รับเข้า (เดือนนี้)', 'จ่ายออก (เดือนนี้)', 'สินค้าใกล้หมด'].forEach((l, i) => c.push(tile(232 + i * 237, 116, 222, l))); c.push(rect(232, 218, 610, 300, { fill: '#fff', stroke: '#b5b5b5', rx: 6 }), text(250, 240, 'กราฟความเคลื่อนไหวสินค้า 12 เดือน', { size: 13.5, anchor: 'start', weight: 700 })); for (let i = 0; i < 12; i += 1) { const h1 = 40 + ((i * 53) % 150), h2 = 20 + ((i * 31) % 110); c.push(rect(262 + i * 48, 490 - h1, 16, h1, { fill: '#9fd3ea', stroke: 'none', rx: 2 }), rect(280 + i * 48, 490 - h2, 16, h2, { fill: '#c9c9c9', stroke: 'none', rx: 2 })); } c.push(rect(860, 218, 318, 300, { fill: '#fff', stroke: '#b5b5b5', rx: 6 }), text(876, 240, 'กิจกรรมล่าสุด', { size: 13.5, anchor: 'start', weight: 700 })); for (let i = 0; i < 6; i += 1) c.push(``, rect(906, 272 + i * 38, 170, 10, { fill: WF_DARK, stroke: 'none', rx: 3 }), rect(1110, 272 + i * 38, 50, 10, { fill: '#c9c9c9', stroke: 'none', rx: 3 })); c.push(rect(232, 535, 946, 200, { fill: '#fff', stroke: '#b5b5b5', rx: 6 }), text(250, 557, 'สินค้าใกล้หมด / สินค้าเคลื่อนไหวมากที่สุด', { size: 13.5, anchor: 'start', weight: 700 })); c.push(grid(250, 575, [['สินค้า', 330], ['คลัง', 230], ['คงเหลือ', 170, 'r'], ['จุดสั่งซื้อ', 180, 'r']], 4)); return shell('แดชบอร์ด', 'แดชบอร์ด', c.join('')); } function wfList() { const c = []; c.push(btn(1040, 112, 138, '+ สร้างใหม่', true)); ['ทั้งหมด', 'รอกำหนดคลัง', 'ร่าง', 'ยืนยันแล้ว', 'ยกเลิก'].forEach((l, i) => c.push(tile(232 + i * 190, 158, 176, l))); c.push(rect(232, 258, 946, 56, { fill: WF_FILL, stroke: '#c8c8c8', rx: 6 })); c.push(rect(246, 270, 300, 32, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }), text(260, 286, 'ค้นหาเลขที่เอกสาร / ผู้ติดต่อ', { size: 12, anchor: 'start', fill: '#aaa' })); c.push(rect(560, 270, 170, 32, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }), text(572, 286, 'สถานะ ▾', { size: 12.5, anchor: 'start' })); c.push(rect(744, 270, 250, 32, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }), text(756, 286, 'ช่วงวันที่ DD/MM/YYYY – DD/MM/YYYY', { size: 12, anchor: 'start', fill: '#aaa' })); c.push(btn(1010, 270, 80, 'ค้นหา'), btn(1098, 270, 70, 'ล้าง')); c.push(grid(232, 330, [['เลขที่', 150], ['วันที่', 120], ['ผู้ติดต่อ', 250], ['ยอดรวม', 140, 'r'], ['สถานะ', 140], ['การทำงาน', 146]], 9)); c.push(text(232, 695, 'แสดง 1–10 จาก 120 รายการ', { size: 12.5, anchor: 'start', fill: '#555' })); ['‹', '1', '2', '3', '›'].forEach((p, i) => c.push(rect(990 + i * 38, 680, 32, 30, { fill: p === '1' ? BLUE : '#fff', stroke: '#b5b5b5', rx: 4 }), text(1006 + i * 38, 695, p, { size: 13.2, fill: p === '1' ? '#fff' : INK }))); return shell('ใบสั่งขาย (หน้ารายการ)', 'ขาย', c.join('')); } function wfDocument() { const c = []; c.push(rect(232, 112, 946, 150, { fill: '#fff', stroke: '#b5b5b5', rx: 6 }), text(250, 132, 'ข้อมูลเอกสาร', { size: 13.5, anchor: 'start', weight: 700 })); c.push(field(250, 152, 280, 'ผู้ติดต่อ *', 'ค้นหาผู้ติดต่อ…'), field(550, 152, 180, 'วันที่เอกสาร *', 'DD/MM/YYYY'), field(750, 152, 200, 'แผนก *', 'เลือกแผนก ▾'), field(970, 152, 190, 'สถานะ', 'ร่าง')); c.push(field(250, 208, 910, 'หมายเหตุ', '')); c.push(rect(232, 278, 946, 300, { fill: '#fff', stroke: '#b5b5b5', rx: 6 }), text(250, 298, 'รายการสินค้า', { size: 13.5, anchor: 'start', weight: 700 }), btn(1060, 284, 104, '+ เพิ่มรายการ')); c.push(grid(250, 322, [['SKU', 110], ['สินค้า', 240], ['คลัง', 130], ['จำนวน', 90, 'r'], ['ราคา/หน่วย', 110, 'r'], ['ภาษี %', 70, 'r'], ['รวม', 110, 'r'], ['', 40]], 5)); c.push(rect(232, 594, 946, 150, { fill: '#fff', stroke: '#b5b5b5', rx: 6 })); ['ยอดรวมก่อนภาษี', 'ส่วนลด', 'ภาษีมูลค่าเพิ่ม', 'ค่าขนส่ง'].forEach((l, i) => c.push(text(900, 612 + i * 26, l, { size: 12.5, anchor: 'end', fill: '#555' }), rect(920, 603 + i * 26, 240, 18, { fill: WF_FILL, stroke: '#c8c8c8', rx: 3 }))); c.push(text(250, 616, 'ยอดสุทธิ', { size: 14, anchor: 'start', weight: 700 }), rect(250, 632, 200, 34, { fill: WF_DARK, stroke: 'none', rx: 4 })); c.push(btn(250, 690, 110, 'บันทึกร่าง', true), btn(372, 690, 110, 'ยืนยัน'), btn(494, 690, 90, 'พิมพ์'), btn(596, 690, 90, 'ยกเลิก')); return shell('ใบสั่งขาย (หน้าบันทึก/แก้ไข)', 'ขาย', c.join('')); } function wfStockIn() { const c = []; c.push(rect(232, 112, 600, 620, { fill: '#fff', stroke: '#b5b5b5', rx: 6 }), text(250, 134, 'บันทึกรับสินค้าเข้า', { size: 13.5, anchor: 'start', weight: 700 })); c.push(field(250, 156, 270, 'คลังสินค้า *', 'เลือกคลัง ▾'), field(540, 156, 270, 'วันที่รับ *', 'DD/MM/YYYY')); c.push(field(250, 216, 460, 'สินค้า (พิมพ์หรือสแกนบาร์โค้ด) *', 'SKU / ชื่อสินค้า'), btn(720, 226, 90, 'สแกน')); c.push(field(250, 276, 170, 'จำนวน *', '0.0000'), field(440, 276, 170, 'Lot Number', ''), field(630, 276, 180, 'วันหมดอายุ', 'DD/MM/YYYY')); c.push(field(250, 336, 560, 'Serial Number', 'คั่นด้วยเครื่องหมายจุลภาค')); c.push(text(250, 400, 'ตำแหน่งจัดเก็บ *', { size: 12.5, anchor: 'start', fill: '#444' })); c.push(rect(250, 410, 180, 32, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }), text(262, 426, 'Zone ▾', { size: 12.5, anchor: 'start' }), rect(440, 410, 180, 32, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }), text(452, 426, 'Aisle ▾', { size: 12.5, anchor: 'start' }), rect(630, 410, 180, 32, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }), text(642, 426, 'Bin ▾', { size: 12.5, anchor: 'start' })); c.push(field(250, 462, 560, 'เอกสารอ้างอิง', 'เลขที่ PO / ใบส่งของ')); c.push(rect(250, 528, 560, 80, { fill: WF_FILL, stroke: '#b5b5b5', rx: 6, dash: '6 4' }), text(530, 568, ['แนบไฟล์ (ลากวางหรือคลิกเพื่อเลือก)', 'ไฟล์ที่อนุญาต: PDF, JPG, PNG'], { size: 12.5, fill: '#777' })); c.push(btn(250, 640, 120, 'บันทึกร่าง', true), btn(382, 640, 120, 'อนุมัติรับเข้า'), btn(514, 640, 90, 'ยกเลิก')); c.push(rect(850, 112, 328, 620, { fill: '#fff', stroke: '#b5b5b5', rx: 6 }), text(866, 134, 'ข้อมูลช่วยตัดสินใจ', { size: 13.5, anchor: 'start', weight: 700 })); c.push(tile(866, 156, 296, 'ยอดคงเหลือในคลังนี้'), tile(866, 252, 296, 'ช่องจัดเก็บที่ว่าง')); c.push(text(866, 370, 'ประวัติความเคลื่อนไหวล่าสุด', { size: 13.2, anchor: 'start', weight: 700 }), grid(866, 386, [['วันที่', 110], ['ประเภท', 90], ['จำนวน', 96, 'r']], 6)); return shell('รับสินค้าเข้า (Stock-in)', 'คลังสินค้า', c.join('')); } function wfReport() { const c = []; c.push(rect(232, 112, 946, 96, { fill: WF_FILL, stroke: '#c8c8c8', rx: 6 }), text(250, 132, 'ตัวกรองรายงาน', { size: 13.5, anchor: 'start', weight: 700 })); c.push(rect(250, 148, 200, 32, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }), text(262, 164, 'คลังสินค้า ▾', { size: 12.5, anchor: 'start' })); c.push(rect(462, 148, 200, 32, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }), text(474, 164, 'หมวดสินค้า ▾', { size: 12.5, anchor: 'start' })); c.push(rect(674, 148, 250, 32, { fill: '#fff', stroke: '#b5b5b5', rx: 4 }), text(686, 164, 'ช่วงวันที่', { size: 12.5, anchor: 'start', fill: '#aaa' })); c.push(btn(936, 148, 70, 'แสดง', true), btn(1014, 148, 70, 'พิมพ์'), btn(1092, 148, 76, 'ส่งออก')); ['ยอดยกมา', 'รับเข้า', 'จ่ายออก', 'คงเหลือ'].forEach((l, i) => c.push(tile(232 + i * 237, 224, 222, l))); c.push(grid(232, 324, [['SKU', 130], ['สินค้า', 260], ['คลัง', 160], ['ยกมา', 100, 'r'], ['รับเข้า', 100, 'r'], ['จ่ายออก', 100, 'r'], ['คงเหลือ', 96, 'r']], 10)); c.push(text(232, 715, 'รายงานแสดงเฉพาะบริษัทและคลังที่ผู้ใช้ได้รับสิทธิ์', { size: 12.5, anchor: 'start', fill: '#555' })); return shell('รายงานความเคลื่อนไหวสินค้า', 'รายงาน', c.join('')); } /** [file, caption, svg] — in the order they appear in the Software Design document. */ export const FIGURES = { architecture: ['hld-architecture', 'รูปที่ 1 High Level Architecture Diagram — โครงสร้างระบบแบ่งตามชั้นการทำงาน', architecture], useCase: ['hld-use-case', 'รูปที่ 2 Use Case Diagram — ผู้ใช้งานและกลุ่มการใช้งานหลัก', useCase], component: ['hld-component', 'รูปที่ 3 Component Diagram — โมดูลของระบบและความสัมพันธ์', components], deployment: ['hld-deployment', 'รูปที่ 4 Deployment Diagram — การติดตั้งบนสภาพแวดล้อมใช้งานจริง', deployment], wfLogin: ['wf-login', 'รูปที่ 5 Wireframe หน้าจอเข้าสู่ระบบ', wfLogin], wfDashboard: ['wf-dashboard', 'รูปที่ 6 Wireframe หน้าจอแดชบอร์ด', wfDashboard], wfList: ['wf-list', 'รูปที่ 7 Wireframe หน้ารายการเอกสาร (ตัวอย่างใบสั่งขาย) — ใช้รูปแบบเดียวกันทุกเอกสาร', wfList], wfDocument: ['wf-document', 'รูปที่ 8 Wireframe หน้าบันทึกเอกสาร (ตัวอย่างใบสั่งขาย) — ใช้กับเอกสารขาย จัดซื้อ และการเงิน', wfDocument], wfStockIn: ['wf-stock-in', 'รูปที่ 9 Wireframe หน้าจอรับสินค้าเข้า', wfStockIn], wfReport: ['wf-report', 'รูปที่ 10 Wireframe หน้าจอรายงาน', wfReport], }; /** Markdown image line for a figure; the path is relative to the document's folder. */ export const figure = (key) => `![${FIGURES[key][1]}](figures/${FIGURES[key][0]}.png)`;