You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
319 lines
17 KiB
TypeScript
319 lines
17 KiB
TypeScript
// apps/hq/test/pipeline.test.ts — Phase 5: pipeline chase-list (D-PIPE option B).
|
|
// Union of latest-quotation-per-client + bare leads; derived stage/age/next-action/band
|
|
// (bands from resolveSchedule — never hardcoded 3/7/14); staff server-forced to own rows;
|
|
// actionable-oldest-first sort; LIMIT/OFFSET pagination with total.
|
|
import express from 'express'
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
import { openDb, type DB } from '../src/db'
|
|
import { createStaff } from '../src/auth'
|
|
import { createClient } from '../src/repos-clients'
|
|
import { createModule, setPrice } from '../src/repos-modules'
|
|
import { createDraft, convertDocument, markStatus } from '../src/repos-documents'
|
|
import { listPipeline } from '../src/repos-pipeline'
|
|
import { apiRouter } from '../src/api'
|
|
|
|
const TODAY = '2026-07-17'
|
|
const OWNER_VIEW = { viewerRole: 'owner', viewerId: 'boss', today: TODAY } as const
|
|
|
|
function daysAgo(n: number): string {
|
|
return new Date(Date.parse(TODAY) - n * 86_400_000).toISOString()
|
|
}
|
|
|
|
async function setup(): Promise<{ db: DB; moduleId: string }> {
|
|
const db = openDb(':memory:')
|
|
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
|
|
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
|
|
const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
|
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
|
|
return { db, moduleId: m.id }
|
|
}
|
|
|
|
/** Draft QUOTATION for the client; optionally marked sent with the event backdated `ageDays`. */
|
|
async function quote(db: DB, clientId: string, moduleId: string, opts: { by?: string; sentAgeDays?: number } = {}) {
|
|
const by = opts.by ?? 'u1'
|
|
const d = await createDraft(db, by, {
|
|
docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
|
|
})
|
|
if (opts.sentAgeDays !== undefined) {
|
|
await markStatus(db, by, d.id, 'sent')
|
|
await db.run(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`,
|
|
daysAgo(opts.sentAgeDays), d.id)
|
|
}
|
|
return d
|
|
}
|
|
|
|
describe('listPipeline — stage derivation (spec §9)', () => {
|
|
it('a bare lead with no quotation appears as Enquiry owned by client.owner_id', async () => {
|
|
const { db, moduleId } = await setup()
|
|
const emp = await createStaff(db, { email: 'v@x.co', displayName: 'Vikram', role: 'staff', password: 'password2' })
|
|
const lead = await createClient(db, 'u1', { name: 'Nagari Sah.', stateCode: '32', status: 'lead' })
|
|
await db.run(`UPDATE client SET owner_id=? WHERE id=?`, emp.id, lead.id)
|
|
// an active client with no quotation is NOT pipeline material
|
|
await createClient(db, 'u1', { name: 'Steady Customer', stateCode: '32', status: 'active' })
|
|
// a lead WITH a quotation shows as its quote row, not a duplicate enquiry
|
|
const quotedLead = await createClient(db, 'u1', { name: 'Quoted Lead', stateCode: '32', status: 'lead' })
|
|
await quote(db, quotedLead.id, moduleId)
|
|
|
|
const out = await listPipeline(db, { ...OWNER_VIEW })
|
|
const enquiry = out.rows.find((r) => r.clientId === lead.id)
|
|
expect(enquiry).toMatchObject({
|
|
stage: 'enquiry', nextAction: 'send_quote', docId: null, amountPaise: null,
|
|
ownerId: emp.id, ownerName: 'Vikram', ageDays: null, band: null,
|
|
})
|
|
expect(out.rows.filter((r) => r.clientId === quotedLead.id)).toHaveLength(1)
|
|
expect(out.rows.find((r) => r.clientId === quotedLead.id)!.stage).toBe('new_project')
|
|
expect(out.rows.some((r) => r.clientName === 'Steady Customer')).toBe(false)
|
|
})
|
|
|
|
it('derives New Project / Quoted-Waiting / Won from the latest quotation status', async () => {
|
|
const { db, moduleId } = await setup()
|
|
const draft = await createClient(db, 'u1', { name: 'Draft Co', stateCode: '32', status: 'lead' })
|
|
await quote(db, draft.id, moduleId)
|
|
const sent = await createClient(db, 'u1', { name: 'Sent Co', stateCode: '32', status: 'lead' })
|
|
await quote(db, sent.id, moduleId, { sentAgeDays: 1 })
|
|
const accepted = await createClient(db, 'u1', { name: 'Accepted Co', stateCode: '32' })
|
|
const qa = await quote(db, accepted.id, moduleId, { sentAgeDays: 2 })
|
|
await markStatus(db, 'u1', qa.id, 'accepted')
|
|
const invoiced = await createClient(db, 'u1', { name: 'Invoiced Co', stateCode: '32' })
|
|
const qi = await quote(db, invoiced.id, moduleId, { sentAgeDays: 2 })
|
|
await markStatus(db, 'u1', qi.id, 'accepted')
|
|
await convertDocument(db, 'u1', qi.id, 'INVOICE')
|
|
|
|
const by = new Map((await listPipeline(db, { ...OWNER_VIEW })).rows.map((r) => [r.clientName, r]))
|
|
expect(by.get('Draft Co')).toMatchObject({ stage: 'new_project', nextAction: 'send_quote' })
|
|
expect(by.get('Sent Co')).toMatchObject({ stage: 'quoted_waiting', ageDays: 1 })
|
|
expect(by.get('Sent Co')!.amountPaise).toBe(11_800_00)
|
|
expect(by.get('Accepted Co')).toMatchObject({ stage: 'won', nextAction: 'convert_invoice' })
|
|
expect(by.get('Invoiced Co')).toMatchObject({ stage: 'won', nextAction: 'none' })
|
|
})
|
|
|
|
it('the LATEST quotation wins when a client has several', async () => {
|
|
const { db, moduleId } = await setup()
|
|
const c = await createClient(db, 'u1', { name: 'Two Quotes', stateCode: '32' })
|
|
const old = await quote(db, c.id, moduleId, { sentAgeDays: 40 })
|
|
await markStatus(db, 'u1', old.id, 'lost')
|
|
await quote(db, c.id, moduleId, { sentAgeDays: 2 })
|
|
const rows = (await listPipeline(db, { ...OWNER_VIEW })).rows.filter((r) => r.clientId === c.id)
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0]).toMatchObject({ stage: 'quoted_waiting', ageDays: 2 })
|
|
})
|
|
|
|
it('Lost (quote lost or client lost) is hidden unless filtered', async () => {
|
|
const { db, moduleId } = await setup()
|
|
const lostQuote = await createClient(db, 'u1', { name: 'Lost Quote Co', stateCode: '32' })
|
|
const q = await quote(db, lostQuote.id, moduleId, { sentAgeDays: 5 })
|
|
await markStatus(db, 'u1', q.id, 'lost')
|
|
const lostClient = await createClient(db, 'u1', { name: 'Lost Client Co', stateCode: '32', status: 'lost' })
|
|
await quote(db, lostClient.id, moduleId, { sentAgeDays: 5 })
|
|
const alive = await createClient(db, 'u1', { name: 'Alive Co', stateCode: '32' })
|
|
await quote(db, alive.id, moduleId, { sentAgeDays: 1 })
|
|
|
|
const all = await listPipeline(db, { ...OWNER_VIEW })
|
|
expect(all.rows.map((r) => r.clientName)).toEqual(['Alive Co'])
|
|
expect(all.total).toBe(1)
|
|
const lost = await listPipeline(db, { ...OWNER_VIEW, filter: 'lost' })
|
|
expect(lost.rows.map((r) => r.stage)).toEqual(['lost', 'lost'])
|
|
expect(lost.total).toBe(2)
|
|
})
|
|
|
|
it('a quote-less LOST client is reviewable under filter=lost (spec §9), hidden otherwise', async () => {
|
|
const { db } = await setup()
|
|
await createClient(db, 'u1', { name: 'Quote-less Lost Co', stateCode: '32', status: 'lost' })
|
|
expect((await listPipeline(db, { ...OWNER_VIEW })).total).toBe(0)
|
|
const lost = await listPipeline(db, { ...OWNER_VIEW, filter: 'lost' })
|
|
expect(lost.total).toBe(1)
|
|
expect(lost.rows[0]).toMatchObject({
|
|
clientName: 'Quote-less Lost Co', stage: 'lost', nextAction: 'none', docId: null,
|
|
})
|
|
})
|
|
|
|
it('QT→PI conversion moves the client out of Quoted/Waiting — no stuck chase row', async () => {
|
|
const { db, moduleId } = await setup()
|
|
const c = await createClient(db, 'u1', { name: 'Converted Co', stateCode: '32' })
|
|
const q = await quote(db, c.id, moduleId, { sentAgeDays: 10 })
|
|
await convertDocument(db, 'u1', q.id, 'PROFORMA')
|
|
const rows = (await listPipeline(db, { ...OWNER_VIEW })).rows.filter((r) => r.clientId === c.id)
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0]).toMatchObject({ stage: 'won', nextAction: 'none' }) // moved forward, no chase buttons
|
|
})
|
|
})
|
|
|
|
describe('listPipeline — next action + band from resolveSchedule (no hardcoded 3/7/14)', () => {
|
|
it('default offsets 3/7/14: waiting<3, chase>=3, nudge>=7, final nudge>=14; bands green/amber/red', async () => {
|
|
const { db, moduleId } = await setup()
|
|
const mk = async (name: string, age: number) => {
|
|
const c = await createClient(db, 'u1', { name, stateCode: '32' })
|
|
await quote(db, c.id, moduleId, { sentAgeDays: age })
|
|
}
|
|
await mk('Age2', 2); await mk('Age5', 5); await mk('Age8', 8); await mk('Age15', 15)
|
|
const out = await listPipeline(db, { ...OWNER_VIEW })
|
|
expect(out.dayOffsets).toEqual([3, 7, 14])
|
|
const by = new Map(out.rows.map((r) => [r.clientName, r]))
|
|
expect(by.get('Age2')).toMatchObject({ nextAction: 'waiting', band: 'green', ageDays: 2 })
|
|
expect(by.get('Age5')).toMatchObject({ nextAction: 'chase', band: 'amber', ageDays: 5 })
|
|
expect(by.get('Age8')).toMatchObject({ nextAction: 'nudge', band: 'amber', ageDays: 8 })
|
|
expect(by.get('Age15')).toMatchObject({ nextAction: 'final_nudge', band: 'red', ageDays: 15 })
|
|
})
|
|
|
|
it('a dated reminder_schedule row changes the bands without a code edit', async () => {
|
|
const { db, moduleId } = await setup()
|
|
await db.run(
|
|
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
|
|
VALUES ('rs1', 'quote_followup', '2026-01-01', NULL, '2,4,6', NULL, NULL)`,
|
|
)
|
|
const c = await createClient(db, 'u1', { name: 'Fast Lane', stateCode: '32' })
|
|
await quote(db, c.id, moduleId, { sentAgeDays: 5 })
|
|
const out = await listPipeline(db, { ...OWNER_VIEW })
|
|
expect(out.dayOffsets).toEqual([2, 4, 6])
|
|
// age 5 crossed 2 and 4 but not 6 → nudge, amber (would be 'chase' under 3/7/14)
|
|
expect(out.rows[0]).toMatchObject({ nextAction: 'nudge', band: 'amber' })
|
|
})
|
|
})
|
|
|
|
describe('listPipeline — role gate (staff server-forced to own rows)', () => {
|
|
async function team(db: DB) {
|
|
const a = await createStaff(db, { email: 'a@x.co', displayName: 'Asha', role: 'staff', password: 'password2' })
|
|
const b = await createStaff(db, { email: 'b@x.co', displayName: 'Priya', role: 'staff', password: 'password2' })
|
|
return { a: a.id, b: b.id }
|
|
}
|
|
|
|
it('staff see only quotes they created and leads they own; widening params are ignored', async () => {
|
|
const { db, moduleId } = await setup()
|
|
const { a, b } = await team(db)
|
|
const c1 = await createClient(db, 'u1', { name: 'Asha Quote Co', stateCode: '32' })
|
|
await quote(db, c1.id, moduleId, { by: a, sentAgeDays: 4 })
|
|
const c2 = await createClient(db, 'u1', { name: 'Priya Quote Co', stateCode: '32' })
|
|
await quote(db, c2.id, moduleId, { by: b, sentAgeDays: 4 })
|
|
const leadA = await createClient(db, 'u1', { name: 'Asha Lead', stateCode: '32', status: 'lead' })
|
|
await db.run(`UPDATE client SET owner_id=? WHERE id=?`, a, leadA.id)
|
|
await createClient(db, 'u1', { name: 'Unassigned Lead', stateCode: '32', status: 'lead' })
|
|
|
|
const mine = await listPipeline(db, { viewerRole: 'staff', viewerId: a, today: TODAY })
|
|
expect(mine.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co'])
|
|
// a widening owner param cannot escape the self scope
|
|
const widened = await listPipeline(db, { viewerRole: 'staff', viewerId: a, ownerId: b, today: TODAY })
|
|
expect(widened.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co'])
|
|
// owner/manager see everything, including the unassigned lead (F13 safety net)
|
|
const boss = await listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', today: TODAY })
|
|
expect(boss.total).toBe(4)
|
|
// ...and may narrow to one owner, or to themselves via filter=mine
|
|
const narrowed = await listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', ownerId: b, today: TODAY })
|
|
expect(narrowed.rows.map((r) => r.clientName)).toEqual(['Priya Quote Co'])
|
|
const bossMine = await listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', filter: 'mine', today: TODAY })
|
|
expect(bossMine.total).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('listPipeline — sort, overdue filter, pagination', () => {
|
|
it('sorts actionable oldest first: overdue by age desc, then enquiry/draft, then won', async () => {
|
|
const { db, moduleId } = await setup()
|
|
const mkSent = async (name: string, age: number) => {
|
|
const c = await createClient(db, 'u1', { name, stateCode: '32' })
|
|
return quote(db, c.id, moduleId, { sentAgeDays: age })
|
|
}
|
|
await mkSent('Mid 5d', 5)
|
|
await mkSent('Oldest 15d', 15)
|
|
await mkSent('Waiting 2d', 2)
|
|
await createClient(db, 'u1', { name: 'A Lead', stateCode: '32', status: 'lead' })
|
|
const won = await createClient(db, 'u1', { name: 'Won Co', stateCode: '32' })
|
|
await markStatus(db, 'u1', (await quote(db, won.id, moduleId, { sentAgeDays: 30 })).id, 'accepted')
|
|
|
|
const names = (await listPipeline(db, { ...OWNER_VIEW })).rows.map((r) => r.clientName)
|
|
expect(names).toEqual(['Oldest 15d', 'Mid 5d', 'Waiting 2d', 'A Lead', 'Won Co'])
|
|
})
|
|
|
|
it('filter=overdue keeps only sent quotes past the first offset', async () => {
|
|
const { db, moduleId } = await setup()
|
|
const mkSent = async (name: string, age: number) => {
|
|
const c = await createClient(db, 'u1', { name, stateCode: '32' })
|
|
await quote(db, c.id, moduleId, { sentAgeDays: age })
|
|
}
|
|
await mkSent('Fresh 1d', 1)
|
|
await mkSent('Over 9d', 9)
|
|
await createClient(db, 'u1', { name: 'Some Lead', stateCode: '32', status: 'lead' })
|
|
const out = await listPipeline(db, { ...OWNER_VIEW, filter: 'overdue' })
|
|
expect(out.rows.map((r) => r.clientName)).toEqual(['Over 9d'])
|
|
expect(out.total).toBe(1)
|
|
})
|
|
|
|
it('paginates with LIMIT/OFFSET semantics and an honest total', async () => {
|
|
const { db, moduleId } = await setup()
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const c = await createClient(db, 'u1', { name: `Client ${i}`, stateCode: '32' })
|
|
await quote(db, c.id, moduleId, { sentAgeDays: 10 + i })
|
|
}
|
|
const p1 = await listPipeline(db, { ...OWNER_VIEW, page: 1, pageSize: 2 })
|
|
expect(p1.total).toBe(5)
|
|
expect(p1.rows).toHaveLength(2)
|
|
expect(p1.rows[0]!.ageDays).toBe(14) // oldest first
|
|
const p3 = await listPipeline(db, { ...OWNER_VIEW, page: 3, pageSize: 2 })
|
|
expect(p3.rows).toHaveLength(1)
|
|
expect(p3.page).toBe(3)
|
|
expect(p3.pageSize).toBe(2)
|
|
// no overlap, nothing dropped
|
|
const all = [
|
|
...p1.rows,
|
|
...(await listPipeline(db, { ...OWNER_VIEW, page: 2, pageSize: 2 })).rows,
|
|
...p3.rows,
|
|
]
|
|
expect(new Set(all.map((r) => r.clientId)).size).toBe(5)
|
|
})
|
|
})
|
|
|
|
describe('GET /pipeline', () => {
|
|
let server: ReturnType<ReturnType<typeof express>['listen']>
|
|
let base: string
|
|
beforeAll(async () => {
|
|
const { db, moduleId } = await setup()
|
|
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
|
|
await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
|
|
const staffDbId = (await db.get(`SELECT id FROM staff_user WHERE email='staff@test.in'`) as { id: string }).id
|
|
const mineClient = await createClient(db, 'seed', { name: 'Staff Own Co', stateCode: '32' })
|
|
await quote(db, mineClient.id, moduleId, { by: staffDbId, sentAgeDays: 4 })
|
|
const otherClient = await createClient(db, 'seed', { name: 'Someone Else Co', stateCode: '32' })
|
|
await quote(db, otherClient.id, moduleId, { sentAgeDays: 4 })
|
|
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
|
|
server = app.listen(0)
|
|
base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
|
})
|
|
afterAll(() => server.close())
|
|
|
|
const tokenOf = async (email: string, password: string) =>
|
|
(await (await fetch(`${base}/auth/login`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
})).json() as { token: string }).token
|
|
|
|
const get = async (token: string, qs: string) => {
|
|
const res = await fetch(`${base}/pipeline${qs}`, { headers: { authorization: `Bearer ${token}` } })
|
|
return { status: res.status, json: await res.json() as any }
|
|
}
|
|
|
|
it('returns the derived rows with total/page/pageSize; staff are forced to their own rows', async () => {
|
|
const owner = await tokenOf('owner@test.in', 'owner-password')
|
|
const all = await get(owner, '')
|
|
expect(all.status).toBe(200)
|
|
expect(all.json.total).toBe(2)
|
|
expect(all.json.page).toBe(1)
|
|
expect(all.json.rows).toHaveLength(2)
|
|
|
|
const staff = await tokenOf('staff@test.in', 'staff-password')
|
|
const own = await get(staff, '')
|
|
expect(own.json.total).toBe(1)
|
|
expect(own.json.rows[0].clientName).toBe('Staff Own Co')
|
|
// the widening ?owner= param is ignored server-side for staff
|
|
const widened = await get(staff, `?owner=someone-else`)
|
|
expect(widened.json.total).toBe(1)
|
|
expect(widened.json.rows[0].clientName).toBe('Staff Own Co')
|
|
})
|
|
|
|
it('rejects a bad filter; honours page/pageSize', async () => {
|
|
const owner = await tokenOf('owner@test.in', 'owner-password')
|
|
expect((await get(owner, '?filter=bogus')).status).toBe(400)
|
|
const paged = await get(owner, '?page=2&pageSize=1')
|
|
expect(paged.json.total).toBe(2)
|
|
expect(paged.json.rows).toHaveLength(1)
|
|
expect(paged.json.pageSize).toBe(1)
|
|
})
|
|
})
|