@ -35,6 +35,13 @@ const STATUS_RANK: Record<string, number> = {
}
}
const STEP_STATUS : Record < string , string > = {
const STEP_STATUS : Record < string , string > = {
quotation_approved : 'ordered' , installation : 'installed' , training : 'trained' , go_live : 'live' ,
quotation_approved : 'ordered' , installation : 'installed' , training : 'trained' , go_live : 'live' ,
// Pre-ship audit FIX E: TAIL_FALLBACK (used by any module with no seeded/owner-configured
// tail — e.g. WHATSAPP, ATM, AMC) still uses the legacy 'installed' key, not the new
// template vocabulary's 'installation'. Map it to the same target so those modules still
// auto-advance status (and stamp installed_on, below) instead of getting stuck at 'quoted'.
// TAIL_FALLBACK has no training-equivalent step and no payment key ever drove status, so
// no other legacy key needs a mapping here.
installed : 'installed' ,
}
}
/ * *
/ * *
@ -330,6 +337,11 @@ export async function addProjectMilestone(
* Bulk - tick one milestone across many projects at once ( D26 ) — for onboarding cleanup
* Bulk - tick one milestone across many projects at once ( D26 ) — for onboarding cleanup
* ( e . g . mark the already - live services 'installed' + 'go-live' ) . One transaction ; a
* ( e . g . mark the already - live services 'installed' + 'go-live' ) . One transaction ; a
* single summary audit row . Only touches projects that actually have the milestone .
* single summary audit row . Only touches projects that actually have the milestone .
*
* FIX F : routes each project through ` setMilestone ` — the SAME status - advance and
* payment / document link - clearing logic a single tick uses — instead of a bare UPDATE .
* Before this , a bulk ` go_live ` left ` client_module.status ` at 'quoted' ( never advanced )
* and a bulk untick left a stale ` payment_id ` / ` document_id ` link on the row .
* /
* /
export async function bulkSetMilestone (
export async function bulkSetMilestone (
db : DB , userId : string , clientModuleIds : string [ ] , key : string , done : boolean , doneOn? : string ,
db : DB , userId : string , clientModuleIds : string [ ] , key : string , done : boolean , doneOn? : string ,
@ -342,11 +354,17 @@ export async function bulkSetMilestone(
return db . transaction ( async ( ) = > {
return db . transaction ( async ( ) = > {
let updated = 0
let updated = 0
for ( const id of clientModuleIds ) {
for ( const id of clientModuleIds ) {
const r = await db . run (
try {
` UPDATE project_milestone SET done=?, done_on=? WHERE client_module_id=? AND key=? ` ,
await setMilestone ( db , userId , id , key , done , doneOn )
done ? 1 : 0 , stamp , id , key ,
updated ++
)
} catch ( err ) {
updated += r . changes
// Bulk ops only ever touch projects that actually carry this milestone key — same
// as before this fix, just expressed via setMilestone's own "not found" error
// instead of a bare UPDATE quietly affecting 0 rows. Anything else (a genuine,
// unexpected failure) must still surface, not be swallowed.
if ( err instanceof Error && err . message === 'Milestone not found on this project' ) continue
throw err
}
}
}
await writeAudit ( db , userId , 'bulk_set_milestone' , 'project_milestone' , '*' , undefined ,
await writeAudit ( db , userId , 'bulk_set_milestone' , 'project_milestone' , '*' , undefined ,
{ key , done , doneOn : stamp , projects : clientModuleIds.length , updated } )
{ key , done , doneOn : stamp , projects : clientModuleIds.length , updated } )
@ -400,15 +418,20 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise<Pen
* Active projects with at least one pending step whose progress last moved more than
* Active projects with at least one pending step whose progress last moved more than
* ` olderThanDays ` ago ( measured against ` today ` , passed in — never ` new Date() ` here , so this
* ` olderThanDays ` ago ( measured against ` today ` , passed in — never ` new Date() ` here , so this
* stays testable ) . "Last moved" is the most recent ` done_on ` across the project ' s ticked
* stays testable ) . "Last moved" is the most recent ` done_on ` across the project ' s ticked
* steps ; a project with nothing ticked yet counts as parked since creation ( no lower bound ) .
* steps ; a project with nothing ticked yet is measured from ` client_module.created_at `
* instead ( FIX D — treating a NULL last - moved date as an infinitely old one made every
* freshly - assigned project show up as stalled immediately ) . When even that isn ' t known ( a
* project created before the created_at column existed , or carried over by an import that
* doesn 't stamp it) there is no way to prove it' s actually old , so it is excluded rather than
* guessed at — the intent is "parked past N days" , not "we don't know, so assume forever" .
* Non - aggregated columns are wrapped in MIN ( ) — grouping by ` pm.client_module_id ` alone isn ' t
* Non - aggregated columns are wrapped in MIN ( ) — grouping by ` pm.client_module_id ` alone isn ' t
* enough for Postgres to infer they ' re single - valued per group ( D15 portability ) .
* enough for Postgres to infer they ' re single - valued per group ( D15 portability ) .
* /
* /
export async function stalledProjects ( db : DB , olderThanDays : number , today : string ) : Promise < PendingProject [ ] > {
export async function stalledProjects ( db : DB , olderThanDays : number , today : string ) : Promise < PendingProject [ ] > {
const rows = await db . all < PendingProject & { last_done : string | null } > (
const rows = await db . all < PendingProject & { last_done : string | null ; created_at : string | null } > (
` SELECT pm.client_module_id AS "clientModuleId", MIN(cm.client_id) AS "clientId",
` SELECT pm.client_module_id AS "clientModuleId", MIN(cm.client_id) AS "clientId",
MIN ( c . name ) AS "clientName" , MIN ( m . code ) AS "moduleCode" , MIN ( m . name ) AS "moduleName" ,
MIN ( c . name ) AS "clientName" , MIN ( m . code ) AS "moduleCode" , MIN ( m . name ) AS "moduleName" ,
MAX ( pm . done_on ) AS last_done
MAX ( pm . done_on ) AS last_done , MIN ( cm . created_at ) AS created_at
FROM project_milestone pm
FROM project_milestone pm
JOIN client_module cm ON cm . id = pm . client_module_id
JOIN client_module cm ON cm . id = pm . client_module_id
JOIN client c ON c . id = cm . client_id
JOIN client c ON c . id = cm . client_id
@ -419,6 +442,10 @@ export async function stalledProjects(db: DB, olderThanDays: number, today: stri
)
)
const cutoff = new Date ( Date . parse ( today ) - olderThanDays * 86 _400_000 ) . toISOString ( ) . slice ( 0 , 10 )
const cutoff = new Date ( Date . parse ( today ) - olderThanDays * 86 _400_000 ) . toISOString ( ) . slice ( 0 , 10 )
return rows
return rows
. filter ( ( r ) = > ( r . last_done ? ? '0000-00-00' ) < cutoff )
. filter ( ( r ) = > {
. map ( ( { last_done : _last_done , . . . rest } ) = > rest )
if ( r . last_done !== null ) return r . last_done < cutoff
if ( r . created_at !== null ) return r . created_at . slice ( 0 , 10 ) < cutoff
return false
} )
. map ( ( { last_done : _last_done , created_at : _created_at , . . . rest } ) = > rest )
}
}