gigafibre-fsm/services/targo-hub/lib/subcontractor.js
louispaulb 83338ca57b feat(acte): barème sous-traitant — transport/jour + FTTH 250m+ (base + $/m)
- Ajout acte 'transport' (Tarif transport, 100$/jour, unité qte) ; retrait de
  l'ancien 'tarif_horaire + transport' combiné (remplacé par transport + perte_temps).
- amountOf : palier « FTTH 250 m + » = base 150-250 m (120$) + 1,20$/m au-delà de 250 m
  (l'input mètres = distance TOTALE).
- Prix du barème global saisis via /acte/rates (35/26/26/14/29/19/21/60/80/120/…).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:04:11 -04:00

329 lines
28 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 0100 m', 'case', 'ftth', null],
['ftth_100_150', 'FTTH 100150 m', 'case', 'ftth', null],
['ftth_150_250', 'FTTH 150250 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],
['transport', 'Tarif transport (jour)', 'qte', null, null], // 100$/jour — remplace l'ancien « Tarif horaire + transport »
['perte_temps', 'Tarif horaire perte de temps (h)', 'heures', null, null], // 70$/h — À VALIDER/APPROUVER par TARGO (flux d'approbation existant)
]
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)
await p.query("DELETE FROM subcontractor.rate_card WHERE code='tarif_horaire'") // remplacé par 'transport' (jour) + 'perte_temps' (h) — n'affecte pas les soumissions passées (montant figé à la soumission)
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
const base250 = +((rates.find(x => x.code === 'ftth_150_250') || {}).price) || 0 // base du palier « 250 m + » = tarif 150-250 m
for (const r of rates) {
const v = acts ? acts[r.code] : null
if (r.code === 'ftth_250_plus') { const q = Number(v) || 0; if (q > 0) amt += base250 + (+r.price) * Math.max(0, q - 250) } // 120$ + 1,20$/m au-delà de 250 m (q = distance TOTALE en m)
else 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 }