@synity/bitrix-skills 1.3.9 → 1.3.11
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 +18 -0
- package/dist/cli.js +7 -2
- package/package.json +8 -1
- package/src/features/bx/feature.json +1 -1
- package/src/features/bx-calendar/feature.json +1 -1
- package/src/features/bx-crm/assets/SKILL.md +7 -3
- package/src/features/bx-crm/assets/lifecycle.md +198 -0
- package/src/features/bx-crm/assets/onboard.md +242 -1
- package/src/features/bx-crm/assets/referral.md +231 -0
- package/src/features/bx-crm/feature.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1260083: docs(bx-crm): add deal optional products flow to onboard.md + routing table; fix TypeScript null assertion in update command
|
|
8
|
+
|
|
9
|
+
## 1.3.10
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- feat(features): promote bx, bx-crm, bx-calendar from planned to active — now installable via `install --all`
|
|
14
|
+
|
|
3
15
|
## 1.3.9
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -22,6 +34,12 @@
|
|
|
22
34
|
|
|
23
35
|
docs(task-sync): document --key flag and fix stale package name (@synity/bitrix-task-sync → @synity/bitrix-skills) in SKILL.md and README to prevent agent hallucinating --token flag
|
|
24
36
|
|
|
37
|
+
## 1.3.2
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- Restore `--all` flag on `install` command and fix non-TTY safety guard. Both behaviors were dropped in 1.3.0 cleanup but remained under test contract — `install --all` previously errored with "unknown option", and CI runs would silently bulk-install everything. Also repairs `sync-assets.mjs` so test/build pipelines populate `assets/scripts/` correctly after the single-repo migration.
|
|
42
|
+
|
|
25
43
|
## 1.3.1
|
|
26
44
|
|
|
27
45
|
### Patch Changes
|
package/dist/cli.js
CHANGED
|
@@ -1186,7 +1186,7 @@ var InstallCommand = class extends Command {
|
|
|
1186
1186
|
]
|
|
1187
1187
|
});
|
|
1188
1188
|
key = Option.String("--key", { description: "License key to unlock paid tier features" });
|
|
1189
|
-
all = Option.Boolean("--all", false, { description: "Install all
|
|
1189
|
+
all = Option.Boolean("--all", false, { description: "Install all installable features (required in non-TTY)" });
|
|
1190
1190
|
featuresFlag = Option.String("--features", { description: "Comma-separated feature names" });
|
|
1191
1191
|
featureArgs = Option.Rest({ required: 0 });
|
|
1192
1192
|
async execute() {
|
|
@@ -1227,8 +1227,13 @@ var InstallCommand = class extends Command {
|
|
|
1227
1227
|
selectedNames = this.featuresFlag.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1228
1228
|
} else if (this.featureArgs.length > 0) {
|
|
1229
1229
|
selectedNames = this.featureArgs;
|
|
1230
|
-
} else {
|
|
1230
|
+
} else if (this.all || process.stdin.isTTY) {
|
|
1231
1231
|
selectedNames = installable.map((f) => f.name);
|
|
1232
|
+
} else {
|
|
1233
|
+
this.context.stderr.write(
|
|
1234
|
+
chalk.red("Refusing to install in non-TTY without explicit selection.\n") + chalk.gray(" Pass --all, --features <csv>, or feature names as positional args.\n")
|
|
1235
|
+
);
|
|
1236
|
+
return 1;
|
|
1232
1237
|
}
|
|
1233
1238
|
const availableNames = new Set(available.map((f) => f.name));
|
|
1234
1239
|
const invalid = selectedNames.filter((n) => !availableNames.has(n));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synity/bitrix-skills",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.11",
|
|
4
4
|
"description": "Multi-feature Bitrix24 tooling CLI for Synity projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -47,15 +47,22 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@changesets/cli": "^2.31.0",
|
|
50
|
+
"@eslint/js": "^9.39.4",
|
|
50
51
|
"@types/node": "^20.0.0",
|
|
52
|
+
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
|
53
|
+
"@typescript-eslint/parser": "^8.59.3",
|
|
54
|
+
"eslint": "^9.39.4",
|
|
55
|
+
"globals": "^17.6.0",
|
|
51
56
|
"tsup": "^8.0.0",
|
|
52
57
|
"typescript": "^5.4.0",
|
|
58
|
+
"typescript-eslint": "^8.59.3",
|
|
53
59
|
"vitest": "^2.1.0"
|
|
54
60
|
},
|
|
55
61
|
"scripts": {
|
|
56
62
|
"prebuild": "node scripts/prebuild-bash-sync.mjs && node scripts/sync-assets.mjs",
|
|
57
63
|
"build": "tsup --config tsup.config.ts",
|
|
58
64
|
"dev": "tsup --config tsup.config.ts --watch",
|
|
65
|
+
"pretest": "node scripts/prebuild-bash-sync.mjs && node scripts/sync-assets.mjs",
|
|
59
66
|
"test": "vitest run",
|
|
60
67
|
"test:watch": "vitest",
|
|
61
68
|
"lint": "eslint src --ext .ts",
|
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
name: bx:crm
|
|
3
3
|
description: "Bitrix24 CRM via MCP Synity: contacts, companies, deals, leads, estimates, invoices, customer 360, pipeline reports. NOT for project tasks (bx:task) or calendar events (bx:calendar)."
|
|
4
4
|
argument-hint: "<intent or operation>"
|
|
5
|
-
version: "2.
|
|
5
|
+
version: "2.2.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# /bx:crm — Bitrix24 CRM via MCP Synity
|
|
9
|
-
|
|
10
9
|
Use this skill for CRM entities only: contacts, companies, deals, leads, estimates, invoices, customer analysis, and pipeline reports.
|
|
11
10
|
|
|
12
11
|
**Tool:** MCP Synity at `b24-mcp.synity.so`. Start with `codemode.search()` then execute the selected helper.
|
|
@@ -15,11 +14,14 @@ Use this skill for CRM entities only: contacts, companies, deals, leads, estimat
|
|
|
15
14
|
|
|
16
15
|
| User intent | Load file | Key helpers |
|
|
17
16
|
|---|---|---|
|
|
18
|
-
| create/update contact, company, deal, lead | `onboard.md` | `upsertContact`, `upsertCompanyByTaxCode`, `createDealWithParties` |
|
|
17
|
+
| create/update contact, company, deal, lead | `onboard.md` | `upsertContact`, `upsertCompanyByTaxCode`, `createDealWithParties`, `setDealProducts`, `findProducts` |
|
|
19
18
|
| VN phone, honorific, address, MST format | `vn-norms.md` | normalization rules |
|
|
20
19
|
| convert/qualify lead to deal | `convert.md` | `convertLeadToDeal` |
|
|
21
20
|
| estimate, báo giá, invoice, payment link | `commerce.md` | `createEstimate`, `approveEstimate`, `createSmartInvoice` |
|
|
22
21
|
| document, contract, PDF generation | `document.md` | `codemode.request` for `crm.documentgenerator.*` |
|
|
22
|
+
| referral mention, "giới thiệu bởi", recommend | `referral.md` (+ `onboard.md` § Referral UFs) | SOP: ensure UFs → resolve referrer → forward + reverse link |
|
|
23
|
+
| auto-detect source / source unclear | `referral.md` § Source Auto-Detection | SOP: keyword score + confidence threshold + ask fallback |
|
|
24
|
+
| contact/company lifecycle, customer journey, MQL/SQL/Customer classification | `lifecycle.md` (+ `onboard.md` § Lifecycle UF) | SOP: ensure UF → detect stage → write enum ID |
|
|
23
25
|
| customer 360, signals, meeting prep | `research.md` | `customer360`, `contactSignals`, `dealSignals` |
|
|
24
26
|
| pipeline report, forecast, AR, overdue | `report.md` | `dealForecast`, `stuckInStage`, `arReport` |
|
|
25
27
|
| multi-step CRM workflows | `flows.md` | combo orchestration |
|
|
@@ -38,6 +40,8 @@ Use this skill for CRM entities only: contacts, companies, deals, leads, estimat
|
|
|
38
40
|
## Mandatory Workflow
|
|
39
41
|
|
|
40
42
|
1. Detect intent and load the right subfile.
|
|
43
|
+
- First-time referral mention this session → run `ensureReferralUFs` (onboard.md § Referral UFs).
|
|
44
|
+
- First-time contact/company create this session → perform `onboard.md` § Lifecycle UF SOP.
|
|
41
45
|
2. Confirm helper schema with `codemode.search()` before writes.
|
|
42
46
|
3. Ask user confirmation before MCP writes or irreversible status changes.
|
|
43
47
|
4. Execute through helper first; use raw REST only when a subfile marks an MCP gap.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# bx:crm — Contact + Company Lifecycle
|
|
2
|
+
|
|
3
|
+
Detection + classification of customer-journey stage. Load when creating contact/company, or when user mentions lifecycle, MQL, SQL, customer, subscriber, or customer journey.
|
|
4
|
+
|
|
5
|
+
UF prerequisite lives in [onboard.md § Lifecycle UF](./onboard.md#lifecycle-uf-prerequisite). Perform that SOP once per session before writing lifecycle fields.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Stages
|
|
10
|
+
|
|
11
|
+
| XML_ID | Meaning | SORT | Default |
|
|
12
|
+
|---|---|---:|---|
|
|
13
|
+
| `SUBSCRIBER` | Opted into content, newsletter, community, or follows | 100 | N |
|
|
14
|
+
| `LEAD` | New or low-context prospect | 200 | Y |
|
|
15
|
+
| `MQL` | Marketing-qualified from content, webinar, form, ebook | 300 | N |
|
|
16
|
+
| `SQL` | Sales-qualified: demo, meeting, quote, consultation requested | 400 | N |
|
|
17
|
+
| `OPPORTUNITY` | Active commercial opportunity or negotiation | 500 | N |
|
|
18
|
+
| `CUSTOMER` | Paid, signed, existing, or active customer | 600 | N |
|
|
19
|
+
| `EVANGELIST` | Advocate/referrer/loyal customer generating referrals | 700 | N |
|
|
20
|
+
| `OTHER` | Explicit user override not covered above | 800 | N |
|
|
21
|
+
|
|
22
|
+
Default fallback is `LEAD`. User can always override.
|
|
23
|
+
|
|
24
|
+
## Keyword Table
|
|
25
|
+
|
|
26
|
+
| XML_ID | Triggers |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `SUBSCRIBER` | "đăng ký nhận tin", "subscribe", "newsletter", "follow page", "theo dõi fanpage" |
|
|
29
|
+
| `LEAD` | "lead mới", "khách mới", "potential", "vừa biết tới", "first contact" |
|
|
30
|
+
| `MQL` | "mql", "marketing qualified", "đã tải tài liệu", "đăng ký webinar", "tải ebook" |
|
|
31
|
+
| `SQL` | "sql", "sales qualified", "đã demo", "đã book meeting", "muốn báo giá", "asking for quote" |
|
|
32
|
+
| `OPPORTUNITY` | "deal đang chạy", "đang đàm phán", "negotiating", "active deal", "có cơ hội" |
|
|
33
|
+
| `CUSTOMER` | "đã mua", "đã ký hợp đồng", "khách hàng hiện tại", "existing customer", "paying customer", "đã thanh toán" |
|
|
34
|
+
| `EVANGELIST` | "giới thiệu nhiều khách", "khách trung thành", "referrer", "advocate", "loyal customer" |
|
|
35
|
+
| `OTHER` | "stage khác", "other", "khác", "không phân loại" |
|
|
36
|
+
|
|
37
|
+
## Detection Algorithm
|
|
38
|
+
|
|
39
|
+
Reuse Source Auto-Detection constants for predictable behavior. Pseudocode only; do not call a fictional helper.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
INPUT: userRequestText, entityType ('CONTACT'|'COMPANY'), referralActive
|
|
43
|
+
CONST gap = 0.2
|
|
44
|
+
CONST scoreUnit = 5
|
|
45
|
+
CONST confidenceThreshold = 0.8
|
|
46
|
+
CONST defaultStage = "LEAD"
|
|
47
|
+
|
|
48
|
+
IF referralActive:
|
|
49
|
+
RETURN {
|
|
50
|
+
needsAsk: true,
|
|
51
|
+
candidates: top5Default(),
|
|
52
|
+
reason: "referral capture in progress; lifecycle should be user-confirmed"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
normalized = norm(userRequestText)
|
|
56
|
+
scores = []
|
|
57
|
+
FOR each stage IN keywordTable:
|
|
58
|
+
score = 0; matched = []
|
|
59
|
+
FOR each keyword IN keywordTable[stage]:
|
|
60
|
+
IF normalized.includes(norm(keyword)):
|
|
61
|
+
score += keyword.length / scoreUnit
|
|
62
|
+
matched.push(keyword)
|
|
63
|
+
IF score > 0: scores.push({ stage, score, matched })
|
|
64
|
+
|
|
65
|
+
top = scores.sort(desc by score).slice(0, 3)
|
|
66
|
+
IF top.length === 0:
|
|
67
|
+
RETURN { lifecycle: defaultStage, confidence: 0.5, reason: "no keyword match → default LEAD" }
|
|
68
|
+
|
|
69
|
+
confidence = top[0].score / sum(scores.score)
|
|
70
|
+
IF confidence >= confidenceThreshold AND (top[0].score - (top[1]?.score || 0)) > gap:
|
|
71
|
+
RETURN { lifecycle: top[0].stage, confidence, reason: top[0].matched }
|
|
72
|
+
|
|
73
|
+
RETURN { needsAsk: true, candidates: top, reason: "ambiguous match" }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`top5Default()` = `[LEAD, MQL, SQL, CUSTOMER, OTHER]`.
|
|
77
|
+
|
|
78
|
+
## Diacritic Normalization
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
const norm = s => s.toLowerCase()
|
|
82
|
+
.normalize('NFD')
|
|
83
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
84
|
+
.replace(/đ/g, 'd');
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Heuristic Shortcuts
|
|
88
|
+
|
|
89
|
+
| Signal | Auto-pick | Confidence |
|
|
90
|
+
|---|---|---:|
|
|
91
|
+
| "stage là MQL" / "lifecycle = SQL" | Exact XML_ID | 1.0 |
|
|
92
|
+
| "đã thanh toán invoice" / "deal WON" | `CUSTOMER` | 0.95 |
|
|
93
|
+
| "form webinar" + "newsletter" | `MQL` | 0.85 |
|
|
94
|
+
| Phone-only contact, no other context | `LEAD` | 0.5 |
|
|
95
|
+
| "giới thiệu cho 3 người" / referrer-themself | `EVANGELIST` | 0.85 |
|
|
96
|
+
|
|
97
|
+
Apply exact user-declared stage before keyword scoring. If declared stage is invalid, ask.
|
|
98
|
+
|
|
99
|
+
## Referral-Active Suppression
|
|
100
|
+
|
|
101
|
+
When `referralActive === true`, do not auto-pick from referral words. Ask user instead.
|
|
102
|
+
|
|
103
|
+
Reason: referral capture is attribution, not lifecycle. A referrer mention may mean the new contact was referred, not that the referrer is an `EVANGELIST`.
|
|
104
|
+
|
|
105
|
+
## Ask Fallback
|
|
106
|
+
|
|
107
|
+
`needsAsk: true` → `AskUserQuestion`:
|
|
108
|
+
|
|
109
|
+
- Header: "Lifecycle"
|
|
110
|
+
- Question: "Giai đoạn nào phù hợp với {entityType.toLowerCase()} này? (matched: {keywords})"
|
|
111
|
+
- Options:
|
|
112
|
+
- Top-3 candidates with matched keywords
|
|
113
|
+
- `Other`
|
|
114
|
+
- "Skip lần này"
|
|
115
|
+
|
|
116
|
+
Descriptions:
|
|
117
|
+
|
|
118
|
+
| Option | Description |
|
|
119
|
+
|---|---|
|
|
120
|
+
| Lead | New/low-context prospect |
|
|
121
|
+
| MQL | Marketing-qualified from form/content/webinar |
|
|
122
|
+
| SQL | Ready for sales conversation |
|
|
123
|
+
| Customer | Already paid/signed/current customer |
|
|
124
|
+
| Other | User-defined stage outside default taxonomy |
|
|
125
|
+
|
|
126
|
+
## Integration Hooks
|
|
127
|
+
|
|
128
|
+
Contact create/update path after phone normalization, before write:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
// Pseudocode: apply Detection Algorithm above, then AskUserQuestion if needsAsk.
|
|
132
|
+
const lifecycle = applyLifecycleDetection({ text: userRequestText, entityType: 'CONTACT', referralActive });
|
|
133
|
+
const lifecycleXmlId = lifecycle.needsAsk ? AskUserQuestion(lifecycle.candidates) : lifecycle.lifecycle;
|
|
134
|
+
|
|
135
|
+
let enumId = enumCache.CONTACT[lifecycleXmlId];
|
|
136
|
+
if (!enumId) {
|
|
137
|
+
// Re-fetch crm.contact.userfield.list once and rebuild CONTACT cache.
|
|
138
|
+
enumId = enumCache.CONTACT[lifecycleXmlId];
|
|
139
|
+
}
|
|
140
|
+
if (enumId) fields.UF_CRM_LIFECYCLE = enumId;
|
|
141
|
+
// Else omit lifecycle and show UF spec/cache-miss warning.
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Company create/update path is identical with `entityType: 'COMPANY'` and `enumCache.COMPANY[...]`:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
let enumId = enumCache.COMPANY[lifecycleXmlId];
|
|
148
|
+
if (!enumId) {
|
|
149
|
+
// Re-fetch crm.company.userfield.list once and rebuild COMPANY cache.
|
|
150
|
+
enumId = enumCache.COMPANY[lifecycleXmlId];
|
|
151
|
+
}
|
|
152
|
+
if (enumId) fields.UF_CRM_LIFECYCLE = enumId;
|
|
153
|
+
// Else omit lifecycle and show UF spec/cache-miss warning.
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
If user selects "Skip lần này", omit `UF_CRM_LIFECYCLE`; Bitrix UI default may still show Lead if the field exists.
|
|
157
|
+
|
|
158
|
+
## Enum-ID Resolution
|
|
159
|
+
|
|
160
|
+
Never write raw XML_ID string to `UF_CRM_LIFECYCLE`.
|
|
161
|
+
|
|
162
|
+
Correct runtime contract:
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
const enumId = enumCache[entityType][xmlId];
|
|
166
|
+
if (!enumId) {
|
|
167
|
+
// Re-fetch crm.{entity}.userfield.list once, rebuild cache, then ask if still missing.
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Bitrix assigns independent numeric IDs per entity. `CONTACT.LEAD` and `COMPANY.LEAD` usually differ.
|
|
172
|
+
|
|
173
|
+
## Logging
|
|
174
|
+
|
|
175
|
+
Do not log names, phone, email, tax code, or raw user text.
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
[Lifecycle] auto-picked "MQL" for contact 12345 (confidence 0.88, matched: "đã tải tài liệu", "webinar")
|
|
179
|
+
[Lifecycle] asked user for company 6789 (ambiguous: SQL 0.62 vs OPPORTUNITY 0.55)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Entity Coupling
|
|
183
|
+
|
|
184
|
+
- CONTACT and COMPANY lifecycle are independent in V1.
|
|
185
|
+
- Do not auto-sync company lifecycle from primary contact.
|
|
186
|
+
- Do not auto-advance from deal `STAGE_ID`.
|
|
187
|
+
- V2 can add reconciliation/history once real usage patterns are known.
|
|
188
|
+
|
|
189
|
+
## Edge Cases
|
|
190
|
+
|
|
191
|
+
| Case | Handling |
|
|
192
|
+
|---|---|
|
|
193
|
+
| No keyword signal | Default `LEAD`, confidence 0.5 |
|
|
194
|
+
| Multiple weak signals | Ask top-3 |
|
|
195
|
+
| Referral flow active | Ask, do not auto-pick |
|
|
196
|
+
| Explicit invalid stage | Ask user to pick valid stage |
|
|
197
|
+
| Cache missing enum ID | Re-fetch once; if still missing, skip lifecycle and show UF spec |
|
|
198
|
+
| Existing contact/company found | Confirm before changing lifecycle; do not overwrite silently |
|
|
@@ -58,7 +58,7 @@ Use `createDealWithParties` for atomic contact + company + deal.
|
|
|
58
58
|
| `OPPORTUNITY` | user input number | `588` |
|
|
59
59
|
| `SOURCE_ID` | mapping below | `OTHER` |
|
|
60
60
|
|
|
61
|
-
**SOURCE_ID
|
|
61
|
+
**SOURCE_ID:** Use § Source Auto-Detection algorithm; falls back to ask when ambiguous. Force `RECOMMENDATION` if referrer detected.
|
|
62
62
|
|
|
63
63
|
**COMMENTS — BANT template:**
|
|
64
64
|
```text
|
|
@@ -69,6 +69,42 @@ Timeline: <start date>
|
|
|
69
69
|
Notes: <context>
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
## Deal — Optional Products
|
|
73
|
+
|
|
74
|
+
Add products only when the user explicitly mentions product names or asks for products on the deal.
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
// 1. Extract short keyword (1-2 words) from user's product name — do NOT pass full name
|
|
78
|
+
// name = substring match; "%keyword%" wildcard syntax breaks the param
|
|
79
|
+
const matches = await findProducts({ name: "<short keyword>", limit: 5 })
|
|
80
|
+
// → [{ id, name, sku, price, currency, vatRate, matched }]
|
|
81
|
+
|
|
82
|
+
// 2. Confirm match with user if multiple or ambiguous results
|
|
83
|
+
// 3. Add to deal (currency omitted — Bitrix inherits from deal)
|
|
84
|
+
await setDealProducts({
|
|
85
|
+
dealId,
|
|
86
|
+
items: [{ productId: matches[0].id, price: matches[0].price, quantity: 1 }],
|
|
87
|
+
mode: "replace"
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**MCP gap fallback** — use only when `setDealProducts` is absent from `codemode.catalog()`:
|
|
92
|
+
```js
|
|
93
|
+
codemode.request({
|
|
94
|
+
method: "POST",
|
|
95
|
+
path: "/crm.deal.productrows.set",
|
|
96
|
+
body: { id: dealId, rows: [{ PRODUCT_ID: id, PRICE: price, QUANTITY: quantity }] }
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Rules:**
|
|
101
|
+
- Products are optional — add only when user mentions specific product names.
|
|
102
|
+
- Always `findProducts` to resolve name → id; never hardcode `PRODUCT_ID`.
|
|
103
|
+
- Pass a short keyword (1-2 words) to `findProducts({ name })`, not the full product name.
|
|
104
|
+
- Omit `currency` in items — Bitrix inherits it from the deal automatically.
|
|
105
|
+
- Custom line items (no catalog id): pass `{ name, price, quantity }` without `id`.
|
|
106
|
+
- Commerce catalog variants (SKU variants) require `catalog` scope on the MCP token; if 401, fall back to custom line item and note the MCP gap.
|
|
107
|
+
|
|
72
108
|
## Lead — Create + Products
|
|
73
109
|
|
|
74
110
|
Use lead when the prospect is unqualified or missing deal-level budget/timeline.
|
|
@@ -92,6 +128,203 @@ setLeadProducts({
|
|
|
92
128
|
|
|
93
129
|
For qualification, convert via [convert.md](./convert.md).
|
|
94
130
|
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Referral UFs (Prerequisite)
|
|
134
|
+
|
|
135
|
+
First-time referral mention each session → run `ensureReferralUFs` to verify the 5 user-fields exist on portal. Missing → confirm with user → `userfield.add`. Idempotent.
|
|
136
|
+
|
|
137
|
+
### Required UFs
|
|
138
|
+
|
|
139
|
+
| Entity | FIELD_NAME | XML_ID | USER_TYPE_ID | MULTIPLE | SETTINGS |
|
|
140
|
+
|---|---|---|---|---|---|
|
|
141
|
+
| CRM_CONTACT | UF_CRM_REFERRER | REFERRER | crm | N | `{ CONTACT: "Y" }` |
|
|
142
|
+
| CRM_COMPANY | UF_CRM_REFERRER | REFERRER | crm | N | `{ CONTACT: "Y" }` |
|
|
143
|
+
| CRM_DEAL | UF_CRM_REFERRER | REFERRER | crm | N | `{ CONTACT: "Y" }` |
|
|
144
|
+
| CRM_LEAD | UF_CRM_REFERRER | REFERRER | crm | N | `{ CONTACT: "Y" }` |
|
|
145
|
+
| CRM_CONTACT | UF_CRM_REFERRED_CONTACTS | REFERRED_CONTACTS | crm | **Y** | `{ CONTACT: "Y" }` |
|
|
146
|
+
|
|
147
|
+
`USER_TYPE_ID="crm"` binds UF → CRM **entity records** (not UF↔UF). Reverse sync is app logic.
|
|
148
|
+
|
|
149
|
+
### Discovery — filter by XML_ID, not FIELD_NAME
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
async function ensureReferralUFs() {
|
|
153
|
+
const required = [
|
|
154
|
+
{ entity: 'CONTACT', list: 'crm.contact.userfield.list', add: 'crm.contact.userfield.add', xmlIds: ['REFERRER', 'REFERRED_CONTACTS'] },
|
|
155
|
+
{ entity: 'COMPANY', list: 'crm.company.userfield.list', add: 'crm.company.userfield.add', xmlIds: ['REFERRER'] },
|
|
156
|
+
{ entity: 'DEAL', list: 'crm.deal.userfield.list', add: 'crm.deal.userfield.add', xmlIds: ['REFERRER'] },
|
|
157
|
+
{ entity: 'LEAD', list: 'crm.lead.userfield.list', add: 'crm.lead.userfield.add', xmlIds: ['REFERRER'] },
|
|
158
|
+
];
|
|
159
|
+
const missing = [];
|
|
160
|
+
for (const r of required) {
|
|
161
|
+
const res = await codemode.request({ method: 'POST', path: `/${r.list}/`, body: { filter: {} } });
|
|
162
|
+
const fields = res.result || res;
|
|
163
|
+
for (const xml of r.xmlIds) {
|
|
164
|
+
if (!fields.find(f => f.XML_ID === xml)) missing.push({ entity: r.entity, xml, add: r.add });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return missing;
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
After first successful verify in a session → set `referralUFsReady = true`, skip subsequent calls.
|
|
172
|
+
|
|
173
|
+
### User confirm gate
|
|
174
|
+
|
|
175
|
+
If `missing.length > 0`, MUST `AskUserQuestion`:
|
|
176
|
+
|
|
177
|
+
- Header: "UF setup"
|
|
178
|
+
- Question: "Portal thiếu N userfield cần thiết cho referral tracking. Tạo bây giờ?"
|
|
179
|
+
- Options:
|
|
180
|
+
- "Tạo tất cả ({list})" — call `userfield.add` per missing
|
|
181
|
+
- "Skip lần này" — abort referral flow, continue plain create
|
|
182
|
+
- "Hiển thị chi tiết" — show full spec, ask again
|
|
183
|
+
|
|
184
|
+
### Create payloads
|
|
185
|
+
|
|
186
|
+
REFERRER (single, forward — applies to contact/company/deal/lead):
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
{
|
|
190
|
+
fields: {
|
|
191
|
+
FIELD_NAME: "UF_CRM_REFERRER",
|
|
192
|
+
USER_TYPE_ID: "crm",
|
|
193
|
+
XML_ID: "REFERRER",
|
|
194
|
+
MULTIPLE: "N",
|
|
195
|
+
MANDATORY: "N",
|
|
196
|
+
SHOW_FILTER: "I",
|
|
197
|
+
SHOW_IN_LIST: "Y",
|
|
198
|
+
EDIT_IN_LIST: "Y",
|
|
199
|
+
SETTINGS: { CONTACT: "Y" },
|
|
200
|
+
EDIT_FORM_LABEL: { vi: "Người giới thiệu", en: "Referrer" },
|
|
201
|
+
LIST_COLUMN_LABEL: { vi: "Người giới thiệu", en: "Referrer" },
|
|
202
|
+
SORT: 100
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
REFERRED_CONTACTS (multiple, reverse — contact only):
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
{
|
|
211
|
+
fields: {
|
|
212
|
+
FIELD_NAME: "UF_CRM_REFERRED_CONTACTS",
|
|
213
|
+
USER_TYPE_ID: "crm",
|
|
214
|
+
XML_ID: "REFERRED_CONTACTS",
|
|
215
|
+
MULTIPLE: "Y",
|
|
216
|
+
MANDATORY: "N",
|
|
217
|
+
SHOW_FILTER: "I",
|
|
218
|
+
SHOW_IN_LIST: "Y",
|
|
219
|
+
EDIT_IN_LIST: "Y",
|
|
220
|
+
SETTINGS: { CONTACT: "Y" },
|
|
221
|
+
EDIT_FORM_LABEL: { vi: "Khách đã giới thiệu", en: "Referred Customers" },
|
|
222
|
+
LIST_COLUMN_LABEL: { vi: "Khách đã giới thiệu", en: "Referred" },
|
|
223
|
+
SORT: 101
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Error handling
|
|
229
|
+
|
|
230
|
+
| Error | Action |
|
|
231
|
+
|---|---|
|
|
232
|
+
| `Access denied` (non-admin webhook) | Stop flow, show UF spec, ask admin to create manually |
|
|
233
|
+
| `INTERNAL_SERVER_ERROR` (UF limit / timeout) | Show count from `userfield.list`, suggest cleanup |
|
|
234
|
+
| Duplicate `FIELD_NAME` (race) | Treat as success, re-fetch to confirm XML_ID match |
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Lifecycle UF (Prerequisite)
|
|
239
|
+
First-time contact/company create each session → perform this SOP to verify `UF_CRM_LIFECYCLE` exists on both entities. Missing/schema mismatch → confirm with user → `userfield.add` with 8-stage enumeration. After first verify → `lifecycleUFsReady = true`.
|
|
240
|
+
|
|
241
|
+
### Required UFs
|
|
242
|
+
- CONTACT + COMPANY runtime field: `UF_CRM_LIFECYCLE`; add payload field code: `FIELD_NAME=LIFECYCLE`; `USER_TYPE_ID=enumeration`, `MULTIPLE=N`, default `LEAD`.
|
|
243
|
+
|
|
244
|
+
### Discovery + enum-ID cache
|
|
245
|
+
|
|
246
|
+
```js
|
|
247
|
+
// Pseudocode SOP: execute with codemode.request; not a named MCP helper.
|
|
248
|
+
const lifecycleXmlIds = ['SUBSCRIBER', 'LEAD', 'MQL', 'SQL', 'OPPORTUNITY', 'CUSTOMER', 'EVANGELIST', 'OTHER'];
|
|
249
|
+
const validLifecycleUF = f => {
|
|
250
|
+
const list = f?.LIST || [], ids = new Set(list.map(x => x.XML_ID));
|
|
251
|
+
return f?.FIELD_NAME === 'UF_CRM_LIFECYCLE' && f.USER_TYPE_ID === 'enumeration' && f.MULTIPLE === 'N'
|
|
252
|
+
&& lifecycleXmlIds.every(x => ids.has(x)) && list.find(x => x.XML_ID === 'LEAD')?.DEF === 'Y';
|
|
253
|
+
};
|
|
254
|
+
async function lifecycleUfSop() {
|
|
255
|
+
const required = [{ entity: 'CONTACT', list: 'crm.contact.userfield.list', add: 'crm.contact.userfield.add' },
|
|
256
|
+
{ entity: 'COMPANY', list: 'crm.company.userfield.list', add: 'crm.company.userfield.add' }];
|
|
257
|
+
const missing = [], enumCache = { CONTACT: {}, COMPANY: {} };
|
|
258
|
+
|
|
259
|
+
for (const r of required) {
|
|
260
|
+
try {
|
|
261
|
+
const res = await codemode.request({ method: 'POST', path: `/${r.list}/`, body: { filter: {} } });
|
|
262
|
+
const f = (res.result || res).find(x => x.XML_ID === 'LIFECYCLE');
|
|
263
|
+
if (!validLifecycleUF(f)) missing.push({ entity: r.entity, add: r.add, reason: f ? 'schema mismatch' : 'missing' });
|
|
264
|
+
else for (const item of f.LIST) enumCache[r.entity][item.XML_ID] = item.ID;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return { ok: false, error, missing: [{ entity: r.entity, add: r.add, reason: 'list failed' }], enumCache };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { ok: true, missing, enumCache };
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
Cache shape: `{ CONTACT: { LEAD: 1102 }, COMPANY: { LEAD: 1202 } }`. Resolve XML_ID → numeric before write. After `userfield.add`, re-fetch `userfield.list`.
|
|
273
|
+
|
|
274
|
+
### Create payload
|
|
275
|
+
```js
|
|
276
|
+
{
|
|
277
|
+
fields: {
|
|
278
|
+
FIELD_NAME: "LIFECYCLE",
|
|
279
|
+
USER_TYPE_ID: "enumeration",
|
|
280
|
+
XML_ID: "LIFECYCLE",
|
|
281
|
+
MULTIPLE: "N",
|
|
282
|
+
MANDATORY: "N",
|
|
283
|
+
SHOW_FILTER: "I",
|
|
284
|
+
SHOW_IN_LIST: "Y",
|
|
285
|
+
EDIT_IN_LIST: "Y",
|
|
286
|
+
EDIT_FORM_LABEL: { vi: "Giai đoạn vòng đời", en: "Lifecycle Stage" },
|
|
287
|
+
LIST_COLUMN_LABEL: { vi: "Vòng đời", en: "Lifecycle" },
|
|
288
|
+
SORT: 110,
|
|
289
|
+
LIST: [
|
|
290
|
+
{ VALUE: "Subscriber", DEF: "N", SORT: 100, XML_ID: "SUBSCRIBER" },
|
|
291
|
+
{ VALUE: "Lead", DEF: "Y", SORT: 200, XML_ID: "LEAD" },
|
|
292
|
+
{ VALUE: "MQL", DEF: "N", SORT: 300, XML_ID: "MQL" },
|
|
293
|
+
{ VALUE: "SQL", DEF: "N", SORT: 400, XML_ID: "SQL" },
|
|
294
|
+
{ VALUE: "Opportunity", DEF: "N", SORT: 500, XML_ID: "OPPORTUNITY" },
|
|
295
|
+
{ VALUE: "Customer", DEF: "N", SORT: 600, XML_ID: "CUSTOMER" },
|
|
296
|
+
{ VALUE: "Evangelist", DEF: "N", SORT: 700, XML_ID: "EVANGELIST" },
|
|
297
|
+
{ VALUE: "Other", DEF: "N", SORT: 800, XML_ID: "OTHER" }
|
|
298
|
+
]
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
### User confirm gate
|
|
303
|
+
If `missing.length > 0`, MUST `AskUserQuestion`: header "UF setup"; question "Portal thiếu UF_CRM_LIFECYCLE trên N entity. Tạo bây giờ với 8 giai đoạn HubSpot-style?"; options "Tạo tất cả ({list})" / "Skip lần này" / "Hiển thị chi tiết".
|
|
304
|
+
|
|
305
|
+
### Error handling
|
|
306
|
+
|
|
307
|
+
| Error | Action |
|
|
308
|
+
|---|---|
|
|
309
|
+
| `Access denied` | Stop lifecycle flow, show UF spec, ask admin to create manually |
|
|
310
|
+
| `INTERNAL_SERVER_ERROR` | Show `userfield.list` count, suggest cleanup/retry |
|
|
311
|
+
| Duplicate `FIELD_NAME` | Treat as success, re-fetch to confirm XML_ID match |
|
|
312
|
+
| Cache miss for XML_ID | Re-fetch field LIST once; if still missing, ask user to pick a valid stage |
|
|
313
|
+
|
|
314
|
+
Lifecycle classification + integration → [lifecycle.md](./lifecycle.md). Load when creating contact/company.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Referral Flow + Source Auto-Detection
|
|
319
|
+
|
|
320
|
+
Moved to [referral.md](./referral.md):
|
|
321
|
+
- **Referral Flow** — detect "do/được X giới thiệu" → resolve referrer → forward + reverse UF linking.
|
|
322
|
+
- **Source Auto-Detection** — keyword-scored `SOURCE_ID` pick with confidence threshold + ask fallback. Forces `RECOMMENDATION` when referrer active.
|
|
323
|
+
|
|
324
|
+
Load `referral.md` when: user mentions referrer; OR when creating lead/deal and SOURCE_ID is unclear.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
95
328
|
## Update Existing Entity
|
|
96
329
|
|
|
97
330
|
1. Search before update: `codemode.search({ keywords, entities: ["contact","company","deal","lead"], intent: "read" })`.
|
|
@@ -129,6 +362,10 @@ Omit `idempotencyKey`. **Verified 2026-05-15:** D1 table `idempotency_keys` miss
|
|
|
129
362
|
- [ ] Contact: VN phone stored with `+84`; HONORIFIC matches [vn-norms.md](./vn-norms.md).
|
|
130
363
|
- [ ] Company: `RQ_VAT_ID` populated, address entity exists, MST format valid.
|
|
131
364
|
- [ ] Deal: `STAGE_ID` set, `OPPORTUNITY > 0`, payer linked.
|
|
365
|
+
- [ ] Deal (with products): products set; `OPPORTUNITY` matches product total.
|
|
366
|
+
- [ ] Deal/Lead: `SOURCE_ID` non-empty; matches detect result or user pick.
|
|
367
|
+
- [ ] Referral: `UF_CRM_REFERRER` populated if referrer mentioned; referrer's `UF_CRM_REFERRED_CONTACTS` updated (contact target only).
|
|
368
|
+
- [ ] Lifecycle: `UF_CRM_LIFECYCLE` set on contact/company; numeric enum ID resolved from cache (not raw XML_ID).
|
|
132
369
|
- [ ] Lead: `SOURCE_ID` set, phone/email present, products set when user requested products.
|
|
133
370
|
- [ ] Update: only intended fields changed.
|
|
134
371
|
|
|
@@ -142,4 +379,8 @@ Omit `idempotencyKey`. **Verified 2026-05-15:** D1 table `idempotency_keys` miss
|
|
|
142
379
|
| `crm.contact.add` directly | No dedup, phone not normalized | `upsertContact` |
|
|
143
380
|
| `crm.company.add` directly | Requisite missing tax fields + address | `upsertCompanyByTaxCode` |
|
|
144
381
|
| Passed `idempotencyKey` | D1 table error at runtime | Omit the field |
|
|
382
|
+
| Webhook user not admin | `Access denied` on `userfield.add` | Document UF spec, ask admin to add manually |
|
|
383
|
+
| Set REFERRER without reverse update | Referrer can't see who they referred in UI | Run forward + reverse UF write (referral.md steps 4-5) |
|
|
384
|
+
| Force `RECOMMENDATION` without referrer | Wrong attribution | Only force when `referralActive` flag true |
|
|
385
|
+
| Wrote XML_ID string to `UF_CRM_LIFECYCLE` | Bitrix rejects / silently stores wrong value | Resolve XML_ID → numeric via `enumCache[entity][XML_ID]` before write |
|
|
145
386
|
| Skipped verify step | Bugs discovered sessions later | Always verify |
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# bx:crm — Referral + Source Detection
|
|
2
|
+
|
|
3
|
+
Detection, linking, and SOURCE_ID auto-pick. Load when user mentions referrer or when SOURCE_ID is unclear during lead/deal creation. UF prereq lives in [onboard.md § Referral UFs](./onboard.md#referral-ufs-prerequisite) — run `ensureReferralUFs` once per session before the flows below.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Referral Flow
|
|
8
|
+
|
|
9
|
+
Detect "do/được X giới thiệu" mentions → resolve referrer contact → set forward UF on target → append reverse UF on referrer.
|
|
10
|
+
|
|
11
|
+
### Trigger keywords (case-insensitive, substring)
|
|
12
|
+
|
|
13
|
+
| Pattern | Capture |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `(được\|do)\s+(.+?)\s+giới thiệu` | $2 |
|
|
16
|
+
| `giới thiệu (bởi\|từ)\s+(.+)` | $2 |
|
|
17
|
+
| `(khách\|người)\s+của\s+(anh\|chị)\s+(.+)` | $3 |
|
|
18
|
+
| `từ cộng đồng (của\|c\.)\s+(.+)` | $2 |
|
|
19
|
+
| `referral from\s+(.+)` | $1 |
|
|
20
|
+
| `introduced by\s+(.+)` | $1 |
|
|
21
|
+
| `refer(red)? (by\|từ)\s+(.+)` | $3 |
|
|
22
|
+
| `partner contact\s+(.+)` | $1 |
|
|
23
|
+
|
|
24
|
+
**Negation guard:** "không có ai giới thiệu" / "no referrer" → skip flow.
|
|
25
|
+
|
|
26
|
+
Strip trailing tokens ("ạ", "nha", "anh ấy"...) and trim. Show extracted name to user for verification before search.
|
|
27
|
+
|
|
28
|
+
### Flow
|
|
29
|
+
|
|
30
|
+
```mermaid
|
|
31
|
+
flowchart TD
|
|
32
|
+
A[Mention detected] --> B[Extract name]
|
|
33
|
+
B --> C{Confirm with user}
|
|
34
|
+
C -->|adjust| B
|
|
35
|
+
C -->|OK| D[Search existing contacts]
|
|
36
|
+
D --> E{Found?}
|
|
37
|
+
E -->|1 match| F[Confirm referrer]
|
|
38
|
+
E -->|N matches| G[User picks from list]
|
|
39
|
+
E -->|none| H[Collect name+phone/email+HONORIFIC]
|
|
40
|
+
H --> I[upsertContact create]
|
|
41
|
+
F --> J[referrerContactId]
|
|
42
|
+
G --> J
|
|
43
|
+
I --> J
|
|
44
|
+
J --> K[Create target with UF_CRM_REFERRER]
|
|
45
|
+
K --> L[Force SOURCE_ID=RECOMMENDATION on lead/deal]
|
|
46
|
+
L --> M[Read referrer's REFERRED_CONTACTS]
|
|
47
|
+
M --> N[Append targetId if target=contact]
|
|
48
|
+
N --> O[Write merged array back]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Steps
|
|
52
|
+
|
|
53
|
+
**1. Confirm name.** `AskUserQuestion` header "Referrer": "Người giới thiệu tên là '{extracted}' đúng không?" — options: ["Đúng", "Đổi tên...", "Bỏ qua referral"].
|
|
54
|
+
|
|
55
|
+
**2. Find referrer.**
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
codemode.search({
|
|
59
|
+
keywords: [referrerName, ...nameTokens(referrerName)],
|
|
60
|
+
entities: ["contact"],
|
|
61
|
+
intent: "read",
|
|
62
|
+
topK: 5
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- 1 exact + similarity ≥ 95% → confirm "Anh/chị X (0xxx...xxx) phải không?"
|
|
67
|
+
- N matches → list with phone/email masked, user picks
|
|
68
|
+
- 0 → create flow
|
|
69
|
+
|
|
70
|
+
**3. Create referrer (if not found).** Collect: name (have), phone OR email, HONORIFIC (per [vn-norms.md](./vn-norms.md)). Then:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
upsertContact({
|
|
74
|
+
name: extractedName,
|
|
75
|
+
phone: collected.phone,
|
|
76
|
+
email: collected.email,
|
|
77
|
+
HONORIFIC: collected.honorific,
|
|
78
|
+
match: collected.phone ? { phone: collected.phone } : { email: collected.email }
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**4. Link forward UF on target.**
|
|
83
|
+
|
|
84
|
+
- **Contact:** `crm.contact.update` with `fields: { UF_CRM_REFERRER: referrerContactId }`
|
|
85
|
+
- **Company:** `crm.company.update` same shape
|
|
86
|
+
- **Deal:** via `createDealWithParties` add `UF_CRM_REFERRER` + `SOURCE_ID: "RECOMMENDATION"` to `fields`
|
|
87
|
+
- **Lead:** via `createLeadWithParties` add `UF_CRM_REFERRER` + `sourceId: "RECOMMENDATION"`
|
|
88
|
+
|
|
89
|
+
**5. Reverse append on referrer.** Read → merge dedup → write:
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
// Guard: self-refer not allowed
|
|
93
|
+
if (String(referrerContactId) === String(targetContactId)) {
|
|
94
|
+
throw new Error('Referrer cannot reference itself; ask user to re-confirm');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const r = await codemode.request({ method: 'POST', path: '/crm.contact.get/', body: { id: referrerContactId } });
|
|
98
|
+
const raw = r.result?.UF_CRM_REFERRED_CONTACTS;
|
|
99
|
+
const current = Array.isArray(raw) ? raw : []; // never-set multi UF may return false/null
|
|
100
|
+
if (targetEntityType === 'CONTACT') {
|
|
101
|
+
const merged = [...new Set([...current, String(targetContactId)])];
|
|
102
|
+
await codemode.request({
|
|
103
|
+
method: 'POST', path: '/crm.contact.update/',
|
|
104
|
+
body: { id: referrerContactId, fields: { UF_CRM_REFERRED_CONTACTS: merged } }
|
|
105
|
+
});
|
|
106
|
+
// Race verify: re-read; if targetContactId missing, retry merge once.
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Reverse field stores **contact IDs only**. For deal/lead/company targets, link forward only; optionally append the deal's primary contact party if attached.
|
|
111
|
+
|
|
112
|
+
### Race condition
|
|
113
|
+
|
|
114
|
+
2 concurrent referrals for same referrer → may lose 1 ID. Mitigation: re-read after write, retry merge once if mismatch.
|
|
115
|
+
|
|
116
|
+
### SOURCE_ID coupling
|
|
117
|
+
|
|
118
|
+
Referral active → Lead/Deal force `SOURCE_ID = "RECOMMENDATION"` (skip auto-detect). Contact/Company: no SOURCE_ID field, skip. Log: "Source set to RECOMMENDATION (referrer detected)".
|
|
119
|
+
|
|
120
|
+
### Edge cases
|
|
121
|
+
|
|
122
|
+
| Case | Handling |
|
|
123
|
+
|---|---|
|
|
124
|
+
| Ambiguous ("anh X" no surname) | List top-5 named X, user picks |
|
|
125
|
+
| Self-refer (referrer == target) | Reject + ask user re-confirm |
|
|
126
|
+
| User skips mid-flow | Skip UF link, plain create, run Source Auto-Detection |
|
|
127
|
+
| upsertContact dedup hit existing | Reuse found ID, no duplicate |
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Source Auto-Detection
|
|
132
|
+
|
|
133
|
+
Auto-pick `SOURCE_ID` for lead/deal from user context. Referrer present → force `RECOMMENDATION`. Else keyword score → top-1 if confidence ≥80%, else `AskUserQuestion` top-3.
|
|
134
|
+
|
|
135
|
+
### Keyword → STATUS_ID
|
|
136
|
+
|
|
137
|
+
| STATUS_ID | Triggers |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `RECOMMENDATION` | (forced when referral active) |
|
|
140
|
+
| `PARTNER` | "khách cũ", "existing", "đối tác", "partner" |
|
|
141
|
+
| `WEB` | "website", "web", "trang chủ", "homepage" |
|
|
142
|
+
| `WEBFORM` | "form", "crm form", "đăng ký form" |
|
|
143
|
+
| `CALL` | "gọi", "call", "điện thoại", "phone call" |
|
|
144
|
+
| `EMAIL` | "email", "mail", "thư điện tử" |
|
|
145
|
+
| `ADVERTISING` | "quảng cáo", "ad", "ads", "advertising" |
|
|
146
|
+
| `TRADE_SHOW` | "sự kiện", "event", "exhibition", "triển lãm", "hội chợ" |
|
|
147
|
+
| `STORE` | "store", "shop", "cửa hàng" |
|
|
148
|
+
| `REPEAT_SALE` | "mua lại", "repeat", "tái mua" |
|
|
149
|
+
| `OTHER` | (final fallback) |
|
|
150
|
+
|
|
151
|
+
### Channel-specific (open-channel sources)
|
|
152
|
+
|
|
153
|
+
| STATUS_ID | Match |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `*\|FACEBOOK` | "facebook", "fb", "messenger" |
|
|
156
|
+
| `*\|FACEBOOKCOMMENTS` | "comment fb", "fb comment" |
|
|
157
|
+
| `24\|SYNITY_ZALO_OA_CHAT` | "zalo oa", "zalo official", "zalo synity" |
|
|
158
|
+
| `26\|SYNITY_ZALO_BOT` | "zalo bot" |
|
|
159
|
+
| `28\|SYNITY_ZALO_PERSONAL` | "zalo cá nhân chinh" |
|
|
160
|
+
| `*\|TELEGRAM` | "telegram", "tele" |
|
|
161
|
+
| `*\|OPENLINE` | "live chat", "livechat" |
|
|
162
|
+
|
|
163
|
+
Runtime fetch `crm.status.list ENTITY_ID=SOURCE` to avoid stale list.
|
|
164
|
+
|
|
165
|
+
### Algorithm
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
INPUT: userRequestText, referralActive
|
|
169
|
+
CONST gap = 0.2 # min score-delta to consider top[0] clearly ahead of top[1]
|
|
170
|
+
CONST scoreUnit = 5 # keyword.length / scoreUnit; longer keyword = stronger signal
|
|
171
|
+
IF referralActive:
|
|
172
|
+
RETURN { sourceId: "RECOMMENDATION", confidence: 1.0, reason: "referrer detected" }
|
|
173
|
+
|
|
174
|
+
sources = crm.status.list ENTITY_ID=SOURCE
|
|
175
|
+
scores = []
|
|
176
|
+
FOR each source IN sources:
|
|
177
|
+
score = 0; matched = []
|
|
178
|
+
FOR each keyword IN keywordTable[source.STATUS_ID]:
|
|
179
|
+
IF normalize(userRequestText).includes(normalize(keyword)):
|
|
180
|
+
score += keyword.length / scoreUnit
|
|
181
|
+
matched.push(keyword)
|
|
182
|
+
IF score > 0: scores.push({ source, score, matched })
|
|
183
|
+
|
|
184
|
+
top = scores.sort(desc by score).slice(0, 3)
|
|
185
|
+
IF top.length === 0:
|
|
186
|
+
RETURN { needsAsk: true, candidates: defaultTop5(), reason: "no keyword match" }
|
|
187
|
+
|
|
188
|
+
confidence = top[0].score / sum(scores.score)
|
|
189
|
+
IF confidence >= 0.8 AND (top[0].score - (top[1]?.score || 0)) > gap:
|
|
190
|
+
RETURN { sourceId: top[0].source.STATUS_ID, confidence, reason: top[0].matched }
|
|
191
|
+
|
|
192
|
+
RETURN { needsAsk: true, candidates: top, reason: "ambiguous match" }
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Heuristic shortcuts
|
|
196
|
+
|
|
197
|
+
| Signal | Auto-pick |
|
|
198
|
+
|---|---|
|
|
199
|
+
| Explicit "source là facebook" | Exact match, confidence=1.0 |
|
|
200
|
+
| Phone-only contact | Default `CALL`, confidence=0.7 → ask |
|
|
201
|
+
| Form submission | `WEBFORM`, confidence=0.9 |
|
|
202
|
+
| Email-only inbound | `EMAIL`, confidence=0.85 |
|
|
203
|
+
| "mua lại" exact | `REPEAT_SALE` over `PARTNER` |
|
|
204
|
+
|
|
205
|
+
### Diacritic normalization (VN)
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
const norm = s => s.toLowerCase()
|
|
209
|
+
.normalize('NFD')
|
|
210
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
211
|
+
.replace(/đ/g, 'd');
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
"quang cao" or "quảng cáo" → same match.
|
|
215
|
+
|
|
216
|
+
### Ask fallback
|
|
217
|
+
|
|
218
|
+
`needsAsk: true` → `AskUserQuestion` header "Source", question "Source nào phù hợp nhất? (matched: {keywords})", options = top-3 source NAME + matched keywords. Zero match → default top-5: `CALL, WEB, RECOMMENDATION, ADVERTISING, OTHER`.
|
|
219
|
+
|
|
220
|
+
### Logging
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
[Source] auto-picked "Facebook - FB Page" (confidence 0.92, matched: "facebook", "fb page")
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
User can override → revert to ask.
|
|
227
|
+
|
|
228
|
+
### Entity coupling
|
|
229
|
+
|
|
230
|
+
- **Lead/Deal:** `SOURCE_ID` required (1 per entity).
|
|
231
|
+
- **Contact/Company:** no SOURCE_ID field; skip. If part of `createDealWithParties` → apply to the deal.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bx-crm",
|
|
3
3
|
"displayName": "Bitrix CRM Skill",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"target": "global",
|
|
6
6
|
"description": "Claude Code skill for Bitrix24 CRM: contacts, companies, deals, leads, estimates, invoices, customer analysis, pipeline reports",
|
|
7
|
-
"status": "
|
|
7
|
+
"status": "active",
|
|
8
8
|
"requires": {
|
|
9
9
|
"mcp": [
|
|
10
10
|
"bitrix-synity-mcp"
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
13
|
"tier": 0
|
|
14
|
-
}
|
|
14
|
+
}
|