fullstackgtm 0.21.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/bulkUpdate.d.ts +16 -1
- package/dist/bulkUpdate.js +88 -5
- package/dist/cli.js +214 -4
- package/dist/dedupe.d.ts +14 -0
- package/dist/dedupe.js +140 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/marketReport.js +95 -42
- package/dist/reassign.d.ts +19 -0
- package/dist/reassign.js +87 -0
- package/dist/suggest.js +67 -5
- package/package.json +1 -1
- package/src/bulkUpdate.ts +109 -6
- package/src/cli.ts +231 -4
- package/src/dedupe.ts +182 -0
- package/src/index.ts +3 -1
- package/src/marketReport.ts +110 -56
- package/src/reassign.ts +117 -0
- package/src/suggest.ts +69 -5
package/src/marketReport.ts
CHANGED
|
@@ -438,50 +438,112 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
438
438
|
const e = escapeHtml;
|
|
439
439
|
const axisHtml = axisSectionsHtml(config, set);
|
|
440
440
|
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
|
|
441
|
+
const vendorNamesById = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
|
|
442
|
+
const frontByClaim = new Map(model.fronts.map((front) => [front.claimId, front]));
|
|
443
|
+
|
|
444
|
+
const matrixRow = (claimId: string): string => {
|
|
445
|
+
const claim = claimsById.get(claimId);
|
|
446
|
+
if (!claim) return "";
|
|
447
|
+
const state = stateByClaim.get(claimId) ?? "vacant";
|
|
448
|
+
const cells = config.vendors
|
|
449
|
+
.map((vendor) => {
|
|
450
|
+
const intensity = model.cell(vendor.id, claimId)?.intensity ?? "unobservable";
|
|
451
|
+
const anchorClass = vendor.id === anchor ? " anchor-col" : "";
|
|
452
|
+
return `<td class="cell${anchorClass}"><span class="g g-${intensity}" title="${e(vendor.name)}: ${intensity}"></span></td>`;
|
|
453
|
+
})
|
|
454
|
+
.join("");
|
|
455
|
+
return (
|
|
456
|
+
`<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
|
|
457
|
+
`<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
|
|
458
|
+
`<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`
|
|
459
|
+
);
|
|
460
|
+
};
|
|
460
461
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}
|
|
462
|
+
// Claims grouped by front state, each group a collapsed <details> whose
|
|
463
|
+
// summary carries the stats a skimmer needs; the full matrix is one click
|
|
464
|
+
// away, not a wall the reader must climb to reach the takeaway.
|
|
465
|
+
const GROUPS: Array<{ states: FrontState[]; title: string; blurb: string }> = [
|
|
466
|
+
{ states: ["open", "vacant"], title: "Open ground", blurb: "no vendor is loud here" },
|
|
467
|
+
{ states: ["contested"], title: "Contested fronts", blurb: "2–3 vendors loud" },
|
|
468
|
+
{ states: ["owned"], title: "Owned fronts", blurb: "exactly one vendor loud" },
|
|
469
|
+
{ states: ["saturated"], title: "Saturated fronts", blurb: "4+ vendors loud" },
|
|
470
|
+
];
|
|
471
|
+
const groupedMatrix = GROUPS.map((group) => {
|
|
472
|
+
const claimIds = model.orderedClaimIds.filter((claimId) => group.states.includes(stateByClaim.get(claimId) ?? "vacant"));
|
|
473
|
+
if (claimIds.length === 0) return "";
|
|
474
|
+
const anchorLoud = anchor
|
|
475
|
+
? claimIds.filter((claimId) => model.cell(anchor, claimId)?.intensity === "loud").length
|
|
476
|
+
: 0;
|
|
477
|
+
const anchorNote = anchor ? ` · ${vendorNamesById.get(anchor) ?? anchor} loud on ${anchorLoud}` : "";
|
|
478
|
+
return `<details class="claim-group"><summary><b>${e(group.title)}</b> — ${claimIds.length} claim${claimIds.length === 1 ? "" : "s"} <span class="sum-soft">(${e(group.blurb)}${anchorNote})</span></summary>
|
|
479
|
+
<table><thead><tr><th></th>${"${vendorHeads}"}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
|
|
480
|
+
</details>`;
|
|
481
|
+
}).join("");
|
|
482
|
+
|
|
483
|
+
// The closing argument: walk from open ground to a reasoned target list.
|
|
484
|
+
const openFronts = model.orderedClaimIds.filter((claimId) => {
|
|
485
|
+
const state = stateByClaim.get(claimId);
|
|
486
|
+
return state === "open" || state === "vacant";
|
|
487
|
+
});
|
|
488
|
+
const targetItems = openFronts
|
|
466
489
|
.map((claimId) => {
|
|
467
490
|
const claim = claimsById.get(claimId);
|
|
468
|
-
|
|
491
|
+
const front = frontByClaim.get(claimId);
|
|
492
|
+
if (!claim || !front) return "";
|
|
493
|
+
const quietNames = front.quietVendorIds.map((id) => vendorNamesById.get(id) ?? id);
|
|
494
|
+
const anchorIntensity = anchor ? model.cell(anchor, claimId)?.intensity ?? "unobservable" : null;
|
|
495
|
+
const nearest =
|
|
496
|
+
quietNames.length > 0
|
|
497
|
+
? `Closest contenders (quiet): ${quietNames.join(", ")}.`
|
|
498
|
+
: "Nobody even ships it quietly — vacant ground.";
|
|
499
|
+
const move =
|
|
500
|
+
anchorIntensity === "quiet"
|
|
501
|
+
? `${e(vendorNamesById.get(anchor as string) ?? "The anchor")} already ships this quietly — a promote-to-loud candidate.`
|
|
502
|
+
: anchorIntensity === "absent"
|
|
503
|
+
? "Unclaimed by the anchor: a first-mover messaging opportunity if the capability is real or buildable."
|
|
504
|
+
: "";
|
|
505
|
+
return `<li><b>${e(claim.capability.split(":")[0])}</b> <span class="sum-soft">(${e(claim.icp)} · ${e(claim.pricingStructure)})</span><br>
|
|
506
|
+
No vendor is loud on this claim. ${e(nearest)} ${move}</li>`;
|
|
469
507
|
})
|
|
470
508
|
.join("");
|
|
509
|
+
const heldFronts = anchor
|
|
510
|
+
? model.fronts.filter((front) => front.state === "owned" && front.loudVendorIds[0] === anchor)
|
|
511
|
+
: [];
|
|
512
|
+
const heldLine =
|
|
513
|
+
heldFronts.length > 0
|
|
514
|
+
? `<p><b>Held ground:</b> ${e(vendorNamesById.get(anchor as string) ?? "the anchor")} is the sole loud vendor on ${heldFronts
|
|
515
|
+
.map((front) => `<i>${e(claimsById.get(front.claimId)?.capability.split(":")[0] ?? front.claimId)}</i>`)
|
|
516
|
+
.join(", ")} — positions to defend, not abandon.</p>`
|
|
517
|
+
: "";
|
|
518
|
+
const crowdLine =
|
|
519
|
+
counts.saturated > 0
|
|
520
|
+
? `<p><b>Crowded ground:</b> ${counts.saturated} claim${counts.saturated === 1 ? " is" : "s are"} saturated (4+ vendors loud) — message budget spent there buys the least differentiation.</p>`
|
|
521
|
+
: "";
|
|
522
|
+
const takeaway = `<section>
|
|
523
|
+
<h2>Where to attack</h2>
|
|
524
|
+
<p class="lede">${openFronts.length === 0 ? "No open fronts this run — every claim has at least one loud vendor. Watch the drift between runs for windows opening." : `${openFronts.length} claim${openFronts.length === 1 ? "" : "s"} in this category ${openFronts.length === 1 ? "is" : "are"} open: buyers can be reached there without out-shouting anyone.`}</p>
|
|
525
|
+
<ul class="targets">${targetItems}</ul>
|
|
526
|
+
${heldLine}
|
|
527
|
+
${crowdLine}
|
|
528
|
+
<p class="sum-soft">These are messaging fronts, not verdicts — join the map to CRM ground truth (\`market overlay\`) for evidence-backed OCCUPY / PROMOTE / URGENT / RETREAT directives with win-rate stats.</p>
|
|
529
|
+
</section>`;
|
|
471
530
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
531
|
+
// Evidence grouped by vendor, collapsed: receipts on demand, not a scroll wall.
|
|
532
|
+
const appendixGroups = config.vendors
|
|
533
|
+
.map((vendor) => {
|
|
534
|
+
const items = model.orderedClaimIds.flatMap((claimId) => {
|
|
475
535
|
const obs = model.cell(vendor.id, claimId);
|
|
476
536
|
if (!obs || obs.evidence.length === 0) return [];
|
|
477
537
|
return obs.evidence.map(
|
|
478
538
|
(evidence) =>
|
|
479
|
-
`<div class="ev"><span class="ev-head">${e(
|
|
539
|
+
`<div class="ev"><span class="ev-head">${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
|
|
480
540
|
`<blockquote>“${e(evidence.text)}”</blockquote>` +
|
|
481
541
|
`<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`,
|
|
482
542
|
);
|
|
483
|
-
})
|
|
484
|
-
|
|
543
|
+
});
|
|
544
|
+
if (items.length === 0) return "";
|
|
545
|
+
return `<details class="ev-group"><summary><b>${e(vendor.name)}</b> <span class="sum-soft">— ${items.length} quoted span${items.length === 1 ? "" : "s"}</span></summary>${items.join("")}</details>`;
|
|
546
|
+
})
|
|
485
547
|
.join("");
|
|
486
548
|
|
|
487
549
|
const vendorHeads = config.vendors
|
|
@@ -506,14 +568,16 @@ h1 { font-size:27px; font-weight:600; line-height:1.2; }
|
|
|
506
568
|
.meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
|
|
507
569
|
section { margin-top:44px; }
|
|
508
570
|
h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
|
|
509
|
-
.
|
|
510
|
-
.
|
|
511
|
-
.
|
|
512
|
-
.
|
|
513
|
-
.
|
|
514
|
-
.
|
|
515
|
-
.
|
|
516
|
-
.
|
|
571
|
+
details.claim-group, details.ev-group { border:1px solid var(--line); border-radius:3px; margin-top:10px; }
|
|
572
|
+
details.claim-group summary, details.ev-group summary { cursor:pointer; padding:10px 14px; font-size:14px; list-style-position:inside; }
|
|
573
|
+
details.claim-group summary:hover, details.ev-group summary:hover { background:var(--faint); }
|
|
574
|
+
details.claim-group[open] summary, details.ev-group[open] summary { border-bottom:1px solid var(--line); }
|
|
575
|
+
details.claim-group table { margin:4px 12px 12px; width:calc(100% - 24px); }
|
|
576
|
+
details.ev-group .ev { margin:0 14px; }
|
|
577
|
+
.sum-soft { color:var(--soft); font-size:12px; }
|
|
578
|
+
.lede { font-size:16px; margin-top:12px; }
|
|
579
|
+
.targets { margin-top:12px; font-size:14.5px; line-height:1.6; }
|
|
580
|
+
.targets li { margin:10px 0 10px 20px; }
|
|
517
581
|
.key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
|
|
518
582
|
.lg { display:inline-flex; align-items:center; gap:6px; }
|
|
519
583
|
table { border-collapse:collapse; width:100%; margin-top:6px; }
|
|
@@ -574,34 +638,24 @@ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; fon
|
|
|
574
638
|
<span>extractor: ${e(set.extractor)}</span>
|
|
575
639
|
</div>
|
|
576
640
|
</header>
|
|
641
|
+
${axisHtml.strategicMap}
|
|
577
642
|
<section>
|
|
578
|
-
<h2>
|
|
579
|
-
<div class="fronts">
|
|
580
|
-
<div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
|
|
581
|
-
<div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
|
|
582
|
-
<div class="fcard"><b>${counts.owned}</b><span>Owned</span></div>
|
|
583
|
-
<div class="fcard"><b>${counts.saturated}</b><span>Saturated</span></div>
|
|
584
|
-
</div>
|
|
585
|
-
<ul class="openlist">${openList}</ul>
|
|
586
|
-
</section>
|
|
587
|
-
<section>
|
|
588
|
-
<h2>Claim × vendor intensity matrix</h2>
|
|
643
|
+
<h2>Claims, front by front</h2>
|
|
589
644
|
<div class="key">
|
|
590
645
|
<span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
|
|
591
646
|
<span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
|
|
592
647
|
<span class="lg"><i class="g g-absent"></i>ABSENT</span>
|
|
593
648
|
<span class="lg"><i class="g g-unobservable"></i>UNOBSERVABLE — capture failed</span>
|
|
594
649
|
</div>
|
|
595
|
-
|
|
596
|
-
<thead><tr><th></th>${vendorHeads}<th></th></tr></thead>
|
|
597
|
-
<tbody>${matrixRows}</tbody>
|
|
598
|
-
</table>
|
|
650
|
+
${groupedMatrix}
|
|
599
651
|
</section>
|
|
600
|
-
${
|
|
652
|
+
${takeaway}
|
|
601
653
|
<section>
|
|
602
654
|
<h2>Evidence appendix</h2>
|
|
603
|
-
|
|
655
|
+
<p class="sum-soft">Every loud/quiet reading is grounded in a verbatim span from a stored page capture; expand a vendor for its receipts.</p>
|
|
656
|
+
${appendixGroups}
|
|
604
657
|
</section>
|
|
658
|
+
<script>window.addEventListener("beforeprint",function(){document.querySelectorAll("details").forEach(function(d){d.open=true;});});</script>
|
|
605
659
|
<footer>
|
|
606
660
|
<span>Generated by fullstackgtm market · deterministic render of ${e(set.runLabel)}</span>
|
|
607
661
|
<span>Front rule v1: 0 loud=open · 1=owned · 2–3=contested · ≥4=saturated</span>
|
package/src/reassign.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ownership-handoff playbook: `reassign` compiles one bulk-update-style
|
|
3
|
+
* dry-run plan PER object type (accounts, contacts, deals by default) that
|
|
4
|
+
* moves every record owned by --from to --to. It NEVER writes — each plan
|
|
5
|
+
* flows through the same plans-approve → apply gate, and because every plan
|
|
6
|
+
* carries its full filter, eligibility (extra --where scoping AND the
|
|
7
|
+
* --except-deal-stage exclusion) is re-verified per record against a FRESH
|
|
8
|
+
* snapshot at apply time, with mid-apply rechecks. A record that drifts into
|
|
9
|
+
* the exception set mid-run surfaces as a conflict, not a bad write.
|
|
10
|
+
*
|
|
11
|
+
* --except-deal-stage <stage> excludes in-flight business end to end:
|
|
12
|
+
* - deal plans drop deals in that stage (stage!=<stage>), and
|
|
13
|
+
* - ALL plans drop records whose account has an OPEN deal in that stage
|
|
14
|
+
* (accounts: openDealStages!~<stage>; deals/contacts:
|
|
15
|
+
* account.openDealStages!~<stage>).
|
|
16
|
+
*
|
|
17
|
+
* Deal plans cover OPEN deals only unless includeClosedDeals is set: closed
|
|
18
|
+
* deals keep their historical owner in a handoff.
|
|
19
|
+
*
|
|
20
|
+
* Extra --where clauses are account-scoped where needed: a clause on a field
|
|
21
|
+
* that only exists on accounts (e.g. domain~.de) is lifted to the relational
|
|
22
|
+
* pseudo-field (account.domain~.de) for contact and deal plans, so one
|
|
23
|
+
* invocation scopes all three object types consistently.
|
|
24
|
+
*/
|
|
25
|
+
import { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.ts";
|
|
26
|
+
import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
27
|
+
|
|
28
|
+
export type ReassignObjectType = "account" | "contact" | "deal";
|
|
29
|
+
|
|
30
|
+
export type ReassignOptions = {
|
|
31
|
+
/** the owner whose records are being handed off */
|
|
32
|
+
fromOwnerId: string;
|
|
33
|
+
/** the receiving owner — must be a known user in the snapshot */
|
|
34
|
+
toOwnerId: string;
|
|
35
|
+
/** which object types to compile plans for (default all three) */
|
|
36
|
+
objects?: ReassignObjectType[];
|
|
37
|
+
/** extra --where scoping, AND-ed into every plan (account fields lifted) */
|
|
38
|
+
where?: string[];
|
|
39
|
+
/** exclude records tied to an open deal in this stage (see module doc) */
|
|
40
|
+
exceptDealStage?: string;
|
|
41
|
+
/** also reassign closed deals (default false: history keeps its owner) */
|
|
42
|
+
includeClosedDeals?: boolean;
|
|
43
|
+
reason?: string;
|
|
44
|
+
maxOperations?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ALL_OBJECTS: ReassignObjectType[] = ["account", "contact", "deal"];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Scope an extra --where clause to one object type: pass through when the
|
|
51
|
+
* field is valid there, lift account-only fields to account.<field> for
|
|
52
|
+
* contacts and deals. Unknown fields fall through to buildBulkUpdatePlan's
|
|
53
|
+
* strict validation, which throws with the valid-field list.
|
|
54
|
+
*/
|
|
55
|
+
function scopeWhere(objectType: ReassignObjectType, raw: string): string {
|
|
56
|
+
const clause = parseWhere(raw);
|
|
57
|
+
if (isFilterableField(objectType, clause.field)) return raw;
|
|
58
|
+
if (objectType !== "account" && isFilterableField(objectType, `account.${clause.field}`)) {
|
|
59
|
+
return `account.${raw}`;
|
|
60
|
+
}
|
|
61
|
+
return raw;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildReassignPlans(
|
|
65
|
+
snapshot: CanonicalGtmSnapshot,
|
|
66
|
+
options: ReassignOptions,
|
|
67
|
+
): PatchPlan[] {
|
|
68
|
+
if (!options.fromOwnerId || !options.toOwnerId) {
|
|
69
|
+
throw new Error("reassign requires both --from <ownerId> and --to <ownerId>.");
|
|
70
|
+
}
|
|
71
|
+
if (options.fromOwnerId === options.toOwnerId) {
|
|
72
|
+
throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
|
|
73
|
+
}
|
|
74
|
+
// The receiving owner must exist: a typo'd --to would otherwise write an
|
|
75
|
+
// invalid owner onto every matched record.
|
|
76
|
+
if (!snapshot.users.some((user) => user.id === options.toOwnerId)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`reassign --to ${options.toOwnerId} is not a known user in the snapshot. Known users: ${snapshot.users
|
|
79
|
+
.map((user) => `${user.id} (${user.name})`)
|
|
80
|
+
.join(", ") || "none"}.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const objects = options.objects ?? ALL_OBJECTS;
|
|
84
|
+
for (const objectType of objects) {
|
|
85
|
+
if (!ALL_OBJECTS.includes(objectType)) {
|
|
86
|
+
throw new Error(`reassign --objects supports account, contact, deal — got "${objectType}".`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return objects.map((objectType) => {
|
|
91
|
+
const where = [`ownerId=${options.fromOwnerId}`];
|
|
92
|
+
if (objectType === "deal" && !options.includeClosedDeals) {
|
|
93
|
+
where.push("isClosed=false"); // closed deals keep their historical owner
|
|
94
|
+
}
|
|
95
|
+
for (const extra of options.where ?? []) {
|
|
96
|
+
where.push(scopeWhere(objectType, extra));
|
|
97
|
+
}
|
|
98
|
+
if (options.exceptDealStage) {
|
|
99
|
+
const stage = options.exceptDealStage;
|
|
100
|
+
if (objectType === "deal") where.push(`stage!=${stage}`);
|
|
101
|
+
where.push(
|
|
102
|
+
objectType === "account"
|
|
103
|
+
? `openDealStages!~${stage}`
|
|
104
|
+
: `account.openDealStages!~${stage}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return buildBulkUpdatePlan(snapshot, {
|
|
108
|
+
objectType,
|
|
109
|
+
where,
|
|
110
|
+
set: { ownerId: options.toOwnerId },
|
|
111
|
+
reason:
|
|
112
|
+
options.reason ??
|
|
113
|
+
`reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`,
|
|
114
|
+
maxOperations: options.maxOperations,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
package/src/suggest.ts
CHANGED
|
@@ -114,11 +114,40 @@ function suggestDealAccount(
|
|
|
114
114
|
return { ...base, suggestedValue: null, confidence: "none", reason: "Deal not found in the snapshot." };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
// Convention: "Contact Name - Company Name"
|
|
118
|
-
// independent; agreement upgrades confidence,
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
117
|
+
// Convention 1: "Contact Name - Company Name" (hyphen, en or em dash).
|
|
118
|
+
// Both signals below are independent; agreement upgrades confidence,
|
|
119
|
+
// conflict downgrades it.
|
|
120
|
+
const separator = [" - ", " – ", " — "]
|
|
121
|
+
.map((s) => ({ s, index: deal.name.indexOf(s) }))
|
|
122
|
+
.filter(({ index }) => index >= 0)
|
|
123
|
+
.sort((a, b) => a.index - b.index)[0];
|
|
124
|
+
const left = separator ? deal.name.slice(0, separator.index).trim() : deal.name;
|
|
125
|
+
const right = separator ? deal.name.slice(separator.index + separator.s.length).trim() : "";
|
|
126
|
+
|
|
127
|
+
// Convention 2: "Company - Deal descriptor" (e.g. "Globex – Expansion",
|
|
128
|
+
// "Hooli – New Business"). When the right side is purely deal-descriptor
|
|
129
|
+
// words, the company is on the LEFT. Only an exact account-name match
|
|
130
|
+
// counts as high; an unknown left side proposes a create — the engine
|
|
131
|
+
// never guesses at an existing record.
|
|
132
|
+
if (left && right && isDealDescriptor(right)) {
|
|
133
|
+
const leftMatch = accountsByNorm.get(normalize(left));
|
|
134
|
+
if (leftMatch) {
|
|
135
|
+
return {
|
|
136
|
+
...base,
|
|
137
|
+
suggestedValue: leftMatch.id,
|
|
138
|
+
confidence: "high",
|
|
139
|
+
reason: `Deal name leads with the exact account name "${leftMatch.name}" ("${right}" is a deal descriptor, not a company).`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (!contactsByName.get(normalize(left))) {
|
|
143
|
+
return {
|
|
144
|
+
...base,
|
|
145
|
+
suggestedValue: `create:${left}`,
|
|
146
|
+
confidence: "create",
|
|
147
|
+
reason: `No account named "${left}" exists ("${right}" is a deal descriptor) — approving creates the company, then links.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
122
151
|
|
|
123
152
|
// Signal 1: company-name match against account names.
|
|
124
153
|
let nameMatch: { id: string; name: string } | null = null;
|
|
@@ -189,6 +218,22 @@ function suggestDealAccount(
|
|
|
189
218
|
reason: `No account named "${right}" exists and contact "${left}" has none — approving creates the company, then links.`,
|
|
190
219
|
};
|
|
191
220
|
}
|
|
221
|
+
// Convention 3: "<Company> <descriptor…>" without a separator (e.g.
|
|
222
|
+
// "INITECH renewal"): strip trailing descriptor words and accept only an
|
|
223
|
+
// exact account-name match on what remains.
|
|
224
|
+
if (!separator) {
|
|
225
|
+
const words = normalize(deal.name).split(" ").filter(Boolean);
|
|
226
|
+
while (words.length > 1 && DEAL_DESCRIPTOR_WORDS.has(words[words.length - 1])) words.pop();
|
|
227
|
+
const strippedMatch = words.length > 0 ? accountsByNorm.get(words.join(" ")) : undefined;
|
|
228
|
+
if (strippedMatch) {
|
|
229
|
+
return {
|
|
230
|
+
...base,
|
|
231
|
+
suggestedValue: strippedMatch.id,
|
|
232
|
+
confidence: "high",
|
|
233
|
+
reason: `Deal name leads with the exact account name "${strippedMatch.name}" (trailing words are deal descriptors).`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
192
237
|
return {
|
|
193
238
|
...base,
|
|
194
239
|
suggestedValue: null,
|
|
@@ -199,6 +244,25 @@ function suggestDealAccount(
|
|
|
199
244
|
};
|
|
200
245
|
}
|
|
201
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Words that describe the deal rather than name a company. Used to recognize
|
|
249
|
+
* the "Company - Deal descriptor" naming convention: a segment counts as a
|
|
250
|
+
* descriptor only when EVERY word is in this list, so any real company name
|
|
251
|
+
* ("Brand New Startup") falls through to the contact/company conventions.
|
|
252
|
+
*/
|
|
253
|
+
const DEAL_DESCRIPTOR_WORDS = new Set([
|
|
254
|
+
"renewal", "expansion", "pilot", "annual", "monthly", "platform", "new", "business",
|
|
255
|
+
"add", "on", "addon", "upsell", "upgrade", "trial", "poc", "proof", "concept",
|
|
256
|
+
"subscription", "license", "licence", "contract", "opportunity", "deal", "engagement",
|
|
257
|
+
"implementation", "onboarding", "services", "inbound", "outbound",
|
|
258
|
+
"q1", "q2", "q3", "q4",
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
function isDealDescriptor(segment: string): boolean {
|
|
262
|
+
const words = normalize(segment).split(" ").filter(Boolean);
|
|
263
|
+
return words.length > 0 && words.every((word) => DEAL_DESCRIPTOR_WORDS.has(word));
|
|
264
|
+
}
|
|
265
|
+
|
|
202
266
|
/**
|
|
203
267
|
* Survivor selection for merge_records. Ranking is deterministic and
|
|
204
268
|
* evidence-based: most complete record first (count of populated canonical
|