feat(api): dashboard kpis + activity feed + top-late + ActivityEvent
Migration activity_events (uuid id, organization_id FK CASCADE, kind ENUM PG natif relance_sent/invoice_paid/invoice_imported/warning_drafted, at, label HTML léger, meta jsonb). Append-only — pas de mutation. Index (org, at).
Schema rules : kind typé en union + meta typé { invoiceId?, clientId?, planStepOrder? }.
Service activity_recorder.ts : recordActivity({orgId, kind, label, meta, trx?}). Branché dans :
- InvoicesController.markPaid → invoice_paid
- ImportBatchesController.validateDraft → invoice_imported
À venir : SendRelanceJob (relance_sent + warning_drafted) quand BullMQ sera là.
Service dashboard.ts :
- computeKpis(orgId) : 1 requête FILTER pour les counts par status + 1 requête pour les sommes paid this month / prev month / DSO. miseEnDemeurePending=0 et percentile=undefined V1 (placeholders honnêtes plutôt que faux chiffres).
- topLatePayers(orgId, 5) : INNER JOIN clients + agrégation count() par client_id, due_date < today + status actif.
Controller DashboardController :
- GET /dashboard/kpis : computeKpis
- GET /dashboard/activity : 20 derniers events de l'org, plus récent en tête
- GET /dashboard/top-late : top 5
Routes /api/v1/dashboard/* (auth requise).
Bruno : nouveau dossier 07-Dashboard avec 3 requêtes documentées.
Pour générer du contenu activity feed : encaisser une facture (Invoices → Mark paid) ou valider un draft (Imports → Validate). KPIs : créer des factures puis les marquer payées (paidAt rentre dans les sommes).
This commit is contained in:
parent
5d3408fafa
commit
704f472729
65
apps/api/app/controllers/dashboard_controller.ts
Normal file
65
apps/api/app/controllers/dashboard_controller.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import ActivityEvent from '#models/activity_event'
|
||||
import { computeKpis, topLatePayers } from '#services/dashboard'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
|
||||
const ACTIVITY_DEFAULT_LIMIT = 20
|
||||
|
||||
function requireOrgId(auth: HttpContext['auth']): string {
|
||||
const user = auth.getUserOrFail()
|
||||
if (!user.organizationId) {
|
||||
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
|
||||
}
|
||||
return user.organizationId
|
||||
}
|
||||
|
||||
export default class DashboardController {
|
||||
/**
|
||||
* GET /dashboard/kpis
|
||||
*
|
||||
* Cf. service dashboard.ts — quelques metrics V1 sont placeholder
|
||||
* (miseEnDemeurePending=0 tant que RelanceTask pas branché, percentile
|
||||
* undefined tant que cohorte trop petite).
|
||||
*/
|
||||
async kpis({ auth, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const data = await computeKpis(organizationId)
|
||||
return response.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/activity
|
||||
*
|
||||
* Journal append-only. Limit 20 par défaut, plus récent en tête.
|
||||
*/
|
||||
async activity({ auth, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
|
||||
const events = await ActivityEvent.query()
|
||||
.where('organization_id', organizationId)
|
||||
.orderBy('at', 'desc')
|
||||
.limit(ACTIVITY_DEFAULT_LIMIT)
|
||||
|
||||
return response.json({
|
||||
data: events.map((e) => ({
|
||||
id: e.id,
|
||||
kind: e.kind,
|
||||
at: e.at.toISO()!,
|
||||
label: e.label,
|
||||
meta: e.meta,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/top-late
|
||||
*
|
||||
* Top 5 clients avec le plus de factures en retard (status actif +
|
||||
* due_date dépassée).
|
||||
*/
|
||||
async topLate({ auth, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const data = await topLatePayers(organizationId)
|
||||
return response.json({ data })
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from '#validators/import_batch'
|
||||
import { resolveClient } from '#services/resolve_client'
|
||||
import { createImportBatchFromFilenames } from '#services/import_batch'
|
||||
import { recordActivity } from '#services/activity_recorder'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
@ -151,6 +152,14 @@ export default class ImportBatchesController {
|
||||
draft.invoiceId = created.id
|
||||
await draft.save()
|
||||
|
||||
await recordActivity({
|
||||
organizationId,
|
||||
kind: 'invoice_imported',
|
||||
label: `Facture <b>${created.numero}</b> importée et validée`,
|
||||
meta: { invoiceId: created.id, clientId: client.id },
|
||||
trx,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import { Exception } from '@adonisjs/core/exceptions'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { DateTime } from 'luxon'
|
||||
import { resolveClient } from '#services/resolve_client'
|
||||
import { recordActivity } from '#services/activity_recorder'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
@ -333,6 +334,15 @@ export default class InvoicesController {
|
||||
.from('organizations')
|
||||
.where('id', organizationId)
|
||||
.increment('rubis_count', 1)
|
||||
|
||||
// Journal d'activité (cf. dashboard activity feed).
|
||||
await recordActivity({
|
||||
organizationId,
|
||||
kind: 'invoice_paid',
|
||||
label: `Facture <b>${invoice.numero}</b> marquée encaissée`,
|
||||
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
|
||||
trx,
|
||||
})
|
||||
})
|
||||
|
||||
return response.json({ data: serializeInvoice(invoice) })
|
||||
|
||||
9
apps/api/app/models/activity_event.ts
Normal file
9
apps/api/app/models/activity_event.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { ActivityEventSchema } from '#database/schema'
|
||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import Organization from '#models/organization'
|
||||
|
||||
export default class ActivityEvent extends ActivityEventSchema {
|
||||
@belongsTo(() => Organization)
|
||||
declare organization: BelongsTo<typeof Organization>
|
||||
}
|
||||
40
apps/api/app/services/activity_recorder.ts
Normal file
40
apps/api/app/services/activity_recorder.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { DateTime } from 'luxon'
|
||||
import ActivityEvent from '#models/activity_event'
|
||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||
|
||||
type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
|
||||
|
||||
type RecordOpts = {
|
||||
organizationId: string
|
||||
kind: EventKind
|
||||
label: string
|
||||
meta?: Record<string, unknown>
|
||||
at?: DateTime
|
||||
trx?: TransactionClientContract
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un événement dans le journal d'activité (append-only).
|
||||
* Appelé depuis :
|
||||
* - SendRelanceJob (relance_sent)
|
||||
* - InvoicesController.markPaid (invoice_paid)
|
||||
* - ImportBatchesController.validateDraft (invoice_imported)
|
||||
* - SendRelanceJob quand step.requires_manual_validation (warning_drafted)
|
||||
*
|
||||
* Les labels acceptent un HTML léger (<b>) pour permettre au SPA de
|
||||
* mettre en gras les noms d'entité — toujours composé côté serveur,
|
||||
* jamais d'input utilisateur brut.
|
||||
*/
|
||||
export async function recordActivity(opts: RecordOpts): Promise<ActivityEvent> {
|
||||
const { organizationId, kind, label, meta = {}, at, trx } = opts
|
||||
return ActivityEvent.create(
|
||||
{
|
||||
organizationId,
|
||||
kind,
|
||||
label,
|
||||
meta,
|
||||
at: at ?? DateTime.now(),
|
||||
},
|
||||
trx ? { client: trx } : undefined
|
||||
)
|
||||
}
|
||||
150
apps/api/app/services/dashboard.ts
Normal file
150
apps/api/app/services/dashboard.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import Organization from '#models/organization'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
|
||||
|
||||
export type DashboardKpis = {
|
||||
rubisCount: number
|
||||
rubisThisMonth: number
|
||||
// 1 rubis = 10 minutes libérées (cf. CLAUDE.md → glossaire)
|
||||
hoursLiberatedThisMonth: number
|
||||
encaisseCents: number
|
||||
encaisseDeltaCents: number
|
||||
dsoDays: number
|
||||
dsoDeltaDays: number
|
||||
factureToRelance: number
|
||||
factureInRelance: number
|
||||
factureNewToday: number
|
||||
miseEnDemeurePending: number
|
||||
monthlyGoalProgress: number
|
||||
// Rang relatif à la cohorte (placeholder V1, calculé en V2 avec assez de data)
|
||||
percentile?: number
|
||||
}
|
||||
|
||||
function startOfMonth(d: DateTime): Date {
|
||||
return d.startOf('month').toJSDate()
|
||||
}
|
||||
|
||||
function startOfDay(d: DateTime): Date {
|
||||
return d.startOf('day').toJSDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les KPIs dashboard pour une organisation.
|
||||
*
|
||||
* V1 — implémentation simple sans cache. Quelques metrics avancés
|
||||
* (DSO, percentile) sont à 0 ou null tant qu'on a pas assez d'historique.
|
||||
* Le contrat reste stable côté SPA.
|
||||
*/
|
||||
export async function computeKpis(organizationId: string): Promise<DashboardKpis> {
|
||||
const now = DateTime.now()
|
||||
const monthStart = startOfMonth(now)
|
||||
const todayStart = startOfDay(now)
|
||||
const prevMonthStart = startOfMonth(now.minus({ months: 1 }))
|
||||
|
||||
const org = await Organization.findOrFail(organizationId)
|
||||
|
||||
// Counts par statut + factures récentes
|
||||
const counts = (await db
|
||||
.from('invoices')
|
||||
.where('organization_id', organizationId)
|
||||
.select(
|
||||
db.raw(`count(*) filter (where status = 'pending')::int as to_relance`),
|
||||
db.raw(`count(*) filter (where status = 'in_relance')::int as in_relance`),
|
||||
db.raw(`count(*) filter (where created_at >= ?)::int as new_today`, [todayStart])
|
||||
)
|
||||
.first()) as { to_relance: number; in_relance: number; new_today: number } | undefined
|
||||
|
||||
// Sommes d'encaissement (paid_at) ce mois et le précédent
|
||||
const paidStats = (await db
|
||||
.from('invoices')
|
||||
.where('organization_id', organizationId)
|
||||
.where('status', 'paid')
|
||||
.select(
|
||||
db.raw(
|
||||
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ?), 0)::int as this_month`,
|
||||
[monthStart]
|
||||
),
|
||||
db.raw(
|
||||
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ? and paid_at < ?), 0)::int as prev_month`,
|
||||
[prevMonthStart, monthStart]
|
||||
),
|
||||
db.raw(
|
||||
`coalesce(sum(rubis_earned) filter (where paid_at >= ?), 0)::int as rubis_this_month`,
|
||||
[monthStart]
|
||||
),
|
||||
db.raw(
|
||||
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ?), 0)::int as dso_this_month`,
|
||||
[monthStart]
|
||||
),
|
||||
db.raw(
|
||||
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ? and paid_at < ?), 0)::int as dso_prev_month`,
|
||||
[prevMonthStart, monthStart]
|
||||
)
|
||||
)
|
||||
.first()) as
|
||||
| {
|
||||
this_month: number
|
||||
prev_month: number
|
||||
rubis_this_month: number
|
||||
dso_this_month: number
|
||||
dso_prev_month: number
|
||||
}
|
||||
| undefined
|
||||
|
||||
const encaisseCents = paidStats?.this_month ?? 0
|
||||
const encaisseDeltaCents = encaisseCents - (paidStats?.prev_month ?? 0)
|
||||
const rubisThisMonth = paidStats?.rubis_this_month ?? 0
|
||||
const dsoDays = paidStats?.dso_this_month ?? 0
|
||||
const dsoDeltaDays = dsoDays - (paidStats?.dso_prev_month ?? 0)
|
||||
|
||||
return {
|
||||
rubisCount: org.rubisCount,
|
||||
rubisThisMonth,
|
||||
hoursLiberatedThisMonth: rubisThisMonth * 10,
|
||||
encaisseCents,
|
||||
encaisseDeltaCents,
|
||||
dsoDays,
|
||||
dsoDeltaDays,
|
||||
factureToRelance: counts?.to_relance ?? 0,
|
||||
factureInRelance: counts?.in_relance ?? 0,
|
||||
factureNewToday: counts?.new_today ?? 0,
|
||||
// Mise en demeure pending — sera calculé quand RelanceTask est branché
|
||||
// (count des steps requires_manual_validation programmées). Pour V1 : 0.
|
||||
miseEnDemeurePending: 0,
|
||||
// Goal progress (V1 placeholder) : ratio rubis_count / 250 (objectif
|
||||
// mensuel arbitraire). À paramétrer plus tard.
|
||||
monthlyGoalProgress: Math.min(100, Math.round((rubisThisMonth / 25) * 100)),
|
||||
percentile: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Top des clients en retard (top 5 par défaut).
|
||||
* Compte les factures actives dont due_date est dépassée, agrégées par client.
|
||||
*/
|
||||
export async function topLatePayers(
|
||||
organizationId: string,
|
||||
limit = 5
|
||||
): Promise<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
|
||||
const today = startOfDay(DateTime.now())
|
||||
|
||||
const rows = await db
|
||||
.from('invoices')
|
||||
.innerJoin('clients', 'clients.id', 'invoices.client_id')
|
||||
.where('invoices.organization_id', organizationId)
|
||||
.whereRaw(`invoices.status::text in ${ACTIVE_INVOICE_STATUSES}`)
|
||||
.where('invoices.due_date', '<', today)
|
||||
.groupBy('clients.id', 'clients.name')
|
||||
.select('clients.id as client_id', 'clients.name as name')
|
||||
.select(db.raw('count(*)::int as late_invoices_count'))
|
||||
.orderBy('late_invoices_count', 'desc')
|
||||
.limit(limit)
|
||||
|
||||
return rows.map((r) => ({
|
||||
clientId: r.client_id,
|
||||
name: r.name,
|
||||
lateInvoicesCount: r.late_invoices_count,
|
||||
}))
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'activity_events'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
|
||||
table
|
||||
.uuid('organization_id')
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('organizations')
|
||||
.onDelete('CASCADE')
|
||||
|
||||
// Append-only — pas d'update sur cette table. Les events sont des
|
||||
// snapshots historiques, pas un état mutable.
|
||||
table
|
||||
.enum(
|
||||
'kind',
|
||||
['relance_sent', 'invoice_paid', 'invoice_imported', 'warning_drafted'],
|
||||
{ useNative: true, enumName: 'activity_event_kind' }
|
||||
)
|
||||
.notNullable()
|
||||
table.timestamp('at').notNullable()
|
||||
// Label HTML léger autorisé (<b> pour le nom client/numero) — sanitisé
|
||||
// côté serveur quand on construit le label, pas de user input direct.
|
||||
table.text('label').notNullable()
|
||||
// Méta jsonb : { invoiceId?, clientId?, ... } pour permettre au SPA
|
||||
// de cliquer sur l'event vers la facture/le client concerné.
|
||||
table.jsonb('meta').notNullable().defaultTo('{}')
|
||||
|
||||
table.timestamp('created_at').notNullable()
|
||||
table.timestamp('updated_at').nullable()
|
||||
|
||||
table.index(['organization_id', 'at'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
this.schema.raw('DROP TYPE IF EXISTS activity_event_kind')
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,27 @@
|
||||
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export class ActivityEventSchema extends BaseModel {
|
||||
static $columns = ['at', 'createdAt', 'id', 'kind', 'label', 'meta', 'organizationId', 'updatedAt'] as const
|
||||
$columns = ActivityEventSchema.$columns
|
||||
@column.dateTime()
|
||||
declare at: DateTime
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
@column({ isPrimary: true })
|
||||
declare id: string
|
||||
@column()
|
||||
declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
|
||||
@column()
|
||||
declare label: string
|
||||
@column()
|
||||
declare meta: { invoiceId?: string; clientId?: string; planStepOrder?: number; [k: string]: unknown }
|
||||
@column()
|
||||
declare organizationId: string
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime | null
|
||||
}
|
||||
|
||||
export class AuthAccessTokenSchema extends BaseModel {
|
||||
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
|
||||
$columns = AuthAccessTokenSchema.$columns
|
||||
|
||||
@ -21,6 +21,18 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
activity_events: {
|
||||
columns: {
|
||||
kind: {
|
||||
tsType:
|
||||
"'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'",
|
||||
},
|
||||
meta: {
|
||||
tsType:
|
||||
"{ invoiceId?: string; clientId?: string; planStepOrder?: number; [k: string]: unknown }",
|
||||
},
|
||||
},
|
||||
},
|
||||
import_drafts: {
|
||||
columns: {
|
||||
status: {
|
||||
|
||||
@ -85,6 +85,19 @@ router
|
||||
.as('plans')
|
||||
.use(middleware.auth())
|
||||
|
||||
/**
|
||||
* Dashboard — auth requise. Calculs agrégés on-the-fly (pas de cache V1).
|
||||
*/
|
||||
router
|
||||
.group(() => {
|
||||
router.get('kpis', [controllers.Dashboard, 'kpis']).as('kpis')
|
||||
router.get('activity', [controllers.Dashboard, 'activity']).as('activity')
|
||||
router.get('top-late', [controllers.Dashboard, 'topLate']).as('top-late')
|
||||
})
|
||||
.prefix('dashboard')
|
||||
.as('dashboard')
|
||||
.use(middleware.auth())
|
||||
|
||||
/**
|
||||
* Invoices — auth requise. Ordre IMPORTANT : les routes statiques
|
||||
* (/upload, /counts, /import-batch/...) sont déclarées AVANT /:id
|
||||
|
||||
48
bruno/07-Dashboard/01 KPIs.bru
Normal file
48
bruno/07-Dashboard/01 KPIs.bru
Normal file
@ -0,0 +1,48 @@
|
||||
meta {
|
||||
name: 01 KPIs
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/dashboard/kpis
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
tests {
|
||||
test("200 OK", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
test("KPIs shape", function () {
|
||||
const k = res.getBody().data;
|
||||
for (const key of [
|
||||
"rubisCount", "rubisThisMonth", "hoursLiberatedThisMonth",
|
||||
"encaisseCents", "encaisseDeltaCents",
|
||||
"dsoDays", "dsoDeltaDays",
|
||||
"factureToRelance", "factureInRelance", "factureNewToday",
|
||||
"miseEnDemeurePending", "monthlyGoalProgress"
|
||||
]) {
|
||||
expect(k).to.have.property(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
GET /api/v1/dashboard/kpis
|
||||
|
||||
Calculs agrégés sur les invoices de l'org :
|
||||
- `rubisCount` lu sur Organization (compteur cumulé)
|
||||
- `rubisThisMonth` = sum(rubis_earned where paid_at >= startOfMonth)
|
||||
- `hoursLiberatedThisMonth` = rubisThisMonth × 10 (1 rubis = 10 min)
|
||||
- `encaisseCents` = sum(amount_ttc_cents where paid this month)
|
||||
- `encaisseDeltaCents` = ce mois − mois précédent
|
||||
- `dsoDays` = avg(paid_at − issue_date) en jours, sur factures payées ce mois
|
||||
- `dsoDeltaDays` = idem delta vs mois précédent
|
||||
- `factureToRelance` = count(status='pending')
|
||||
- `factureInRelance` = count(status='in_relance')
|
||||
- `factureNewToday` = count(created_at >= today)
|
||||
- `miseEnDemeurePending` = 0 (à brancher quand RelanceTask sera là)
|
||||
- `monthlyGoalProgress` = clamp(rubisThisMonth/25*100, 0, 100) — placeholder
|
||||
- `percentile` = undefined V1
|
||||
}
|
||||
48
bruno/07-Dashboard/02 Activity.bru
Normal file
48
bruno/07-Dashboard/02 Activity.bru
Normal file
@ -0,0 +1,48 @@
|
||||
meta {
|
||||
name: 02 Activity
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/dashboard/activity
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
tests {
|
||||
test("200 OK", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
test("data is array", function () {
|
||||
expect(res.getBody().data).to.be.an("array");
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
GET /api/v1/dashboard/activity
|
||||
|
||||
Journal d'activité — append-only, 20 derniers events de l'org, plus
|
||||
récent en tête.
|
||||
|
||||
Kinds émis :
|
||||
- `invoice_paid` (Invoices → Mark paid)
|
||||
- `invoice_imported` (Imports → Validate draft)
|
||||
- `relance_sent` (à venir, SendRelanceJob)
|
||||
- `warning_drafted` (à venir, mise en demeure)
|
||||
|
||||
Chaque event a :
|
||||
```json
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"kind": "...",
|
||||
"at": "ISO 8601",
|
||||
"label": "Texte HTML léger (<b> autorisé pour les noms d'entité)",
|
||||
"meta": { "invoiceId": "...", "clientId": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
Pour générer des events : encaisse une facture (Invoices → 06 Mark paid)
|
||||
ou valide un draft d'import (Imports → 03 Validate draft) puis re-call
|
||||
cette route.
|
||||
}
|
||||
37
bruno/07-Dashboard/03 Top late payers.bru
Normal file
37
bruno/07-Dashboard/03 Top late payers.bru
Normal file
@ -0,0 +1,37 @@
|
||||
meta {
|
||||
name: 03 Top late payers
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/dashboard/top-late
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
tests {
|
||||
test("200 OK", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
test("data shape", function () {
|
||||
const list = res.getBody().data;
|
||||
expect(list).to.be.an("array");
|
||||
if (list.length > 0) {
|
||||
expect(list[0]).to.have.property("clientId");
|
||||
expect(list[0]).to.have.property("name");
|
||||
expect(list[0]).to.have.property("lateInvoicesCount");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
GET /api/v1/dashboard/top-late
|
||||
|
||||
Top 5 des clients ayant le plus de factures en retard. "En retard" =
|
||||
status actif (pending / in_relance / awaiting_user_confirmation) ET
|
||||
due_date < today.
|
||||
|
||||
Pour générer des données : crée des factures avec une `dueDate` dans
|
||||
le passé via Invoices → 04 Create.
|
||||
}
|
||||
21
bruno/07-Dashboard/folder.bru
Normal file
21
bruno/07-Dashboard/folder.bru
Normal file
@ -0,0 +1,21 @@
|
||||
meta {
|
||||
name: Dashboard
|
||||
seq: 8
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{token}}
|
||||
}
|
||||
|
||||
docs {
|
||||
## Dashboard — KPIs, journal d'activité, top clients en retard
|
||||
|
||||
Tout est calculé on-the-fly côté SQL (pas de cache V1). Le journal
|
||||
d'activité est append-only et alimenté automatiquement par les
|
||||
controllers métier (mark-paid → invoice_paid, validate-draft →
|
||||
invoice_imported, etc.).
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user