top of page
Cover.jpg

import { Permissions, webMethod } from "wix-web-module"; import wixData from "wix-data"; import { currentMember } from "wix-members-backend"; import { mediaManager } from "wix-media-backend"; import { triggeredEmails } from "wix-crm-backend"; import { Buffer } from "buffer"; const ORDERS = "WorkOrders"; const LOGS = "WorkOrderLogs"; const REPAIR_ESTIMATES = "RepairEstimates"; const PRODUCTION_PROJECTS = "ProductionProjects"; const PRODUCTION_STAGES = "ProductionStages"; const RENTAL_TYPE = "เช่ารถ"; const PRODUCTION_TYPE = "งานผลิต"; const REPAIR_TYPE = "งานซ่อม"; const SALES_SPEC_EMAIL_ID = "sales_spec_review_required"; const SALES_OWNER = Object.freeze({ GOVERNMENT: Object.freeze({ sector: "GOVERNMENT", memberId: "556bc7e3-9fc1-4043-bd92-efec07662eb9", memberName: "ณัฏฐวี แก้วศิริ", }), PRIVATE: Object.freeze({ sector: "PRIVATE", memberId: "2e8a1cbc-01b1-4c99-ba17-711868269852", memberName: "ปิยะรัตน์ เจริญชัย", }), }); const REPAIR_JOB_STATUSES = [ "รับงาน", "นัดหมายแล้ว", "รออะไหล่", "กำลังซ่อม", "รอตรวจสอบ", "ซ่อมเสร็จ", "ส่งมอบแล้ว", "ปิดงานซ่อม", "ยกเลิก" ]; const STATUSES = [ "รับงาน", "รออนุมัติ", "อนุมัติแล้ว", "จัดรถ/ทีม", "พร้อมออกจ๊อบ", "กำลังปฏิบัติงาน", "เสร็จงาน", "ปิดงาน", "ยกเลิก" ]; const PENDING_STATUSES = ["แบบร่าง", "รออนุมัติ", "รออนุมัติผู้บริหาร"]; const COMPLETED_STATUSES = ["เสร็จงาน", "ปิดงาน", "ส่งมอบแล้ว", "ปิดโครงการ", "ซ่อมเสร็จ", "ปิดงานซ่อม"]; const DASHBOARD_STATUSES = Array.from(new Set([ ...STATUSES, "แบบร่าง", "เปิดโครงการ", "กำลังผลิต", "ติดปัญหา", "พร้อมส่งมอบ/ปิดโครงการ", "ส่งมอบแล้ว", "ปิดโครงการ", ...REPAIR_JOB_STATUSES ])); function text(value, max = 500) { return String(value ?? "").trim().slice(0, max); } function resolveCustomerSector(data = {}) { const explicit = text( data.customerSector || data.customerType || data.organizationType || data.salesChannel, 80 ).toLowerCase(); if (["government", "public", "ราชการ", "ภาครัฐ", "รัฐวิสาหกิจ"].some((marker) => explicit.includes(marker))) { return SALES_OWNER.GOVERNMENT.sector; } if (["private", "เอกชน", "บริษัท", "บุคคล"].some((marker) => explicit.includes(marker))) { return SALES_OWNER.PRIVATE.sector; } const customerName = text( data.customer || data.customer_name || data.customerName, 300 ).toLowerCase().replace(/\s+/g, " "); const privateMarkers = ["บริษัท", "บจก.", "บจก ", "จำกัด", "ห้างหุ้นส่วน", "หจก.", "หจก ", "บุคคล"]; const governmentMarkers = [ "เทศบาล", "องค์การบริหารส่วนตำบล", "องค์การบริหารส่วนจังหวัด", "อบต.", "อบต ", "อบจ.", "อบจ ", "กรม", "กระทรวง", "จังหวัด", "อำเภอ", "เมืองพัทยา", "สำนักงานตำรวจ", "สถานีตำรวจ", "กองทัพ", "การไฟฟ้า", "การประปา", "การรถไฟ", "การท่าเรือ", "รัฐวิสาหกิจ" ]; if (privateMarkers.some((marker) => customerName.includes(marker))) { return SALES_OWNER.PRIVATE.sector; } if (governmentMarkers.some((marker) => customerName.includes(marker))) { return SALES_OWNER.GOVERNMENT.sector; } return SALES_OWNER.PRIVATE.sector; } function salesOwnerForWorkOrder(data = {}) { return resolveCustomerSector(data) === SALES_OWNER.GOVERNMENT.sector ? SALES_OWNER.GOVERNMENT : SALES_OWNER.PRIVATE; } function number(value) { const result = Number(String(value ?? "").replace(/,/g, "")); return Number.isFinite(result) ? result : 0; } function date(value) { if (!value) return undefined; const result = new Date(value); return Number.isNaN(result.getTime()) ? undefined : result; } function dataCopy(value) { return JSON.parse(JSON.stringify(value ?? {})); } function fail(condition, message) { if (!condition) throw new Error(message); } async function requireMember() { const member = await currentMember.getMember(); fail(member?._id, "กรุณาเข้าสู่ระบบสมาชิกเว็บไซต์"); return member; } const COMPANY_OWNER_MEMBER_IDS = new Set([ "7c094a37-eb7d-43b6-8bfd-ed1cbc78a3c7", "2e8a1cbc-01b1-4c99-ba17-711868269852" ]); const WORK_ORDER_ADMIN_MEMBER_IDS = new Set([ "18471e9f-c61c-4c5a-9785-8738ab50a951" ]); const WORK_ORDER_ADMIN_EMAILS = new Set([ "pat4pt@gmail.com", "piyarat@4pinongs.com", "kokeak@gmail.com" ]); const PRODUCTION_MEMBER_EMAILS = new Set([ "production@4pinongs.com" ]); function memberEmail(member) { return text(member?.loginEmail || member?.contactDetails?.emails?.[0] || "", 160).toLowerCase(); } function canAcceptProduction(member) { return PRODUCTION_MEMBER_EMAILS.has(memberEmail(member)); } function requireProductionMember(member) { fail(COMPANY_OWNER_MEMBER_IDS.has(member?._id) || canAcceptProduction(member), "สิทธิ์นี้สำหรับฝ่ายผลิตเท่านั้น"); return member; } function requireCompanyOwner(member) { fail( canEditWorkOrders(member), "สิทธิ์แก้ไขสงวนไว้สำหรับเจ้าของระบบ QMR และ Admin ที่ได้รับมอบหมายเท่านั้น" ); return member; } function canEditWorkOrders(member) { return COMPANY_OWNER_MEMBER_IDS.has(member?._id) || WORK_ORDER_ADMIN_MEMBER_IDS.has(member?._id) || WORK_ORDER_ADMIN_EMAILS.has(memberEmail(member)); } function requireWorkOrderEditor(member) { fail( canEditWorkOrders(member), "บัญชีนี้มีสิทธิ์ดูรายงานเท่านั้น ไม่สามารถสร้างหรือแก้ไข Work Order ได้" ); return member; } function actorName(member) { const first = text(member?.contactDetails?.firstName, 80); const last = text(member?.contactDetails?.lastName, 80); return text([first, last].filter(Boolean).join(" ") || member?.profile?.nickname || member?.loginEmail || "Site Member", 160); } function clientOrder(item) { return item ? dataCopy(item) : null; } async function audit({ order, id, action, fromStatus = "", toStatus = "", actor, note = "", snapshot = {}, source }) { await wixData.insert(LOGS, { title: `${order} • ${action}`, workOrder: order, workOrderId: id, action, fromStatus, toStatus, actorId: actor?._id || "", actorName: actorName(actor), note: text(note, 500), snapshot: dataCopy(snapshot), occurredAt: new Date(), source }, { suppressAuth: true }); } async function findOrder(order, workType) { const result = await wixData.query(ORDERS) .eq("workOrder", order) .eq("workType", workType) .limit(1) .find({ suppressAuth: true }); return result.items[0] || null; } function productionContractKey(value) { return text(value?.contractNo || value?.contract_number, 160) .replace(/\s+/g, "") .toLowerCase(); } function productionSpecKey(value) { const files = Array.isArray(value?.specFiles) ? value.specFiles : []; for (const file of files) { const raw = text(file?.fileUrl || file?.url, 1000); if (!raw) continue; const match = raw.match(/^wix:document:\/\/v1\/([^/?#]+)/i); return text(match?.[1] || raw, 1000).toLowerCase(); } return ""; } async function findLikelyDuplicateProductionOrder(data, excludeId = "") { const contractKey = productionContractKey(data); const specKey = productionSpecKey(data); if (!contractKey || !specKey) return null; const result = await wixData.query(ORDERS) .eq("workType", PRODUCTION_TYPE) .descending("_updatedDate") .limit(1000) .find({ suppressAuth: true, consistentRead: true }); return result.items.find((item) => { if (excludeId && item._id === excludeId) return false; const existing = item.formData || {}; return productionContractKey(existing) === contractKey && productionSpecKey(existing) === specKey; }) || null; } export const getNextRentalWorkOrderNo = webMethod(Permissions.SiteMember, async () => { await requireMember(); const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, "0"); const d = String(now.getDate()).padStart(2, "0"); const prefix = `RENT-${y}${m}${d}-`; const result = await wixData.query(ORDERS) .eq("workType", RENTAL_TYPE) .startsWith("workOrder", prefix) .descending("workOrder") .limit(1) .find({ suppressAuth: true }); const last = text(result.items[0]?.workOrder, 80); const sequence = Math.max(0, Number(last.slice(prefix.length)) || 0) + 1; return `${prefix}${String(sequence).padStart(3, "0")}`; }); async function nextProductionWorkOrderNo() { const parts = Object.fromEntries( new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Bangkok", year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(new Date()).map((part) => [part.type, part.value]) ); const prefix = `FT-${parts.year}${parts.month}${parts.day}-`; const result = await wixData.query(ORDERS) .eq("workType", PRODUCTION_TYPE) .startsWith("workOrder", prefix) .descending("workOrder") .limit(1) .find({ suppressAuth: true, consistentRead: true }); const last = text(result.items[0]?.workOrder, 80); const sequence = Math.max(0, Number(last.slice(prefix.length)) || 0) + 1; return `${prefix}${String(sequence).padStart(3, "0")}`; } async function nextRepairWorkOrderNo() { const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, "0"); const d = String(now.getDate()).padStart(2, "0"); const prefix = `WORP-${y}${m}${d}-`; const result = await wixData.query(ORDERS) .eq("workType", REPAIR_TYPE) .startsWith("workOrder", prefix) .descending("workOrder") .limit(1) .find({ suppressAuth: true }); const last = text(result.items[0]?.workOrder, 80); const sequence = Math.max(0, Number(last.slice(prefix.length)) || 0) + 1; return `${prefix}${String(sequence).padStart(3, "0")}`; } export const getNextRepairWorkOrderNo = webMethod(Permissions.SiteMember, async () => { await requireMember(); return nextRepairWorkOrderNo(); }); export const listRepairWorkOrders = webMethod(Permissions.SiteMember, async (input = {}) => { await requireMember(); const limit = Math.min(Math.max(number(input?.limit) || 30, 1), 100); const result = await wixData.query(ORDERS) .eq("workType", REPAIR_TYPE) .descending("_updatedDate") .limit(limit) .find({ suppressAuth: true }); return result.items.map(clientOrder); }); export const saveRepairWorkOrder = webMethod(Permissions.SiteMember, async (input = {}) => { const actor = await requireMember(); requireWorkOrderEditor(actor); const data = dataCopy(input?.data || input || {}); const order = text(data.workOrder || data.work_order_no, 80) || await nextRepairWorkOrderNo(); const old = await findOrder(order, REPAIR_TYPE); const status = text(data.status || old?.status || "รับงาน", 100); const coverage = text(data.coverage, 80) === "ในประกัน" ? "ในประกัน" : "นอกประกัน"; const customerName = text(data.customerName, 250); const contactPhone = text(data.contactPhone, 80); const repairReport = text(data.repairReport, 5000); fail(REPAIR_JOB_STATUSES.includes(status), "สถานะงานซ่อมไม่ถูกต้อง"); fail(customerName, "กรุณาระบุชื่อลูกค้า/หน่วยงาน"); fail(contactPhone, "กรุณาระบุเบอร์ติดต่อ"); fail(repairReport, "กรุณาระบุอาการหรือรายงานการซ่อม"); const assignedTeam = Array.isArray(data.assignedTeam) ? data.assignedTeam.map((value) => text(value, 120)).filter(Boolean) : text(data.assignedTeam, 500).split(/[,\n]/).map((value) => text(value, 120)).filter(Boolean); const oldDashboard = dashboardData(old || {}); const formData = { ...data, workOrder: order, coverage, parts: repairJobParts(data.parts), photos: { items: repairPhotos(data.photos) }, _dashboard: { ...oldDashboard, currentStage: status, progressPercent: repairEstimateProgress(status), deliveryDueDate: date(data.dueDate) || oldDashboard.deliveryDueDate || null, deliveredDate: status === "ส่งมอบแล้ว" || status === "ปิดงานซ่อม" ? (date(data.deliveredDate) || oldDashboard.deliveredDate || new Date()) : oldDashboard.deliveredDate || null, nextAction: repairJobNextAction(status) } }; const record = { ...(old || {}), title: `${order} • ${customerName} • ${coverage}`, workOrder: order, status, customerName, contactName: text(data.contactName, 180), contactPhone, jobLocation: text(data.jobLocation, 1000), workType: REPAIR_TYPE, formData, startDate: date(data.appointmentDate) || old?.startDate || new Date(), endDate: date(data.dueDate) || old?.endDate || null, vehicleNo: text(data.chassisNumber || data.vehicleInfo, 150), licensePlate: text(data.licensePlate, 100), assignedTeam: Array.from(new Set(assignedTeam)), totalAmount: Math.max(0, number(old?.totalAmount)), lastAction: old ? `อัปเดตใบสั่งงานซ่อม${coverage}` : `สร้างใบสั่งงานซ่อม${coverage}`, lastActionAt: new Date(), lastActionBy: actorName(actor) }; const saved = old ? await wixData.update(ORDERS, record, { suppressAuth: true }) : await wixData.insert(ORDERS, record, { suppressAuth: true }); await audit({ order: saved.workOrder, id: saved._id, action: old ? "UPDATE" : "CREATE", fromStatus: old?.status || "", toStatus: status, actor, note: data.note, snapshot: formData, source: "Repair Work Order Website" }); return clientOrder(saved); }); export const saveRentalWorkOrder = webMethod(Permissions.SiteMember, async (input) => { const actor = await requireMember(); requireWorkOrderEditor(actor); const data = dataCopy(input?.data || input || {}); const order = text(data.work_order_no || data.workOrder || data.jobNo, 80); fail(order, "กรุณาระบุเลขที่ใบสั่งงาน"); const old = await findOrder(order, RENTAL_TYPE); const fromStatus = old?.status || ""; const status = text(data.status || old?.status || "รับงาน", 80); fail(STATUSES.includes(status), "สถานะงานไม่ถูกต้อง"); const customer = text(data.customer_name || data.customerName, 200); const assignedTeam = Array.isArray(data.assigned_team) ? data.assigned_team.map((value) => text(value, 120)).filter(Boolean) : text(data.assigned_team || data.staff_names, 500).split(/[,\n]/).map((value) => text(value, 120)).filter(Boolean); const record = { ...(old || {}), title: `${order} • ${customer || "งานเช่ารถ"}`, workOrder: order, status, customerName: customer, contactName: text(data.contact_name || data.contactName, 160), contactPhone: text(data.contact_phone || data.contactPhone, 80), jobLocation: text(data.job_location || data.jobLocation, 500), workType: RENTAL_TYPE, formData: data, startDate: date(data.start_date || data.startDate), endDate: date(data.end_date || data.endDate), vehicleNo: text(data.vehicle_no || data.vehicleNo, 100), licensePlate: text(data.license_plate || data.licensePlate, 100), assignedTeam: Array.from(new Set(assignedTeam)), totalAmount: number(data.total_amount || data.totalAmount), lastAction: old ? "อัปเดตใบสั่งงานเช่ารถ" : "สร้างใบสั่งงานเช่ารถ", lastActionAt: new Date(), lastActionBy: actorName(actor) }; const saved = old ? await wixData.update(ORDERS, record, { suppressAuth: true }) : await wixData.insert(ORDERS, record, { suppressAuth: true }); await audit({ order: saved.workOrder, id: saved._id, action: old ? "UPDATE" : "CREATE", fromStatus, toStatus: status, actor, note: input?.note, snapshot: data, source: "Rental Work Order Website" }); return clientOrder(saved); }); export const getLatestRentalWorkOrder = webMethod(Permissions.SiteMember, async () => { await requireMember(); const result = await wixData.query(ORDERS) .eq("workType", RENTAL_TYPE) .descending("_updatedDate") .limit(1) .find({ suppressAuth: true }); return clientOrder(result.items[0]); }); export const getRentalWorkOrder = webMethod(Permissions.SiteMember, async (input) => { await requireMember(); const id = text(input?.id, 80); if (id) return clientOrder(await wixData.get(ORDERS, id, { suppressAuth: true })); const order = text(input?.workOrder || input, 80); fail(order, "กรุณาระบุเลขที่ใบสั่งงาน"); return clientOrder(await findOrder(order, RENTAL_TYPE)); }); export const listRentalWorkOrders = webMethod(Permissions.SiteMember, async (input = {}) => { await requireMember(); let query = wixData.query(ORDERS).eq("workType", RENTAL_TYPE); const status = text(input?.status, 80); if (status) query = query.eq("status", status); const limit = Math.min(Math.max(number(input?.limit) || 50, 1), 100); const result = await query.descending("_updatedDate").limit(limit).find({ suppressAuth: true }); return result.items.map(clientOrder); }); export const updateRentalWorkOrderStatus = webMethod(Permissions.SiteMember, async ({ id, status, note = "" }) => { const actor = await requireMember(); requireWorkOrderEditor(actor); const next = text(status, 80); fail(id, "ไม่พบรหัสรายการ"); fail(STATUSES.includes(next), "สถานะงานไม่ถูกต้อง"); const old = await wixData.get(ORDERS, id, { suppressAuth: true }); fail(old?._id, "ไม่พบ Work Order"); const fromStatus = old.status || ""; old.status = next; old.lastAction = `เปลี่ยนสถานะเป็น ${next}`; old.lastActionAt = new Date(); old.lastActionBy = actorName(actor); const saved = await wixData.update(ORDERS, old, { suppressAuth: true }); await audit({ order: saved.workOrder, id: saved._id, action: "STATUS_CHANGE", fromStatus, toStatus: next, actor, note, snapshot: saved.formData || {}, source: saved.workType === PRODUCTION_TYPE ? "Production Work Order Website" : "Rental Work Order Website" }); return clientOrder(saved); }); export const saveProductionWorkOrder = webMethod(Permissions.SiteMember, async (input) => { const actor = await requireMember(); requireWorkOrderEditor(actor); const data = dataCopy(input?.data || input || {}); const requestedMode = text(input?.mode || data.editMode, 20).toUpperCase(); const requestedRecordId = text( data._id || data.id || data.recordId || input?._id || input?.id || input?.recordId || input?.workOrderId, 80 ); delete data._id; delete data.id; delete data.recordId; delete data.editMode; delete data.editingWorkOrderId; let order = text(data.jobNo || data.job_number || data.work_order_no, 80); fail(order, "กรุณาระบุเลขที่งาน"); if (requestedMode === "EDIT") { fail( requestedRecordId, "ข้อมูลแก้ไขไม่มี Work Order ID เดิม ระบบหยุดบันทึกเพื่อป้องกันการสร้างจ๊อบซ้ำ กรุณาโหลดรายการเดิมอีกครั้ง" ); } let old = null; if (requestedRecordId) { try { old = await wixData.get(ORDERS, requestedRecordId, { suppressAuth: true }); } catch (error) { const code = String(error?.code || error?.details?.applicationError?.code || ""); const message = String(error?.message || ""); const missing = code.includes("WDE0073") || /not found|does not exist|ไม่พบ/i.test(message); if (!missing || requestedMode !== "EDIT") { throw error; } old = null; } if (old?._id) { fail(old.workType === PRODUCTION_TYPE, "Work Order ที่เลือกไม่ใช่งานผลิต"); order = text(old.workOrder, 80); fail(order, "Work Order เดิมไม่มีเลขจ๊อบ กรุณาให้ผู้ดูแลตรวจสอบ"); } else { fail(requestedMode === "EDIT", "ไม่พบ Work Order เดิม กรุณารีเฟรชแล้วลองอีกครั้ง"); order = await nextProductionWorkOrderNo(); } } if (!old) { const existing = await findOrder(order, PRODUCTION_TYPE); fail( !existing, "เลขจ๊อบ " + order + " มีอยู่แล้ว หากต้องการแก้ไขให้กดโหลดรายการเดิมก่อนบันทึก" ); const duplicate = await findLikelyDuplicateProductionOrder(data); fail( !duplicate, "ตรวจพบข้อมูลซ้ำกับ " + duplicate?.workOrder + " (เลขสัญญาและไฟล์ PDF เดียวกัน) ระบบหยุดการสร้างจ๊อบใหม่ กรุณาโหลดรายการเดิมเพื่อแก้ไข" ); } const productionDocuments = Array.isArray(data.specFiles) ? data.specFiles.filter((file) => { const fileName = text(file?.fileName, 240).toLowerCase(); const mimeType = text(file?.mimeType, 120).toLowerCase(); const fileUrl = text(file?.fileUrl || file?.url, 1000).toLowerCase(); return mimeType === "application/pdf" || fileName.endsWith(".pdf") || fileUrl.includes(".pdf"); }) : []; fail( productionDocuments.length > 0, "กรุณาแนบไฟล์ PDF สเปค TOR สัญญา หรือแบบรถอย่างน้อย 1 ไฟล์ก่อนออกจ๊อบ" ); data.jobNo = order; data.job_number = order; data.work_order_no = order; data.specFiles = productionDocuments; const fromStatus = old?.status || ""; data.specApproved = false; data.reviewer = ""; data.approvalDate = ""; const status = "รอฝ่ายขายทบทวน"; const customer = text(data.customer || data.customer_name, 200); const customerSector = resolveCustomerSector({ ...data, customer }); const salesOwner = salesOwnerForWorkOrder({ ...data, customer, customerSector }); data.customerSector = customerSector; data.salesOwnerId = salesOwner.memberId; data.salesOwnerName = salesOwner.memberName; const owners = Array.from(new Set( [1, 2, 3, 4, 5, 6, 7] .map((index) => text(data[`owner${index}`], 120)) .filter(Boolean) )); const record = { ...(old || {}), title: `${order} • ${customer || "งานผลิต"}`, workOrder: order, status, customerName: customer, customerSector, salesOwnerId: salesOwner.memberId, salesOwnerName: salesOwner.memberName, contactName: text(data.contact || data.contact_name, 160), contactPhone: text(data.phone || data.contact_phone, 80), jobLocation: text(data.deliveryAddress || data.job_location, 500), workType: PRODUCTION_TYPE, formData: data, startDate: date(data.start1 || data.issueDate), endDate: date(data.deliveryDate || data.end7), vehicleNo: text(data.vehicleNo, 100), licensePlate: text(data.licensePlate, 100), assignedTeam: owners, totalAmount: number(data.totalAmount), lastAction: old ? "อัปเดตใบสั่งงานผลิต" : "สร้างใบสั่งงานผลิต", lastActionAt: new Date(), lastActionBy: actorName(actor) }; const saved = old ? await wixData.update(ORDERS, record, { suppressAuth: true }) : await wixData.insert(ORDERS, record, { suppressAuth: true }); await audit({ order: saved.workOrder, id: saved._id, action: old ? "UPDATE" : "CREATE", fromStatus, toStatus: status, actor, note: input?.note, snapshot: data, source: "Production Work Order Website" }); // การส่งต่อให้ฝ่ายขายใช้คิวในระบบเท่านั้น ไม่ส่งอีเมลเมื่อ Admin กดบันทึก const salesReviewEmailSent = false; let responseOrder = saved; const delivered = status === "ส่งมอบแล้ว" || status === "ปิดโครงการ"; if (delivered) { const actualDeliveryDate = date(data.actualDeliveryDate || data.deliveredDate) || new Date(); const warrantyMonths = Math.max(1, Math.min(120, Math.round(number(data.warrantyMonths) || 12))); const addDaysValue = (base, daysToAdd) => { const value = new Date(base); value.setDate(value.getDate() + daysToAdd); return value; }; const warrantyEndDate = new Date(actualDeliveryDate); warrantyEndDate.setMonth(warrantyEndDate.getMonth() + warrantyMonths); warrantyEndDate.setDate(warrantyEndDate.getDate() - 1); const existingPlanResult = await wixData.query("AfterSalesPlans") .eq("relatedWorkOrder", saved.workOrder) .limit(1) .find({ suppressAuth: true, consistentRead: true }); const existingPlan = existingPlanResult.items[0] || null; const planNo = existingPlan?.planNo || ("AS-" + saved.workOrder); const planValues = { ...(existingPlan || {}), title: planNo + " • " + (saved.customerName || customer || saved.workOrder), planNo, relatedWorkOrder: saved.workOrder, contractNo: text(data.contractNumber || data.contractNo, 100), customerName: saved.customerName || customer, contactName: text(data.contactName || data.contact, 200), contactPhone: text(data.contactPhone || data.phone, 100), jobLocation: saved.jobLocation || text(data.deliveryAddress || data.job_location, 500), vehicleType: text(data.vehicleType || data.vehicle_type || data.jobType, 160), vehicleNo: saved.vehicleNo || text(data.vehicleNo, 200), licensePlate: saved.licensePlate || text(data.licensePlate, 80), chassisNo: text(data.chassisNumber || data.chassisNo, 120), deliveryDate: actualDeliveryDate, warrantyMonths, warrantyStartDate: actualDeliveryDate, warrantyEndDate, warrantyStatus: "อยู่ในประกัน", check15Date: addDaysValue(actualDeliveryDate, 15), check30Date: addDaysValue(actualDeliveryDate, 30), check90Date: addDaysValue(actualDeliveryDate, 90), check180Date: addDaysValue(actualDeliveryDate, 180), postWarrantyContactDate: addDaysValue(warrantyEndDate, 7), postWarrantyInspectionDate: addDaysValue(warrantyEndDate, 30), nextActionDate: addDaysValue(actualDeliveryDate, 15), nextAction: "ติดตามหลังส่งมอบ 15 วัน", status: existingPlan?.status || "ติดตาม", responsibleName: text(data.afterSalesResponsible || "สิริภา", 200), pricingMode: text(data.afterSalesPricingMode || "ตามเงื่อนไขรับประกัน/ใบเสนอราคา", 200), notes: text( data.afterSalesNotes || "ระบบสร้างอัตโนมัติเมื่อ Admin เปลี่ยนสถานะจ๊อบเป็นส่งมอบแล้ว", 2000 ) }; const plan = existingPlan ? await wixData.update("AfterSalesPlans", planValues, { suppressAuth: true }) : await wixData.insert("AfterSalesPlans", planValues, { suppressAuth: true }); const checkDefinitions = [ { checkType: "ติดตามหลังส่งมอบ 15 วัน", dueDate: addDaysValue(actualDeliveryDate, 15) }, { checkType: "ติดตามหลังส่งมอบ 30 วัน", dueDate: addDaysValue(actualDeliveryDate, 30) }, { checkType: "ติดตามหลังส่งมอบ 90 วัน", dueDate: addDaysValue(actualDeliveryDate, 90) }, { checkType: "ติดตามหลังส่งมอบ 180 วัน", dueDate: addDaysValue(actualDeliveryDate, 180) }, { checkType: "ติดต่อลูกค้าหลังหมดประกัน", dueDate: addDaysValue(warrantyEndDate, 7) }, { checkType: "ตรวจสภาพหลังหมดประกัน", dueDate: addDaysValue(warrantyEndDate, 30) } ]; const existingChecksResult = await wixData.query("AfterSalesChecks") .eq("planId", plan._id) .limit(100) .find({ suppressAuth: true, consistentRead: true }); const checksByType = new Map(existingChecksResult.items.map((item) => [item.checkType, item])); for (const definition of checkDefinitions) { const currentCheck = checksByType.get(definition.checkType); const checkValues = { ...(currentCheck || {}), title: definition.checkType + " • " + planNo, planId: plan._id, planNo, checkType: definition.checkType, dueDate: definition.dueDate, serviceMode: definition.checkType.includes("หลังหมดประกัน") ? "หลังหมดประกัน" : "ติดตามในระยะรับประกัน", status: currentCheck?.status || "รอดำเนินการ", notes: currentCheck?.notes || "", recordedBy: currentCheck?.recordedBy || actorName(actor) }; if (currentCheck) { await wixData.update("AfterSalesChecks", checkValues, { suppressAuth: true }); } else { await wixData.insert("AfterSalesChecks", checkValues, { suppressAuth: true }); } } responseOrder = await wixData.update(ORDERS, { ...saved, afterSalesPlanId: plan._id, afterSalesPlanNo: planNo, actualDeliveryDate, formData: { ...(saved.formData || {}), actualDeliveryDate: actualDeliveryDate.toISOString().slice(0, 10) }, lastAction: "ส่งมอบแล้วและสร้างแผนบริการหลังการขาย", lastActionAt: new Date(), lastActionBy: actorName(actor) }, { suppressAuth: true }); await audit({ order: responseOrder.workOrder, id: responseOrder._id, action: existingPlan ? "AFTER_SALES_PLAN_UPDATE" : "AFTER_SALES_PLAN_CREATE", fromStatus: status, toStatus: status, actor, note: "สร้าง/อัปเดตแผนติดตาม 15/30/90/180 วันและหลังหมดประกัน", snapshot: { planId: plan._id, planNo, actualDeliveryDate, warrantyEndDate }, source: "Production Work Order Website" }); } return { ...clientOrder(responseOrder), salesReviewEmailSent }; }); export const getLatestProductionWorkOrder = webMethod(Permissions.SiteMember, async () => { await requireMember(); const result = await wixData.query(ORDERS) .eq("workType", PRODUCTION_TYPE) .descending("_updatedDate") .limit(1) .find({ suppressAuth: true }); return clientOrder(result.items[0]); }); export const getProductionWorkOrderEditState = webMethod(Permissions.SiteMember, async (recordId) => { const actor = await requireMember(); requireWorkOrderEditor(actor); const id = text(recordId, 80); if (!id) { return { exists: false, reason: "EMPTY_ID" }; } try { const item = await wixData.get(ORDERS, id, { suppressAuth: true }); return { exists: Boolean(item?._id && item.workType === PRODUCTION_TYPE), recordId: item?._id || "", workOrder: item?.workOrder || "" }; } catch (error) { const code = String(error?.code || error?.details?.applicationError?.code || ""); const message = String(error?.message || ""); if (code.includes("WDE0073") || /not found|does not exist|ไม่พบ/i.test(message)) { return { exists: false, reason: "DELETED" }; } throw error; } }); function calendarDay(value) { const parsed = date(value); return parsed ? Date.UTC(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()) : null; } function calendarDaysBetween(from, to) { const start = calendarDay(from); const finish = calendarDay(to); if (start === null || finish === null) return 0; return Math.round((finish - start) / 86400000); } function dashboardData(item) { const form = item?.formData && typeof item.formData === "object" ? item.formData : {}; return form._dashboard && typeof form._dashboard === "object" ? form._dashboard : {}; } function currentProductionStage(project, stages) { const related = stages .filter((stage) => stage.projectId === project?._id || stage.jobNumber === project?.jobNumber) .sort((a, b) => number(a.stageNumber) - number(b.stageNumber)); const active = related.find((stage) => ["ติดปัญหา", "กำลังดำเนินการ", "รอตรวจ QC"].includes(stage.status)) || related.find((stage) => number(stage.progressPercent) < 100 && stage.status !== "เสร็จแล้ว") || related[related.length - 1]; return text(active?.stageName || project?.currentStageName || project?.projectStatus, 200); } function decorateDashboardItem(item, project, stages, now = new Date()) { const management = dashboardData(item); const productionAssignment = dataCopy(item?.formData?._productionAssignment || {}); const productionTemplateType = text(item?.formData?.productionTemplateType || "fire-water", 80); const isProduction = item.workType === PRODUCTION_TYPE; const linkedProject = isProduction ? project : null; const dueDate = management.deliveryDueDate || linkedProject?.contractDeadline || linkedProject?.plannedDeliveryDate || linkedProject?.plannedDelivery || item.endDate; const deliveredDate = management.deliveredDate || management.actualDeliveryDate || ""; const daysToDue = dueDate ? calendarDaysBetween(now, dueDate) : null; const delayDays = dueDate ? Math.max(0, calendarDaysBetween(dueDate, deliveredDate || now)) : 0; const deliveryStatus = deliveredDate ? "ส่งมอบแล้ว" : daysToDue === null ? "ยังไม่กำหนด" : daysToDue < 0 ? "เกินกำหนด" : daysToDue <= 7 ? "ใกล้กำหนด" : "ตามแผน"; const totalAmount = Math.max(0, number(item.totalAmount || management.totalAmount)); const amountReceived = Math.max(0, number(management.amountReceived)); const outstandingAmount = Math.max(0, totalAmount - amountReceived); const paymentDueDate = management.paymentDueDate || ""; const unpaidBaseDate = paymentDueDate || deliveredDate || ""; const unpaidDays = outstandingAmount > 0 && unpaidBaseDate ? Math.max(0, calendarDaysBetween(unpaidBaseDate, now)) : 0; const paymentOverdue = outstandingAmount > 0 && paymentDueDate && calendarDaysBetween(paymentDueDate, now) > 0; const paymentStatus = totalAmount <= 0 ? "ยังไม่ระบุ" : outstandingAmount <= 0 ? "ชำระครบ" : paymentOverdue ? "เกินกำหนดชำระ" : amountReceived > 0 ? "รับบางส่วน" : deliveredDate ? "รอชำระ" : "ยังไม่ถึงกำหนด"; const isReceivable = outstandingAmount > 0 && Boolean(deliveredDate || paymentDueDate || ["รอชำระ", "รับบางส่วน", "เกินกำหนดชำระ"].includes(management.paymentStatus)); const progressPercent = Math.min(100, Math.max(0, number( linkedProject?.progressPercent ?? management.progressPercent ?? (COMPLETED_STATUSES.includes(item.status) ? 100 : 0) ))); return { ...clientOrder(item), sourceStatus: item.status || "", workflowStatus: text(linkedProject?.projectStatus || item.status, 120), currentStage: linkedProject ? currentProductionStage(linkedProject, stages) : text(management.currentStage || item.status, 200), progressPercent, dueDate: dueDate || null, deliveredDate: deliveredDate || null, daysToDue, delayDays, deliveryStatus, invoiceNo: text(management.invoiceNo, 120), invoiceDate: management.invoiceDate || null, paymentDueDate: paymentDueDate || null, paymentStatus, amountReceived, outstandingAmount, unpaidDays, isReceivable, nextAction: text(management.nextAction || item.lastAction, 500), managementNote: text(management.note, 1000), productionProjectId: linkedProject?._id || "", productionTemplateType, productionTemplateLabel: productionTemplateType === "dump-truck" ? "ผลิตรถบรรทุกเทท้าย" : "ผลิตรถบรรทุกน้ำดับเพลิง", productionAcceptanceStatus: productionAssignment.status || (isProduction ? "รอรับจ๊อบ" : ""), productionWorkflowStatus: productionAssignment.workflowStatus || "", productionAcceptedAt: productionAssignment.acceptedAt || null, productionAcceptedBy: productionAssignment.acceptedByName || "", source: item.source || "WorkOrders", readOnly: Boolean(item.readOnly) }; } function legacyProjectOrder(project) { return { _id: `project:${project._id}`, title: project.title || `${project.jobNumber} • ${project.customerName || "งานผลิต"}`, workOrder: project.jobNumber, status: project.projectStatus || "เปิดโครงการ", customerName: project.customerName || "", contactName: "", contactPhone: "", jobLocation: "", workType: PRODUCTION_TYPE, formData: { contractNo: project.contractNumber || "", vehicleType: project.vehicleType || "", generalNote: project.notes || "" }, startDate: project.plannedStart || project.contractDate, endDate: project.contractDeadline || project.plannedDeliveryDate || project.plannedDelivery, vehicleNo: project.chassisNumber || "", licensePlate: "", assignedTeam: [project.projectManagerName].filter(Boolean), totalAmount: 0, lastAction: project.projectStatus || "เปิดโครงการผลิต", lastActionAt: project._updatedDate, lastActionBy: project.projectManagerName || "Production Dashboard", _createdDate: project._createdDate, _updatedDate: project._updatedDate, source: "ProductionProjects", readOnly: false }; } function repairEstimateProgress(status) { const value = text(status, 100); if (["ซ่อมเสร็จ", "ส่งมอบแล้ว", "ปิดงานซ่อม", "ปิดงาน"].includes(value)) return 100; if (["รอตรวจสอบ"].includes(value)) return 90; if (["กำลังซ่อม", "อยู่ระหว่างซ่อม"].includes(value)) return 80; if (["รออะไหล่"].includes(value)) return 55; if (["นัดหมายแล้ว"].includes(value)) return 35; if (["ลูกค้ายืนยัน", "อนุมัติซ่อม"].includes(value)) return 65; if (["เสนอราคาแล้ว", "ส่งใบเสนอราคาแล้ว"].includes(value)) return 45; if (["อนุมัติแล้ว"].includes(value)) return 35; if (["รออนุมัติ", "รออนุมัติผู้บริหาร"].includes(value)) return 20; return 10; } function repairEstimateOrder(estimate) { const status = text(estimate?.status || "แบบร่าง", 100); const materials = estimate?.materials && typeof estimate.materials === "object" ? estimate.materials : { items: [] }; return { _id: `repair:${estimate._id}`, title: estimate.title || `${estimate.estimateNo} • ${estimate.customerName || "ประเมินงานซ่อม"}`, workOrder: estimate.estimateNo, status, customerName: estimate.customerName || "", contactName: estimate.contactName || "", contactPhone: estimate.contactPhone || "", jobLocation: estimate.jobLocation || "", workType: REPAIR_TYPE, formData: { vehicleType: estimate.vehicleInfo || "งานซ่อมรถ/อุปกรณ์", repairReport: estimate.repairReport || "", relatedWorkOrder: estimate.relatedWorkOrder || "", distanceKm: number(estimate.distanceKm), materials, pricingRule: estimate.pricingRule || "", requiresApproval: Boolean(estimate.requiresApproval), _dashboard: { currentStage: status, progressPercent: repairEstimateProgress(status), nextAction: estimate.requiresApproval ? "รอผู้บริหารอนุมัติอัตรากำไรและราคาเสนอขาย" : status === "แบบร่าง" ? "ตรวจสอบแบบประเมินและจัดทำใบเสนอราคา" : "ติดตามสถานะงานซ่อม" } }, startDate: estimate._createdDate, endDate: null, vehicleNo: estimate.vehicleInfo || "", licensePlate: estimate.licensePlate || "", assignedTeam: [estimate.lastUpdatedBy || estimate.createdByName].filter(Boolean), totalAmount: Math.max(0, number(estimate.sellingPrice)), lastAction: status, lastActionAt: estimate.lastUpdatedAt || estimate._updatedDate, lastActionBy: estimate.lastUpdatedBy || estimate.createdByName || "Repair Estimate", _createdDate: estimate._createdDate, _updatedDate: estimate._updatedDate, source: "RepairEstimates", readOnly: true }; } async function orderForDashboardUpdate(id, actor) { if (!id.startsWith("project:")) { return wixData.get(ORDERS, id, { suppressAuth: true }); } const projectId = id.slice("project:".length); const project = await wixData.get(PRODUCTION_PROJECTS, projectId, { suppressAuth: true }); fail(project?._id, "ไม่พบโครงการผลิต"); const existing = await findOrder(text(project.jobNumber, 80), PRODUCTION_TYPE); if (existing) return existing; const legacy = legacyProjectOrder(project); return wixData.insert(ORDERS, { title: legacy.title, workOrder: legacy.workOrder, status: legacy.status, customerName: legacy.customerName, contactName: "", contactPhone: "", jobLocation: "", workType: PRODUCTION_TYPE, formData: legacy.formData, startDate: date(legacy.startDate), endDate: date(legacy.endDate), vehicleNo: legacy.vehicleNo, licensePlate: "", assignedTeam: legacy.assignedTeam, totalAmount: 0, lastAction: "สร้าง Work Order กลางจาก Production Dashboard", lastActionAt: new Date(), lastActionBy: actorName(actor) }, { suppressAuth: true }); } export const getWorkOrderDashboard = webMethod(Permissions.SiteMember, async () => { const member = await requireMember(); const [orderResult, projectResult, stageResult] = await Promise.all([ wixData.query(ORDERS).isNotEmpty("workOrder").descending("_createdDate").limit(1000).find({ suppressAuth: true }), wixData.query(PRODUCTION_PROJECTS).eq("active", true).descending("_updatedDate").limit(200).find({ suppressAuth: true }), wixData.query(PRODUCTION_STAGES).ascending("stageNumber").limit(1000).find({ suppressAuth: true }) ]); const projectsByJob = new Map(projectResult.items.map((project) => [text(project.jobNumber, 80), project])); const orderJobs = new Set(orderResult.items.map((item) => text(item.workOrder, 80))); const linkedItems = orderResult.items.map((item) => decorateDashboardItem( item, projectsByJob.get(text(item.workOrder, 80)), stageResult.items )); const legacyItems = projectResult.items .filter((project) => !orderJobs.has(text(project.jobNumber, 80))) .map((project) => decorateDashboardItem(legacyProjectOrder(project), project, stageResult.items)); const items = [...linkedItems, ...legacyItems].sort((a, b) => { const urgencyA = a.deliveryStatus === "เกินกำหนด" || a.paymentStatus === "เกินกำหนดชำระ" ? 1 : 0; const urgencyB = b.deliveryStatus === "เกินกำหนด" || b.paymentStatus === "เกินกำหนดชำระ" ? 1 : 0; if (urgencyA !== urgencyB) return urgencyB - urgencyA; return new Date(b._updatedDate || b.lastActionAt || 0) - new Date(a._updatedDate || a.lastActionAt || 0); }); const completed = items.filter((item) => COMPLETED_STATUSES.includes(item.workflowStatus)).length; const canceled = items.filter((item) => item.workflowStatus === "ยกเลิก").length; const pending = items.filter((item) => PENDING_STATUSES.includes(item.workflowStatus)).length; const receivables = items.filter((item) => item.isReceivable); return { stats: { total: items.length, rental: items.filter((item) => item.workType === RENTAL_TYPE).length, production: items.filter((item) => item.workType === PRODUCTION_TYPE).length, repair: items.filter((item) => item.workType === REPAIR_TYPE).length, pending, active: Math.max(0, items.length - completed - canceled), completed, dueSoon: items.filter((item) => item.deliveryStatus === "ใกล้กำหนด").length, delayed: items.filter((item) => item.deliveryStatus === "เกินกำหนด").length, unpaid: receivables.length, receivableAmount: receivables.reduce((sum, item) => sum + number(item.outstandingAmount), 0) }, totalAvailable: items.length, items, profile: { memberName: actorName(member), email: memberEmail(member), canEdit: canEditWorkOrders(member), canAcceptProduction: COMPANY_OWNER_MEMBER_IDS.has(member._id) || canAcceptProduction(member) }, generatedAt: new Date() }; }); export const updateWorkOrderDashboard = webMethod(Permissions.SiteMember, async (input) => { const actor = await requireMember(); requireWorkOrderEditor(actor); const id = text(input?.id, 100); fail(id, "ไม่พบรหัส Work Order"); const old = await orderForDashboardUpdate(id, actor); fail(old?._id, "ไม่พบ Work Order"); const oldForm = old.formData && typeof old.formData === "object" ? dataCopy(old.formData) : {}; const oldDashboard = oldForm._dashboard && typeof oldForm._dashboard === "object" ? oldForm._dashboard : {}; const nextStatus = text(input?.status || old.status, 120); fail(DASHBOARD_STATUSES.includes(nextStatus), "สถานะงานไม่ถูกต้อง"); const management = { ...oldDashboard, currentStage: text(input?.currentStage || oldDashboard.currentStage, 200), progressPercent: Math.min(100, Math.max(0, number(input?.progressPercent ?? oldDashboard.progressPercent))), deliveryDueDate: text(input?.deliveryDueDate, 40), deliveredDate: text(input?.deliveredDate, 40), invoiceNo: text(input?.invoiceNo, 120), invoiceDate: text(input?.invoiceDate, 40), paymentDueDate: text(input?.paymentDueDate, 40), amountReceived: Math.max(0, number(input?.amountReceived)), nextAction: text(input?.nextAction, 500), note: text(input?.note, 1000), updatedAt: new Date().toISOString(), updatedBy: actorName(actor) }; const record = { ...old, status: nextStatus, formData: { ...oldForm, _dashboard: management }, endDate: date(management.deliveryDueDate) || old.endDate, totalAmount: Math.max(0, number(input?.totalAmount ?? old.totalAmount)), lastAction: "อัปเดตสถานะงานและข้อมูลการเงิน", lastActionAt: new Date(), lastActionBy: actorName(actor) }; const saved = await wixData.update(ORDERS, record, { suppressAuth: true }); await audit({ order: saved.workOrder, id: saved._id, action: "DASHBOARD_UPDATE", fromStatus: old.status || "", toStatus: saved.status || "", actor, note: management.note, snapshot: management, source: "Work Order Dashboard" }); return clientOrder(saved); }); export const uploadProductionSpecLegacy = webMethod(Permissions.SiteMember, async (input) => { const actor = await requireMember(); requireWorkOrderEditor(actor); const fileName = text(input?.fileName, 180).replace(/[^a-zA-Z0-9ก-๙._() -]/g, "_"); const extension = (fileName.toLowerCase().match(/\.[a-z0-9]+$/) || [""])[0]; const mimeByExtension = { ".pdf": "application/pdf", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png" }; const mimeType = mimeByExtension[extension] || ""; const base64 = String(input?.base64 || "").replace(/^data:[^;]+;base64,/, ""); const allowedTypes = [ "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "image/jpeg", "image/png" ]; fail(fileName, "ไม่พบชื่อไฟล์"); fail(allowedTypes.includes(mimeType), "รองรับเฉพาะ PDF, Word, Excel, JPG และ PNG"); fail(base64 && base64.length <= 14000000, "ไฟล์ต้องมีขนาดไม่เกิน 10 MB"); const buffer = Buffer.from(base64, "base64"); const uploaded = await mediaManager.upload( "/WorkOrders/ProductionSpecs", buffer, fileName, { mediaOptions: { mimeType, mediaType: mimeType.startsWith("image/") ? "image" : "document" }, metadataOptions: { isPrivate: true, isVisitorUpload: false, context: { source: "WO6000", uploadedBy: actorName(actor) } } } ); return { fileName, mimeType, size: buffer.length, fileUrl: uploaded?.fileUrl || uploaded?.url || "", mediaId: uploaded?._id || uploaded?.fileId || uploaded?.mediaId || "", uploadedAt: new Date().toISOString(), uploadedBy: actorName(actor) }; }); export const uploadProductionSpec = webMethod(Permissions.SiteMember, async (input) => { const actor = await requireMember(); requireWorkOrderEditor(actor); const fileName = text(input?.fileName, 180).replace(/[^a-zA-Z0-9ก-๙._() -]/g, "_"); const extension = (fileName.toLowerCase().match(/\.[a-z0-9]+$/) || [""])[0]; const mimeByExtension = { ".pdf": "application/pdf", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".zip": "application/zip" }; const mimeType = mimeByExtension[extension] || ""; const allowedTypes = Object.values(mimeByExtension); const size = Math.max(0, Number(input?.size || 0)); fail(fileName, "ไม่พบชื่อไฟล์"); fail(allowedTypes.includes(mimeType), "รองรับเฉพาะ PDF, Word, Excel, JPG, PNG และ ZIP"); fail(size > 0 && size <= 50 * 1024 * 1024, "ไฟล์ต้องมีขนาดไม่เกิน 50 MB"); const upload = await mediaManager.getUploadUrl( "/WorkOrders/ProductionSpecs", { mediaOptions: { mimeType, mediaType: mimeType.startsWith("image/") ? "image" : "document" }, metadataOptions: { isPrivate: true, isVisitorUpload: false, context: { source: "WOMW", uploadedBy: actorName(actor) } } } ); return { uploadUrl: upload?.uploadUrl || "", uploadToken: upload?.uploadToken || "", fileName, mimeType, size, uploadedAt: new Date().toISOString(), uploadedBy: actorName(actor) }; }); function money(value) { return Math.round(Math.max(0, number(value)) * 100) / 100; } function repairMaterials(value) { const source = Array.isArray(value) ? value : Array.isArray(value?.items) ? value.items : []; return source.slice(0, 100).map((item, index) => { const quantity = Math.max(0, number(item?.quantity)); const unitCost = money(item?.unitCost); return { line: index + 1, description: text(item?.description, 300), quantity, unit: text(item?.unit, 80), unitCost, lineCost: money(quantity * unitCost) }; }).filter((item) => item.description || item.quantity || item.unitCost); } function repairPhotos(value) { const source = Array.isArray(value) ? value : Array.isArray(value?.items) ? value.items : []; return source.slice(0, 10).map((item) => ({ fileName: text(item?.fileName, 180), mimeType: text(item?.mimeType, 120), size: Math.max(0, number(item?.size)), fileUrl: text(item?.fileUrl, 1000), mediaId: text(item?.mediaId, 300), uploadedAt: text(item?.uploadedAt, 80), uploadedBy: text(item?.uploadedBy, 160) })).filter((item) => item.fileUrl || item.mediaId); } function repairJobParts(value) { const source = Array.isArray(value) ? value : Array.isArray(value?.items) ? value.items : []; return { items: source.slice(0, 100).map((item, index) => ({ line: index + 1, description: text(item?.description, 300), quantity: Math.max(0, number(item?.quantity)), unit: text(item?.unit, 80), note: text(item?.note, 500) })).filter((item) => item.description || item.quantity || item.note) }; } function repairJobNextAction(status) { if (status === "รับงาน") return "ติดต่อยืนยันอาการและนัดหมายงานซ่อม"; if (status === "นัดหมายแล้ว") return "เตรียมทีมช่าง อุปกรณ์ และเข้าปฏิบัติงาน"; if (status === "รออะไหล่") return "ติดตามอะไหล่และแจ้งกำหนดการแก่ลูกค้า"; if (status === "กำลังซ่อม") return "ดำเนินการซ่อมและบันทึกผลการปฏิบัติงาน"; if (status === "รอตรวจสอบ") return "ตรวจสอบคุณภาพและทดสอบการใช้งาน"; if (status === "ซ่อมเสร็จ") return "นัดส่งมอบและให้ลูกค้าตรวจรับ"; if (status === "ส่งมอบแล้ว") return "ติดตามเอกสารปิดงานและการรับชำระ"; if (status === "ปิดงานซ่อม") return "ปิดงานเรียบร้อย"; return "ตรวจสอบสถานะงานซ่อม"; } function repairPricing(input = {}) { const materials = repairMaterials(input.materials || input.materialItems); const materialCost = money(materials.reduce((sum, item) => sum + item.lineCost, 0)); const laborCost = money(input.laborCost); const managementCost = money(input.managementCost); const totalCost = money(materialCost + laborCost + managementCost); const distanceKm = Math.max(0, number(input.distanceKm)); let profitRate = 0; let fixedSurcharge = 0; let pricingRule = ""; let requiresApproval = false; if (totalCost <= 100000) { if (distanceKm <= 30) { profitRate = 0.8; pricingRule = "ต้นทุนไม่เกิน 100,000 บาท • ระยะทางไม่เกิน 30 กม. • กำไร 80%"; } else if (distanceKm <= 200) { profitRate = 0.5; fixedSurcharge = 5000; pricingRule = "ต้นทุนไม่เกิน 100,000 บาท • ระยะทาง 31–200 กม. • กำไร 50% + 5,000 บาท"; } else { profitRate = 1; fixedSurcharge = 10000; pricingRule = "ต้นทุนไม่เกิน 100,000 บาท • ระยะทางเกิน 200 กม. • กำไร 100% + 10,000 บาท"; } } else if (totalCost <= 500000) { profitRate = 0.5; if (distanceKm <= 30) { pricingRule = "ต้นทุน 100,000.01–500,000 บาท • ระยะทางไม่เกิน 30 กม. • กำไร 50%"; } else if (distanceKm <= 200) { fixedSurcharge = 10000; pricingRule = "ต้นทุน 100,000.01–500,000 บาท • ระยะทาง 31–200 กม. • กำไร 50% + 10,000 บาท"; } else { fixedSurcharge = 30000; pricingRule = "ต้นทุน 100,000.01–500,000 บาท • ระยะทางเกิน 200 กม. • กำไร 50% + 30,000 บาท"; } } else { requiresApproval = true; pricingRule = "ต้นทุนเกิน 500,000 บาท • ต้องขออนุมัติผู้บริหารเพื่อกำหนดราคา"; } const profitAmount = requiresApproval ? 0 : money((totalCost * profitRate) + fixedSurcharge); const sellingPrice = requiresApproval ? 0 : money(totalCost + profitAmount); const markupPercent = totalCost > 0 ? Math.round((profitAmount / totalCost) * 10000) / 100 : 0; const grossMarginPercent = sellingPrice > 0 ? Math.round((profitAmount / sellingPrice) * 10000) / 100 : 0; return { materials, materialCost, laborCost, managementCost, totalCost, distanceKm, profitRate, profitRatePercent: Math.round(profitRate * 100), fixedSurcharge, profitAmount, sellingPrice, markupPercent, grossMarginPercent, pricingRule, requiresApproval, formulaVersion: "REPAIR-PRICING-2026-08-17" }; } async function nextRepairEstimateNo() { const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, "0"); const d = String(now.getDate()).padStart(2, "0"); const prefix = `REP-${y}${m}${d}-`; const result = await wixData.query(REPAIR_ESTIMATES) .startsWith("estimateNo", prefix) .descending("estimateNo") .limit(1) .find({ suppressAuth: true }); const last = text(result.items[0]?.estimateNo, 80); const sequence = Math.max(0, Number(last.slice(prefix.length)) || 0) + 1; return `${prefix}${String(sequence).padStart(3, "0")}`; } export const getNextRepairEstimateNo = webMethod(Permissions.SiteMember, async () => { await requireMember(); return nextRepairEstimateNo(); }); export const previewRepairEstimate = webMethod(Permissions.SiteMember, async (input = {}) => { await requireMember(); return repairPricing(input); }); export const listRepairEstimates = webMethod(Permissions.SiteMember, async (input = {}) => { await requireMember(); const limit = Math.min(Math.max(number(input?.limit) || 30, 1), 100); const result = await wixData.query(REPAIR_ESTIMATES) .descending("_updatedDate") .limit(limit) .find({ suppressAuth: true }); return result.items.map(clientOrder); }); export const saveRepairEstimate = webMethod(Permissions.SiteMember, async (input = {}) => { const actor = await requireMember(); const data = dataCopy(input?.data || input || {}); const id = text(data._id || data.id, 100); const old = id ? await wixData.get(REPAIR_ESTIMATES, id, { suppressAuth: true }).catch(() => null) : null; const estimateNo = text(old?.estimateNo || data.estimateNo, 80) || await nextRepairEstimateNo(); const customerName = text(data.customerName, 250); const contactPhone = text(data.contactPhone, 80); const repairReport = text(data.repairReport, 5000); fail(customerName, "กรุณาระบุชื่อลูกค้า/หน่วยงาน"); fail(contactPhone, "กรุณาระบุเบอร์ติดต่อ"); fail(repairReport, "กรุณาระบุรายงานการซ่อม"); const pricing = repairPricing(data); const photos = repairPhotos(data.photos); const status = pricing.requiresApproval ? "รออนุมัติผู้บริหาร" : text(data.status || old?.status || "แบบร่าง", 100); const record = { ...(old || {}), title: `${estimateNo} • ${customerName}`, estimateNo, status, customerName, contactName: text(data.contactName, 180), contactPhone, email: text(data.email, 180), taxId: text(data.taxId, 80), billingAddress: text(data.billingAddress, 2000), jobLocation: text(data.jobLocation, 1000), vehicleInfo: text(data.vehicleInfo, 500), licensePlate: text(data.licensePlate, 100), relatedWorkOrder: text(data.relatedWorkOrder, 100), repairReport, distanceKm: pricing.distanceKm, materials: { items: pricing.materials }, materialCost: pricing.materialCost, laborCost: pricing.laborCost, managementCost: pricing.managementCost, totalCost: pricing.totalCost, profitRate: pricing.profitRate, fixedSurcharge: pricing.fixedSurcharge, profitAmount: pricing.profitAmount, sellingPrice: pricing.sellingPrice, markupPercent: pricing.markupPercent, grossMarginPercent: pricing.grossMarginPercent, pricingRule: pricing.pricingRule, requiresApproval: pricing.requiresApproval, formulaVersion: pricing.formulaVersion, photos: { items: photos }, note: text(data.note, 2000), createdById: old?.createdById || actor?._id || "", createdByName: old?.createdByName || actorName(actor), lastUpdatedBy: actorName(actor), lastUpdatedAt: new Date() }; const saved = old ? await wixData.update(REPAIR_ESTIMATES, record, { suppressAuth: true }) : await wixData.insert(REPAIR_ESTIMATES, record, { suppressAuth: true }); let notificationEmailSent = false; if (!old) { try { await triggeredEmails.emailMember( "repair_estimate_created", "2e8a1cbc-01b1-4c99-ba17-711868269852" ); notificationEmailSent = true; } catch (error) { console.error("Repair estimate email notification failed", error); } } return { ...clientOrder(saved), notificationEmailSent }; }); export const uploadRepairEstimatePhoto = webMethod(Permissions.SiteMember, async (input = {}) => { const actor = await requireMember(); const fileName = text(input?.fileName, 180).replace(/[^a-zA-Z0-9ก-๙._() -]/g, "_"); const mimeType = text(input?.mimeType, 120).toLowerCase(); const base64 = String(input?.base64 || "").replace(/^data:[^;]+;base64,/, ""); const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; fail(fileName, "ไม่พบชื่อไฟล์"); fail(allowedTypes.includes(mimeType), "รองรับเฉพาะรูป JPG, PNG และ WEBP"); fail(base64 && base64.length <= 6000000, "รูปแต่ละไฟล์ต้องมีขนาดไม่เกิน 4 MB"); const buffer = Buffer.from(base64, "base64"); const uploaded = await mediaManager.upload( "/RepairEstimates/Photos", buffer, fileName, { mediaOptions: { mimeType, mediaType: "image" }, metadataOptions: { isPrivate: true, isVisitorUpload: false, context: { source: "WOMW-RepairEstimate", uploadedBy: actorName(actor) } } } ); return { fileName, mimeType, size: buffer.length, fileUrl: uploaded?.fileUrl || uploaded?.url || "", mediaId: uploaded?._id || uploaded?.fileId || uploaded?.mediaId || "", uploadedAt: new Date().toISOString(), uploadedBy: actorName(actor) }; }); export const uploadRepairWorkOrderPhoto = webMethod(Permissions.SiteMember, async (input = {}) => { const actor = await requireMember(); requireWorkOrderEditor(actor); const fileName = text(input?.fileName, 180).replace(/[^a-zA-Z0-9ก-๙._() -]/g, "_"); const mimeType = text(input?.mimeType, 120).toLowerCase(); const base64 = String(input?.base64 || "").replace(/^data:[^;]+;base64,/, ""); const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; fail(fileName, "ไม่พบชื่อไฟล์"); fail(allowedTypes.includes(mimeType), "รองรับเฉพาะรูป JPG, PNG และ WEBP"); fail(base64 && base64.length <= 6000000, "รูปแต่ละไฟล์ต้องมีขนาดไม่เกิน 4 MB"); const buffer = Buffer.from(base64, "base64"); const uploaded = await mediaManager.upload( "/WorkOrders/RepairJobs", buffer, fileName, { mediaOptions: { mimeType, mediaType: "image" }, metadataOptions: { isPrivate: true, isVisitorUpload: false, context: { source: "WORP-RepairJob", uploadedBy: actorName(actor) } } } ); return { fileName, mimeType, size: buffer.length, fileUrl: uploaded?.fileUrl || uploaded?.url || "", mediaId: uploaded?._id || uploaded?.fileId || uploaded?.mediaId || "", uploadedAt: new Date().toISOString(), uploadedBy: actorName(actor) }; }); function productionAssignmentFrom(item) { return dataCopy(item?.formData?._productionAssignment || {}); } function productionTemplateLabel(value) { return value === "dump-truck" ? "ผลิตรถบรรทุกเทท้าย" : "ผลิตรถบรรทุกน้ำดับเพลิง"; } function productionSpecSummary(formData) { const data = formData || {}; const specs = Array.isArray(data.specifications) ? data.specifications : []; const parts = specs.slice(0, 4).map((spec) => { const name = text(spec?.item || spec?.name || spec?.title || spec?.label || spec?.specName, 120); const value = text(spec?.detail || spec?.value || spec?.requirement || spec?.specValue, 180); return [name, value].filter(Boolean).join(": "); }).filter(Boolean); return text( parts.join(" • ") || data.vehicleDescription || data.vehicleType || data.model || "ดูรายละเอียดสเปคในใบสั่งงาน", 700 ); } export const acceptProductionWorkOrder = webMethod(Permissions.SiteMember, async (input = {}) => { const actor = await requireMember(); requireProductionMember(actor); const id = text(input.id, 120); fail(id, "ไม่พบรหัสจ๊อบงาน"); const item = await wixData.get(ORDERS, id, { suppressAuth: true }); fail(item?._id && item.workType === PRODUCTION_TYPE, "ไม่พบใบสั่งงานผลิต"); fail(item.status !== "แบบร่าง", "ใบสั่งงานยังเป็นแบบร่าง กรุณาอนุมัติสเปคและมอบงานให้ฝ่ายผลิตก่อน"); const formData = dataCopy(item.formData || {}); const previous = productionAssignmentFrom(item); const acceptedAt = text(previous.acceptedAt, 80) || new Date().toISOString(); formData._productionAssignment = { ...previous, status: "รับจ๊อบแล้ว", workflowStatus: previous.workflowStatus || "ยังไม่เริ่ม", assignedDepartment: "ฝ่ายผลิต", acceptedById: actor._id, acceptedByName: actorName(actor), acceptedByEmail: memberEmail(actor), acceptedAt, note: text(previous.note, 1000), updatedAt: new Date().toISOString() }; const saved = await wixData.update(ORDERS, { ...item, formData, lastAction: "ฝ่ายผลิตรับจ๊อบงานแล้ว", lastActionAt: new Date(), lastActionBy: actorName(actor) }, { suppressAuth: true }); await audit({ order: saved.workOrder, id: saved._id, action: "ฝ่ายผลิตรับจ๊อบงาน", fromStatus: previous.status || "รอรับจ๊อบ", toStatus: "รับจ๊อบแล้ว", actor, note: text(input.note || "รับจ๊อบจากห้อง 4P Team", 500), snapshot: formData._productionAssignment, source: "4P Team" }); return { ok: true, id: saved._id, workOrder: saved.workOrder, status: "รับจ๊อบแล้ว", acceptedByName: actorName(actor), acceptedAt }; }); export const listMyAcceptedProductionJobs = webMethod(Permissions.SiteMember, async () => { const actor = await requireMember(); requireProductionMember(actor); const result = await wixData.query(ORDERS) .eq("workType", PRODUCTION_TYPE) .descending("_updatedDate") .limit(1000) .find({ suppressAuth: true }); return result.items .filter((item) => { const assignment = productionAssignmentFrom(item); return assignment.status === "รับจ๊อบแล้ว" || Boolean(assignment.acceptedAt); }) .map((item) => { const data = item.formData || {}; const assignment = productionAssignmentFrom(item); const plannedStart = date(item.startDate || data.start1 || data.issueDate); const plannedDelivery = date(item.endDate || data.deliveryDate || data.end7); return { _id: item._id, recordType: "productionJob", jobNumber: item.workOrder || data.jobNo || "", taskNumber: "", taskName: productionTemplateLabel(data.productionTemplateType) + " • สเปค: " + productionSpecSummary(data), title: item.title || item.workOrder || "จ๊อบงานผลิต", customerName: item.customerName || data.customer || "", department: "กำหนดเริ่ม: " + (plannedStart ? plannedStart.toLocaleDateString("th-TH") : "ยังไม่กำหนด") + " • กำหนดส่ง: " + (plannedDelivery ? plannedDelivery.toLocaleDateString("th-TH") : "ยังไม่กำหนด"), status: assignment.workflowStatus || "ยังไม่เริ่ม", blocker: assignment.note || "", completionNote: assignment.note || "", qcRequired: true, productionTemplateType: data.productionTemplateType || "fire-water", productionTemplateLabel: productionTemplateLabel(data.productionTemplateType), specSummary: productionSpecSummary(data), plannedStart, plannedDelivery, acceptedByName: assignment.acceptedByName || "", acceptedAt: assignment.acceptedAt || null }; }); }); export const updateAcceptedProductionJob = webMethod(Permissions.SiteMember, async (input = {}) => { const actor = await requireMember(); requireProductionMember(actor); const id = text(input.id, 120); const nextStatus = text(input.status, 80); const allowed = ["ยังไม่เริ่ม", "กำลังดำเนินการ", "รอตรวจ QC", "ติดปัญหา", "เสร็จแล้ว"]; fail(id, "ไม่พบรหัสจ๊อบงาน"); fail(allowed.includes(nextStatus), "สถานะจ๊อบงานไม่ถูกต้อง"); const item = await wixData.get(ORDERS, id, { suppressAuth: true }); fail(item?._id && item.workType === PRODUCTION_TYPE, "ไม่พบใบสั่งงานผลิต"); const formData = dataCopy(item.formData || {}); const assignment = productionAssignmentFrom(item); fail(assignment.status === "รับจ๊อบแล้ว" || assignment.acceptedAt, "กรุณารับจ๊อบงานในห้อง 4P Team ก่อน"); const note = text(input.note, 1000); formData._productionAssignment = { ...assignment, status: "รับจ๊อบแล้ว", workflowStatus: nextStatus, note, updatedById: actor._id, updatedByName: actorName(actor), updatedAt: new Date().toISOString() }; const orderStatus = nextStatus === "กำลังดำเนินการ" ? "กำลังผลิต" : nextStatus === "รอตรวจ QC" ? "ตรวจสอบคุณภาพ" : item.status; const saved = await wixData.update(ORDERS, { ...item, status: orderStatus, formData, lastAction: "อัปเดตจ๊อบฝ่ายผลิต: " + nextStatus, lastActionAt: new Date(), lastActionBy: actorName(actor) }, { suppressAuth: true }); await audit({ order: saved.workOrder, id: saved._id, action: "อัปเดตสถานะจ๊อบฝ่ายผลิต", fromStatus: assignment.workflowStatus || "ยังไม่เริ่ม", toStatus: nextStatus, actor, note, snapshot: formData._productionAssignment, source: "Production" }); return { ok: true, id: saved._id, status: nextStatus }; }); const REPAIR_RETURN_LETTERS = "RepairReturnLetters"; function repairLetterText(value, maxLength = 2000) { return String(value ?? "").trim().slice(0, maxLength); } function repairLetterDate(value) { if (!value) return null; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed; } function repairLetterMemberName(member) { const firstName = repairLetterText(member?.contactDetails?.firstName, 120); const lastName = repairLetterText(member?.contactDetails?.lastName, 120); return repairLetterText( member?.profile?.nickname || [firstName, lastName].filter(Boolean).join(" ") || member?.loginEmail, 250 ); } function repairLetterItemFromInput(input, existing, member) { const now = new Date(); const payload = input && typeof input === "object" ? input : {}; const repairJobNo = repairLetterText(payload.repairJobNo, 120); if (!repairJobNo) throw new Error("กรุณาระบุเลขที่งานซ่อม"); const item = { ...(existing || {}), title: repairLetterText(payload.title || (payload.letterNo ? "หนังสือขอรับกลับเพื่อซ่อม " + payload.letterNo : "หนังสือขอรับกลับเพื่อซ่อม"), 250), documentType: "หนังสือขอรับกลับเพื่อซ่อม", letterNo: repairLetterText(payload.letterNo, 120), repairJobNo, repairWorkOrderId: repairLetterText(payload.repairWorkOrderId, 120), status: repairLetterText(payload.status || "ฉบับร่าง", 80), letterDate: repairLetterDate(payload.letterDate), subject: repairLetterText(payload.subject, 500), recipientTitle: repairLetterText(payload.recipientTitle, 500), referenceText: repairLetterText(payload.referenceText, 1000), customerOrg: repairLetterText(payload.customerOrg, 500), province: repairLetterText(payload.province, 250), assetName: repairLetterText(payload.assetName, 500), registrationNo: repairLetterText(payload.registrationNo, 250), assetNo: repairLetterText(payload.assetNo, 250), warrantyType: repairLetterText(payload.warrantyType, 80), pickupDate: repairLetterDate(payload.pickupDate), repairDetails: repairLetterText(payload.repairDetails, 5000), receiverName: repairLetterText(payload.receiverName, 250), receiverRole: repairLetterText(payload.receiverRole, 250), signatoryName: repairLetterText(payload.signatoryName || "นายธเนศร์ เจริญชัย", 250), signatoryPosition: repairLetterText(payload.signatoryPosition || "กรรมการผู้จัดการ", 250), companyName: repairLetterText(payload.companyName || "บริษัท สี่พี่น้องรัก จำกัด", 250), documentCode: repairLetterText(payload.documentCode, 120), revision: Math.max(0, Number(payload.revision ?? existing?.revision ?? 0) || 0), active: payload.active !== false, dataSnapshot: payload, issuedAt: repairLetterDate(payload.issuedAt), printCount: Math.max(0, Number(existing?.printCount || 0)), createdByMemberId: existing?.createdByMemberId || repairLetterText(member?._id, 120), createdByName: existing?.createdByName || repairLetterMemberName(member) }; return item; } export const saveRepairReturnLetter = webMethod( Permissions.SiteMember, async (input) => { const member = await currentMember.getMember(); if (!member?._id) throw new Error("กรุณาเข้าสู่ระบบสมาชิก 4P Team"); const payload = input && typeof input === "object" ? input : {}; let existing = null; if (payload._id) { existing = await wixData.get(REPAIR_RETURN_LETTERS, repairLetterText(payload._id, 120), { suppressAuth: true, consistentRead: true }); } if (!existing && payload.repairJobNo) { const found = await wixData.query(REPAIR_RETURN_LETTERS) .eq("repairJobNo", repairLetterText(payload.repairJobNo, 120)) .eq("active", true) .descending("_updatedDate") .limit(1) .find({ suppressAuth: true, consistentRead: true }); existing = found.items[0] || null; } const item = repairLetterItemFromInput(payload, existing, member); const saved = existing ? await wixData.update(REPAIR_RETURN_LETTERS, { ...item, _id: existing._id }, { suppressAuth: true }) : await wixData.insert(REPAIR_RETURN_LETTERS, item, { suppressAuth: true }); return { ok: true, item: saved, message: "บันทึกหนังสือขอรับกลับเพื่อซ่อมแล้ว" }; } ); export const getRepairReturnLetterByJobNo = webMethod( Permissions.SiteMember, async (repairJobNo) => { const member = await currentMember.getMember(); if (!member?._id) throw new Error("กรุณาเข้าสู่ระบบสมาชิก 4P Team"); const jobNo = repairLetterText(repairJobNo, 120); if (!jobNo) return { ok: true, item: null }; const result = await wixData.query(REPAIR_RETURN_LETTERS) .eq("repairJobNo", jobNo) .eq("active", true) .descending("_updatedDate") .limit(1) .find({ suppressAuth: true, consistentRead: true }); return { ok: true, item: result.items[0] || null }; } ); export const recordRepairReturnLetterPrint = webMethod( Permissions.SiteMember, async (letterId) => { const member = await currentMember.getMember(); if (!member?._id) throw new Error("กรุณาเข้าสู่ระบบสมาชิก 4P Team"); const id = repairLetterText(letterId, 120); if (!id) throw new Error("ไม่พบรหัสเอกสาร"); const item = await wixData.get(REPAIR_RETURN_LETTERS, id, { suppressAuth: true, consistentRead: true }); if (!item) throw new Error("ไม่พบหนังสือขอรับกลับเพื่อซ่อม"); const saved = await wixData.update(REPAIR_RETURN_LETTERS, { ...item, printCount: Math.max(0, Number(item.printCount || 0)) + 1, lastPrintedAt: new Date() }, { suppressAuth: true }); return { ok: true, item: saved }; } );

Catalog_page-.jpg
ปฏิทินทำดีได้ทุกเดือน
ปี 2569

รายละเอียดคุณลักษณะทั่วไป

Screenshot 2024-01-08 180236.png
รถรดน้ำต้นไม้_edited.png

เดือนมกราคม     เลี้ยงอาหาร

เดือนกุมภาพันธ์ฺ  ปลูกต้นไม้

เดือนมีนาคม      มอบถุงยังชีพ

Gemini_Generated_Image_k36urdk36urdk36u.png
Gemini_Generated_Image_k36urdk36urdk36u.png
รถขยะอัด 10 ลบ.เมตร
12 เมตร 1 ท่อน.png
image.png
รถดุดโคลน
รถกวาดถนน
รถขยะปิคอัพ
n20190411155720_3159.jpg

นโยบายป้องกันทุจริต

800px-ตราสำนักงานมาตรฐานผลิตภัณฑ์อุตสาหกรรม.png
202106233c21f738c52785c9dac0daad9562dad6093428.jpg

รับซ่อมและดัดแปลงรถทุกชนิด

ตารางคิวการเช่ารถดับเพลิง 6,000 ลิตร

Blurry Background

August 2026

SUN

MON

TUE

WED

THU

FRI

SAT

Rental.png

ตัวแทนจำหน่าย

image.png

ดาวน์โหลดแคตตาล็อค

image_edited.jpg

บริษัท สี่พี่น้องทรัค จำกัด ร่วมสนับสนุนน้ำดื่มและสมุดจดในการฝึกอบรมพัฒนาระบบ AI ในการจัดการและพัฒนาท้องถิ่น เพราะเราคิดว่า ระบบ AI สามารถตอบโจทย์การบริหารราชการส่วนท้องถิ่นให้ทันสมัยได้ ในวันเสาร์ที่ 13 ธันวาคม 2568

ติดต่อสอบถาม

88/4 หมู่ 1 ตำบลมาบโป่ง อำเภอพานทอง

จังหวัดชลบุรี 20160

โทร.    038-154567

แฟกซ์ 038-452649

มือถือ  084-8888-901 AIS

            08-666-21-999  DTAC

  • Facebook
  • Instagram
S__475186.jpg
5345.jpg
5345a.jpg

© 2026 by บริษัท สี่พี่น้องทรัค จำกัด

แนบไฟล์รูป (ถ้ามี)
แนบไฟล์วีดีโอ (ถ้ามี)

ขอบคุณที่ติดต่อ

bottom of page