Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré / juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts, sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle week-end + placeholder « en attente du quart », clustering + lasso + filtre-date sur la carte, accès rapide « À assigner » (badge). Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive + timeout fetch global + authFetch durci → fin des rechargements manuels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
322 lines
27 KiB
JavaScript
322 lines
27 KiB
JavaScript
// Sous-traitants payés à L'ACTE : page mobile à TOKEN où le sous-traitant inscrit les actes accomplis sur un ticket
|
||
// (chips/quantités), certifie, et confirme. Enregistre la soumission (= base de paie + historique « qui a fait quoi »).
|
||
// Barème GLOBAL éditable (prix à 0 au départ). Acomba reste le payeur (export en P2). Stockage Postgres durable (schéma subcontractor).
|
||
const crypto = require('crypto')
|
||
const fs = require('fs')
|
||
const { Pool } = require('pg')
|
||
const cfg = require('./config')
|
||
const { log, json, parseBody } = require('./helpers')
|
||
const erp = require('./erp')
|
||
|
||
const SECRET = process.env.ST_SECRET || cfg.JWT_SECRET || cfg.HUB_SERVICE_TOKEN || 'targo-acte-secret'
|
||
const BASE = cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'
|
||
const TTL_MS = 75 * 24 * 3600 * 1000 // lien valide ~75 jours
|
||
|
||
// Barème par défaut (codes du screenshot HOUSSAM) — unité: case (☑) | qte | metres | heures ; prix 0, éditable ensuite.
|
||
// [code, label, unit (case|qte|metres|heures), grp (options mutuellement exclusives — 1 seule rangée segmentée), implies (code auto-coché)]
|
||
const SEED = [
|
||
['accueil', 'Accueil (temps client + déplacement)', 'case', null, null],
|
||
['branchement_sca', 'Branchement SCA', 'case', null, null],
|
||
['set_up_internet', 'Set Up Internet', 'case', null, 'set_up_wifi'], // Internet → on fait quasi toujours le Wifi : coché par défaut
|
||
['set_up_wifi', 'Set Up Wifi', 'case', null, null],
|
||
['borne_wifi', 'Borne Wifi', 'qte', null, null],
|
||
['setup_box_1', '1er setup BOX TV', 'case', 'box_tv', null], // groupe count box_tv : compte 0..6, 1er=case, suivants=qte additionnel
|
||
['setup_box_add', 'Setup BOX TV additionnel', 'qte', 'box_tv', null],
|
||
['demi_drop', 'FTTH Demie', 'case', 'ftth', null], // option « Demie » du groupe FTTH/drop, AVANT 0-100 (segment affiché = « Demie »)
|
||
['ftth_0_100', 'FTTH 0–100 m', 'case', 'ftth', null],
|
||
['ftth_100_150', 'FTTH 100–150 m', 'case', 'ftth', null],
|
||
['ftth_150_250', 'FTTH 150–250 m', 'case', 'ftth', null],
|
||
['ftth_250_plus', 'FTTH 250 m + (au mètre)', 'metres', 'ftth', null], // option « précise » : révèle l'input mètres
|
||
['conduit', 'Conduit (m)', 'metres', null, null],
|
||
['demarcation', 'Démarcation Fibre', 'qte', null, null],
|
||
['connecteur', 'Connecteur Fibre', 'qte', null, null],
|
||
['tarif_horaire', 'Tarif horaire + transport (h)', 'heures', null, null],
|
||
['perte_temps', 'Tarif horaire perte de temps (h)', 'heures', null, null],
|
||
]
|
||
const DEFAULT_CHECKED = ['accueil'] // « Accueil » presque toujours coché par défaut (temps client + déplacement)
|
||
// Groupes d'options (config code, PAS de colonne DB) : 'seg' = segmenté mutuellement exclusif (FTTH, on en choisit 1) ;
|
||
// 'count' = un nombre 0..max → réparti sur le 1er (membre `case`) + additionnels (membre `qte`) → 1 SEULE rangée. Le form lit GROUPS[grp].
|
||
const GROUPS = { ftth: { kind: 'seg', label: 'FTTH / Drop' }, box_tv: { kind: 'count', label: 'Setup BOX TV', max: 6 } }
|
||
|
||
let _pool = null
|
||
function pool () {
|
||
if (!_pool) {
|
||
_pool = new Pool({
|
||
host: process.env.COMMS_DB_HOST || process.env.ADDR_DB_HOST || 'erpnext-db-1',
|
||
port: +(process.env.COMMS_DB_PORT || process.env.ADDR_DB_PORT || 5432),
|
||
user: process.env.COMMS_DB_USER || process.env.ADDR_DB_USER || 'postgres',
|
||
password: process.env.COMMS_DB_PASS || process.env.ADDR_DB_PASS || '123',
|
||
database: process.env.COMMS_DB_NAME || process.env.ADDR_DB_NAME || '_eb65bdc0c4b1b2d6',
|
||
max: 4, idleTimeoutMillis: 30000, connectionTimeoutMillis: 6000,
|
||
})
|
||
_pool.on('error', (e) => log('subcontractor-pg pool error:', e.message))
|
||
}
|
||
return _pool
|
||
}
|
||
let _ready = null
|
||
async function ensure () {
|
||
if (_ready) return _ready
|
||
_ready = (async () => {
|
||
const p = pool()
|
||
await p.query('CREATE SCHEMA IF NOT EXISTS subcontractor')
|
||
await p.query('CREATE TABLE IF NOT EXISTS subcontractor.rate_card (code text PRIMARY KEY, label text, unit text, price numeric DEFAULT 0, sort int, grp text, implies text)')
|
||
await p.query('ALTER TABLE subcontractor.rate_card ADD COLUMN IF NOT EXISTS grp text')
|
||
await p.query('ALTER TABLE subcontractor.rate_card ADD COLUMN IF NOT EXISTS implies text')
|
||
await p.query(`CREATE TABLE IF NOT EXISTS subcontractor.submissions (
|
||
id bigserial PRIMARY KEY, ref text, ticket text, subcontractor text, acts jsonb, amount numeric,
|
||
certified boolean DEFAULT false, ip text, status text DEFAULT 'soumis', created_at timestamptz DEFAULT now())`)
|
||
await p.query('ALTER TABLE subcontractor.submissions ADD COLUMN IF NOT EXISTS photos jsonb')
|
||
await p.query('ALTER TABLE subcontractor.submissions ADD COLUMN IF NOT EXISTS notes text')
|
||
await p.query('ALTER TABLE subcontractor.submissions ADD COLUMN IF NOT EXISTS approved_by text')
|
||
await p.query('ALTER TABLE subcontractor.submissions ADD COLUMN IF NOT EXISTS approved_at timestamptz')
|
||
await p.query('ALTER TABLE subcontractor.submissions ADD COLUMN IF NOT EXISTS reject_reason text')
|
||
await p.query('ALTER TABLE subcontractor.submissions ADD COLUMN IF NOT EXISTS paid_at timestamptz')
|
||
await p.query('CREATE TABLE IF NOT EXISTS subcontractor.subcontractors (name text PRIMARY KEY, registrant boolean DEFAULT false, gst_no text, qst_no text, email text, phone text, updated_at timestamptz DEFAULT now())')
|
||
// Prix PAR sous-traitant (surcharge du barème global). Absent = hérite du prix global.
|
||
await p.query('CREATE TABLE IF NOT EXISTS subcontractor.sub_rates (sub_name text, code text, price numeric DEFAULT 0, PRIMARY KEY (sub_name, code))')
|
||
await p.query('CREATE TABLE IF NOT EXISTS subcontractor.settings (key text PRIMARY KEY, value text, updated_at timestamptz DEFAULT now())')
|
||
// ON CONFLICT DO UPDATE : met à jour grp/implies/sort (structure) SANS écraser price/label/unit (édités par le staff)
|
||
for (let i = 0; i < SEED.length; i++) { const code = SEED[i][0], label = SEED[i][1], unit = SEED[i][2], grp = SEED[i][3], implies = SEED[i][4], sort = (i + 1) * 10; await p.query('INSERT INTO subcontractor.rate_card(code,label,unit,price,sort,grp,implies) VALUES($1,$2,$3,0,$4,$5,$6) ON CONFLICT (code) DO UPDATE SET grp=EXCLUDED.grp, implies=EXCLUDED.implies, sort=EXCLUDED.sort', [code, label, unit, sort, grp, implies]) }
|
||
await p.query("UPDATE subcontractor.rate_card SET label='FTTH Demie' WHERE code='demi_drop' AND label='Demi Drop Fibre'") // renommage ponctuel (le DO UPDATE ne touche pas au libellé ; conditionnel = ne clobbe pas une édition admin)
|
||
return true
|
||
})().catch(e => { log('subcontractor.ensure:', e.message); _ready = null; throw e })
|
||
return _ready
|
||
}
|
||
|
||
// ── Token HMAC (ticket+sous-traitant+exp) ─────────────────────────────────────
|
||
function sign (obj) { const b = Buffer.from(JSON.stringify(obj)).toString('base64url'); const h = crypto.createHmac('sha256', SECRET).update(b).digest('hex').slice(0, 16); return b + '.' + h }
|
||
function verify (token) {
|
||
try {
|
||
const [b, h] = String(token || '').split('.'); if (!b || !h) return null
|
||
const exp = crypto.createHmac('sha256', SECRET).update(b).digest('hex').slice(0, 16)
|
||
if (!crypto.timingSafeEqual(Buffer.from(h), Buffer.from(exp))) return null
|
||
const o = JSON.parse(Buffer.from(b, 'base64url').toString('utf8'))
|
||
if (o.x && Date.now() > o.x) return null // expiré
|
||
return o
|
||
} catch (e) { return null }
|
||
}
|
||
|
||
async function getRates () { await ensure(); const r = await pool().query('SELECT code,label,unit,price::float8 AS price,sort,grp,implies FROM subcontractor.rate_card ORDER BY sort'); return r.rows }
|
||
// Barème EFFECTIF d'un sous-traitant : barème global + surcharge de prix par acte (sub_rates). Repli sur le global si non défini.
|
||
async function getRatesForSub (subName) {
|
||
const base = await getRates()
|
||
if (!subName) return base
|
||
const ov = (await pool().query('SELECT code, price::float8 AS price FROM subcontractor.sub_rates WHERE sub_name=$1', [String(subName)])).rows
|
||
if (!ov.length) return base
|
||
const map = {}; for (const r of ov) map[r.code] = r.price
|
||
return base.map(r => (map[r.code] != null ? { ...r, price: map[r.code], _override: true } : r))
|
||
}
|
||
function amountOf (acts, rates) {
|
||
let amt = 0
|
||
for (const r of rates) { const v = acts ? acts[r.code] : null; if (r.unit === 'case') { if (v === true || v === 1 || v === '1') amt += +r.price } else { const q = Number(v) || 0; if (q > 0) amt += +r.price * q } }
|
||
return Math.round(amt * 100) / 100
|
||
}
|
||
async function context (ticket) { // libellé du ticket pour la page : DJ lié → sujet/client/adresse
|
||
try { const x = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(ticket)]], fields: ['subject', 'customer_name', 'address'], limit: 1 }); const d = x[0]; if (d) return { label: d.subject || '', customer: d.customer_name || '', address: d.address || '' } } catch (e) {}
|
||
return { label: '', customer: '', address: '' }
|
||
}
|
||
const TPS = 0.05, TVQ = 0.09975 // taux QC — appliqués SEULEMENT si le sous-traitant est inscrit (autofacturation)
|
||
async function buildReleve (subName, from, to) { // relevé = soumissions APPROUVÉES d'un sous-traitant sur une période + taxes selon son statut
|
||
await ensure()
|
||
const args = [subName]; let w = "subcontractor=$1 AND status='approuvé'"
|
||
if (from) { args.push(from); w += ' AND created_at >= $' + args.length }
|
||
if (to) { args.push(to + ' 23:59:59'); w += ' AND created_at <= $' + args.length }
|
||
const r = await pool().query('SELECT id,ref,ticket,acts,amount::float8 AS amount,created_at,paid_at FROM subcontractor.submissions WHERE ' + w + ' ORDER BY created_at', args)
|
||
const cf = (await pool().query('SELECT name,registrant,gst_no,qst_no,email,phone FROM subcontractor.subcontractors WHERE name=$1', [subName])).rows[0] || { name: subName, registrant: false }
|
||
const subtotal = Math.round(r.rows.reduce((a, x) => a + (+x.amount || 0), 0) * 100) / 100
|
||
const tps = cf.registrant ? Math.round(subtotal * TPS * 100) / 100 : 0
|
||
const tvq = cf.registrant ? Math.round(subtotal * TVQ * 100) / 100 : 0
|
||
return { subcontractor: subName, config: cf, from: from || null, to: to || null, count: r.rows.length, lines: r.rows, subtotal, tps, tvq, total: Math.round((subtotal + tps + tvq) * 100) / 100 }
|
||
}
|
||
function clientIp (req) { return (String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || (req.socket && req.socket.remoteAddress) || '' }
|
||
// Approbation réservée admin/superviseur : groupe Authentik (transmis par le proxy) OU email dans ACTE_APPROVERS (défaut = le proprio).
|
||
const _APPROVERS = (process.env.ACTE_APPROVERS || 'louis@targo.ca').toLowerCase().split(',').map(s => s.trim()).filter(Boolean)
|
||
function isApprover (req) {
|
||
const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '').toLowerCase()
|
||
if (/(^|[,\s])(admin|superviseur|supervisor)([,\s]|$)/.test(g)) return true
|
||
const email = String(req.headers['x-authentik-email'] || '').toLowerCase().trim()
|
||
return !!email && _APPROVERS.includes(email)
|
||
}
|
||
function approverName (req) { return String(req.headers['x-authentik-email'] || req.headers['x-authentik-username'] || 'staff').toLowerCase().trim() }
|
||
function savePhoto (ticket, dataUrl) { // base64 data-URL → /app/uploads/acte → nom de fichier (cap 8 Mo)
|
||
const m = /^data:image\/(\w+);base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return null
|
||
const buf = Buffer.from(m[2], 'base64'); if (!buf.length || buf.length > 8 * 1024 * 1024) return null
|
||
const dir = '/app/uploads/acte'; try { fs.mkdirSync(dir, { recursive: true }) } catch (e) {}
|
||
const fn = 'a_' + String(ticket).replace(/[^a-zA-Z0-9_-]/g, '') + '_' + Date.now() + '_' + Math.floor(Math.random() * 1000) + '.' + (m[1] === 'png' ? 'png' : 'jpg')
|
||
try { fs.writeFileSync(dir + '/' + fn, buf); return fn } catch (e) { return null }
|
||
}
|
||
function savePdf (dataUrl) { // base64 PDF (data-URL) → /app/uploads/entente.pdf (cap 12 Mo)
|
||
const m = /^data:application\/pdf;base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return false
|
||
const buf = Buffer.from(m[1], 'base64'); if (!buf.length || buf.length > 12 * 1024 * 1024) return false
|
||
const dir = '/app/uploads'; try { fs.mkdirSync(dir, { recursive: true }) } catch (e) {}
|
||
try { fs.writeFileSync(dir + '/entente.pdf', buf); return true } catch (e) { return false }
|
||
}
|
||
async function getSetting (key) { await ensure(); const r = await pool().query('SELECT value FROM subcontractor.settings WHERE key=$1', [key]); return r.rows.length ? r.rows[0].value : null }
|
||
async function setSetting (key, value) { await ensure(); await pool().query('INSERT INTO subcontractor.settings(key,value,updated_at) VALUES($1,$2,now()) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()', [key, value]) }
|
||
|
||
async function handle (req, res, method, path, url) {
|
||
try {
|
||
const q = (url && url.searchParams) || new URL(req.url, 'http://localhost').searchParams
|
||
// ── PAGE (publique ; token lu côté client) ──
|
||
if (path === '/acte/form' && method === 'GET') {
|
||
try { const html = fs.readFileSync(__dirname + '/acte-form.html', 'utf8'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('formulaire indisponible') }
|
||
}
|
||
// ── DATA (publique, token) : contexte ticket + barème + défauts ──
|
||
if (path === '/acte/data' && method === 'GET') {
|
||
const tok = verify(q.get('t')); if (!tok) return json(res, 401, { ok: false, error: 'lien invalide ou expiré' })
|
||
const rates = await getRatesForSub(tok.s); const ctx = await context(tok.t); const entente = await getSetting('entente_url')
|
||
return json(res, 200, { ok: true, ticket: tok.t, subcontractor: tok.s || '', label: ctx.label, customer: ctx.customer, address: ctx.address, rates, groups: GROUPS, defaults: DEFAULT_CHECKED, entente: entente || '' })
|
||
}
|
||
// ── PHOTO (publique, token) : upload base64 → /app/uploads/acte → nom de fichier ──
|
||
if (path === '/acte/photo' && method === 'POST') {
|
||
const body = await parseBody(req) || {}
|
||
const tok = verify(body.t); if (!tok) return json(res, 401, { ok: false, error: 'lien invalide ou expiré' })
|
||
const fn = savePhoto(tok.t, body.image); if (!fn) return json(res, 400, { ok: false, error: 'image invalide (max 8 Mo)' })
|
||
return json(res, 200, { ok: true, file: fn })
|
||
}
|
||
// ── SUBMIT (publique, token) : enregistre la soumission OU une visite infructueuse (ticket reste à replanifier) ──
|
||
if (path === '/acte/submit' && method === 'POST') {
|
||
const body = await parseBody(req) || {}
|
||
const tok = verify(body.t); if (!tok) return json(res, 401, { ok: false, error: 'lien invalide ou expiré' })
|
||
const infructueuse = body.outcome === 'infructueuse'
|
||
if (!infructueuse && !body.certified) return json(res, 400, { ok: false, error: 'certification requise' })
|
||
if (infructueuse && !body.reason) return json(res, 400, { ok: false, error: 'raison requise' })
|
||
const rates = await getRatesForSub(tok.s)
|
||
const acts = infructueuse ? { _outcome: 'infructueuse', _reason: String(body.reason).slice(0, 200) } : (body.acts || {})
|
||
const amount = infructueuse ? 0 : amountOf(acts, rates)
|
||
const photos = Array.isArray(body.photos) ? body.photos.map(p => (typeof p === 'string' ? { file: p } : (p || {}))).filter(p => /^a_[\w.-]+\.(jpg|png)$/.test(String(p.file || ''))).map(p => { const o = { file: p.file }; if (isFinite(+p.lat) && isFinite(+p.lon) && Math.abs(+p.lat) > 0.01) { o.lat = +(+p.lat).toFixed(6); o.lon = +(+p.lon).toFixed(6); if (isFinite(+p.acc)) o.acc = Math.round(+p.acc) } return o }).slice(0, 12) : []
|
||
if (body.dryRun) return json(res, 200, { ok: true, dryRun: true, ticket: tok.t, amount, infructueuse, photos })
|
||
await ensure()
|
||
const c = await pool().query('SELECT count(*)::int AS n FROM subcontractor.submissions')
|
||
const ref = 'ST-' + new Date().getFullYear() + '-' + String((c.rows[0].n || 0) + 1).padStart(4, '0')
|
||
const notes = String(body.notes || '').slice(0, 1000)
|
||
const ins = await pool().query('INSERT INTO subcontractor.submissions(ref,ticket,subcontractor,acts,amount,certified,ip,status,photos,notes) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id', [ref, String(tok.t), tok.s || '', JSON.stringify(acts), amount, !infructueuse, clientIp(req), infructueuse ? 'infructueuse' : 'soumis', JSON.stringify(photos), notes])
|
||
log('subcontractor: ' + ref + ' ticket ' + tok.t + ' ' + (infructueuse ? 'INFRUCTUEUSE (' + body.reason + ')' : amount + '$') + (photos.length ? ' · ' + photos.length + ' photo' : ''))
|
||
return json(res, 200, { ok: true, ref, amount, status: infructueuse ? 'infructueuse' : 'soumis', id: ins.rows[0].id })
|
||
}
|
||
// ── STAFF (gaté) : générer + envoyer le lien ──
|
||
if (path === '/acte/send' && method === 'POST') {
|
||
const body = await parseBody(req) || {}
|
||
if (!body.ticket) return json(res, 400, { ok: false, error: 'ticket requis' })
|
||
const token = sign({ t: String(body.ticket), s: body.subcontractor || '', x: Date.now() + TTL_MS })
|
||
const link = BASE + '/acte/form?t=' + token
|
||
let sent = null
|
||
try {
|
||
if (body.phone) { const tw = require('./twilio'); if (tw && tw.sendSms) { await tw.sendSms(body.phone, 'Targo — saisir les actes du ticket #' + body.ticket + ' : ' + link); sent = 'sms' } }
|
||
} catch (e) { log('acte/send sms:', e.message) }
|
||
return json(res, 200, { ok: true, link, token, sent })
|
||
}
|
||
// ── STAFF (gaté) : barème (lire / éditer un prix ou libellé) ──
|
||
// GET ?sub=X → barème effectif du sous-traitant (global + surcharge) ; sans sub → barème global.
|
||
if (path === '/acte/rates' && method === 'GET') { const sub = q.get('sub'); return json(res, 200, { ok: true, sub: sub || null, rates: sub ? await getRatesForSub(sub) : await getRates() }) }
|
||
if (path === '/acte/rates' && method === 'POST') {
|
||
const body = await parseBody(req) || {}; await ensure()
|
||
const items = Array.isArray(body.rates) ? body.rates : (body.code ? [body] : [])
|
||
const sub = body.sub ? String(body.sub) : null
|
||
if (sub) {
|
||
// Surcharge PAR sous-traitant : on stocke le prix par acte (upsert). Sert de « liste de prix associée ».
|
||
for (const it of items) { if (!it.code) continue; await pool().query('INSERT INTO subcontractor.sub_rates(sub_name,code,price) VALUES($1,$2,$3) ON CONFLICT (sub_name,code) DO UPDATE SET price=EXCLUDED.price', [sub, it.code, Number(it.price) || 0]) }
|
||
return json(res, 200, { ok: true, sub, rates: await getRatesForSub(sub) })
|
||
}
|
||
for (const it of items) { if (!it.code) continue; await pool().query('UPDATE subcontractor.rate_card SET price=COALESCE($2,price), label=COALESCE($3,label), unit=COALESCE($4,unit) WHERE code=$1', [it.code, it.price != null ? it.price : null, it.label || null, it.unit || null]) }
|
||
return json(res, 200, { ok: true, rates: await getRates() })
|
||
}
|
||
// ── ENTENTE de services : lien (web) ou PDF des conditions, montré sur le formulaire à la certification ──
|
||
if (path === '/acte/entente' && method === 'GET') return json(res, 200, { ok: true, url: await getSetting('entente_url') || '' })
|
||
if (path === '/acte/entente' && method === 'POST') {
|
||
if (!isApprover(req)) return json(res, 403, { ok: false, error: 'réservé admin/superviseur' })
|
||
const body = await parseBody(req) || {}
|
||
if (body.clear) { await setSetting('entente_url', ''); try { fs.unlinkSync('/app/uploads/entente.pdf') } catch (e) {} return json(res, 200, { ok: true, url: '' }) }
|
||
if (body.pdf) { if (!savePdf(body.pdf)) return json(res, 400, { ok: false, error: 'PDF invalide (max 12 Mo)' }); const u = BASE + '/acte/entente/doc'; await setSetting('entente_url', u); return json(res, 200, { ok: true, url: u }) }
|
||
const u = String(body.url || '').trim(); if (!/^https?:\/\//i.test(u)) return json(res, 400, { ok: false, error: 'URL http(s) requise' })
|
||
await setSetting('entente_url', u); return json(res, 200, { ok: true, url: u })
|
||
}
|
||
if (path === '/acte/entente/doc' && method === 'GET') { // PUBLIC : le sous-traitant ouvre le PDF depuis son téléphone
|
||
try { const buf = fs.readFileSync('/app/uploads/entente.pdf'); res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': 'inline; filename="entente-de-services.pdf"', 'Cache-Control': 'private, max-age=300' }); return res.end(buf) } catch (e) { res.writeHead(404); return res.end('aucune entente') }
|
||
}
|
||
// ── STAFF admin/superviseur (gaté) : approuver / refuser une soumission ──
|
||
if (path === '/acte/approve' && method === 'POST') {
|
||
if (!isApprover(req)) return json(res, 403, { ok: false, error: 'réservé admin/superviseur' })
|
||
const body = await parseBody(req) || {}
|
||
const id = parseInt(body.id, 10); if (!id) return json(res, 400, { ok: false, error: 'id requis' })
|
||
const reject = body.action === 'reject'
|
||
await ensure()
|
||
const r = await pool().query('UPDATE subcontractor.submissions SET status=$1, reject_reason=$2, approved_by=$3, approved_at=now() WHERE id=$4 RETURNING ref,status', [reject ? 'refusé' : 'approuvé', reject ? String(body.reason || '').slice(0, 200) : null, approverName(req), id])
|
||
if (!r.rows.length) return json(res, 404, { ok: false, error: 'introuvable' })
|
||
log('subcontractor: ' + r.rows[0].ref + ' → ' + r.rows[0].status + ' par ' + approverName(req))
|
||
return json(res, 200, { ok: true, ref: r.rows[0].ref, status: r.rows[0].status })
|
||
}
|
||
// ── STAFF (gaté) : servir une photo de soumission (revue OPS) ──
|
||
if (path === '/acte/img' && method === 'GET') {
|
||
const f = String(q.get('file') || ''); if (!/^a_[\w.-]+\.(jpg|png)$/.test(f)) return json(res, 400, { ok: false, error: 'fichier invalide' })
|
||
try { const buf = fs.readFileSync('/app/uploads/acte/' + f); res.writeHead(200, { 'Content-Type': f.endsWith('.png') ? 'image/png' : 'image/jpeg', 'Cache-Control': 'private, max-age=3600' }); return res.end(buf) } catch (e) { res.writeHead(404); return res.end('introuvable') }
|
||
}
|
||
// ── STAFF (gaté) : liste des soumissions (revue/approbation P2) ──
|
||
if (path === '/acte/list' && method === 'GET') {
|
||
await ensure()
|
||
const where = []; const args = []
|
||
if (q.get('ticket')) { args.push(q.get('ticket')); where.push('ticket=$' + args.length) }
|
||
if (q.get('subcontractor')) { args.push(q.get('subcontractor')); where.push('subcontractor=$' + args.length) }
|
||
const sql = 'SELECT id,ref,ticket,subcontractor,acts,amount::float8 AS amount,certified,status,photos,notes,approved_by,approved_at,reject_reason,paid_at,created_at FROM subcontractor.submissions' + (where.length ? ' WHERE ' + where.join(' AND ') : '') + ' ORDER BY id DESC LIMIT 500'
|
||
const r = await pool().query(sql, args)
|
||
return json(res, 200, { ok: true, count: r.rows.length, submissions: r.rows })
|
||
}
|
||
// ── STAFF (gaté) : config fiscale par sous-traitant (statut TPS/TVQ + n°), pour l'autofacturation ──
|
||
if (path === '/acte/subs' && method === 'GET') {
|
||
await ensure()
|
||
const r = await pool().query('SELECT name,registrant,gst_no,qst_no,email,phone FROM subcontractor.subcontractors ORDER BY name')
|
||
// + noms vus dans les soumissions mais pas encore configurés (pour les proposer dans l'UI)
|
||
const seen = await pool().query("SELECT DISTINCT subcontractor AS name FROM subcontractor.submissions WHERE subcontractor <> '' ORDER BY 1")
|
||
return json(res, 200, { ok: true, subs: r.rows, names: seen.rows.map(x => x.name) })
|
||
}
|
||
if (path === '/acte/subs' && method === 'POST') {
|
||
if (!isApprover(req)) return json(res, 403, { ok: false, error: 'réservé admin/superviseur' })
|
||
const body = await parseBody(req) || {}; if (!body.name) return json(res, 400, { ok: false, error: 'nom requis' })
|
||
await ensure()
|
||
await pool().query('INSERT INTO subcontractor.subcontractors(name,registrant,gst_no,qst_no,email,phone,updated_at) VALUES($1,$2,$3,$4,$5,$6,now()) ON CONFLICT (name) DO UPDATE SET registrant=EXCLUDED.registrant,gst_no=EXCLUDED.gst_no,qst_no=EXCLUDED.qst_no,email=EXCLUDED.email,phone=EXCLUDED.phone,updated_at=now()', [String(body.name), !!body.registrant, body.gst_no || null, body.qst_no || null, body.email || null, body.phone || null])
|
||
return json(res, 200, { ok: true })
|
||
}
|
||
// ── STAFF (gaté) : relevé d'un sous-traitant sur une période (soumissions approuvées + taxes selon statut) ──
|
||
if (path === '/acte/releve' && method === 'GET') {
|
||
const sub = q.get('subcontractor'); if (!sub) return json(res, 400, { ok: false, error: 'subcontractor requis' })
|
||
return json(res, 200, { ok: true, releve: await buildReleve(sub, q.get('from'), q.get('to')) })
|
||
}
|
||
// ── STAFF admin/superviseur (gaté) : marquer payé (approuvé → payé) ──
|
||
if (path === '/acte/mark-paid' && method === 'POST') {
|
||
if (!isApprover(req)) return json(res, 403, { ok: false, error: 'réservé admin/superviseur' })
|
||
const body = await parseBody(req) || {}; await ensure()
|
||
if (Array.isArray(body.ids) && body.ids.length) {
|
||
const ids = body.ids.map(n => parseInt(n, 10)).filter(Boolean)
|
||
const r = await pool().query("UPDATE subcontractor.submissions SET status='payé', paid_at=now() WHERE status='approuvé' AND id = ANY($1::bigint[]) RETURNING id", [ids])
|
||
return json(res, 200, { ok: true, paid: r.rowCount })
|
||
}
|
||
if (body.subcontractor) {
|
||
const args = [body.subcontractor]; let w = "subcontractor=$1 AND status='approuvé'"
|
||
if (body.from) { args.push(body.from); w += ' AND created_at >= $' + args.length }
|
||
if (body.to) { args.push(body.to + ' 23:59:59'); w += ' AND created_at <= $' + args.length }
|
||
const r = await pool().query("UPDATE subcontractor.submissions SET status='payé', paid_at=now() WHERE " + w + ' RETURNING id', args)
|
||
return json(res, 200, { ok: true, paid: r.rowCount })
|
||
}
|
||
return json(res, 400, { ok: false, error: 'ids ou subcontractor requis' })
|
||
}
|
||
// ── STAFF (gaté) : export CSV du relevé (pour saisie/import Acomba) ──
|
||
if (path === '/acte/export' && method === 'GET') {
|
||
const sub = q.get('subcontractor'); if (!sub) return json(res, 400, { ok: false, error: 'subcontractor requis' })
|
||
const rel = await buildReleve(sub, q.get('from'), q.get('to'))
|
||
const esc = (v) => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'
|
||
const rows = [['Date', 'Reference', 'Ticket', 'Description', 'Montant']]
|
||
for (const l of rel.lines) { const codes = Object.keys(l.acts || {}).filter(k => k[0] !== '_').join(' '); rows.push([new Date(l.created_at).toISOString().slice(0, 10), l.ref, l.ticket, codes, (+l.amount || 0).toFixed(2)]) }
|
||
rows.push(['', '', '', 'Sous-total', rel.subtotal.toFixed(2)])
|
||
if (rel.config.registrant) { rows.push(['', '', '', 'TPS (5%)' + (rel.config.gst_no ? ' #' + rel.config.gst_no : ''), rel.tps.toFixed(2)]); rows.push(['', '', '', 'TVQ (9,975%)' + (rel.config.qst_no ? ' #' + rel.config.qst_no : ''), rel.tvq.toFixed(2)]) }
|
||
rows.push(['', '', '', 'TOTAL', rel.total.toFixed(2)])
|
||
const csv = '' + rows.map(r => r.map(esc).join(',')).join('\r\n')
|
||
const fn = 'releve_' + String(sub).replace(/[^a-zA-Z0-9]+/g, '_') + (rel.from ? '_' + rel.from : '') + '.csv'
|
||
res.writeHead(200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': 'attachment; filename="' + fn + '"' })
|
||
return res.end(csv)
|
||
}
|
||
return json(res, 404, { ok: false, error: 'route inconnue' })
|
||
} catch (e) { log('subcontractor.handle:', e.message); return json(res, 500, { ok: false, error: e.message }) }
|
||
}
|
||
|
||
module.exports = { handle, sign, verify, getRates, amountOf }
|