|
|
import { ownerScope } from './auth'
|
|
|
import type { DB } from './db'
|
|
|
import { resolveSchedule } from './repos-reminders'
|
|
|
|
|
|
/**
|
|
|
* Pipeline chase-list (D-PIPE option B, spec §6b/§9) — one cross-client ranked list.
|
|
|
* Stage is NEVER stored: every row derives it from client.status + the client's
|
|
|
* latest non-cancelled QUOTATION. Rows are the union of latest-quote-per-client and
|
|
|
* bare leads (client.status='lead' with no live quotation). Age anchors on the FIRST
|
|
|
* 'sent' document_event (F15); the next-action ladder and colour band read the same
|
|
|
* resolveSchedule('quote_followup') day_offsets the reminder engine uses — a dated
|
|
|
* config row changes both, no code edit (rule 3, no hardcoded 3/7/14).
|
|
|
*/
|
|
|
|
|
|
export type PipelineStage = 'enquiry' | 'new_project' | 'quoted_waiting' | 'won' | 'lost'
|
|
|
export type PipelineBand = 'green' | 'amber' | 'red'
|
|
|
export type PipelineNextAction =
|
|
|
| 'send_quote' | 'waiting' | 'chase' | 'nudge' | 'final_nudge' | 'convert_invoice' | 'none'
|
|
|
export type PipelineFilter = 'all' | 'mine' | 'overdue' | 'lost'
|
|
|
export const PIPELINE_FILTERS: PipelineFilter[] = ['all', 'mine', 'overdue', 'lost']
|
|
|
|
|
|
export interface PipelineRow {
|
|
|
clientId: string; clientCode: string; clientName: string
|
|
|
/** null for a bare-lead enquiry (no quotation yet). */
|
|
|
docId: string | null; docNo: string | null; docStatus: string | null
|
|
|
amountPaise: number | null
|
|
|
/** document.created_by for quote rows (A3); client.owner_id for bare leads (A4). */
|
|
|
ownerId: string | null; ownerName: string | null
|
|
|
stage: PipelineStage
|
|
|
/** Whole days since the first 'sent' event; null when never sent. */
|
|
|
ageDays: number | null
|
|
|
nextAction: PipelineNextAction
|
|
|
/** Colour band for sent quotes: green < offset₁, amber offset₁–₃, red ≥ last offset. */
|
|
|
band: PipelineBand | null
|
|
|
}
|
|
|
|
|
|
export interface ListPipelineOpts {
|
|
|
filter?: PipelineFilter
|
|
|
/** Narrow to one owner — managerial viewers only; ignored (self-forced) for staff. */
|
|
|
ownerId?: string
|
|
|
/** From res.locals.staff — never from the request body. */
|
|
|
viewerRole: string
|
|
|
viewerId: string
|
|
|
page?: number
|
|
|
pageSize?: number
|
|
|
/** Injectable business date (YYYY-MM-DD) for deterministic tests. */
|
|
|
today?: string
|
|
|
}
|
|
|
|
|
|
export interface PipelinePage {
|
|
|
rows: PipelineRow[]; total: number; page: number; pageSize: number
|
|
|
/** The resolved quote_followup day offsets, echoed for the UI legend. */
|
|
|
dayOffsets: number[]
|
|
|
}
|
|
|
|
|
|
interface QuoteRow {
|
|
|
doc_id: string; doc_no: string | null; doc_status: string; payable_paise: number
|
|
|
created_by: string; first_sent: string | null
|
|
|
client_id: string; client_code: string; client_name: string; client_status: string
|
|
|
}
|
|
|
|
|
|
interface LeadRow {
|
|
|
client_id: string; client_code: string; client_name: string
|
|
|
client_status: string; owner_id: string | null
|
|
|
}
|
|
|
|
|
|
const DEFAULT_PAGE_SIZE = 50
|
|
|
const MAX_PAGE_SIZE = 200
|
|
|
/** Escalation ladder past each crossed offset; clamps at the last rung. */
|
|
|
const LADDER: PipelineNextAction[] = ['chase', 'nudge', 'final_nudge']
|
|
|
|
|
|
/** Whole days between the first-sent timestamp's date and `today` (never negative). */
|
|
|
function ageDaysOf(firstSent: string | null, today: string): number | null {
|
|
|
if (firstSent === null) return null
|
|
|
const ms = Date.parse(today) - Date.parse(firstSent.slice(0, 10))
|
|
|
return Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 86_400_000)) : null
|
|
|
}
|
|
|
|
|
|
export async function listPipeline(db: DB, opts: ListPipelineOpts): Promise<PipelinePage> {
|
|
|
const filter = opts.filter ?? 'all'
|
|
|
const today = opts.today ?? new Date().toISOString().slice(0, 10)
|
|
|
const page = Math.max(opts.page ?? 1, 1)
|
|
|
const pageSize = Math.min(Math.max(opts.pageSize ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE)
|
|
|
const dayOffsets = (await resolveSchedule(db, 'quote_followup', today)).dayOffsets
|
|
|
const lastOffset = dayOffsets[dayOffsets.length - 1]!
|
|
|
|
|
|
// Latest non-cancelled QUOTATION per client (uuidv7 ids sort by creation time, so
|
|
|
// MAX(id) is the newest — same trick as listDocuments). A cancelled quote is dead
|
|
|
// paper: it neither represents the client nor blocks the bare-lead Enquiry row.
|
|
|
const quotes = await db.all<QuoteRow>(
|
|
|
`SELECT d.id AS doc_id, d.doc_no, d.status AS doc_status, d.payable_paise, d.created_by,
|
|
|
(SELECT MIN(e.at_wall) FROM document_event e
|
|
|
WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent,
|
|
|
c.id AS client_id, c.code AS client_code, c.name AS client_name, c.status AS client_status
|
|
|
FROM document d JOIN client c ON c.id = d.client_id
|
|
|
WHERE d.doc_type = 'QUOTATION' AND d.status != 'cancelled'
|
|
|
AND d.id = (SELECT MAX(d2.id) FROM document d2
|
|
|
WHERE d2.client_id = d.client_id AND d2.doc_type = 'QUOTATION'
|
|
|
AND d2.status != 'cancelled')`,
|
|
|
)
|
|
|
|
|
|
// Bare leads: client.status='lead' with no live quotation → derived stage Enquiry.
|
|
|
// Quote-less LOST clients ride the same arm as stage Lost (spec §9: Lost = latest
|
|
|
// quotation lost OR client.status='lost') so the Lost filter can review them.
|
|
|
const leads = await db.all<LeadRow>(
|
|
|
`SELECT c.id AS client_id, c.code AS client_code, c.name AS client_name,
|
|
|
c.status AS client_status, c.owner_id
|
|
|
FROM client c
|
|
|
WHERE c.status IN ('lead', 'lost')
|
|
|
AND NOT EXISTS (SELECT 1 FROM document d
|
|
|
WHERE d.client_id = c.id AND d.doc_type = 'QUOTATION'
|
|
|
AND d.status != 'cancelled')`,
|
|
|
)
|
|
|
|
|
|
const staffNames = new Map(
|
|
|
(await db.all<{ id: string; display_name: string }>(`SELECT id, display_name FROM staff_user`))
|
|
|
.map((s) => [s.id, s.display_name]),
|
|
|
)
|
|
|
const nameOf = (id: string | null): string | null =>
|
|
|
id === null ? null : staffNames.get(id) ?? null
|
|
|
|
|
|
const rows: PipelineRow[] = []
|
|
|
for (const q of quotes) {
|
|
|
const stage: PipelineStage =
|
|
|
q.client_status === 'lost' || q.doc_status === 'lost' ? 'lost'
|
|
|
: q.doc_status === 'draft' ? 'new_project'
|
|
|
: q.doc_status === 'sent' ? 'quoted_waiting'
|
|
|
: 'won' // accepted | invoiced (part_paid/paid cannot occur on a quotation)
|
|
|
const age = ageDaysOf(q.first_sent, today)
|
|
|
let nextAction: PipelineNextAction = 'none'
|
|
|
let band: PipelineBand | null = null
|
|
|
if (stage === 'new_project') nextAction = 'send_quote'
|
|
|
else if (stage === 'won') nextAction = q.doc_status === 'accepted' ? 'convert_invoice' : 'none'
|
|
|
else if (stage === 'quoted_waiting') {
|
|
|
const crossed = age === null ? 0 : dayOffsets.filter((o) => age >= o).length
|
|
|
nextAction = crossed === 0 ? 'waiting' : LADDER[Math.min(crossed, LADDER.length) - 1]!
|
|
|
band = crossed === 0 ? 'green' : age !== null && age >= lastOffset ? 'red' : 'amber'
|
|
|
}
|
|
|
rows.push({
|
|
|
clientId: q.client_id, clientCode: q.client_code, clientName: q.client_name,
|
|
|
docId: q.doc_id, docNo: q.doc_no, docStatus: q.doc_status, amountPaise: q.payable_paise,
|
|
|
ownerId: q.created_by, ownerName: nameOf(q.created_by),
|
|
|
stage, ageDays: age, nextAction, band,
|
|
|
})
|
|
|
}
|
|
|
for (const l of leads) {
|
|
|
const lost = l.client_status === 'lost'
|
|
|
rows.push({
|
|
|
clientId: l.client_id, clientCode: l.client_code, clientName: l.client_name,
|
|
|
docId: null, docNo: null, docStatus: null, amountPaise: null,
|
|
|
ownerId: l.owner_id, ownerName: nameOf(l.owner_id),
|
|
|
stage: lost ? 'lost' : 'enquiry', ageDays: null,
|
|
|
nextAction: lost ? 'none' : 'send_quote', band: null,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
// Role gate: staff are server-forced to their own rows (ownerScope ignores any
|
|
|
// widening param); managerial viewers may narrow via ownerId or filter=mine.
|
|
|
const requested = filter === 'mine' ? opts.viewerId : opts.ownerId
|
|
|
const scopedOwner = ownerScope({ id: opts.viewerId, role: opts.viewerRole }, requested)
|
|
|
let filtered = scopedOwner === undefined ? rows : rows.filter((r) => r.ownerId === scopedOwner)
|
|
|
filtered = filter === 'lost'
|
|
|
? filtered.filter((r) => r.stage === 'lost')
|
|
|
: filtered.filter((r) => r.stage !== 'lost') // Lost hidden unless filtered (spec §9)
|
|
|
if (filter === 'overdue') {
|
|
|
filtered = filtered.filter((r) => r.stage === 'quoted_waiting' && r.nextAction !== 'waiting')
|
|
|
}
|
|
|
|
|
|
// Sort: sent quotes by age desc (oldest unanswered on top — Waiting sinks by its
|
|
|
// small age), then Enquiry/draft (needs a quote), then Won (convertible first),
|
|
|
// Lost last when shown. Name tie-break keeps pages deterministic.
|
|
|
const rank = (r: PipelineRow): number =>
|
|
|
r.stage === 'quoted_waiting' ? 0
|
|
|
: r.stage === 'enquiry' || r.stage === 'new_project' ? 1
|
|
|
: r.stage === 'won' ? (r.nextAction === 'convert_invoice' ? 2 : 3)
|
|
|
: 4
|
|
|
filtered.sort((a, b) =>
|
|
|
rank(a) - rank(b)
|
|
|
|| (b.ageDays ?? -1) - (a.ageDays ?? -1)
|
|
|
|| a.clientName.localeCompare(b.clientName))
|
|
|
|
|
|
// LIMIT/OFFSET + honest total (rule 8 — no silent caps). The candidate set is
|
|
|
// bounded by the client book (~300), so deriving in memory then slicing keeps the
|
|
|
// date math and schedule lookup portable across SQLite/Postgres.
|
|
|
const total = filtered.length
|
|
|
const start = (page - 1) * pageSize
|
|
|
return { rows: filtered.slice(start, start + pageSize), total, page, pageSize, dayOffsets }
|
|
|
}
|