gigafibre-fsm/services/targo-hub/lib/reports.js
louispaulb 607ea54b5c refactor: reduce token count, DRY code, consolidate docs
Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:39:58 -04:00

573 lines
20 KiB
JavaScript

'use strict'
const cfg = require('./config')
const { log, json, parseBody, erpFetch } = require('./helpers')
// ── ERPNext GL query helpers ──────────────────────────────────────────────
/**
* Query GL Entry grouped by account & month.
* Used for revenue report and tax report.
* ERPNext API: frappe.client.get_list with group_by isn't great for aggregation,
* so we use the report_builder run endpoint instead.
*/
async function glQuery (filters, fields, groupBy, orderBy, limit = 500) {
const params = new URLSearchParams({
doctype: 'GL Entry',
filters: JSON.stringify(filters),
fields: JSON.stringify(fields),
group_by: groupBy || '',
order_by: orderBy || 'posting_date asc',
limit_page_length: limit,
})
const res = await erpFetch('/api/resource/GL Entry?' + params)
if (res.status !== 200) {
log('GL query error:', res.status, res.statusText)
return []
}
return res.data?.data || []
}
/**
* Fetch raw GL entries for a date range + accounts.
* Returns [{posting_date, account, debit, credit, voucher_type, voucher_no, party}]
*/
async function fetchGLEntries (startDate, endDate, accounts = [], limit = 5000) {
const filters = [
['posting_date', '>=', startDate],
['posting_date', '<=', endDate],
['is_cancelled', '=', 0],
]
if (accounts.length) {
filters.push(['account', 'in', accounts])
}
const fields = ['posting_date', 'account', 'debit', 'credit', 'voucher_type', 'voucher_no', 'party', 'against_voucher']
const params = new URLSearchParams({
fields: JSON.stringify(fields),
filters: JSON.stringify(filters),
limit_page_length: limit,
order_by: 'posting_date asc',
})
const res = await erpFetch('/api/resource/GL Entry?' + params)
if (res.status !== 200) return []
return res.data?.data || []
}
/**
* Fetch accounts from Chart of Accounts matching a range or pattern.
*/
async function fetchAccounts (rootType, parentAccount) {
const filters = []
if (rootType) filters.push(['root_type', '=', rootType])
if (parentAccount) filters.push(['parent_account', 'like', '%' + parentAccount + '%'])
const params = new URLSearchParams({
fields: JSON.stringify(['name', 'account_name', 'account_number', 'root_type', 'parent_account', 'is_group']),
filters: JSON.stringify(filters),
limit_page_length: 200,
order_by: 'account_number asc, name asc',
})
const res = await erpFetch('/api/resource/Account?' + params)
if (res.status !== 200) return []
return res.data?.data || []
}
/**
* Fetch Sales Invoices with items and taxes for the sales report.
*/
async function fetchSalesInvoices (startDate, endDate, limit = 2000) {
const filters = [
['posting_date', '>=', startDate],
['posting_date', '<=', endDate],
['docstatus', '=', 1], // submitted only
]
const fields = [
'name', 'posting_date', 'customer', 'customer_name',
'net_total', 'total_taxes_and_charges', 'grand_total',
'outstanding_amount', 'status', 'is_return',
]
const params = new URLSearchParams({
fields: JSON.stringify(fields),
filters: JSON.stringify(filters),
limit_page_length: limit,
order_by: 'posting_date asc',
})
const res = await erpFetch('/api/resource/Sales Invoice?' + params)
if (res.status !== 200) return []
return res.data?.data || []
}
/**
* Fetch tax breakdown for a set of invoices.
*/
async function fetchInvoiceTaxes (invoiceNames) {
if (!invoiceNames.length) return []
// Fetch in chunks of 100
const results = []
for (let i = 0; i < invoiceNames.length; i += 100) {
const chunk = invoiceNames.slice(i, i + 100)
const params = new URLSearchParams({
fields: JSON.stringify(['parent', 'account_head', 'tax_amount', 'description']),
filters: JSON.stringify([['parent', 'in', chunk], ['parenttype', '=', 'Sales Invoice']]),
limit_page_length: chunk.length * 5,
})
const res = await erpFetch('/api/resource/Sales Taxes and Charges?' + params)
if (res.status === 200 && res.data?.data) results.push(...res.data.data)
}
return results
}
/**
* Fetch ALL GL entries for a date range + accounts, paginating in chunks.
* ERPNext REST API limits to ~100k per request; we loop until exhausted.
*/
async function fetchAllGLEntries (startDate, endDate, accounts = []) {
const PAGE = 50000
let all = []
let offset = 0
while (true) {
const filters = [
['posting_date', '>=', startDate],
['posting_date', '<=', endDate],
['is_cancelled', '=', 0],
]
if (accounts.length) filters.push(['account', 'in', accounts])
const fields = ['posting_date', 'account', 'debit', 'credit']
const params = new URLSearchParams({
fields: JSON.stringify(fields),
filters: JSON.stringify(filters),
limit_page_length: PAGE,
limit_start: offset,
order_by: 'posting_date asc',
})
const res = await erpFetch('/api/resource/GL Entry?' + params)
const rows = res.status === 200 ? (res.data?.data || []) : []
all.push(...rows)
if (rows.length < PAGE) break
offset += PAGE
log(` GL fetch: ${all.length} entries so far...`)
}
return all
}
// ── Report endpoints ──────────────────────────────────────────────────────
/**
* GET /reports/revenue?start=2026-01-01&end=2026-12-31&mode=gl|items
* Revenue by GL account per month (mirrors report_revenu.php)
* mode=gl (default): aggregate GL entries by Income account
* mode=items: aggregate Sales Invoice Items by item_group (useful when GL accounts not detailed)
* filter=account1,account2: only show selected accounts (optional)
*/
async function handleRevenue (req, res, url) {
const start = url.searchParams.get('start')
const end = url.searchParams.get('end')
const mode = url.searchParams.get('mode') || 'gl'
const filterParam = url.searchParams.get('filter') || ''
if (!start || !end) return json(res, 400, { error: 'start and end dates required (YYYY-MM-DD)' })
try {
if (mode === 'items') {
return await handleRevenueByItems(res, start, end, filterParam)
}
return await handleRevenueByGL(res, start, end, filterParam)
} catch (e) {
log('Revenue report error:', e.message)
return json(res, 500, { error: e.message })
}
}
async function handleRevenueByGL (res, start, end, filterParam) {
// 1. Get all Income accounts
const accounts = await fetchAccounts('Income')
const filterList = filterParam ? filterParam.split(',').map(s => s.trim()) : []
const filteredAccounts = filterList.length
? accounts.filter(a => filterList.includes(a.name) || filterList.includes(a.account_name) || filterList.includes(a.account_number))
: accounts
const accountNames = filteredAccounts.map(a => a.name)
if (!accountNames.length) return json(res, 200, { accounts: [], months: [], all_accounts: accounts.filter(a => !a.is_group).map(a => ({ name: a.name, label: a.account_name, number: a.account_number })) })
// 2. Fetch GL entries (paginate to handle large datasets)
const entries = await fetchAllGLEntries(start, end, accountNames)
// 3. Aggregate by account + month
const data = {}
const monthSet = new Set()
for (const e of entries) {
const month = e.posting_date.slice(0, 7)
monthSet.add(month)
if (!data[e.account]) data[e.account] = {}
if (!data[e.account][month]) data[e.account][month] = 0
data[e.account][month] += (e.credit || 0) - (e.debit || 0)
}
const months = [...monthSet].sort()
const accountData = filteredAccounts
.filter(a => data[a.name])
.map(a => ({
name: a.name,
label: a.account_name || a.name,
number: a.account_number || '',
monthly: months.map(m => Math.round((data[a.name][m] || 0) * 100) / 100),
total: Math.round(Object.values(data[a.name]).reduce((s, v) => s + v, 0) * 100) / 100,
}))
.sort((a, b) => (b.total - a.total))
return json(res, 200, {
accounts: accountData,
months,
mode: 'gl',
all_accounts: accounts.filter(a => !a.is_group).map(a => ({ name: a.name, label: a.account_name, number: a.account_number })),
})
}
async function handleRevenueByItems (res, start, end, filterParam) {
// Fetch Sales Invoice Items grouped by item_group
const filters = [
['posting_date', '>=', start],
['posting_date', '<=', end],
['docstatus', '=', 1],
]
const params = new URLSearchParams({
fields: JSON.stringify(['posting_date', 'item_group', 'base_net_amount']),
filters: JSON.stringify(filters),
limit_page_length: 50000,
order_by: 'posting_date asc',
})
const r = await erpFetch('/api/resource/Sales Invoice Item?' + params)
const items = r.status === 200 ? (r.data?.data || []) : []
const filterList = filterParam ? filterParam.split(',').map(s => s.trim()) : []
// Aggregate by item_group + month
const data = {}
const monthSet = new Set()
const groupSet = new Set()
for (const item of items) {
const group = item.item_group || 'Sans catégorie'
if (filterList.length && !filterList.includes(group)) continue
const month = (item.posting_date || '').slice(0, 7)
if (!month) continue
monthSet.add(month)
groupSet.add(group)
if (!data[group]) data[group] = {}
if (!data[group][month]) data[group][month] = 0
data[group][month] += item.base_net_amount || 0
}
const months = [...monthSet].sort()
const groups = [...groupSet]
const accountData = groups
.map(g => ({
name: g,
label: g,
number: '',
monthly: months.map(m => Math.round((data[g][m] || 0) * 100) / 100),
total: Math.round(Object.values(data[g]).reduce((s, v) => s + v, 0) * 100) / 100,
}))
.sort((a, b) => (b.total - a.total))
return json(res, 200, {
accounts: accountData,
months,
mode: 'items',
all_accounts: groups.map(g => ({ name: g, label: g, number: '' })),
})
}
/**
* GET /reports/sales?start=2026-01-01&end=2026-01-31
* Sales detail with tax breakdown (mirrors report_vente.php)
*/
async function handleSales (req, res, url) {
const start = url.searchParams.get('start')
const end = url.searchParams.get('end')
if (!start || !end) return json(res, 400, { error: 'start and end dates required' })
try {
// 1. Fetch invoices
const invoices = await fetchSalesInvoices(start, end)
// 2. Fetch taxes for all invoices
const names = invoices.map(i => i.name)
const taxes = await fetchInvoiceTaxes(names)
// 3. Build tax map: { invoiceName: { TPS: amount, TVQ: amount } }
const taxMap = {}
for (const t of taxes) {
if (!taxMap[t.parent]) taxMap[t.parent] = {}
// Detect TPS vs TVQ by account head name
const key = (t.account_head || '').toLowerCase().includes('tps') ? 'TPS'
: (t.account_head || '').toLowerCase().includes('tvq') ? 'TVQ'
: (t.description || '').toLowerCase().includes('tps') ? 'TPS'
: (t.description || '').toLowerCase().includes('tvq') ? 'TVQ'
: 'Autre'
taxMap[t.parent][key] = (taxMap[t.parent][key] || 0) + (t.tax_amount || 0)
}
// 4. Compose result
const rows = invoices.map(inv => ({
name: inv.name,
date: inv.posting_date,
customer: inv.customer,
customer_name: inv.customer_name,
subtotal: inv.net_total,
tps: taxMap[inv.name]?.TPS || 0,
tvq: taxMap[inv.name]?.TVQ || 0,
total: inv.grand_total,
outstanding: inv.outstanding_amount,
status: inv.status,
is_return: inv.is_return,
}))
// 5. Summary
const summary = rows.reduce((s, r) => {
s.subtotal += r.subtotal
s.tps += r.tps
s.tvq += r.tvq
s.total += r.total
s.count++
if (r.is_return) s.returns++
return s
}, { subtotal: 0, tps: 0, tvq: 0, total: 0, count: 0, returns: 0 })
return json(res, 200, { rows, summary })
} catch (e) {
log('Sales report error:', e.message)
return json(res, 500, { error: e.message })
}
}
/**
* GET /reports/taxes?start=2026-01-01&end=2026-03-31&period=monthly|quarterly
* Tax report: TPS/TVQ collected vs paid (mirrors rapport_tax.php)
*/
async function handleTaxes (req, res, url) {
const start = url.searchParams.get('start')
const end = url.searchParams.get('end')
const period = url.searchParams.get('period') || 'monthly' // monthly | quarterly
if (!start || !end) return json(res, 400, { error: 'start and end dates required' })
try {
// Get all accounts to find tax accounts
const allAccounts = await fetchAccounts()
// Find TPS/TVQ accounts by name pattern or account number
// Legacy: 2300=TPS perçue, 2305=TPS payée, 2350=TVQ perçue, 2355=TVQ payée
const matchAny = (a, keywords) => {
const name = (a.account_name || '').toLowerCase()
const num = a.account_number || ''
return keywords.some(k => name.includes(k) || num === k)
}
const tpsCollected = allAccounts.filter(a =>
matchAny(a, ['2300']) ||
(matchAny(a, ['tps']) && matchAny(a, ['perçue', 'percue', 'collect', 'à payer']))
).map(a => a.name)
const tpsPaid = allAccounts.filter(a =>
matchAny(a, ['2305']) ||
(matchAny(a, ['tps']) && matchAny(a, ['payée', 'payee', 'paid', 'input']))
).map(a => a.name)
const tvqCollected = allAccounts.filter(a =>
matchAny(a, ['2350']) ||
(matchAny(a, ['tvq']) && matchAny(a, ['perçue', 'percue', 'collect', 'à payer']))
).map(a => a.name)
const tvqPaid = allAccounts.filter(a =>
matchAny(a, ['2355']) ||
(matchAny(a, ['tvq']) && matchAny(a, ['payée', 'payee', 'paid', 'input']))
).map(a => a.name)
// Also find Income accounts for revenue total
const incomeAccounts = allAccounts
.filter(a => a.root_type === 'Income')
.map(a => a.name)
// Fetch all relevant GL entries
const allRelevant = [...tpsCollected, ...tpsPaid, ...tvqCollected, ...tvqPaid, ...incomeAccounts]
const entries = await fetchGLEntries(start, end, allRelevant, 10000)
// Generate period buckets
const buckets = generatePeriodBuckets(start, end, period)
// Aggregate per bucket
const result = buckets.map(bucket => {
const bucketEntries = entries.filter(e =>
e.posting_date >= bucket.start && e.posting_date <= bucket.end
)
const sumByAccounts = (accts, creditPositive = true) => {
return bucketEntries
.filter(e => accts.includes(e.account))
.reduce((sum, e) => sum + (creditPositive ? (e.credit - e.debit) : (e.debit - e.credit)), 0)
}
return {
label: bucket.label,
start: bucket.start,
end: bucket.end,
tps_collected: Math.round(sumByAccounts(tpsCollected) * 100) / 100,
tps_paid: Math.round(sumByAccounts(tpsPaid, false) * 100) / 100,
tvq_collected: Math.round(sumByAccounts(tvqCollected) * 100) / 100,
tvq_paid: Math.round(sumByAccounts(tvqPaid, false) * 100) / 100,
revenue: Math.round(sumByAccounts(incomeAccounts) * 100) / 100,
}
})
// Add net amounts
for (const r of result) {
r.tps_net = Math.round((r.tps_collected - r.tps_paid) * 100) / 100
r.tvq_net = Math.round((r.tvq_collected - r.tvq_paid) * 100) / 100
r.total_tax_net = Math.round((r.tps_net + r.tvq_net) * 100) / 100
}
return json(res, 200, {
periods: result,
accounts: {
tps_collected: tpsCollected,
tps_paid: tpsPaid,
tvq_collected: tvqCollected,
tvq_paid: tvqPaid,
income: incomeAccounts.length,
},
})
} catch (e) {
log('Tax report error:', e.message)
return json(res, 500, { error: e.message })
}
}
/**
* GET /reports/accounts-receivable?as_of=2026-04-01
* Aging report (mirrors rapport_age_compte.php)
*/
async function handleAR (req, res, url) {
const asOf = url.searchParams.get('as_of') || new Date().toISOString().slice(0, 10)
try {
// Fetch unpaid invoices
const filters = [
['docstatus', '=', 1],
['outstanding_amount', '!=', 0],
['posting_date', '<=', asOf],
]
const fields = [
'name', 'posting_date', 'due_date', 'customer', 'customer_name',
'grand_total', 'outstanding_amount',
]
const params = new URLSearchParams({
fields: JSON.stringify(fields),
filters: JSON.stringify(filters),
limit_page_length: 5000,
order_by: 'posting_date asc',
})
const r = await erpFetch('/api/resource/Sales Invoice?' + params)
const invoices = r.status === 200 ? (r.data?.data || []) : []
const asOfDate = new Date(asOf)
const buckets = { current: 0, d30: 0, d60: 0, d90: 0, d120: 0 }
const customers = {}
for (const inv of invoices) {
const due = new Date(inv.due_date || inv.posting_date)
const daysOverdue = Math.floor((asOfDate - due) / 86400000)
const amt = inv.outstanding_amount || 0
const bucket = daysOverdue <= 0 ? 'current'
: daysOverdue <= 30 ? 'd30'
: daysOverdue <= 60 ? 'd60'
: daysOverdue <= 90 ? 'd90' : 'd120'
buckets[bucket] += amt
if (!customers[inv.customer]) {
customers[inv.customer] = {
customer: inv.customer,
customer_name: inv.customer_name,
current: 0, d30: 0, d60: 0, d90: 0, d120: 0, total: 0,
}
}
customers[inv.customer][bucket] += amt
customers[inv.customer].total += amt
}
const rows = Object.values(customers).sort((a, b) => b.total - a.total)
const total = Object.values(buckets).reduce((s, v) => s + v, 0)
return json(res, 200, {
as_of: asOf,
summary: { ...buckets, total: Math.round(total * 100) / 100 },
rows: rows.map(r => ({
...r,
current: Math.round(r.current * 100) / 100,
d30: Math.round(r.d30 * 100) / 100,
d60: Math.round(r.d60 * 100) / 100,
d90: Math.round(r.d90 * 100) / 100,
d120: Math.round(r.d120 * 100) / 100,
total: Math.round(r.total * 100) / 100,
})),
invoice_count: invoices.length,
})
} catch (e) {
log('AR report error:', e.message)
return json(res, 500, { error: e.message })
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
function generatePeriodBuckets (startStr, endStr, period) {
const buckets = []
const start = new Date(startStr + 'T00:00:00')
const end = new Date(endStr + 'T23:59:59')
const FR_MONTHS = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
if (period === 'quarterly') {
let cur = new Date(start.getFullYear(), Math.floor(start.getMonth() / 3) * 3, 1)
while (cur <= end) {
const qEnd = new Date(cur.getFullYear(), cur.getMonth() + 3, 0) // last day of quarter
const Q = Math.floor(cur.getMonth() / 3) + 1
buckets.push({
label: `T${Q} ${cur.getFullYear()}`,
start: cur.toISOString().slice(0, 10),
end: (qEnd > end ? end : qEnd).toISOString().slice(0, 10),
})
cur = new Date(cur.getFullYear(), cur.getMonth() + 3, 1)
}
} else {
let cur = new Date(start.getFullYear(), start.getMonth(), 1)
while (cur <= end) {
const mEnd = new Date(cur.getFullYear(), cur.getMonth() + 1, 0)
buckets.push({
label: FR_MONTHS[cur.getMonth()] + ' ' + cur.getFullYear(),
start: cur.toISOString().slice(0, 10),
end: (mEnd > end ? end : mEnd).toISOString().slice(0, 10),
})
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1)
}
}
return buckets
}
// ── Route handler ─────────────────────────────────────────────────────────
async function handle (req, res, method, path, url) {
if (method !== 'GET') return json(res, 405, { error: 'GET only' })
if (path === '/reports/revenue') return handleRevenue(req, res, url)
if (path === '/reports/sales') return handleSales(req, res, url)
if (path === '/reports/taxes') return handleTaxes(req, res, url)
if (path === '/reports/ar') return handleAR(req, res, url)
if (path === '/reports/accounts') {
const type = url.searchParams.get('type') || 'Income'
const accounts = await fetchAccounts(type)
return json(res, 200, { accounts })
}
return json(res, 404, { error: 'Report not found' })
}
module.exports = { handle }