@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 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 available features" });
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.9",
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",
@@ -4,7 +4,7 @@
4
4
  "version": "1.0.0",
5
5
  "target": "global",
6
6
  "description": "Claude Code hub skill \u2014 routes to bx:crm, bx:task, bx:calendar. Install for discovery UX.",
7
- "status": "planned",
7
+ "status": "active",
8
8
  "requires": {},
9
9
  "tier": 0
10
10
  }
@@ -4,7 +4,7 @@
4
4
  "version": "1.0.0",
5
5
  "target": "global",
6
6
  "description": "Claude Code skill for Bitrix24 Calendar: meetings, reminders, team availability, CRM activity sync",
7
- "status": "planned",
7
+ "status": "active",
8
8
  "requires": {
9
9
  "env": [
10
10
  "BITRIX_WEBHOOK_URL"
@@ -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.0.0"
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 mapping:** Event/offline=`OTHER`, website/form=`WEB`, partner=`PARTNER`, call=`CALL`, social/ad=`ADVERTISING`.
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.0.0",
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": "planned",
7
+ "status": "active",
8
8
  "requires": {
9
9
  "mcp": [
10
10
  "bitrix-synity-mcp"
11
11
  ]
12
12
  },
13
13
  "tier": 0
14
- }
14
+ }