@@ -37,12 +37,16 @@ const { Surreal } = requireScripts('surrealdb');
3737const { connect } = requireServices ( '@nats-io/transport-node' ) ;
3838
3939// ---- args -------------------------------------------------------------------
40- const args = { client : 'reach-edu' , match : 'Master-Pipeline-Tracker' } ;
40+ const args = { client : 'reach-edu' , match : 'Master-Pipeline-Tracker' , scope : 'pipeline' } ;
4141for ( let i = 2 ; i < process . argv . length ; i += 1 ) {
4242 const k = process . argv [ i ] ;
4343 if ( k === '--client' ) args . client = process . argv [ ++ i ] ;
4444 else if ( k === '--record-set-name-match' ) args . match = process . argv [ ++ i ] ;
4545 else if ( k === '--out-dir' ) args . outDir = process . argv [ ++ i ] ;
46+ // 'pipeline' (default, operator ruling 2026-07-27): one row per PIPELINE
47+ // row — the tracker's shape, enriched. Multi-deal orgs stay multi-row.
48+ // 'all': one row per canonical org (the event-based long tail included).
49+ else if ( k === '--scope' ) args . scope = process . argv [ ++ i ] ;
4650}
4751const today = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
4852const OUT_DIR = resolve ( args . outDir ?? `clients/${ args . client } /outputs/${ today } _crm-starter` ) ;
@@ -161,47 +165,44 @@ console.log(`pipeline rows: ${pipelineRows.length}`);
161165
162166// 6. Join pipeline → orgs: exact by corpus_funder_slug, then name/alias.
163167const orgBySlug = new Map ( orgs . map ( ( o ) => [ o . slug , o ] ) ) ;
164- const byNorm = new Map ( ) ; // normalized name/alias → slug (first wins; collisions logged )
168+ const byNorm = new Map ( ) ; // normalized name/alias → slug (first wins)
165169for ( const o of orgs ) {
166170 for ( const cand of [ o . complete_name , o . conventional_name , o . slug . replace ( / - / g, ' ' ) , ...( o . aliases ?? [ ] ) ] ) {
167171 const n = normName ( cand ) ;
168172 if ( n && ! byNorm . has ( n ) ) byNorm . set ( n , o . slug ) ;
169173 }
170174}
171- const pipelineByOrg = new Map ( ) ; // slug → { row, matched }
172- const unmatched = [ ] ;
173- for ( const row of pipelineRows ) {
175+ // Per-PIPELINE-row resolution — multiple rows may share one org (multi-deal
176+ // orgs like Accelerate the Future); each keeps its own row, tracker-style.
177+ const resolved = pipelineRows . map ( ( row ) => {
174178 const name = row [ 'Prospect / Organization' ] ?? '' ;
175179 const exact = row . corpus_funder_slug && orgBySlug . has ( row . corpus_funder_slug ) ? row . corpus_funder_slug : null ;
176180 const fuzzy = exact ? null : byNorm . get ( normName ( name ) ) ;
177- const slug = exact ?? fuzzy ;
178- if ( ! slug ) {
179- unmatched . push ( row ) ;
180- continue ;
181- }
182- if ( ! pipelineByOrg . has ( slug ) ) {
183- pipelineByOrg . set ( slug , { row, matched : exact ? 'exact' : 'fuzzy' } ) ;
184- } else {
185- console . warn ( ` ⚠ second pipeline row also matches ${ slug } : "${ name } " — kept the first, this one goes to the sidecar` ) ;
186- unmatched . push ( row ) ;
187- }
188- }
189- console . log ( `pipeline matched: ${ pipelineByOrg . size } (exact+fuzzy) · unmatched: ${ unmatched . length } ` ) ;
181+ return { row, slug : exact ?? fuzzy ?? null , matched : exact ? 'exact' : fuzzy ? 'fuzzy' : 'none' } ;
182+ } ) ;
183+ const matchedCount = resolved . filter ( ( r ) => r . slug ) . length ;
184+ console . log ( `pipeline rows matched to a canonical org: ${ matchedCount } /${ resolved . length } ` ) ;
190185
191- // 7. Shape org rows.
186+ // 7. Shape rows — canonical-enrichment column block for one org .
192187const exported_at = new Date ( ) . toISOString ( ) ;
193- const outRows = orgs . map ( ( o ) => {
194- const links = o . org_links ?? [ ] ;
188+ const enrichmentFor = ( o ) => {
189+ if ( ! o ) {
190+ return {
191+ external_id : '' , name : '' , conventional_name : '' , aliases : '' , domains : '' ,
192+ bucket : '' , tags : '' ,
193+ ...Object . fromEntries ( Object . values ( PROMOTED_KINDS ) . map ( ( c ) => [ c , '' ] ) ) ,
194+ other_links : '' , streams : '' , stream_count : '' , related_orgs : '' ,
195+ } ;
196+ }
195197 const flat = { } ;
196198 const other = [ ] ;
197- for ( const l of links ) {
199+ for ( const l of o . org_links ?? [ ] ) {
198200 const col = PROMOTED_KINDS [ l . kind ] ;
199201 if ( col && ! flat [ col ] ) flat [ col ] = l . url ;
200202 else other . push ( `${ l . kind ?? 'other' } : ${ l . url } ` ) ;
201203 }
202204 const streams = ( o . media_streams ?? [ ] ) . map ( ( s ) => `${ s . name ? s . name + ' — ' : '' } ${ s . url } ${ s . kind ? ` (${ s . kind } )` : '' } ` ) ;
203- const p = pipelineByOrg . get ( o . slug ) ;
204- const row = {
205+ return {
205206 external_id : o . slug ,
206207 name : o . complete_name ?? o . conventional_name ?? o . slug ,
207208 conventional_name : o . conventional_name ?? '' ,
@@ -214,17 +215,44 @@ const outRows = orgs.map((o) => {
214215 streams : streams . join ( '\n' ) ,
215216 stream_count : streams . length ,
216217 related_orgs : ( relsByOrg . get ( o . slug ) ?? [ ] ) . join ( '\n' ) ,
217- pipeline_org_name : p ?. row [ 'Prospect / Organization' ] ?? '' ,
218- pipeline_matched : p ?. matched ?? 'none' ,
219- ...Object . fromEntries ( PIPELINE_COLS . map ( ( c ) => [ c , p ?. row [ c ] ?? '' ] ) ) ,
220- exported_at,
221218 } ;
222- return row ;
223- } ) ;
219+ } ;
224220
225- // Pipeline-matched rows first (they're the review priority), then by name.
226- outRows . sort ( ( a , b ) =>
227- ( a . pipeline_matched === 'none' ) - ( b . pipeline_matched === 'none' ) || a . name . localeCompare ( b . name ) ) ;
221+ let outRows ;
222+ let unmatched = [ ] ;
223+ if ( args . scope === 'pipeline' ) {
224+ // The tracker's shape: one row per pipeline row, in tracker order,
225+ // enrichment blank where no canonical org matched. Rows with no match
226+ // ALSO land in the sidecar as the to-capture list.
227+ outRows = resolved . map ( ( { row, slug, matched } ) => ( {
228+ ...enrichmentFor ( slug ? orgBySlug . get ( slug ) : null ) ,
229+ pipeline_org_name : row [ 'Prospect / Organization' ] ?? '' ,
230+ pipeline_matched : matched ,
231+ ...Object . fromEntries ( PIPELINE_COLS . map ( ( c ) => [ c , row [ c ] ?? '' ] ) ) ,
232+ exported_at,
233+ } ) ) ;
234+ unmatched = resolved . filter ( ( r ) => ! r . slug ) . map ( ( r ) => r . row ) ;
235+ } else {
236+ // --scope all: one row per canonical org (first matching pipeline row
237+ // attached), the event-based long tail included.
238+ const pipelineByOrg = new Map ( ) ;
239+ for ( const r of resolved ) {
240+ if ( r . slug && ! pipelineByOrg . has ( r . slug ) ) pipelineByOrg . set ( r . slug , r ) ;
241+ }
242+ outRows = orgs . map ( ( o ) => {
243+ const p = pipelineByOrg . get ( o . slug ) ;
244+ return {
245+ ...enrichmentFor ( o ) ,
246+ pipeline_org_name : p ?. row [ 'Prospect / Organization' ] ?? '' ,
247+ pipeline_matched : p ?. matched ?? 'none' ,
248+ ...Object . fromEntries ( PIPELINE_COLS . map ( ( c ) => [ c , p ?. row [ c ] ?? '' ] ) ) ,
249+ exported_at,
250+ } ;
251+ } ) ;
252+ outRows . sort ( ( a , b ) =>
253+ ( a . pipeline_matched === 'none' ) - ( b . pipeline_matched === 'none' ) || a . name . localeCompare ( b . name ) ) ;
254+ unmatched = resolved . filter ( ( r ) => ! r . slug ) . map ( ( r ) => r . row ) ;
255+ }
228256
229257const HEADERS = [
230258 'external_id' , 'name' , 'conventional_name' , 'aliases' , 'domains' , 'bucket' , 'tags' ,
0 commit comments