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>
573 lines
20 KiB
JavaScript
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 }
|