sophhub 0.1.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.
Files changed (125) hide show
  1. package/bin/sophhub.js +21 -0
  2. package/package.json +32 -0
  3. package/skills/VERSIONS.md +27 -0
  4. package/skills/builtin/clawhub/SKILL.md +77 -0
  5. package/skills/builtin/flight-booking/SKILL.md +288 -0
  6. package/skills/builtin/flight-booking/scripts/flight_booking.py +1232 -0
  7. package/skills/builtin/inventory-management/SKILL.md +241 -0
  8. package/skills/builtin/inventory-management/scripts/inventory.py +1844 -0
  9. package/skills/builtin/schedule-reminder/SKILL.md +619 -0
  10. package/skills/builtin/schedule-reminder/schedule_template.md +68 -0
  11. package/skills/builtin/schedule-reminder/scripts/append_event.py +204 -0
  12. package/skills/builtin/schedule-reminder/scripts/create_reminders.sh +163 -0
  13. package/skills/builtin/schedule-reminder/scripts/daily_activate.sh +175 -0
  14. package/skills/builtin/schedule-reminder/scripts/parse_schedule.py +704 -0
  15. package/skills/builtin/schedule-reminder/scripts/setup.sh +242 -0
  16. package/skills/builtin/schedule-reminder//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -0
  17. package/skills/builtin/skill-creator/SKILL.md +370 -0
  18. package/skills/builtin/skill-creator/license.txt +202 -0
  19. package/skills/builtin/skill-creator/scripts/init_skill.py +378 -0
  20. package/skills/builtin/skill-creator/scripts/package_skill.py +111 -0
  21. package/skills/builtin/skill-creator/scripts/quick_validate.py +101 -0
  22. package/skills/builtin/sophnet-customer-management/SKILL.md +271 -0
  23. package/skills/builtin/sophnet-customer-management/pyproject.toml +15 -0
  24. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__init__.py +2 -0
  25. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__main__.py +5 -0
  26. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/cli.py +67 -0
  27. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/__init__.py +2 -0
  28. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/customer.py +60 -0
  29. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/export_file.py +18 -0
  30. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/import_file.py +15 -0
  31. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/reminder.py +26 -0
  32. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/schema.py +28 -0
  33. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/config.py +54 -0
  34. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/__init__.py +2 -0
  35. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/exporter.py +85 -0
  36. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/models.py +84 -0
  37. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/normalizer.py +144 -0
  38. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/parser.py +241 -0
  39. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/query.py +109 -0
  40. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/reminder.py +121 -0
  41. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/repository.py +397 -0
  42. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/schema.py +106 -0
  43. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/service.py +565 -0
  44. package/skills/builtin/sophnet-customer-management/uv.lock +48 -0
  45. package/skills/builtin/sophnet-customized-marketing/SKILL.md +144 -0
  46. package/skills/builtin/sophnet-customized-marketing/playbooks/campaign-planning.md +187 -0
  47. package/skills/builtin/sophnet-customized-marketing/playbooks/content-generation.md +124 -0
  48. package/skills/builtin/sophnet-customized-marketing/playbooks/marketing-calendar.md +59 -0
  49. package/skills/builtin/sophnet-customized-marketing/playbooks/multi-channel-bundle.md +94 -0
  50. package/skills/builtin/sophnet-customized-marketing/playbooks/poster-generation.md +182 -0
  51. package/skills/builtin/sophnet-customized-marketing/playbooks/style-profile-workflow.md +103 -0
  52. package/skills/builtin/sophnet-customized-marketing/pyproject.toml +9 -0
  53. package/skills/builtin/sophnet-customized-marketing/references/campaign-mechanics.md +168 -0
  54. package/skills/builtin/sophnet-customized-marketing/references/content-safety.md +26 -0
  55. package/skills/builtin/sophnet-customized-marketing/references/marketing-date-checklist.md +99 -0
  56. package/skills/builtin/sophnet-customized-marketing/references/platform-writing-guidelines.md +88 -0
  57. package/skills/builtin/sophnet-customized-marketing/references/quality-checklist.md +44 -0
  58. package/skills/builtin/sophnet-customized-marketing/scripts/generate_poster.py +585 -0
  59. package/skills/builtin/sophnet-customized-marketing/scripts/style_profile.py +215 -0
  60. package/skills/builtin/sophnet-face-search/SKILL.md +115 -0
  61. package/skills/builtin/sophnet-face-search/pyproject.toml +11 -0
  62. package/skills/builtin/sophnet-face-search/scripts/face_search.py +336 -0
  63. package/skills/builtin/sophnet-face-search/uv.lock +508 -0
  64. package/skills/builtin/sophnet-image-edit/SKILL.md +140 -0
  65. package/skills/builtin/sophnet-image-edit/pyproject.toml +9 -0
  66. package/skills/builtin/sophnet-image-edit/scripts/edit_and_preview.sh +68 -0
  67. package/skills/builtin/sophnet-image-edit/scripts/edit_image.py +279 -0
  68. package/skills/builtin/sophnet-image-edit/uv.lock +234 -0
  69. package/skills/builtin/sophnet-image-generate/SKILL.md +62 -0
  70. package/skills/builtin/sophnet-image-generate/pyproject.toml +9 -0
  71. package/skills/builtin/sophnet-image-generate/scripts/generate_image.py +156 -0
  72. package/skills/builtin/sophnet-image-generate/uv.lock +234 -0
  73. package/skills/builtin/sophnet-image-ocr/SKILL.md +167 -0
  74. package/skills/builtin/sophnet-image-ocr/pyproject.toml +13 -0
  75. package/skills/builtin/sophnet-image-ocr/scripts/ocr.py +226 -0
  76. package/skills/builtin/sophnet-image-ocr/uv.lock +234 -0
  77. package/skills/builtin/sophnet-infinite-talk/SKILL.md +140 -0
  78. package/skills/builtin/sophnet-infinite-talk/pyproject.toml +9 -0
  79. package/skills/builtin/sophnet-infinite-talk/scripts/gen.py +172 -0
  80. package/skills/builtin/sophnet-oss/SKILL.md +109 -0
  81. package/skills/builtin/sophnet-oss/pyproject.toml +8 -0
  82. package/skills/builtin/sophnet-oss/scripts/upload_file.py +43 -0
  83. package/skills/builtin/sophnet-qa-install/SKILL.md +210 -0
  84. package/skills/builtin/sophnet-qa-install/pyproject.toml +6 -0
  85. package/skills/builtin/sophnet-qa-install/scripts/backup_md.py +35 -0
  86. package/skills/builtin/sophnet-qa-install/scripts/check_installed.py +143 -0
  87. package/skills/builtin/sophnet-qa-install/scripts/update_config.py +142 -0
  88. package/skills/builtin/sophnet-qa-install/scripts/update_md.py +73 -0
  89. package/skills/builtin/sophnet-training-install/SKILL.md +211 -0
  90. package/skills/builtin/sophnet-training-install/pyproject.toml +6 -0
  91. package/skills/builtin/sophnet-training-install/scripts/backup_md.py +35 -0
  92. package/skills/builtin/sophnet-training-install/scripts/check_installed.py +144 -0
  93. package/skills/builtin/sophnet-training-install/scripts/update_config.py +142 -0
  94. package/skills/builtin/sophnet-training-install/scripts/update_md.py +73 -0
  95. package/skills/builtin/sophnet-tts/SKILL.md +79 -0
  96. package/skills/builtin/sophnet-tts/pyproject.toml +9 -0
  97. package/skills/builtin/sophnet-tts/scripts/gen_tts.py +130 -0
  98. package/skills/builtin/sophnet-video-generate/SKILL.md +116 -0
  99. package/skills/builtin/sophnet-video-generate/scripts/gen_video.py +304 -0
  100. package/skills/builtin/video-understand/SKILL.md +79 -0
  101. package/skills/builtin/video-understand/scripts/video_understand.py +204 -0
  102. package/skills/builtin/weather/SKILL.md +112 -0
  103. package/skills/builtin/web-scraper/SKILL.md +101 -0
  104. package/skills/builtin/web-scraper/scripts/scrape.py +270 -0
  105. package/skills/builtin/website-builder/SKILL.md +266 -0
  106. package/skills/builtin/website-builder/scripts/deploy_site.sh +46 -0
  107. package/skills/store/didi-ride/SKILL.md +309 -0
  108. package/skills/store/didi-ride/_meta.json +6 -0
  109. package/skills/store/didi-ride/assets/PREFERENCE.md +58 -0
  110. package/skills/store/didi-ride/package.json +15 -0
  111. package/skills/store/didi-ride/references/api_references.md +171 -0
  112. package/skills/store/didi-ride/references/error_handling.md +68 -0
  113. package/skills/store/didi-ride/references/setup.md +73 -0
  114. package/skills/store/didi-ride/references/workflow.md +150 -0
  115. package/skills/store/flyai/SKILL.md +119 -0
  116. package/skills/store/flyai/references/fliggy-fast-search.md +53 -0
  117. package/skills/store/flyai/references/search-flight.md +89 -0
  118. package/skills/store/flyai/references/search-hotels.md +57 -0
  119. package/skills/store/flyai/references/search-poi.md +49 -0
  120. package/src/commands/download.js +103 -0
  121. package/src/commands/list.js +67 -0
  122. package/src/utils/config.js +24 -0
  123. package/src/utils/gitlab.js +67 -0
  124. package/src/utils/paths.js +19 -0
  125. package/src/utils/versions.js +38 -0
@@ -0,0 +1,565 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .exporter import export_customers, preview_as_markdown
10
+ from .models import CustomerPayload, ImportPreview
11
+ from .normalizer import (
12
+ clean_text,
13
+ dumps_json,
14
+ normalize_date,
15
+ normalize_optional_text,
16
+ normalize_phone,
17
+ normalize_tags,
18
+ normalize_value_for_type,
19
+ )
20
+ from .parser import parse_file
21
+ from .query import match_filters, query_due_within_days, sort_customers
22
+ from .reminder import build_birthday_plans, build_due_plans, build_follow_up_plan, schedule_plans
23
+ from .repository import SQLiteRepository
24
+ from .schema import (
25
+ create_schema_from_labels,
26
+ is_builtin_field,
27
+ resolve_builtin_field,
28
+ resolve_grouped_record_field,
29
+ resolve_or_create_field,
30
+ )
31
+
32
+
33
+ class CustomerManagementService:
34
+ def __init__(self, db_path: str | Path):
35
+ self.repo = SQLiteRepository(db_path)
36
+ self.repo.init_db()
37
+
38
+ def inspect_schema(self) -> dict[str, Any]:
39
+ return {
40
+ "builtin_fields": [
41
+ {"field_key": "name", "label": "姓名", "value_type": "string", "required": True},
42
+ {"field_key": "primary_phone", "label": "手机号", "value_type": "phone", "required": False},
43
+ {"field_key": "birthday", "label": "生日", "value_type": "date", "required": False},
44
+ {"field_key": "tags", "label": "标签", "value_type": "tags", "required": False},
45
+ {"field_key": "notes", "label": "备注", "value_type": "string", "required": False},
46
+ ],
47
+ "dynamic_fields": self.repo.list_field_defs(),
48
+ }
49
+
50
+ def evolve_schema(self, labels: list[str]) -> dict[str, Any]:
51
+ existing_defs = self.repo.list_field_defs()
52
+ now = _now()
53
+ created = []
54
+ for field_def in create_schema_from_labels(labels, existing_defs):
55
+ self.repo.upsert_field_def(field_def.to_dict(), now)
56
+ created.append(field_def.to_dict())
57
+ return {"created_fields": created}
58
+
59
+ def add_customer(self, payload: dict[str, Any]) -> dict[str, Any]:
60
+ customer_payload = CustomerPayload(
61
+ name=clean_text(payload["name"]),
62
+ primary_phone=normalize_phone(payload.get("primary_phone")),
63
+ birthday=normalize_date(payload.get("birthday")),
64
+ tags=normalize_tags(payload.get("tags")),
65
+ notes=normalize_optional_text(payload.get("notes")),
66
+ dynamic_fields=payload.get("dynamic_fields", {}),
67
+ )
68
+ customer_id, created = self.repo.upsert_customer(customer_payload.to_dict(), _now())
69
+ self._save_dynamic_fields("customer", customer_id, customer_payload.dynamic_fields, source="user_confirmed")
70
+ customer = self.get_customer(customer_id)
71
+ return {"created": created, "customer": customer}
72
+
73
+ def update_customer(self, match: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]:
74
+ customer = self._resolve_single_customer(match)
75
+ merged = {
76
+ "customer_id": customer["customer_id"],
77
+ "name": updates.get("name", customer["name"]),
78
+ "primary_phone": updates.get("primary_phone", customer.get("primary_phone")),
79
+ "birthday": updates.get("birthday", customer.get("birthday")),
80
+ "tags": updates.get("tags", customer.get("tags", [])),
81
+ "notes": updates.get("notes", customer.get("notes")),
82
+ }
83
+ customer_id, _ = self.repo.upsert_customer(
84
+ {
85
+ "customer_id": customer["customer_id"],
86
+ "name": clean_text(merged["name"]),
87
+ "primary_phone": normalize_phone(merged.get("primary_phone")),
88
+ "birthday": normalize_date(merged.get("birthday")),
89
+ "tags": normalize_tags(merged.get("tags")),
90
+ "notes": normalize_optional_text(merged.get("notes")),
91
+ },
92
+ _now(),
93
+ )
94
+ self._save_dynamic_fields("customer", customer_id, updates.get("dynamic_fields", {}), source="user_confirmed")
95
+ return {"customer": self.get_customer(customer_id)}
96
+
97
+ def delete_customer(self, match: dict[str, Any]) -> dict[str, Any]:
98
+ customer = self._resolve_single_customer(match)
99
+ deleted = self.repo.delete_customer(customer["customer_id"])
100
+ return {"deleted": deleted, "customer": customer}
101
+
102
+ def get_customer(self, customer_id: str) -> dict[str, Any] | None:
103
+ customers = self._load_customer_views()
104
+ for customer in customers:
105
+ if customer["customer_id"] == customer_id:
106
+ return customer
107
+ return None
108
+
109
+ def query_customers(self, query: dict[str, Any]) -> dict[str, Any]:
110
+ customers = self._load_customer_views()
111
+ if query.get("due_within_days"):
112
+ matched = query_due_within_days(customers, int(query["due_within_days"]))
113
+ else:
114
+ matched = [customer for customer in customers if match_filters(customer, query)]
115
+ matched = sort_customers(matched)
116
+ offset = max(int(query.get("offset", 0)), 0)
117
+ limit = int(query.get("limit", 20))
118
+ summarized = [self._summarize_customer(customer) for customer in matched[offset : offset + limit]]
119
+ return {
120
+ "count": len(matched),
121
+ "items": summarized,
122
+ "offset": offset,
123
+ "limit": limit,
124
+ "markdown": preview_as_markdown(summarized, limit=limit),
125
+ }
126
+
127
+ def add_record(self, payload: dict[str, Any]) -> dict[str, Any]:
128
+ customer = self._resolve_single_customer(payload.get("match", {}))
129
+ record_id = self.repo.create_business_record(
130
+ {
131
+ "customer_id": customer["customer_id"],
132
+ "record_type": payload["record_type"],
133
+ "status": payload.get("status"),
134
+ "record_date": normalize_date(payload.get("record_date")),
135
+ "amount": payload.get("amount"),
136
+ "title": payload.get("title"),
137
+ "raw_payload": payload.get("raw_payload", {}),
138
+ },
139
+ _now(),
140
+ )
141
+ return {"record_id": record_id}
142
+
143
+ def query_records(self, query: dict[str, Any]) -> dict[str, Any]:
144
+ customer_id = None
145
+ if query.get("match"):
146
+ customer = self._resolve_single_customer(query["match"])
147
+ customer_id = customer["customer_id"]
148
+ records = self.repo.list_business_records(customer_id=customer_id)
149
+ record_type = clean_text(query.get("record_type"))
150
+ if record_type:
151
+ records = [item for item in records if item.get("record_type") == record_type]
152
+ if query.get("keyword"):
153
+ keyword = clean_text(query["keyword"])
154
+ records = [item for item in records if keyword in dumps_json(item)]
155
+ limit = int(query.get("limit", 20))
156
+ return {"count": len(records), "items": records[:limit]}
157
+
158
+ def import_file(self, file_path: str | Path, *, commit: bool) -> dict[str, Any]:
159
+ parsed = parse_file(file_path)
160
+ warnings: list[str] = []
161
+ preview_rows: list[dict[str, Any]] = []
162
+ new_fields: dict[str, dict[str, Any]] = {}
163
+ customer_count = 0
164
+ record_count = 0
165
+ existing_defs = self.repo.list_field_defs()
166
+
167
+ if commit:
168
+ self.repo.init_db()
169
+
170
+ for table in parsed["tables"]:
171
+ if table["mode"] == "grouped_records":
172
+ table_result = self._import_grouped_rows(
173
+ table["rows"],
174
+ existing_defs=existing_defs,
175
+ commit=commit,
176
+ )
177
+ else:
178
+ table_result = self._import_generic_rows(
179
+ table["rows"],
180
+ existing_defs=existing_defs,
181
+ commit=commit,
182
+ )
183
+ customer_count += table_result["customer_count"]
184
+ record_count += table_result["record_count"]
185
+ warnings.extend(table_result["warnings"])
186
+ preview_rows.extend(table_result["preview_rows"])
187
+ for item in table_result["new_fields"]:
188
+ new_fields[item["field_key"]] = item
189
+
190
+ preview = ImportPreview(
191
+ mode="commit" if commit else "preview",
192
+ source_file=str(file_path),
193
+ customer_count=customer_count,
194
+ record_count=record_count,
195
+ new_field_count=len(new_fields),
196
+ warnings=warnings,
197
+ preview_rows=preview_rows[:20],
198
+ )
199
+ summary = preview.to_dict()
200
+ job_id = self.repo.save_import_job(str(file_path), summary["mode"], summary, _now())
201
+ return {"import_job_id": job_id, "summary": summary}
202
+
203
+ def plan_reminders(self, payload: dict[str, Any]) -> dict[str, Any]:
204
+ customers = self._load_customer_views()
205
+ kinds = payload.get("kinds") or ["birthday", "due_date"]
206
+ plans = []
207
+ if "birthday" in kinds:
208
+ plans.extend(build_birthday_plans(customers))
209
+ if "due_date" in kinds:
210
+ plans.extend(build_due_plans(customers))
211
+ if payload.get("follow_up"):
212
+ customer = self._resolve_single_customer(payload["follow_up"]["match"])
213
+ plans.append(build_follow_up_plan(customer, payload["follow_up"]["date"]))
214
+ if payload.get("schedule"):
215
+ scheduled = schedule_plans(plans)
216
+ return {"count": len(scheduled), "items": scheduled}
217
+ return {"count": len(plans), "items": [asdict(plan) for plan in plans]}
218
+
219
+ def export_customers(self, output_path: str | Path, query: dict[str, Any] | None = None) -> dict[str, Any]:
220
+ filters = dict(query or {})
221
+ filters["limit"] = 100000
222
+ query_result = self.query_customers(filters)
223
+ return export_customers(query_result["items"], output_path)
224
+
225
+ def _import_generic_rows(
226
+ self,
227
+ rows: list[dict[str, str]],
228
+ *,
229
+ existing_defs: list[dict[str, Any]],
230
+ commit: bool,
231
+ ) -> dict[str, Any]:
232
+ now = _now()
233
+ customer_count = 0
234
+ record_count = 0
235
+ warnings: list[str] = []
236
+ preview_rows: list[dict[str, Any]] = []
237
+ created_fields: list[dict[str, Any]] = []
238
+
239
+ columns = _collect_columns(rows)
240
+ for column in columns:
241
+ field_def = resolve_or_create_field(
242
+ label=column,
243
+ sample_values=[row.get(column) for row in rows],
244
+ existing_defs=existing_defs,
245
+ source="imported",
246
+ )
247
+ if field_def and field_def.field_key not in {item["field_key"] for item in created_fields}:
248
+ created_fields.append(field_def.to_dict())
249
+ existing_defs.append(field_def.to_dict())
250
+ if commit:
251
+ self.repo.upsert_field_def(field_def.to_dict(), now)
252
+
253
+ for row in rows:
254
+ name = None
255
+ builtin_values: dict[str, Any] = {}
256
+ dynamic_fields: dict[str, Any] = {}
257
+ for label, value in row.items():
258
+ builtin_key = resolve_builtin_field(label)
259
+ if builtin_key:
260
+ builtin_values[builtin_key] = value
261
+ if builtin_key == "name":
262
+ name = clean_text(value)
263
+ else:
264
+ field_key = self._resolve_field_key_from_defs(label, existing_defs)
265
+ if field_key:
266
+ dynamic_fields[field_key] = value
267
+ if not name:
268
+ warnings.append(f"跳过一行:缺少客户姓名 -> {row}")
269
+ continue
270
+ preview_rows.append({"name": name, "dynamic_fields": dynamic_fields})
271
+ customer_count += 1
272
+ if commit:
273
+ result = self.add_customer(
274
+ {
275
+ "name": name,
276
+ "primary_phone": builtin_values.get("primary_phone"),
277
+ "birthday": builtin_values.get("birthday"),
278
+ "tags": builtin_values.get("tags", ""),
279
+ "notes": builtin_values.get("notes"),
280
+ "dynamic_fields": dynamic_fields,
281
+ }
282
+ )
283
+ if not result.get("customer"):
284
+ warnings.append(f"导入失败:{row}")
285
+ return {
286
+ "customer_count": customer_count,
287
+ "record_count": record_count,
288
+ "warnings": warnings,
289
+ "preview_rows": preview_rows,
290
+ "new_fields": created_fields,
291
+ }
292
+
293
+ def _import_grouped_rows(
294
+ self,
295
+ rows: list[dict[str, str]],
296
+ *,
297
+ existing_defs: list[dict[str, Any]],
298
+ commit: bool,
299
+ ) -> dict[str, Any]:
300
+ now = _now()
301
+ warnings: list[str] = []
302
+ preview_rows: list[dict[str, Any]] = []
303
+ created_fields: list[dict[str, Any]] = []
304
+ customer_count = 0
305
+ record_count = 0
306
+
307
+ for label, values in self._collect_grouped_dynamic_field_samples(rows).items():
308
+ field_def = resolve_or_create_field(
309
+ label=label,
310
+ sample_values=values,
311
+ existing_defs=existing_defs,
312
+ source="imported",
313
+ )
314
+ if field_def and field_def.field_key not in {item["field_key"] for item in created_fields}:
315
+ created_fields.append(field_def.to_dict())
316
+ existing_defs.append(field_def.to_dict())
317
+ if commit:
318
+ self.repo.upsert_field_def(field_def.to_dict(), now)
319
+
320
+ seen_customer_ids: set[str] = set()
321
+ preview_customer_keys: set[tuple[str, str | None, str | None]] = set()
322
+ for row in rows:
323
+ entities = self._extract_grouped_entities(row)
324
+ anchor_customer_id = "__preview__"
325
+ for entity in entities:
326
+ preview_rows.append({"name": entity["payload"]["name"], "entity_group": entity["group"]})
327
+ preview_customer_keys.add(
328
+ (
329
+ entity["payload"]["name"],
330
+ entity["payload"].get("primary_phone"),
331
+ entity["payload"].get("birthday"),
332
+ )
333
+ )
334
+ if commit:
335
+ result = self.add_customer({**entity["payload"], "dynamic_fields": entity["dynamic_fields"]})
336
+ customer = result["customer"]
337
+ customer_id = customer["customer_id"]
338
+ seen_customer_ids.add(customer_id)
339
+ if anchor_customer_id == "__preview__":
340
+ anchor_customer_id = customer_id
341
+
342
+ customer_count = len(seen_customer_ids) if commit else len(preview_customer_keys)
343
+
344
+ record_payload = self._build_grouped_record_payload(row, anchor_customer_id if entities else None)
345
+ if record_payload:
346
+ record_count += 1
347
+ if commit and record_payload["customer_id"] != "__preview__":
348
+ self.repo.create_business_record(record_payload, now)
349
+
350
+ return {
351
+ "customer_count": customer_count,
352
+ "record_count": record_count,
353
+ "warnings": warnings,
354
+ "preview_rows": preview_rows,
355
+ "new_fields": created_fields,
356
+ }
357
+
358
+ def _build_grouped_record_payload(self, row: dict[str, str], customer_id: str | None) -> dict[str, Any] | None:
359
+ if not customer_id:
360
+ return None
361
+ record_date = None
362
+ amount = None
363
+ title = None
364
+ payload = {
365
+ "customer_id": customer_id,
366
+ "record_type": "linked_record",
367
+ "status": "active",
368
+ "record_date": None,
369
+ "amount": None,
370
+ "title": None,
371
+ "raw_payload": {},
372
+ }
373
+ for label, value in row.items():
374
+ if not clean_text(value):
375
+ continue
376
+ record_key = resolve_grouped_record_field(label)
377
+ payload["raw_payload"][record_key or label] = value
378
+ if record_key == "record_date" and not record_date:
379
+ record_date = normalize_date(value)
380
+ elif record_key == "amount" and not amount:
381
+ amount = value
382
+ elif record_key == "item_name" and not title:
383
+ title = value
384
+ payload["record_date"] = record_date
385
+ payload["amount"] = amount
386
+ payload["title"] = title or self._guess_record_title(row)
387
+ return payload
388
+
389
+ def _extract_grouped_entities(self, row: dict[str, str]) -> list[dict[str, Any]]:
390
+ grouped_values: dict[str, dict[str, str]] = {}
391
+ for label, value in row.items():
392
+ if "_" not in label or not clean_text(value):
393
+ continue
394
+ group, sub_label = label.split("_", 1)
395
+ if not clean_text(group) or not clean_text(sub_label):
396
+ continue
397
+ grouped_values.setdefault(group, {})[sub_label] = value
398
+
399
+ entities: list[dict[str, Any]] = []
400
+ for group, fields in grouped_values.items():
401
+ builtin_values: dict[str, Any] = {}
402
+ dynamic_fields: dict[str, Any] = {}
403
+ name = None
404
+ for sub_label, value in fields.items():
405
+ builtin_key = resolve_builtin_field(sub_label)
406
+ if builtin_key:
407
+ builtin_values[builtin_key] = value
408
+ if builtin_key == "name":
409
+ name = clean_text(value)
410
+ else:
411
+ dynamic_fields[sub_label] = value
412
+ if not name:
413
+ continue
414
+ payload = {
415
+ "name": name,
416
+ "primary_phone": builtin_values.get("primary_phone"),
417
+ "birthday": builtin_values.get("birthday"),
418
+ "notes": builtin_values.get("notes") or f"来自分组记录导入:{group}",
419
+ "tags": [group],
420
+ }
421
+ entities.append({"group": group, "payload": payload, "dynamic_fields": dynamic_fields})
422
+ return entities
423
+
424
+ def _collect_grouped_dynamic_field_samples(self, rows: list[dict[str, str]]) -> dict[str, list[str]]:
425
+ samples: dict[str, list[str]] = {}
426
+ for row in rows:
427
+ for label, value in row.items():
428
+ if "_" not in label or not clean_text(value):
429
+ continue
430
+ _, sub_label = label.split("_", 1)
431
+ if resolve_builtin_field(sub_label):
432
+ continue
433
+ samples.setdefault(sub_label, []).append(value)
434
+ return samples
435
+
436
+ def _guess_record_title(self, row: dict[str, str]) -> str | None:
437
+ for label, value in row.items():
438
+ if "_" in label:
439
+ continue
440
+ if not clean_text(value):
441
+ continue
442
+ if resolve_grouped_record_field(label) in {"record_no", "record_date", "amount", "term", "owner_name"}:
443
+ continue
444
+ return value
445
+ return None
446
+
447
+ def _save_dynamic_fields(self, entity_type: str, entity_id: str, dynamic_fields: dict[str, Any], *, source: str) -> None:
448
+ if not dynamic_fields:
449
+ return
450
+ existing_defs = self.repo.list_field_defs(entity_type=entity_type)
451
+ now = _now()
452
+ for key_or_label, value in dynamic_fields.items():
453
+ field_key = key_or_label
454
+ field_def = next((item for item in existing_defs if item["field_key"] == field_key), None)
455
+ if not field_def:
456
+ field_def = next(
457
+ (
458
+ item
459
+ for item in existing_defs
460
+ if clean_text(item["label"]) == clean_text(key_or_label)
461
+ or clean_text(key_or_label) in {clean_text(alias) for alias in (item.get("aliases") or [])}
462
+ ),
463
+ None,
464
+ )
465
+ if field_def:
466
+ field_key = field_def["field_key"]
467
+ if not field_def:
468
+ field_def_obj = resolve_or_create_field(
469
+ label=key_or_label,
470
+ sample_values=[value],
471
+ existing_defs=existing_defs,
472
+ source=source,
473
+ )
474
+ if not field_def_obj:
475
+ continue
476
+ field_def = field_def_obj.to_dict()
477
+ self.repo.upsert_field_def(field_def, now)
478
+ existing_defs.append(field_def)
479
+ field_key = field_def["field_key"]
480
+ normalized_value = normalize_value_for_type(value, field_def["value_type"])
481
+ self.repo.upsert_field_value(
482
+ entity_type=entity_type,
483
+ entity_id=entity_id,
484
+ field_key=field_key,
485
+ value=clean_text(value),
486
+ normalized_value=normalized_value,
487
+ display_value=clean_text(value),
488
+ now=now,
489
+ )
490
+
491
+ def _load_customer_views(self) -> list[dict[str, Any]]:
492
+ customers = self.repo.list_customers()
493
+ defs = {item["field_key"]: item for item in self.repo.list_field_defs()}
494
+ values = self.repo.list_field_values(entity_type="customer")
495
+ value_map: dict[str, dict[str, Any]] = {}
496
+ for value in values:
497
+ entity_values = value_map.setdefault(value["entity_id"], {})
498
+ field_def = defs.get(value["field_key"], {})
499
+ entity_values[value["field_key"]] = {
500
+ "key": value["field_key"],
501
+ "label": field_def.get("label", value["field_key"]),
502
+ "type": field_def.get("value_type", "string"),
503
+ "value": value["normalized_value"] or value["value"],
504
+ "displayValue": value["display_value"] or value["value"],
505
+ }
506
+ records_by_customer: dict[str, list[dict[str, Any]]] = {}
507
+ for record in self.repo.list_business_records():
508
+ records_by_customer.setdefault(record["customer_id"], []).append(record)
509
+ for customer in customers:
510
+ customer["dynamic_field_map"] = value_map.get(customer["customer_id"], {})
511
+ customer["dynamic_fields"] = list(customer["dynamic_field_map"].values())
512
+ customer["records"] = records_by_customer.get(customer["customer_id"], [])
513
+ return customers
514
+
515
+ def _resolve_single_customer(self, match: dict[str, Any]) -> dict[str, Any]:
516
+ if match.get("customer_id"):
517
+ customer = self.get_customer(match["customer_id"])
518
+ if customer:
519
+ return customer
520
+ query = self.query_customers({**match, "limit": 5})
521
+ items = query["items"]
522
+ if not items:
523
+ raise ValueError("未找到匹配的客户")
524
+ if len(items) > 1:
525
+ names = "、".join(item["name"] for item in items[:5])
526
+ raise ValueError(f"匹配到多个客户,请缩小范围:{names}")
527
+ return items[0]
528
+
529
+ def _resolve_field_key_from_defs(self, label: str, defs: list[dict[str, Any]]) -> str | None:
530
+ for item in defs:
531
+ if clean_text(label) == clean_text(item["label"]):
532
+ return item["field_key"]
533
+ aliases = item.get("aliases") or []
534
+ if clean_text(label) in {clean_text(alias) for alias in aliases}:
535
+ return item["field_key"]
536
+ return None
537
+
538
+ def _summarize_customer(self, customer: dict[str, Any]) -> dict[str, Any]:
539
+ return {
540
+ "customer_id": customer["customer_id"],
541
+ "name": customer.get("name"),
542
+ "primary_phone": customer.get("primary_phone"),
543
+ "birthday": customer.get("birthday"),
544
+ "notes": customer.get("notes"),
545
+ "created_at": customer.get("created_at"),
546
+ "updated_at": customer.get("updated_at"),
547
+ "tags": customer.get("tags", []),
548
+ "dynamic_field_map": customer.get("dynamic_field_map", {}),
549
+ "dynamic_fields": customer.get("dynamic_fields", []),
550
+ "record_count": len(customer.get("records", [])),
551
+ "due_fields": customer.get("due_fields", []),
552
+ }
553
+
554
+
555
+ def _collect_columns(rows: list[dict[str, str]]) -> list[str]:
556
+ ordered: list[str] = []
557
+ for row in rows:
558
+ for key in row.keys():
559
+ if key not in ordered:
560
+ ordered.append(key)
561
+ return ordered
562
+
563
+
564
+ def _now() -> str:
565
+ return datetime.now().isoformat(timespec="seconds")
@@ -0,0 +1,48 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "et-xmlfile"
7
+ version = "2.0.0"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "openpyxl"
16
+ version = "3.1.5"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ dependencies = [
19
+ { name = "et-xmlfile" },
20
+ ]
21
+ sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
22
+ wheels = [
23
+ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
24
+ ]
25
+
26
+ [[package]]
27
+ name = "sophnet-customer-management"
28
+ version = "1.0.0"
29
+ source = { editable = "." }
30
+ dependencies = [
31
+ { name = "openpyxl" },
32
+ { name = "xlrd" },
33
+ ]
34
+
35
+ [package.metadata]
36
+ requires-dist = [
37
+ { name = "openpyxl", specifier = ">=3.1.0" },
38
+ { name = "xlrd", specifier = ">=2.0.1" },
39
+ ]
40
+
41
+ [[package]]
42
+ name = "xlrd"
43
+ version = "2.0.2"
44
+ source = { registry = "https://pypi.org/simple" }
45
+ sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" }
46
+ wheels = [
47
+ { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" },
48
+ ]