- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained) - Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked) - Commit services/docuseal + services/legacy-db docker-compose configs - Extract client app composables: useOTP, useAddressSearch, catalog data, format utils - Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines - Clean hardcoded credentials from config.js fallback values - Add client portal: catalog, cart, checkout, OTP verification, address search - Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal - Add ops composables: useBestTech, useConversations, usePermissions, useScanner - Add field app: scanner composable, docker/nginx configs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
640 lines
28 KiB
JavaScript
640 lines
28 KiB
JavaScript
'use strict'
|
|
const cfg = require('./config')
|
|
const { log, json, parseBody, erpFetch, httpRequest } = require('./helpers')
|
|
|
|
// ── Supabase Address Search (RQA — Répertoire Québécois des Adresses) ───────
|
|
const SUPABASE_URL = 'https://rddrjzptzhypltuzmere.supabase.co'
|
|
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJkZHJqenB0emh5cGx0dXptZXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MTY4NTYsImV4cCI6MjA4NjM5Mjg1Nn0.EluFlKBze8BYM6AFx88G7kt21EvR18EI3uw1zgCXVzs'
|
|
|
|
async function searchAddresses (term, limit = 8) {
|
|
const clean = term.trim()
|
|
if (clean.length < 3) return []
|
|
|
|
// Parse civic number and street parts
|
|
const numMatch = clean.match(/^\s*(\d+)\s*(.*)/)
|
|
const headers = { apikey: SUPABASE_KEY, Authorization: 'Bearer ' + SUPABASE_KEY }
|
|
const select = 'adresse_formatee,numero_municipal,numero_unite,code_postal,odonyme_recompose_normal,nom_municipalite,latitude,longitude,identifiant_unique_adresse'
|
|
const base = `${SUPABASE_URL}/rest/v1/addresses?select=${select}&limit=${limit}`
|
|
|
|
let results = []
|
|
|
|
// Build ilike pattern from words: "chemin du lac" → "*chemin*lac*"
|
|
function wordsToIlike (str) {
|
|
const words = str.split(/\s+/).filter(w => w.length >= 2)
|
|
if (!words.length) return ''
|
|
return '*' + words.map(w => encodeURIComponent(w)).join('*') + '*'
|
|
}
|
|
|
|
if (numMatch) {
|
|
// Have civic number — fast exact match on numero_municipal + ilike on street
|
|
const num = numMatch[1]
|
|
const street = numMatch[2].trim()
|
|
let url = `${base}&numero_municipal=eq.${num}`
|
|
if (street) url += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
|
|
url += '&order=nom_municipalite'
|
|
const res = await httpRequest(url, '', { headers })
|
|
results = Array.isArray(res.data) ? res.data : []
|
|
|
|
// If no exact match, try prefix on numero_municipal
|
|
if (!results.length && num.length >= 2) {
|
|
let url2 = `${base}&numero_municipal=like.${num}*`
|
|
if (street) url2 += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
|
|
url2 += '&order=nom_municipalite'
|
|
const res2 = await httpRequest(url2, '', { headers })
|
|
results = Array.isArray(res2.data) ? res2.data : []
|
|
}
|
|
} else {
|
|
// No civic number — search by street name only
|
|
const pattern = wordsToIlike(clean)
|
|
if (!pattern) return []
|
|
let url = `${base}&odonyme_recompose_normal=ilike.${pattern}&order=nom_municipalite`
|
|
const res = await httpRequest(url, '', { headers })
|
|
results = Array.isArray(res.data) ? res.data : []
|
|
}
|
|
|
|
// Check fiber availability for results
|
|
return results.map(a => ({
|
|
...a,
|
|
fiber_available: false, // TODO: join with fiber_availability table
|
|
}))
|
|
}
|
|
|
|
// ── OTP store (in-memory, TTL 10 min) ───────────────────────────────────────
|
|
const otpStore = new Map() // key = phone/email → { code, expires, customerId, customerName }
|
|
|
|
function generateOTP () {
|
|
return String(Math.floor(100000 + Math.random() * 900000))
|
|
}
|
|
|
|
async function sendOTP (identifier) {
|
|
const isEmail = identifier.includes('@')
|
|
const code = generateOTP()
|
|
const expires = Date.now() + 10 * 60 * 1000 // 10 min
|
|
|
|
// Lookup customer in ERPNext
|
|
let customer = null
|
|
if (isEmail) {
|
|
const res = await erpFetch(`/api/resource/Customer?filters=${encodeURIComponent(JSON.stringify([['email_id', '=', identifier]]))}&fields=["name","customer_name","cell_phone","email_id"]&limit_page_length=1`)
|
|
if (res.status === 200 && res.data?.data?.length) customer = res.data.data[0]
|
|
} else {
|
|
const { lookupCustomerByPhone } = require('./helpers')
|
|
customer = await lookupCustomerByPhone(identifier)
|
|
}
|
|
|
|
if (!customer) return { found: false }
|
|
|
|
otpStore.set(identifier, { code, expires, customerId: customer.name, customerName: customer.customer_name })
|
|
|
|
// Send code
|
|
if (isEmail) {
|
|
const { sendEmail } = require('./email')
|
|
await sendEmail({
|
|
to: identifier,
|
|
subject: 'Code de vérification Gigafibre',
|
|
html: `<div style="font-family:system-ui;max-width:400px;margin:0 auto;padding:24px">
|
|
<h2 style="color:#3949ab;margin:0 0 16px">Vérification</h2>
|
|
<p style="color:#334155;font-size:14px">Votre code de vérification est :</p>
|
|
<div style="font-size:32px;font-weight:700;letter-spacing:8px;text-align:center;padding:16px;background:#f1f5f9;border-radius:8px;color:#1e293b">${code}</div>
|
|
<p style="color:#94a3b8;font-size:12px;margin-top:16px">Ce code expire dans 10 minutes.</p>
|
|
</div>`,
|
|
})
|
|
} else {
|
|
try {
|
|
const { sendSmsInternal } = require('./twilio')
|
|
await sendSmsInternal(identifier, `Gigafibre — Votre code de vérification : ${code}\nExpire dans 10 min.`)
|
|
} catch (e) { log('OTP SMS failed:', e.message) }
|
|
}
|
|
|
|
log(`OTP sent to ${identifier} for customer ${customer.name}`)
|
|
return { found: true, sent: true, channel: isEmail ? 'email' : 'sms' }
|
|
}
|
|
|
|
async function verifyOTP (identifier, code) {
|
|
const entry = otpStore.get(identifier)
|
|
if (!entry) return { valid: false, reason: 'no_otp' }
|
|
if (Date.now() > entry.expires) { otpStore.delete(identifier); return { valid: false, reason: 'expired' } }
|
|
if (entry.code !== code) return { valid: false, reason: 'wrong_code' }
|
|
otpStore.delete(identifier)
|
|
|
|
// Fetch customer details + addresses
|
|
const result = { valid: true, customer_id: entry.customerId, customer_name: entry.customerName }
|
|
try {
|
|
// Get customer details (phone, email)
|
|
const custRes = await erpFetch(`/api/resource/Customer/${encodeURIComponent(entry.customerId)}?fields=["name","customer_name","cell_phone","email_id","tel_home"]`)
|
|
if (custRes.status === 200 && custRes.data?.data) {
|
|
const c = custRes.data.data
|
|
result.phone = c.cell_phone || c.tel_home || ''
|
|
result.email = c.email_id || ''
|
|
}
|
|
|
|
// If no email on Customer, fetch from linked Contact
|
|
if (!result.email) {
|
|
try {
|
|
const contRes = await erpFetch(`/api/resource/Contact?filters=${encodeURIComponent(JSON.stringify([['Dynamic Link', 'link_doctype', '=', 'Customer'], ['Dynamic Link', 'link_name', '=', entry.customerId]]))}&fields=["email_id"]&limit_page_length=1`)
|
|
if (contRes.status === 200 && contRes.data?.data?.[0]?.email_id) {
|
|
result.email = contRes.data.data[0].email_id
|
|
}
|
|
} catch (e) { log('OTP - Contact email fallback error:', e.message) }
|
|
}
|
|
|
|
// Get Service Locations (addresses)
|
|
const locRes = await erpFetch(`/api/resource/Service%20Location?filters=${encodeURIComponent(JSON.stringify([['customer', '=', entry.customerId]]))}&fields=["name","address_line","city","postal_code","location_name","latitude","longitude"]&limit_page_length=20`)
|
|
if (locRes.status === 200 && locRes.data?.data?.length) {
|
|
result.addresses = locRes.data.data.map(l => ({
|
|
name: l.name,
|
|
address: l.address_line || l.location_name || l.name,
|
|
city: l.city || '',
|
|
postal_code: l.postal_code || '',
|
|
latitude: l.latitude || null,
|
|
longitude: l.longitude || null,
|
|
}))
|
|
}
|
|
} catch (e) {
|
|
log('OTP verify - customer details fetch error:', e.message)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ── Product Catalog API ─────────────────────────────────────────────────────
|
|
// Serves the product catalog from ERPNext Items with custom fields
|
|
|
|
async function getCatalog () {
|
|
const fields = JSON.stringify([
|
|
'name', 'item_code', 'item_name', 'item_group', 'standard_rate',
|
|
'description', 'image',
|
|
'project_template_id', 'requires_visit', 'delivery_method',
|
|
'is_bundle_parent', 'billing_type', 'service_category',
|
|
])
|
|
const filters = JSON.stringify([['is_sales_item', '=', 1], ['disabled', '=', 0]])
|
|
const res = await erpFetch(`/api/resource/Item?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(filters)}&limit_page_length=100&order_by=service_category,standard_rate`)
|
|
|
|
if (res.status !== 200) {
|
|
log('Catalog fetch failed:', res.status)
|
|
return []
|
|
}
|
|
|
|
return (res.data.data || []).map(item => ({
|
|
item_code: item.item_code,
|
|
item_name: item.item_name,
|
|
item_group: item.item_group,
|
|
rate: item.standard_rate || 0,
|
|
description: item.description || '',
|
|
image: item.image || '',
|
|
project_template_id: item.project_template_id || '',
|
|
requires_visit: !!item.requires_visit,
|
|
delivery_method: item.delivery_method || '',
|
|
is_bundle: !!item.is_bundle_parent,
|
|
billing_type: item.billing_type || '',
|
|
service_category: item.service_category || '',
|
|
}))
|
|
}
|
|
|
|
// ── Checkout / Order Processing ─────────────────────────────────────────────
|
|
// Creates Sales Order + Dispatch Jobs from cart items + project template
|
|
|
|
async function processCheckout (body) {
|
|
// Accept both flat format and nested { contact: {...}, installation: {...} } format
|
|
const contact = body.contact || {}
|
|
const installation = body.installation || {}
|
|
|
|
const items = body.items || []
|
|
const customer_name = body.customer_name || contact.name || ''
|
|
const phone = body.phone || contact.phone || ''
|
|
const email = body.email || contact.email || ''
|
|
const address = body.address || contact.address || ''
|
|
const city = body.city || contact.city || ''
|
|
const postal_code = body.postal_code || contact.postal_code || ''
|
|
const preferred_date = body.preferred_date || installation.preferred_date || ''
|
|
const preferred_slot = body.preferred_slot || installation.preferred_slot || ''
|
|
const delivery_method = body.delivery_method || ''
|
|
const notes = body.notes || ''
|
|
const mode = body.mode || 'postpaid'
|
|
const payment_intent_id = body.payment_intent_id || ''
|
|
const accepted_terms = body.accepted_terms
|
|
|
|
log('Checkout request:', { customer_name, phone, email, items: items.length, mode })
|
|
|
|
if (!items?.length) throw new Error('Aucun article dans le panier')
|
|
if (!customer_name) throw new Error('Nom du client requis')
|
|
if (!phone && !email) throw new Error('Téléphone ou courriel requis')
|
|
|
|
const today = new Date().toISOString().split('T')[0]
|
|
const result = { ok: true, items_count: items.length }
|
|
|
|
// ── 1. Find or create Customer ──
|
|
let customerName = ''
|
|
const providedCustomerId = body.customer_id || ''
|
|
try {
|
|
// If OTP-verified customer_id provided, use it directly
|
|
if (providedCustomerId) {
|
|
customerName = providedCustomerId
|
|
result.customer_existing = true
|
|
log('Using OTP-verified customer:', customerName)
|
|
}
|
|
// Search by phone
|
|
const { lookupCustomerByPhone } = require('./helpers')
|
|
if (!customerName && phone) {
|
|
const existing = await lookupCustomerByPhone(phone)
|
|
if (existing) {
|
|
customerName = existing.name
|
|
result.customer_existing = true
|
|
}
|
|
}
|
|
// Create if not found
|
|
if (!customerName) {
|
|
const custPayload = {
|
|
customer_name: customer_name,
|
|
customer_type: 'Individual',
|
|
customer_group: 'Individual',
|
|
territory: 'Canada',
|
|
cell_phone: phone || '',
|
|
email_id: email || '',
|
|
}
|
|
log('Creating customer:', custPayload)
|
|
const custRes = await erpFetch('/api/resource/Customer', {
|
|
method: 'POST',
|
|
body: custPayload,
|
|
})
|
|
log('Customer creation result:', custRes.status, custRes.data?.data?.name || JSON.stringify(custRes.data).substring(0, 200))
|
|
if (custRes.status === 200 && custRes.data?.data) {
|
|
customerName = custRes.data.data.name
|
|
result.customer_created = true
|
|
} else {
|
|
// Fallback: use name as-is
|
|
customerName = customer_name
|
|
}
|
|
}
|
|
} catch (e) {
|
|
log('Customer lookup/create failed:', e.message)
|
|
customerName = customer_name
|
|
}
|
|
|
|
// ── 2. Create Sales Order ──
|
|
const onetimeItems = items.filter(i => i.billing_type !== 'Mensuel' && i.billing_type !== 'Annuel')
|
|
const recurringItems = items.filter(i => i.billing_type === 'Mensuel' || i.billing_type === 'Annuel')
|
|
const allItems = items.map(i => ({
|
|
item_code: i.item_code,
|
|
item_name: i.item_name,
|
|
qty: i.qty || 1,
|
|
rate: i.rate,
|
|
description: i.billing_type === 'Mensuel'
|
|
? `${i.item_name} — ${i.rate}$/mois`
|
|
: i.billing_type === 'Annuel'
|
|
? `${i.item_name} — ${i.rate}$/an`
|
|
: i.item_name,
|
|
}))
|
|
|
|
let orderName = ''
|
|
try {
|
|
const soPayload = {
|
|
customer: customerName,
|
|
company: 'TARGO',
|
|
currency: 'CAD',
|
|
selling_price_list: 'Standard Selling',
|
|
transaction_date: today,
|
|
delivery_date: preferred_date || today,
|
|
items: allItems,
|
|
terms: notes || '',
|
|
}
|
|
const soRes = await erpFetch('/api/resource/Sales%20Order', {
|
|
method: 'POST',
|
|
body: JSON.stringify(soPayload),
|
|
})
|
|
if (soRes.status === 200 && soRes.data?.data) {
|
|
orderName = soRes.data.data.name
|
|
result.sales_order = orderName
|
|
log(`Sales Order created: ${orderName} for ${customerName}`)
|
|
} else {
|
|
log('Sales Order creation failed:', soRes.status, JSON.stringify(soRes.data).substring(0, 300))
|
|
result.sales_order_error = `ERPNext ${soRes.status}`
|
|
}
|
|
} catch (e) {
|
|
log('Sales Order creation failed:', e.message)
|
|
result.sales_order_error = e.message
|
|
}
|
|
|
|
// ── 3. Create Subscriptions for recurring items ──
|
|
for (const item of recurringItems) {
|
|
try {
|
|
// Ensure plan exists
|
|
let planName = null
|
|
try {
|
|
const findRes = await erpFetch(`/api/resource/Subscription%20Plan/${encodeURIComponent(item.item_code)}`)
|
|
if (findRes.status === 200) planName = findRes.data?.data?.name
|
|
} catch {}
|
|
|
|
if (!planName) {
|
|
const planRes = await erpFetch('/api/resource/Subscription%20Plan', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
plan_name: item.item_code,
|
|
item: item.item_code,
|
|
currency: 'CAD',
|
|
price_determination: 'Fixed Rate',
|
|
cost: item.rate,
|
|
billing_interval: item.billing_type === 'Annuel' ? 'Year' : 'Month',
|
|
billing_interval_count: 1,
|
|
}),
|
|
})
|
|
if (planRes.status === 200) planName = planRes.data?.data?.name
|
|
}
|
|
|
|
if (planName) {
|
|
await erpFetch('/api/resource/Subscription', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
party_type: 'Customer',
|
|
party: customerName,
|
|
company: 'TARGO',
|
|
start_date: today,
|
|
generate_invoice_at: 'Beginning of the current subscription period',
|
|
days_until_due: 30,
|
|
follow_calendar_months: 1,
|
|
plans: [{ plan: planName, qty: item.qty || 1 }],
|
|
}),
|
|
})
|
|
log(`Subscription created for ${item.item_code}`)
|
|
}
|
|
} catch (e) {
|
|
log(`Subscription failed for ${item.item_code}:`, e.message)
|
|
}
|
|
}
|
|
|
|
// ── 4. Create Dispatch Jobs from project templates ──
|
|
// Collect unique templates needed
|
|
const templatesNeeded = new Set()
|
|
for (const item of items) {
|
|
if (item.project_template_id) templatesNeeded.add(item.project_template_id)
|
|
}
|
|
log('Templates needed:', [...templatesNeeded], 'from items:', items.map(i => `${i.item_code}:${i.project_template_id || 'none'}`))
|
|
|
|
const createdJobs = []
|
|
if (templatesNeeded.size > 0) {
|
|
const fullAddress = [address, city, postal_code].filter(Boolean).join(', ')
|
|
|
|
for (const templateId of templatesNeeded) {
|
|
// Fetch template steps (stored in frontend config, we replicate here)
|
|
const steps = getTemplateSteps(templateId)
|
|
if (!steps.length) continue
|
|
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const step = steps[i]
|
|
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i
|
|
|
|
let dependsOn = ''
|
|
if (step.depends_on_step !== null && step.depends_on_step !== undefined) {
|
|
const depJob = createdJobs[step.depends_on_step]
|
|
if (depJob) dependsOn = depJob.name
|
|
}
|
|
const parentJob = createdJobs.length > 0 ? createdJobs[0].name : ''
|
|
|
|
const jobPayload = {
|
|
ticket_id: ticketId,
|
|
subject: step.subject,
|
|
address: fullAddress,
|
|
duration_h: step.duration_h || 1,
|
|
priority: step.priority || 'medium',
|
|
status: 'open',
|
|
job_type: step.job_type || 'Autre',
|
|
customer: customerName,
|
|
depends_on: dependsOn,
|
|
parent_job: parentJob,
|
|
step_order: i + 1,
|
|
on_open_webhook: step.on_open_webhook || '',
|
|
on_close_webhook: step.on_close_webhook || '',
|
|
notes: [
|
|
step.assigned_group ? `Groupe: ${step.assigned_group}` : '',
|
|
orderName ? `Sales Order: ${orderName}` : '',
|
|
preferred_date ? `Date souhaitée: ${preferred_date} ${preferred_slot || ''}` : '',
|
|
'Commande en ligne',
|
|
].filter(Boolean).join(' | '),
|
|
scheduled_date: preferred_date || '',
|
|
}
|
|
|
|
try {
|
|
const jobRes = await erpFetch('/api/resource/Dispatch%20Job', {
|
|
method: 'POST',
|
|
body: JSON.stringify(jobPayload),
|
|
})
|
|
if (jobRes.status === 200 && jobRes.data?.data) {
|
|
createdJobs.push(jobRes.data.data)
|
|
}
|
|
} catch (e) {
|
|
log(`Job creation failed: ${step.subject} — ${e.message}`)
|
|
}
|
|
}
|
|
}
|
|
result.jobs_created = createdJobs.length
|
|
}
|
|
|
|
// ── 5. Send confirmation SMS/email ──
|
|
if (phone) {
|
|
try {
|
|
const { sendSmsInternal } = require('./twilio')
|
|
const itemList = items.map(i => `• ${i.item_name}`).join('\n')
|
|
const msg = `Gigafibre — Commande confirmée!\n\n${itemList}\n\n${orderName ? `Réf: ${orderName}\n` : ''}${preferred_date ? `Date souhaitée: ${preferred_date}\n` : ''}Nous vous contacterons pour confirmer les détails.\n\nMerci!`
|
|
await sendSmsInternal(phone, msg)
|
|
result.sms_sent = true
|
|
} catch (e) {
|
|
log('Checkout confirmation SMS failed:', e.message)
|
|
}
|
|
}
|
|
|
|
if (email) {
|
|
try {
|
|
const { sendEmail } = require('./email')
|
|
const itemRows = items.map(i =>
|
|
`<tr><td style="padding:8px 12px;border-bottom:1px solid #e2e8f0">${i.item_name}</td><td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;text-align:right">${i.rate.toFixed(2)}$${i.billing_type === 'Mensuel' ? '/mois' : i.billing_type === 'Annuel' ? '/an' : ''}</td></tr>`
|
|
).join('')
|
|
|
|
await sendEmail({
|
|
to: email,
|
|
subject: `Confirmation de commande${orderName ? ` ${orderName}` : ''} — Gigafibre`,
|
|
html: `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="font-family:system-ui;margin:0;padding:0;background:#f1f5f9">
|
|
<div style="max-width:560px;margin:0 auto;padding:24px">
|
|
<div style="background:white;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.06)">
|
|
<div style="background:linear-gradient(135deg,#22c55e,#16a34a);color:white;padding:24px 28px">
|
|
<h1 style="margin:0;font-size:20px">Commande confirmée</h1>
|
|
<p style="margin:6px 0 0;opacity:0.85;font-size:13px">${orderName || 'Merci pour votre commande'}</p>
|
|
</div>
|
|
<div style="padding:24px 28px">
|
|
<p style="color:#334155;font-size:14px;margin:0 0 16px">Bonjour ${customer_name},</p>
|
|
<table style="width:100%;border-collapse:collapse;font-size:14px"><tbody>${itemRows}</tbody></table>
|
|
${preferred_date ? `<p style="margin:16px 0 0;color:#475569;font-size:13px"><strong>Date souhaitée :</strong> ${preferred_date} ${preferred_slot || ''}</p>` : ''}
|
|
<p style="margin:16px 0 0;color:#475569;font-size:13px">Nous vous contacterons pour confirmer les détails.</p>
|
|
</div>
|
|
<div style="border-top:1px solid #e2e8f0;padding:16px 28px;text-align:center">
|
|
<p style="color:#94a3b8;font-size:11px;margin:0">Gigafibre — Targo Télécommunications</p>
|
|
</div>
|
|
</div>
|
|
</div></body></html>`,
|
|
})
|
|
result.email_sent = true
|
|
} catch (e) {
|
|
log('Checkout confirmation email failed:', e.message)
|
|
}
|
|
}
|
|
|
|
result.order_id = orderName || ('CMD-' + Date.now().toString(36).toUpperCase())
|
|
return result
|
|
}
|
|
|
|
// ── Project template steps (replicated from frontend config) ────────────────
|
|
|
|
function getTemplateSteps (templateId) {
|
|
const TEMPLATES = {
|
|
fiber_install: [
|
|
{ subject: 'Vérification pré-installation (éligibilité & OLT)', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
|
{ subject: 'Installation fibre chez le client', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
|
{ subject: 'Activation du service & configuration ONT', job_type: 'Installation', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
|
{ subject: 'Test de débit & validation client', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Tech Targo', depends_on_step: 2 },
|
|
],
|
|
phone_service: [
|
|
{ subject: 'Importer le numéro de téléphone', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
|
{ subject: 'Installation fibre (pré-requis portage)', job_type: 'Installation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
|
{ subject: 'Portage du numéro vers Gigafibre', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
|
{ subject: 'Validation et test du service téléphonique', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Tech Targo', depends_on_step: 2 },
|
|
],
|
|
move_service: [
|
|
{ subject: 'Préparation déménagement (vérifier éligibilité nouveau site)', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
|
{ subject: 'Retrait équipement ancien site', job_type: 'Retrait', priority: 'medium', duration_h: 1, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
|
{ subject: 'Installation au nouveau site', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 1 },
|
|
{ subject: 'Transfert abonnement & mise à jour adresse', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 2 },
|
|
],
|
|
repair_service: [
|
|
{ subject: 'Diagnostic à distance', job_type: 'Dépannage', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
|
{ subject: 'Intervention terrain', job_type: 'Réparation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
|
{ subject: 'Validation & suivi client', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
|
],
|
|
}
|
|
return TEMPLATES[templateId] || []
|
|
}
|
|
|
|
// ── HTTP Handler ────────────────────────────────────────────────────────────
|
|
|
|
async function handle (req, res, method, path) {
|
|
// GET /api/catalog — Public product catalog
|
|
if (path === '/api/catalog' && method === 'GET') {
|
|
try {
|
|
const catalog = await getCatalog()
|
|
return json(res, 200, { ok: true, items: catalog })
|
|
} catch (e) {
|
|
log('Catalog error:', e.message)
|
|
return json(res, 500, { error: 'Failed to load catalog' })
|
|
}
|
|
}
|
|
|
|
// POST /api/checkout — Process order
|
|
if (path === '/api/checkout' && method === 'POST') {
|
|
try {
|
|
const body = await parseBody(req)
|
|
const result = await processCheckout(body)
|
|
return json(res, 200, result)
|
|
} catch (e) {
|
|
log('Checkout error:', e.message)
|
|
return json(res, 400, { ok: false, error: e.message })
|
|
}
|
|
}
|
|
|
|
// GET /api/orders — List recent orders (Sales Orders)
|
|
if (path === '/api/orders' && method === 'GET') {
|
|
try {
|
|
const { URL } = require('url')
|
|
const url = new URL(req.url, 'http://localhost')
|
|
const limit = parseInt(url.searchParams.get('limit') || '20', 10)
|
|
const customer = url.searchParams.get('customer') || ''
|
|
const fields = JSON.stringify(['name', 'customer', 'customer_name', 'transaction_date', 'grand_total', 'status', 'creation'])
|
|
let filters = [['docstatus', '=', 0]]
|
|
if (customer) filters.push(['customer', '=', customer])
|
|
const fStr = JSON.stringify(filters)
|
|
const soRes = await erpFetch(`/api/resource/Sales%20Order?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(fStr)}&order_by=creation desc&limit_page_length=${limit}`)
|
|
return json(res, 200, { ok: true, orders: soRes.data?.data || [] })
|
|
} catch (e) {
|
|
log('Orders list error:', e.message)
|
|
return json(res, 500, { error: e.message })
|
|
}
|
|
}
|
|
|
|
// GET /api/order/:name — Single order detail
|
|
if (path.startsWith('/api/order/') && method === 'GET') {
|
|
try {
|
|
const orderName = decodeURIComponent(path.replace('/api/order/', ''))
|
|
const soRes = await erpFetch(`/api/resource/Sales%20Order/${encodeURIComponent(orderName)}`)
|
|
if (soRes.status !== 200) return json(res, 404, { error: 'Commande non trouvée' })
|
|
return json(res, 200, { ok: true, order: soRes.data?.data })
|
|
} catch (e) {
|
|
return json(res, 500, { error: e.message })
|
|
}
|
|
}
|
|
|
|
// POST /api/address-search — Address autocomplete (Supabase RQA)
|
|
if (path === '/api/address-search' && method === 'POST') {
|
|
try {
|
|
const body = await parseBody(req)
|
|
if (!body.q || body.q.length < 3) return json(res, 200, { results: [] })
|
|
const results = await searchAddresses(body.q, body.limit || 8)
|
|
return json(res, 200, { results })
|
|
} catch (e) {
|
|
log('Address search error:', e.message)
|
|
return json(res, 500, { error: 'Address search failed' })
|
|
}
|
|
}
|
|
|
|
// POST /api/otp/send — Send OTP to existing customer
|
|
if (path === '/api/otp/send' && method === 'POST') {
|
|
try {
|
|
const body = await parseBody(req)
|
|
if (!body.identifier) return json(res, 400, { error: 'Email ou téléphone requis' })
|
|
const result = await sendOTP(body.identifier)
|
|
return json(res, 200, result)
|
|
} catch (e) {
|
|
log('OTP send error:', e.message)
|
|
return json(res, 500, { error: e.message })
|
|
}
|
|
}
|
|
|
|
// POST /api/otp/verify — Verify OTP code
|
|
if (path === '/api/otp/verify' && method === 'POST') {
|
|
try {
|
|
const body = await parseBody(req)
|
|
if (!body.identifier || !body.code) return json(res, 400, { error: 'identifier et code requis' })
|
|
const result = await verifyOTP(body.identifier, body.code)
|
|
return json(res, 200, result)
|
|
} catch (e) {
|
|
return json(res, 500, { error: e.message })
|
|
}
|
|
}
|
|
|
|
// POST /api/accept-for-client — Agent accepts quotation on behalf of client
|
|
if (path === '/api/accept-for-client' && method === 'POST') {
|
|
try {
|
|
const body = await parseBody(req)
|
|
const { quotation, agent_name } = body
|
|
if (!quotation) return json(res, 400, { error: 'quotation required' })
|
|
|
|
const { acceptQuotation, fetchQuotation } = require('./acceptance')
|
|
// Intentionally not exported but we call it directly here
|
|
const acceptance = require('./acceptance')
|
|
|
|
// Record acceptance by agent
|
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || ''
|
|
await acceptance.acceptQuotation(quotation, {
|
|
method: 'Accepté par agent',
|
|
contact: agent_name || req.headers['x-authentik-email'] || 'Agent',
|
|
ip,
|
|
userAgent: req.headers['user-agent'] || '',
|
|
})
|
|
|
|
return json(res, 200, { ok: true, quotation, message: 'Devis accepté. Tâches créées.' })
|
|
} catch (e) {
|
|
log('Accept-for-client error:', e.message)
|
|
return json(res, 500, { error: e.message })
|
|
}
|
|
}
|
|
|
|
return json(res, 404, { error: 'Not found' })
|
|
}
|
|
|
|
module.exports = { handle, getCatalog, processCheckout, getTemplateSteps }
|