sophhub 0.4.21 → 0.4.22

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 (68) hide show
  1. package/package.json +1 -1
  2. package/skills/insurance-customer-policy/skill.json +12 -0
  3. package/skills/insurance-customer-policy/src/SKILL.md +121 -0
  4. package/skills/insurance-customer-policy/src/pyproject.toml +9 -0
  5. package/skills/insurance-customer-policy/src/scripts/cli.py +16 -0
  6. package/skills/insurance-customer-policy/src/scripts/cloud_insurance_full_test.py +785 -0
  7. package/skills/insurance-customer-policy/src/scripts/dashboard_all.py +205 -0
  8. package/skills/insurance-customer-policy/src/scripts/insurance_customer_cli/__init__.py +0 -0
  9. package/skills/insurance-customer-policy/src/scripts/insurance_customer_cli/__main__.py +4 -0
  10. package/skills/insurance-customer-policy/src/scripts/insurance_customer_cli/cli.py +816 -0
  11. package/skills/insurance-customer-policy/src/scripts/insurance_customer_cli/db.py +181 -0
  12. package/skills/insurance-customer-policy/src/scripts/insurance_customer_cli/insurance_paths.py +184 -0
  13. package/skills/insurance-customer-policy/src/scripts/run_e2e_smoke.py +164 -0
  14. package/skills/insurance-customer-policy/src/scripts/test_cloud_zero_config.py +217 -0
  15. package/skills/insurance-customer-policy/src/scripts/test_dashboard_all.py +113 -0
  16. package/skills/insurance-customer-policy/src/src/insurance_customer_cli/__init__.py +0 -0
  17. package/skills/insurance-customer-policy/src/src/insurance_customer_cli/__main__.py +4 -0
  18. package/skills/insurance-customer-policy/src/src/insurance_customer_cli/cli.py +816 -0
  19. package/skills/insurance-customer-policy/src/src/insurance_customer_cli/db.py +181 -0
  20. package/skills/insurance-customer-policy/src/src/insurance_customer_cli/insurance_paths.py +184 -0
  21. package/skills/insurance-product-analysis/skill.json +12 -0
  22. package/skills/insurance-product-analysis/src/SKILL.md +99 -0
  23. package/skills/insurance-product-analysis/src/pyproject.toml +9 -0
  24. package/skills/insurance-product-analysis/src/scripts/cli.py +16 -0
  25. package/skills/insurance-product-analysis/src/scripts/insurance_product_cli/__init__.py +0 -0
  26. package/skills/insurance-product-analysis/src/scripts/insurance_product_cli/__main__.py +4 -0
  27. package/skills/insurance-product-analysis/src/scripts/insurance_product_cli/cli.py +545 -0
  28. package/skills/insurance-product-analysis/src/scripts/insurance_product_cli/db.py +180 -0
  29. package/skills/insurance-product-analysis/src/scripts/insurance_product_cli/orchestrator.py +163 -0
  30. package/skills/insurance-product-analysis/src/scripts/run_e2e_smoke.py +202 -0
  31. package/skills/insurance-product-analysis/src/src/insurance_product_cli/__init__.py +0 -0
  32. package/skills/insurance-product-analysis/src/src/insurance_product_cli/__main__.py +4 -0
  33. package/skills/insurance-product-analysis/src/src/insurance_product_cli/cli.py +545 -0
  34. package/skills/insurance-product-analysis/src/src/insurance_product_cli/db.py +180 -0
  35. package/skills/insurance-product-analysis/src/src/insurance_product_cli/orchestrator.py +163 -0
  36. package/skills/insurance-sales-pipeline/skill.json +12 -0
  37. package/skills/insurance-sales-pipeline/src/SKILL.md +102 -0
  38. package/skills/insurance-sales-pipeline/src/pyproject.toml +9 -0
  39. package/skills/insurance-sales-pipeline/src/scripts/cli.py +16 -0
  40. package/skills/insurance-sales-pipeline/src/scripts/insurance_pipeline_cli/__init__.py +0 -0
  41. package/skills/insurance-sales-pipeline/src/scripts/insurance_pipeline_cli/__main__.py +4 -0
  42. package/skills/insurance-sales-pipeline/src/scripts/insurance_pipeline_cli/cli.py +496 -0
  43. package/skills/insurance-sales-pipeline/src/scripts/insurance_pipeline_cli/db.py +180 -0
  44. package/skills/insurance-sales-pipeline/src/scripts/insurance_pipeline_cli/orchestrator.py +36 -0
  45. package/skills/insurance-sales-pipeline/src/scripts/run_e2e_smoke.py +208 -0
  46. package/skills/insurance-sales-pipeline/src/src/insurance_pipeline_cli/__init__.py +0 -0
  47. package/skills/insurance-sales-pipeline/src/src/insurance_pipeline_cli/__main__.py +4 -0
  48. package/skills/insurance-sales-pipeline/src/src/insurance_pipeline_cli/cli.py +496 -0
  49. package/skills/insurance-sales-pipeline/src/src/insurance_pipeline_cli/db.py +180 -0
  50. package/skills/insurance-sales-pipeline/src/src/insurance_pipeline_cli/orchestrator.py +36 -0
  51. package/skills/insurance-schedule-renewal/skill.json +12 -0
  52. package/skills/insurance-schedule-renewal/src/SKILL.md +94 -0
  53. package/skills/insurance-schedule-renewal/src/pyproject.toml +9 -0
  54. package/skills/insurance-schedule-renewal/src/scripts/cli.py +16 -0
  55. package/skills/insurance-schedule-renewal/src/scripts/insurance_schedule_cli/__init__.py +0 -0
  56. package/skills/insurance-schedule-renewal/src/scripts/insurance_schedule_cli/__main__.py +4 -0
  57. package/skills/insurance-schedule-renewal/src/scripts/insurance_schedule_cli/cli.py +429 -0
  58. package/skills/insurance-schedule-renewal/src/scripts/insurance_schedule_cli/db.py +180 -0
  59. package/skills/insurance-schedule-renewal/src/scripts/insurance_schedule_cli/orchestrator.py +94 -0
  60. package/skills/insurance-schedule-renewal/src/scripts/run_e2e_smoke.py +218 -0
  61. package/skills/insurance-schedule-renewal/src/src/insurance_schedule_cli/__init__.py +0 -0
  62. package/skills/insurance-schedule-renewal/src/src/insurance_schedule_cli/__main__.py +4 -0
  63. package/skills/insurance-schedule-renewal/src/src/insurance_schedule_cli/cli.py +429 -0
  64. package/skills/insurance-schedule-renewal/src/src/insurance_schedule_cli/db.py +180 -0
  65. package/skills/insurance-schedule-renewal/src/src/insurance_schedule_cli/orchestrator.py +94 -0
  66. package/skills/insurance-shared-data/skill.json +20 -0
  67. package/skills/insurance-shared-data/src/SKILL.md +33 -0
  68. package/skills/insurance-shared-data/src/scripts/cloud_insurance_super_test.py +246 -0
@@ -0,0 +1,816 @@
1
+ """Insurance customer & policy CLI.
2
+
3
+ Subcommands cover customer / policy / followup CRUD, gap-analysis,
4
+ customer-segment, dashboard, and internal helpers used by sibling skills.
5
+ All commands print JSON to stdout: {"ok": true|false, ...}.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import re
12
+ import sqlite3
13
+ import sys
14
+ from datetime import datetime, timedelta
15
+ from typing import Any
16
+
17
+ from insurance_customer_cli import db
18
+
19
+ try:
20
+ sys.stdout.reconfigure(encoding="utf-8")
21
+ sys.stderr.reconfigure(encoding="utf-8")
22
+ except (AttributeError, OSError):
23
+ pass
24
+
25
+ DATE_FMT = "%Y-%m-%d"
26
+
27
+
28
+ def print_json(obj: Any) -> None:
29
+ print(json.dumps(obj, ensure_ascii=False, indent=2))
30
+
31
+
32
+ def next_id(conn: sqlite3.Connection, prefix: str, table: str) -> str:
33
+ rows = conn.execute(
34
+ f"SELECT id FROM {table} WHERE id GLOB ?", (f"{prefix}[0-9]*",)
35
+ ).fetchall()
36
+ max_n = 0
37
+ for r in rows:
38
+ m = re.match(rf"^{prefix}(\d+)$", r["id"])
39
+ if m:
40
+ max_n = max(max_n, int(m.group(1)))
41
+ return f"{prefix}{max_n + 1:03d}"
42
+
43
+
44
+ def parse_date(s: str | None) -> datetime | None:
45
+ if not s:
46
+ return None
47
+ return datetime.strptime(s.strip()[:10], DATE_FMT)
48
+
49
+
50
+ def add_years(d: datetime, years: int) -> datetime:
51
+ try:
52
+ return d.replace(year=d.year + years)
53
+ except ValueError:
54
+ return d.replace(year=d.year + years, day=28)
55
+
56
+
57
+ def add_months(d: datetime, months: int) -> datetime:
58
+ m = d.month - 1 + months
59
+ y = d.year + m // 12
60
+ nm = m % 12 + 1
61
+ day = min(d.day, [31, 29 if y % 4 == 0 and (y % 100 != 0 or y % 400 == 0) else 28,
62
+ 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][nm - 1])
63
+ return d.replace(year=y, month=nm, day=day)
64
+
65
+
66
+ def derive_dates(effective_date: str, pay_period: str | None) -> tuple[str | None, str | None]:
67
+ """Compute (expire_date, next_pay_date) from effective_date + pay_period.
68
+
69
+ Heuristics:
70
+ - "趸交" / "1年" : next_pay_date = effective + 1y, expire = None (开放期/终身保单常见)
71
+ - "N年缴" / "N年" : next_pay_date = effective + 1y; expire = effective + Ny
72
+ - 其他 / 留空 : 保守地把 next_pay_date 设为 effective + 1y。
73
+ """
74
+ eff = parse_date(effective_date)
75
+ if eff is None:
76
+ return None, None
77
+ next_pay = add_years(eff, 1).strftime(DATE_FMT)
78
+ expire: str | None = None
79
+ if pay_period:
80
+ m = re.match(r"^\s*(\d+)\s*年", pay_period)
81
+ if m:
82
+ expire = add_years(eff, int(m.group(1))).strftime(DATE_FMT)
83
+ elif "趸" in pay_period:
84
+ expire = None
85
+ return expire, next_pay
86
+
87
+
88
+ # ---------- customer commands ----------
89
+
90
+ def cmd_customer_add(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
91
+ cid = args.customer_id or next_id(conn, "C", "customers")
92
+ household = args.household or "{}"
93
+ tags = args.tags or "[]"
94
+ fields = args.fields or "{}"
95
+ try:
96
+ json.loads(household)
97
+ json.loads(tags)
98
+ json.loads(fields)
99
+ except json.JSONDecodeError as e:
100
+ print_json({"ok": False, "error": "bad_json", "hint": str(e)})
101
+ return 1
102
+ conn.execute(
103
+ """INSERT INTO customers
104
+ (id, name, phone, age, gender, income, household_json, occupation,
105
+ birthday, tags_json, fields_json)
106
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
107
+ (
108
+ cid,
109
+ args.name,
110
+ args.phone or "",
111
+ args.age,
112
+ args.gender or "",
113
+ args.income,
114
+ household,
115
+ args.occupation or "",
116
+ args.birthday or "",
117
+ tags,
118
+ fields,
119
+ ),
120
+ )
121
+ conn.commit()
122
+ print_json({"ok": True, "id": cid, "name": args.name})
123
+ return 0
124
+
125
+
126
+ def _row_customer(row: sqlite3.Row, conn: sqlite3.Connection | None = None) -> dict:
127
+ d = dict(row)
128
+ for jk in ("household_json", "tags_json", "fields_json"):
129
+ if jk in d and isinstance(d[jk], str):
130
+ try:
131
+ d[jk[: -len("_json")]] = json.loads(d[jk])
132
+ except json.JSONDecodeError:
133
+ d[jk[: -len("_json")]] = None
134
+ if conn is not None:
135
+ cnt = conn.execute(
136
+ "SELECT COUNT(*) AS n FROM policies WHERE customer_id=? AND status='active'",
137
+ (d["id"],),
138
+ ).fetchone()
139
+ d["policy_count"] = int(cnt["n"]) if cnt else 0
140
+ last = conn.execute(
141
+ "SELECT created_at FROM followups WHERE customer_id=? ORDER BY created_at DESC LIMIT 1",
142
+ (d["id"],),
143
+ ).fetchone()
144
+ d["last_followup_at"] = last["created_at"] if last else None
145
+ return d
146
+
147
+
148
+ def cmd_customer_query(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
149
+ cur = conn.cursor()
150
+ rows: list[sqlite3.Row]
151
+ if args.customer_id:
152
+ rows = list(
153
+ cur.execute(
154
+ "SELECT * FROM customers WHERE id=? AND status='active'",
155
+ (args.customer_id,),
156
+ )
157
+ )
158
+ elif args.keyword:
159
+ kw = f"%{args.keyword}%"
160
+ rows = list(
161
+ cur.execute(
162
+ "SELECT * FROM customers WHERE status='active' AND (name LIKE ? OR phone LIKE ?) ORDER BY created_at DESC",
163
+ (kw, kw),
164
+ )
165
+ )
166
+ else:
167
+ rows = list(cur.execute("SELECT * FROM customers WHERE status='active' ORDER BY created_at DESC"))
168
+ out = [_row_customer(r, conn) for r in rows]
169
+ print_json({"ok": True, "customers": out, "count": len(out)})
170
+ return 0
171
+
172
+
173
+ _CUSTOMER_UPDATE_FIELDS = {
174
+ "name": "name",
175
+ "phone": "phone",
176
+ "age": "age",
177
+ "gender": "gender",
178
+ "income": "income",
179
+ "occupation": "occupation",
180
+ "birthday": "birthday",
181
+ "household": "household_json",
182
+ "tags": "tags_json",
183
+ "fields": "fields_json",
184
+ "children": None,
185
+ "spouse": None,
186
+ "elderly": None,
187
+ }
188
+
189
+
190
+ def cmd_customer_update(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
191
+ try:
192
+ payload = json.loads(args.set)
193
+ except json.JSONDecodeError as e:
194
+ print_json({"ok": False, "error": "bad_json", "hint": str(e)})
195
+ return 1
196
+ row = conn.execute(
197
+ "SELECT household_json FROM customers WHERE id=? AND status='active'",
198
+ (args.customer_id,),
199
+ ).fetchone()
200
+ if not row:
201
+ print_json({"ok": False, "error": "customer_not_found"})
202
+ return 1
203
+ sets: list[str] = []
204
+ vals: list[Any] = []
205
+ household_now = json.loads(row["household_json"] or "{}")
206
+ household_dirty = False
207
+ for k, v in payload.items():
208
+ if k in ("children", "spouse", "elderly"):
209
+ household_now[k] = v
210
+ household_dirty = True
211
+ continue
212
+ col = _CUSTOMER_UPDATE_FIELDS.get(k)
213
+ if col is None:
214
+ continue
215
+ if col.endswith("_json"):
216
+ sets.append(f"{col}=?")
217
+ vals.append(json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v)
218
+ else:
219
+ sets.append(f"{col}=?")
220
+ vals.append(v)
221
+ if household_dirty:
222
+ sets.append("household_json=?")
223
+ vals.append(json.dumps(household_now, ensure_ascii=False))
224
+ if sets:
225
+ sets.append("updated_at=datetime('now','localtime')")
226
+ vals.append(args.customer_id)
227
+ conn.execute(f"UPDATE customers SET {', '.join(sets)} WHERE id=?", vals)
228
+ conn.commit()
229
+ print_json({"ok": True, "id": args.customer_id})
230
+ return 0
231
+
232
+
233
+ def cmd_customer_delete(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
234
+ row = conn.execute(
235
+ "SELECT id FROM customers WHERE id=? AND status='active'",
236
+ (args.customer_id,),
237
+ ).fetchone()
238
+ if not row:
239
+ print_json({"ok": False, "error": "customer_not_found"})
240
+ return 1
241
+ cnt = conn.execute(
242
+ "SELECT COUNT(*) AS n FROM policies WHERE customer_id=? AND status='active'",
243
+ (args.customer_id,),
244
+ ).fetchone()
245
+ if int(cnt["n"]) > 0:
246
+ print_json({"ok": False, "error": "has_policies", "policy_count": int(cnt["n"])})
247
+ return 1
248
+ if not args.yes:
249
+ print_json({"ok": False, "error": "need_confirm", "hint": "请加 --yes 确认删除"})
250
+ return 1
251
+ conn.execute(
252
+ "UPDATE customers SET status='deleted', updated_at=datetime('now','localtime') WHERE id=?",
253
+ (args.customer_id,),
254
+ )
255
+ conn.commit()
256
+ print_json({"ok": True})
257
+ return 0
258
+
259
+
260
+ def cmd_customer_exists(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
261
+ row = conn.execute(
262
+ "SELECT id FROM customers WHERE id=? AND status='active'",
263
+ (args.customer_id,),
264
+ ).fetchone()
265
+ print_json({"ok": True, "exists": bool(row)})
266
+ return 0
267
+
268
+
269
+ # ---------- policy commands ----------
270
+
271
+ def cmd_policy_add(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
272
+ cust = conn.execute(
273
+ "SELECT id FROM customers WHERE id=? AND status='active'", (args.customer_id,)
274
+ ).fetchone()
275
+ if not cust:
276
+ print_json({"ok": False, "error": "customer_not_found"})
277
+ return 1
278
+ pid = args.policy_id or next_id(conn, "P", "policies")
279
+ expire = args.expire_date
280
+ next_pay = args.next_pay_date
281
+ if not expire or not next_pay:
282
+ de, dn = derive_dates(args.effective_date, args.pay_period)
283
+ expire = expire or de
284
+ next_pay = next_pay or dn
285
+ conn.execute(
286
+ """INSERT INTO policies
287
+ (id, customer_id, company, product_name, product_type, coverage, premium,
288
+ pay_period, effective_date, expire_date, next_pay_date,
289
+ insured_name, beneficiary, notes)
290
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
291
+ (
292
+ pid,
293
+ args.customer_id,
294
+ args.company,
295
+ args.product,
296
+ args.product_type,
297
+ float(args.coverage),
298
+ float(args.premium),
299
+ args.pay_period or "",
300
+ args.effective_date,
301
+ expire or "",
302
+ next_pay or "",
303
+ args.insured or "",
304
+ args.beneficiary or "",
305
+ args.notes or "",
306
+ ),
307
+ )
308
+ conn.commit()
309
+ print_json(
310
+ {
311
+ "ok": True,
312
+ "id": pid,
313
+ "customer_id": args.customer_id,
314
+ "expire_date": expire,
315
+ "next_pay_date": next_pay,
316
+ }
317
+ )
318
+ return 0
319
+
320
+
321
+ def cmd_policy_query(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
322
+ cur = conn.cursor()
323
+ rows: list[sqlite3.Row]
324
+ if args.policy_id:
325
+ rows = list(cur.execute("SELECT * FROM policies WHERE id=? AND status='active'", (args.policy_id,)))
326
+ elif args.customer_id:
327
+ rows = list(
328
+ cur.execute(
329
+ "SELECT * FROM policies WHERE customer_id=? AND status='active' ORDER BY effective_date DESC",
330
+ (args.customer_id,),
331
+ )
332
+ )
333
+ elif args.expire_before:
334
+ rows = list(
335
+ cur.execute(
336
+ "SELECT * FROM policies WHERE status='active' AND expire_date != '' AND expire_date <= ? ORDER BY expire_date",
337
+ (args.expire_before,),
338
+ )
339
+ )
340
+ elif args.next_pay_before:
341
+ rows = list(
342
+ cur.execute(
343
+ "SELECT * FROM policies WHERE status='active' AND next_pay_date != '' AND next_pay_date <= ? ORDER BY next_pay_date",
344
+ (args.next_pay_before,),
345
+ )
346
+ )
347
+ elif args.product_type:
348
+ rows = list(
349
+ cur.execute(
350
+ "SELECT * FROM policies WHERE status='active' AND product_type=? ORDER BY effective_date DESC",
351
+ (args.product_type,),
352
+ )
353
+ )
354
+ else:
355
+ rows = list(cur.execute("SELECT * FROM policies WHERE status='active' ORDER BY effective_date DESC"))
356
+ print_json({"ok": True, "policies": [dict(r) for r in rows], "count": len(rows)})
357
+ return 0
358
+
359
+
360
+ _POLICY_UPDATE_FIELDS = {
361
+ "company": "company",
362
+ "product_name": "product_name",
363
+ "product_type": "product_type",
364
+ "coverage": "coverage",
365
+ "premium": "premium",
366
+ "pay_period": "pay_period",
367
+ "effective_date": "effective_date",
368
+ "expire_date": "expire_date",
369
+ "next_pay_date": "next_pay_date",
370
+ "insured_name": "insured_name",
371
+ "beneficiary": "beneficiary",
372
+ "notes": "notes",
373
+ }
374
+
375
+
376
+ def cmd_policy_update(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
377
+ try:
378
+ payload = json.loads(args.set)
379
+ except json.JSONDecodeError as e:
380
+ print_json({"ok": False, "error": "bad_json", "hint": str(e)})
381
+ return 1
382
+ row = conn.execute("SELECT id FROM policies WHERE id=? AND status='active'", (args.policy_id,)).fetchone()
383
+ if not row:
384
+ print_json({"ok": False, "error": "policy_not_found"})
385
+ return 1
386
+ sets: list[str] = []
387
+ vals: list[Any] = []
388
+ for k, v in payload.items():
389
+ col = _POLICY_UPDATE_FIELDS.get(k)
390
+ if col is None:
391
+ continue
392
+ sets.append(f"{col}=?")
393
+ vals.append(v)
394
+ if sets:
395
+ sets.append("updated_at=datetime('now','localtime')")
396
+ vals.append(args.policy_id)
397
+ conn.execute(f"UPDATE policies SET {', '.join(sets)} WHERE id=?", vals)
398
+ conn.commit()
399
+ print_json({"ok": True, "id": args.policy_id})
400
+ return 0
401
+
402
+
403
+ def cmd_policy_delete(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
404
+ row = conn.execute("SELECT id FROM policies WHERE id=? AND status='active'", (args.policy_id,)).fetchone()
405
+ if not row:
406
+ print_json({"ok": False, "error": "policy_not_found"})
407
+ return 1
408
+ if not args.yes:
409
+ print_json({"ok": False, "error": "need_confirm", "hint": "请加 --yes 确认删除"})
410
+ return 1
411
+ conn.execute(
412
+ "UPDATE policies SET status='cancelled', updated_at=datetime('now','localtime') WHERE id=?",
413
+ (args.policy_id,),
414
+ )
415
+ conn.commit()
416
+ print_json({"ok": True})
417
+ return 0
418
+
419
+
420
+ def cmd_policy_count_by_customer(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
421
+ row = conn.execute(
422
+ "SELECT COUNT(*) AS n FROM policies WHERE customer_id=? AND status='active'",
423
+ (args.customer_id,),
424
+ ).fetchone()
425
+ print_json({"ok": True, "customer_id": args.customer_id, "count": int(row["n"])})
426
+ return 0
427
+
428
+
429
+ # ---------- followup commands ----------
430
+
431
+ def cmd_followup_add(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
432
+ cust = conn.execute(
433
+ "SELECT id FROM customers WHERE id=? AND status='active'", (args.customer_id,)
434
+ ).fetchone()
435
+ if not cust:
436
+ print_json({"ok": False, "error": "customer_not_found"})
437
+ return 1
438
+ cur = conn.execute(
439
+ """INSERT INTO followups (customer_id, content, next_step, next_date, channel)
440
+ VALUES (?,?,?,?,?)""",
441
+ (
442
+ args.customer_id,
443
+ args.content,
444
+ args.next_step or "",
445
+ args.next_date or "",
446
+ args.channel or "",
447
+ ),
448
+ )
449
+ conn.commit()
450
+ print_json({"ok": True, "id": cur.lastrowid, "customer_id": args.customer_id})
451
+ return 0
452
+
453
+
454
+ def cmd_followup_query(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
455
+ cur = conn.cursor()
456
+ rows: list[sqlite3.Row]
457
+ if args.customer_id:
458
+ rows = list(
459
+ cur.execute(
460
+ "SELECT * FROM followups WHERE customer_id=? ORDER BY created_at DESC",
461
+ (args.customer_id,),
462
+ )
463
+ )
464
+ elif args.days is not None:
465
+ threshold = (datetime.now() - timedelta(days=int(args.days))).strftime("%Y-%m-%d %H:%M:%S")
466
+ rows = list(
467
+ cur.execute(
468
+ "SELECT * FROM followups WHERE created_at >= ? ORDER BY created_at DESC",
469
+ (threshold,),
470
+ )
471
+ )
472
+ else:
473
+ rows = list(cur.execute("SELECT * FROM followups ORDER BY created_at DESC LIMIT 100"))
474
+ print_json({"ok": True, "followups": [dict(r) for r in rows], "count": len(rows)})
475
+ return 0
476
+
477
+
478
+ # ---------- gap-analysis ----------
479
+
480
+ GAP_TYPES = ["重疾险", "医疗险", "意外险", "寿险", "养老险", "教育金"]
481
+
482
+
483
+ def _gap_for_customer(conn: sqlite3.Connection, customer_id: str) -> dict:
484
+ cust = conn.execute(
485
+ "SELECT id, name, household_json, age, income FROM customers WHERE id=? AND status='active'",
486
+ (customer_id,),
487
+ ).fetchone()
488
+ if not cust:
489
+ return {"customer_id": customer_id, "found": False}
490
+ rows = conn.execute(
491
+ "SELECT product_type FROM policies WHERE customer_id=? AND status='active'",
492
+ (customer_id,),
493
+ ).fetchall()
494
+ have = {r["product_type"] for r in rows}
495
+ has_children = False
496
+ try:
497
+ hh = json.loads(cust["household_json"] or "{}")
498
+ kids = hh.get("children")
499
+ has_children = bool(kids) and (kids is True or (isinstance(kids, (int, float)) and kids > 0))
500
+ except json.JSONDecodeError:
501
+ pass
502
+ coverage = {}
503
+ gaps: list[str] = []
504
+ for t in GAP_TYPES:
505
+ present = t in have
506
+ coverage[t] = present
507
+ if not present:
508
+ if t == "教育金" and not has_children:
509
+ continue
510
+ gaps.append(t)
511
+ return {
512
+ "customer_id": cust["id"],
513
+ "name": cust["name"],
514
+ "found": True,
515
+ "coverage": coverage,
516
+ "gaps": gaps,
517
+ "has_children": has_children,
518
+ }
519
+
520
+
521
+ def cmd_gap_analysis(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
522
+ if args.customer_id:
523
+ result = _gap_for_customer(conn, args.customer_id)
524
+ if not result.get("found"):
525
+ print_json({"ok": False, "error": "customer_not_found"})
526
+ return 1
527
+ print_json({"ok": True, "result": result})
528
+ return 0
529
+ rows = conn.execute("SELECT id FROM customers WHERE status='active'").fetchall()
530
+ results = [_gap_for_customer(conn, r["id"]) for r in rows]
531
+ if args.gap_type:
532
+ results = [r for r in results if r.get("found") and args.gap_type in r["gaps"]]
533
+ print_json({"ok": True, "results": results, "count": len(results)})
534
+ return 0
535
+
536
+
537
+ # ---------- customer-segment ----------
538
+
539
+ def _premium_total(conn: sqlite3.Connection, customer_id: str) -> float:
540
+ row = conn.execute(
541
+ "SELECT COALESCE(SUM(premium),0) AS s FROM policies WHERE customer_id=? AND status='active'",
542
+ (customer_id,),
543
+ ).fetchone()
544
+ return float(row["s"] or 0)
545
+
546
+
547
+ def _last_followup_at(conn: sqlite3.Connection, customer_id: str) -> str | None:
548
+ row = conn.execute(
549
+ "SELECT created_at FROM followups WHERE customer_id=? ORDER BY created_at DESC LIMIT 1",
550
+ (customer_id,),
551
+ ).fetchone()
552
+ return row["created_at"] if row else None
553
+
554
+
555
+ def cmd_customer_segment(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
556
+ now = datetime.now()
557
+ rows = conn.execute("SELECT id, name, created_at FROM customers WHERE status='active'").fetchall()
558
+ hi_value: list[dict] = []
559
+ active: list[dict] = []
560
+ dormant: list[dict] = []
561
+ new_clients: list[dict] = []
562
+ for r in rows:
563
+ cid = r["id"]
564
+ name = r["name"]
565
+ premium = _premium_total(conn, cid)
566
+ last = _last_followup_at(conn, cid)
567
+ last_dt = None
568
+ if last:
569
+ try:
570
+ last_dt = datetime.strptime(last[:19], "%Y-%m-%d %H:%M:%S")
571
+ except ValueError:
572
+ last_dt = None
573
+ days_since = (now - last_dt).days if last_dt else None
574
+ try:
575
+ created_dt = datetime.strptime(r["created_at"][:19], "%Y-%m-%d %H:%M:%S")
576
+ except (ValueError, TypeError):
577
+ created_dt = None
578
+ item = {
579
+ "customer_id": cid,
580
+ "name": name,
581
+ "premium_total": premium,
582
+ "last_followup_at": last,
583
+ "days_since_followup": days_since,
584
+ }
585
+ if premium > 50000:
586
+ hi_value.append(item)
587
+ if days_since is not None and days_since <= 30:
588
+ active.append(item)
589
+ if days_since is not None and days_since > 60:
590
+ dormant.append(item)
591
+ elif days_since is None and created_dt and (now - created_dt).days > 60:
592
+ dormant.append(item)
593
+ if created_dt and (now - created_dt).days <= 30:
594
+ new_clients.append(item)
595
+ print_json(
596
+ {
597
+ "ok": True,
598
+ "hi_value": hi_value,
599
+ "active": active,
600
+ "dormant": dormant,
601
+ "new": new_clients,
602
+ "summary": {
603
+ "total": len(rows),
604
+ "hi_value": len(hi_value),
605
+ "active": len(active),
606
+ "dormant": len(dormant),
607
+ "new": len(new_clients),
608
+ },
609
+ }
610
+ )
611
+ return 0
612
+
613
+
614
+ # ---------- dashboard ----------
615
+
616
+ def cmd_dashboard(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
617
+ cust_total = int(conn.execute("SELECT COUNT(*) AS n FROM customers WHERE status='active'").fetchone()["n"])
618
+ pol_row = conn.execute(
619
+ "SELECT COUNT(*) AS n, COALESCE(SUM(premium),0) AS s FROM policies WHERE status='active'"
620
+ ).fetchone()
621
+ pol_total = int(pol_row["n"])
622
+ premium_total = float(pol_row["s"] or 0)
623
+ now = datetime.now()
624
+ seg_rows = conn.execute("SELECT id, created_at FROM customers WHERE status='active'").fetchall()
625
+ hi = active = dormant = new_n = 0
626
+ for r in seg_rows:
627
+ cid = r["id"]
628
+ premium = _premium_total(conn, cid)
629
+ last = _last_followup_at(conn, cid)
630
+ last_dt = None
631
+ if last:
632
+ try:
633
+ last_dt = datetime.strptime(last[:19], "%Y-%m-%d %H:%M:%S")
634
+ except ValueError:
635
+ last_dt = None
636
+ days_since = (now - last_dt).days if last_dt else None
637
+ try:
638
+ created_dt = datetime.strptime(r["created_at"][:19], "%Y-%m-%d %H:%M:%S")
639
+ except (ValueError, TypeError):
640
+ created_dt = None
641
+ if premium > 50000:
642
+ hi += 1
643
+ if days_since is not None and days_since <= 30:
644
+ active += 1
645
+ if (days_since is not None and days_since > 60) or (
646
+ days_since is None and created_dt and (now - created_dt).days > 60
647
+ ):
648
+ dormant += 1
649
+ if created_dt and (now - created_dt).days <= 30:
650
+ new_n += 1
651
+ print_json(
652
+ {
653
+ "ok": True,
654
+ "skill": "insurance-customer-policy",
655
+ "customers_total": cust_total,
656
+ "hi_value": hi,
657
+ "active": active,
658
+ "dormant": dormant,
659
+ "new": new_n,
660
+ "policies_total": pol_total,
661
+ "total_premium": premium_total,
662
+ }
663
+ )
664
+ return 0
665
+
666
+
667
+ # ---------- custom field commands ----------
668
+
669
+ def cmd_customer_field_add(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
670
+ conn.execute(
671
+ "INSERT OR REPLACE INTO customer_fields (name, type) VALUES (?,?)",
672
+ (args.name, args.type or "text"),
673
+ )
674
+ conn.commit()
675
+ print_json({"ok": True, "name": args.name, "type": args.type or "text"})
676
+ return 0
677
+
678
+
679
+ def cmd_customer_field_query(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
680
+ rows = conn.execute("SELECT name, type FROM customer_fields ORDER BY name").fetchall()
681
+ print_json({"ok": True, "fields": [dict(r) for r in rows]})
682
+ return 0
683
+
684
+
685
+ # ---------- argparse ----------
686
+
687
+ def build_parser() -> argparse.ArgumentParser:
688
+ p = argparse.ArgumentParser(prog="insurance_customer_cli")
689
+ sub = p.add_subparsers(dest="cmd", required=True)
690
+
691
+ a = sub.add_parser("customer-add")
692
+ a.add_argument("--customer-id")
693
+ a.add_argument("--name", required=True)
694
+ a.add_argument("--phone")
695
+ a.add_argument("--age", type=int)
696
+ a.add_argument("--gender")
697
+ a.add_argument("--income", type=float)
698
+ a.add_argument("--household")
699
+ a.add_argument("--occupation")
700
+ a.add_argument("--birthday")
701
+ a.add_argument("--tags")
702
+ a.add_argument("--fields")
703
+ a.set_defaults(func=cmd_customer_add)
704
+
705
+ a = sub.add_parser("customer-query")
706
+ a.add_argument("--customer-id")
707
+ a.add_argument("--keyword")
708
+ a.set_defaults(func=cmd_customer_query)
709
+
710
+ a = sub.add_parser("customer-update")
711
+ a.add_argument("--customer-id", required=True)
712
+ a.add_argument("--set", required=True)
713
+ a.set_defaults(func=cmd_customer_update)
714
+
715
+ a = sub.add_parser("customer-delete")
716
+ a.add_argument("--customer-id", required=True)
717
+ a.add_argument("--yes", action="store_true")
718
+ a.set_defaults(func=cmd_customer_delete)
719
+
720
+ a = sub.add_parser("customer-exists")
721
+ a.add_argument("--customer-id", required=True)
722
+ a.add_argument("--json", action="store_true")
723
+ a.set_defaults(func=cmd_customer_exists)
724
+
725
+ a = sub.add_parser("policy-add")
726
+ a.add_argument("--customer-id", required=True)
727
+ a.add_argument("--policy-id")
728
+ a.add_argument("--company", required=True)
729
+ a.add_argument("--product", required=True)
730
+ a.add_argument("--product-type", "--type", dest="product_type", required=True)
731
+ a.add_argument("--coverage", required=True)
732
+ a.add_argument("--premium", required=True)
733
+ a.add_argument("--pay-period")
734
+ a.add_argument("--effective-date", required=True)
735
+ a.add_argument("--expire-date")
736
+ a.add_argument("--next-pay-date")
737
+ a.add_argument("--insured")
738
+ a.add_argument("--beneficiary")
739
+ a.add_argument("--notes")
740
+ a.set_defaults(func=cmd_policy_add)
741
+
742
+ a = sub.add_parser("policy-query")
743
+ a.add_argument("--policy-id")
744
+ a.add_argument("--customer-id")
745
+ a.add_argument("--expire-before")
746
+ a.add_argument("--next-pay-before")
747
+ a.add_argument("--product-type")
748
+ a.set_defaults(func=cmd_policy_query)
749
+
750
+ a = sub.add_parser("policy-update")
751
+ a.add_argument("--policy-id", required=True)
752
+ a.add_argument("--set", required=True)
753
+ a.set_defaults(func=cmd_policy_update)
754
+
755
+ a = sub.add_parser("policy-delete")
756
+ a.add_argument("--policy-id", required=True)
757
+ a.add_argument("--yes", action="store_true")
758
+ a.set_defaults(func=cmd_policy_delete)
759
+
760
+ a = sub.add_parser("policy-count-by-customer")
761
+ a.add_argument("--customer-id", required=True)
762
+ a.add_argument("--json", action="store_true")
763
+ a.set_defaults(func=cmd_policy_count_by_customer)
764
+
765
+ a = sub.add_parser("followup-add")
766
+ a.add_argument("--customer-id", required=True)
767
+ a.add_argument("--content", required=True)
768
+ a.add_argument("--next-step")
769
+ a.add_argument("--next-date")
770
+ a.add_argument("--channel")
771
+ a.set_defaults(func=cmd_followup_add)
772
+
773
+ a = sub.add_parser("followup-query")
774
+ a.add_argument("--customer-id")
775
+ a.add_argument("--days", type=int)
776
+ a.set_defaults(func=cmd_followup_query)
777
+
778
+ a = sub.add_parser("gap-analysis")
779
+ a.add_argument("--customer-id")
780
+ a.add_argument("--all", action="store_true")
781
+ a.add_argument("--gap-type")
782
+ a.set_defaults(func=cmd_gap_analysis)
783
+
784
+ a = sub.add_parser("customer-segment")
785
+ a.set_defaults(func=cmd_customer_segment)
786
+
787
+ a = sub.add_parser("dashboard")
788
+ a.add_argument("--json", action="store_true")
789
+ a.set_defaults(func=cmd_dashboard)
790
+
791
+ a = sub.add_parser("customer-field-add")
792
+ a.add_argument("--name", required=True)
793
+ a.add_argument("--type")
794
+ a.set_defaults(func=cmd_customer_field_add)
795
+
796
+ a = sub.add_parser("customer-field-query")
797
+ a.set_defaults(func=cmd_customer_field_query)
798
+
799
+ return p
800
+
801
+
802
+ def main(argv: list[str] | None = None) -> int:
803
+ parser = build_parser()
804
+ args = parser.parse_args(argv)
805
+ conn = db.connect()
806
+ try:
807
+ return int(args.func(conn, args) or 0)
808
+ except sqlite3.Error as e:
809
+ print_json({"ok": False, "error": "sqlite_error", "hint": str(e)})
810
+ return 1
811
+ finally:
812
+ conn.close()
813
+
814
+
815
+ if __name__ == "__main__":
816
+ raise SystemExit(main())