sophhub 0.4.35 → 0.4.37

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 (173) hide show
  1. package/agents/store/.config.json +23 -0
  2. package/agents/store/AGENTS.md +103 -0
  3. package/agents/store/BOOTSTRAP.md +10 -0
  4. package/agents/store/HEARTBEAT.md +10 -0
  5. package/agents/store/IDENTITY.md +5 -0
  6. package/agents/store/MEMORY.md +3 -0
  7. package/agents/store/SOUL.md +10 -0
  8. package/agents/store/TOOLS.md +9 -0
  9. package/agents/store/USER.md +5 -0
  10. package/agents/store/store_cron.json +18 -0
  11. package/package.json +1 -1
  12. package/skills/sophnet-oss/src/SKILL.md +1 -0
  13. package/skills/store-appointment/skill.json +56 -0
  14. package/skills/store-appointment/src/SKILL.md +66 -0
  15. package/skills/store-appointment/src/pyproject.toml +18 -0
  16. package/skills/store-appointment/src/references/errors.md +32 -0
  17. package/skills/store-appointment/src/references/verified-queries.md +25 -0
  18. package/skills/store-appointment/src/scripts/__init__.py +1 -0
  19. package/skills/store-appointment/src/scripts/cli.py +15 -0
  20. package/skills/store-appointment/src/store_appointment_lib/__init__.py +1 -0
  21. package/skills/store-appointment/src/store_appointment_lib/cli.py +395 -0
  22. package/skills/store-appointment/src/store_appointment_lib/helpers.py +160 -0
  23. package/skills/store-appointment/src/store_appointment_lib/settlement.py +170 -0
  24. package/skills/store-catalog/skill.json +49 -0
  25. package/skills/store-catalog/src/SKILL.md +60 -0
  26. package/skills/store-catalog/src/pyproject.toml +18 -0
  27. package/skills/store-catalog/src/references/errors.md +24 -0
  28. package/skills/store-catalog/src/scripts/__init__.py +1 -0
  29. package/skills/store-catalog/src/scripts/cli.py +15 -0
  30. package/skills/store-catalog/src/store_catalog_lib/__init__.py +0 -0
  31. package/skills/store-catalog/src/store_catalog_lib/cli.py +463 -0
  32. package/skills/store-customer/skill.json +75 -0
  33. package/skills/store-customer/src/SKILL.md +78 -0
  34. package/skills/store-customer/src/pyproject.toml +22 -0
  35. package/skills/store-customer/src/references/errors.md +24 -0
  36. package/skills/store-customer/src/references/verified-queries.md +35 -0
  37. package/skills/store-customer/src/scripts/__init__.py +1 -0
  38. package/skills/store-customer/src/scripts/cli.py +15 -0
  39. package/skills/store-customer/src/store_customer_lib/__init__.py +0 -0
  40. package/skills/store-customer/src/store_customer_lib/__main__.py +0 -0
  41. package/skills/store-customer/src/store_customer_lib/cli.py +199 -0
  42. package/skills/store-customer/src/store_customer_lib/common.py +73 -0
  43. package/skills/store-customer/src/store_customer_lib/fields.py +112 -0
  44. package/skills/store-customer/src/store_customer_lib/followups.py +59 -0
  45. package/skills/store-customer/src/store_customer_lib/import_cmds.py +108 -0
  46. package/skills/store-customer/src/store_customer_lib/import_export/__init__.py +1 -0
  47. package/skills/store-customer/src/store_customer_lib/import_export/exporter.py +83 -0
  48. package/skills/store-customer/src/store_customer_lib/import_export/mapper.py +110 -0
  49. package/skills/store-customer/src/store_customer_lib/import_export/normalizer.py +96 -0
  50. package/skills/store-customer/src/store_customer_lib/import_export/parser.py +216 -0
  51. package/skills/store-customer/src/store_customer_lib/import_export/service.py +145 -0
  52. package/skills/store-customer/src/store_customer_lib/members.py +258 -0
  53. package/skills/store-customer/src/store_customer_lib/query_extras.py +121 -0
  54. package/skills/store-customer/src/store_customer_lib/wallet.py +122 -0
  55. package/skills/store-inventory/skill.json +42 -0
  56. package/skills/store-inventory/src/SKILL.md +61 -0
  57. package/skills/store-inventory/src/pyproject.toml +18 -0
  58. package/skills/store-inventory/src/references/errors.md +23 -0
  59. package/skills/store-inventory/src/scripts/__init__.py +1 -0
  60. package/skills/store-inventory/src/scripts/cli.py +15 -0
  61. package/skills/store-inventory/src/store_inventory_lib/__init__.py +0 -0
  62. package/skills/store-inventory/src/store_inventory_lib/cli.py +327 -0
  63. package/skills/store-marketing/skill.json +71 -0
  64. package/skills/store-marketing/src/SKILL.md +108 -0
  65. package/skills/store-marketing/src/playbooks/campaign-planning.md +187 -0
  66. package/skills/store-marketing/src/playbooks/content-generation.md +122 -0
  67. package/skills/store-marketing/src/playbooks/marketing-calendar.md +60 -0
  68. package/skills/store-marketing/src/playbooks/multi-channel-bundle.md +94 -0
  69. package/skills/store-marketing/src/playbooks/poster-generation.md +183 -0
  70. package/skills/store-marketing/src/playbooks/style-profile-workflow.md +100 -0
  71. package/skills/store-marketing/src/pyproject.toml +22 -0
  72. package/skills/store-marketing/src/references/campaign-mechanics.md +168 -0
  73. package/skills/store-marketing/src/references/content-safety.md +26 -0
  74. package/skills/store-marketing/src/references/errors.md +23 -0
  75. package/skills/store-marketing/src/references/marketing-date-checklist.md +99 -0
  76. package/skills/store-marketing/src/references/platform-writing-guidelines.md +88 -0
  77. package/skills/store-marketing/src/references/playbook.md +43 -0
  78. package/skills/store-marketing/src/references/quality-checklist.md +44 -0
  79. package/skills/store-marketing/src/references/segments.md +28 -0
  80. package/skills/store-marketing/src/references/verified-queries.md +20 -0
  81. package/skills/store-marketing/src/scripts/__init__.py +1 -0
  82. package/skills/store-marketing/src/scripts/cli.py +15 -0
  83. package/skills/store-marketing/src/scripts/generate_poster.py +604 -0
  84. package/skills/store-marketing/src/scripts/style_profile.py +216 -0
  85. package/skills/store-marketing/src/store_marketing_lib/__init__.py +1 -0
  86. package/skills/store-marketing/src/store_marketing_lib/campaign.py +114 -0
  87. package/skills/store-marketing/src/store_marketing_lib/cli.py +207 -0
  88. package/skills/store-marketing/src/store_marketing_lib/context.py +41 -0
  89. package/skills/store-marketing/src/store_marketing_lib/meta.py +22 -0
  90. package/skills/store-marketing/src/store_marketing_lib/segments.py +182 -0
  91. package/skills/store-order/skill.json +42 -0
  92. package/skills/store-order/src/SKILL.md +55 -0
  93. package/skills/store-order/src/pyproject.toml +18 -0
  94. package/skills/store-order/src/references/errors.md +33 -0
  95. package/skills/store-order/src/scripts/__init__.py +1 -0
  96. package/skills/store-order/src/scripts/cli.py +15 -0
  97. package/skills/store-order/src/store_order_lib/__init__.py +1 -0
  98. package/skills/store-order/src/store_order_lib/cli.py +291 -0
  99. package/skills/store-order/src/store_order_lib/helpers.py +12 -0
  100. package/skills/store-order/src/store_order_lib/settlement.py +335 -0
  101. package/skills/store-reporting/skill.json +41 -0
  102. package/skills/store-reporting/src/SKILL.md +50 -0
  103. package/skills/store-reporting/src/pyproject.toml +19 -0
  104. package/skills/store-reporting/src/references/errors.md +26 -0
  105. package/skills/store-reporting/src/references/verified-queries.md +14 -0
  106. package/skills/store-reporting/src/scripts/__init__.py +1 -0
  107. package/skills/store-reporting/src/scripts/cli.py +15 -0
  108. package/skills/store-reporting/src/store_reporting_lib/__init__.py +1 -0
  109. package/skills/store-reporting/src/store_reporting_lib/cli.py +155 -0
  110. package/skills/store-reporting/src/store_reporting_lib/metrics.py +226 -0
  111. package/skills/store-schedule/skill.json +60 -0
  112. package/skills/store-schedule/src/SKILL.md +69 -0
  113. package/skills/store-schedule/src/config/reminder_rules.yaml +30 -0
  114. package/skills/store-schedule/src/config/store_recurring_events.yaml +15 -0
  115. package/skills/store-schedule/src/config/task_registry.yaml +21 -0
  116. package/skills/store-schedule/src/pyproject.toml +21 -0
  117. package/skills/store-schedule/src/references/errors.md +35 -0
  118. package/skills/store-schedule/src/references/sent_reminders.md +16 -0
  119. package/skills/store-schedule/src/references/store_cron.template.json +18 -0
  120. package/skills/store-schedule/src/scripts/__init__.py +1 -0
  121. package/skills/store-schedule/src/scripts/cli.py +15 -0
  122. package/skills/store-schedule/src/store_schedule_lib/__init__.py +1 -0
  123. package/skills/store-schedule/src/store_schedule_lib/change_monitor.py +70 -0
  124. package/skills/store-schedule/src/store_schedule_lib/cli.py +362 -0
  125. package/skills/store-schedule/src/store_schedule_lib/config_loader.py +105 -0
  126. package/skills/store-schedule/src/store_schedule_lib/conflicts.py +33 -0
  127. package/skills/store-schedule/src/store_schedule_lib/cron_registry.py +147 -0
  128. package/skills/store-schedule/src/store_schedule_lib/daily_plan.py +175 -0
  129. package/skills/store-schedule/src/store_schedule_lib/daily_summary.py +94 -0
  130. package/skills/store-schedule/src/store_schedule_lib/message_templates.py +13 -0
  131. package/skills/store-schedule/src/store_schedule_lib/meta.py +24 -0
  132. package/skills/store-schedule/src/store_schedule_lib/queries.py +293 -0
  133. package/skills/store-schedule/src/store_schedule_lib/reminder_planner.py +277 -0
  134. package/skills/store-schedule/src/store_schedule_lib/task_builder.py +118 -0
  135. package/skills/store-staff/skill.json +42 -0
  136. package/skills/store-staff/src/SKILL.md +66 -0
  137. package/skills/store-staff/src/pyproject.toml +18 -0
  138. package/skills/store-staff/src/references/errors.md +22 -0
  139. package/skills/store-staff/src/references/staff-field-def.md +58 -0
  140. package/skills/store-staff/src/scripts/__init__.py +1 -0
  141. package/skills/store-staff/src/scripts/cli.py +15 -0
  142. package/skills/store-staff/src/store_staff_lib/__init__.py +0 -0
  143. package/skills/store-staff/src/store_staff_lib/cli.py +631 -0
  144. package/skills/store-suite/skill.json +60 -0
  145. package/skills/store-suite/src/SKILL.md +53 -0
  146. package/skills/store-suite/src/pyproject.toml +16 -0
  147. package/skills/store-suite/src/references/errors.md +24 -0
  148. package/skills/store-suite/src/references/integration-guide.md +164 -0
  149. package/skills/store-suite/src/references/schema.md +56 -0
  150. package/skills/store-suite/src/scripts/__init__.py +1 -0
  151. package/skills/store-suite/src/scripts/cli.py +15 -0
  152. package/skills/store-suite/src/starter/default/field_defs_seed.json +5 -0
  153. package/skills/store-suite/src/starter/default/lexicon.json +5 -0
  154. package/skills/store-suite/src/starter/default/seed.sql +41 -0
  155. package/skills/store-suite/src/store_db/__init__.py +6 -0
  156. package/skills/store-suite/src/store_db/__main__.py +0 -0
  157. package/skills/store-suite/src/store_db/cli.py +269 -0
  158. package/skills/store-suite/src/store_db/confirm.py +20 -0
  159. package/skills/store-suite/src/store_db/csv_safe.py +36 -0
  160. package/skills/store-suite/src/store_db/db.py +92 -0
  161. package/skills/store-suite/src/store_db/field_defs.py +21 -0
  162. package/skills/store-suite/src/store_db/filters.py +19 -0
  163. package/skills/store-suite/src/store_db/ids.py +18 -0
  164. package/skills/store-suite/src/store_db/operation_log.py +186 -0
  165. package/skills/store-suite/src/store_db/response.py +37 -0
  166. package/skills/store-suite/src/store_db/schema_v1.py +308 -0
  167. package/skills/store-suite/src/store_db/seed.py +83 -0
  168. package/skills/store-suite/src/store_settlement/__init__.py +17 -0
  169. package/skills/store-suite/src/store_settlement/catalog.py +52 -0
  170. package/skills/store-suite/src/store_settlement/errors.py +10 -0
  171. package/skills/store-suite/src/store_settlement/inventory.py +80 -0
  172. package/skills/store-suite/src/store_settlement/money.py +9 -0
  173. package/skills/store-suite/src/store_settlement/payment.py +189 -0
@@ -0,0 +1,395 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import time
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from store_db.db import connect
10
+ from store_db.ids import next_id
11
+ from store_db.operation_log import commit_with_log, log_read, request_dict
12
+ from store_db.response import emit_error, emit_need_confirm, emit_ok
13
+
14
+ from store_appointment_lib.helpers import ( # noqa: E402
15
+ SettlementError,
16
+ appt_interval,
17
+ query_service_price_and_duration,
18
+ staff_list_for_skill,
19
+ staff_schedule_query,
20
+ suggest_available_slots,
21
+ validate_appt_create,
22
+ within_slots,
23
+ )
24
+ from store_appointment_lib.settlement import PAY_MODES, complete_appointment # noqa: E402
25
+
26
+ SKILL = "store-appointment"
27
+
28
+
29
+ def cmd_appt_create(args: argparse.Namespace) -> int:
30
+ t0 = time.perf_counter()
31
+ conn = connect()
32
+ service = args.service
33
+ price, dur = query_service_price_and_duration(conn, service)
34
+ duration_min = int(args.duration) if args.duration else dur
35
+ ok, err = validate_appt_create(
36
+ conn,
37
+ technician_id=args.technician_id,
38
+ datetime_str=args.datetime,
39
+ duration_min=duration_min,
40
+ )
41
+ if not ok:
42
+ code = err.split(":", 1)[0] if err.startswith("schedule_conflict") else err
43
+ detail = {}
44
+ if ":" in err:
45
+ detail = {"conflict_id": err.split(":", 1)[1]}
46
+ emit_error(code, detail if detail else None)
47
+ aid = args.appt_id or next_id(conn, "A", "store_appointments")
48
+ conn.execute(
49
+ """INSERT INTO store_appointments
50
+ (id, member_id, service_name, technician_id, start_dt, duration_min, status, remark)
51
+ VALUES (?,?,?,?,?,?, 'scheduled', ?)""",
52
+ (
53
+ aid,
54
+ args.member_id,
55
+ service,
56
+ args.technician_id,
57
+ args.datetime,
58
+ duration_min,
59
+ args.remark or "",
60
+ ),
61
+ )
62
+ commit_with_log(
63
+ conn, args, skill=SKILL, command="appt-create", op="create", table_name="store_appointments",
64
+ entity_id=aid, duration_ms=int((time.perf_counter() - t0) * 1000),
65
+ )
66
+ emit_ok({"appt_id": aid, "duration_min": duration_min, "price": price})
67
+ return 0
68
+
69
+
70
+ def cmd_appt_query(args: argparse.Namespace) -> int:
71
+ t0 = time.perf_counter()
72
+ conn = connect()
73
+ if args.appt_id:
74
+ row = conn.execute("SELECT * FROM store_appointments WHERE id=?", (args.appt_id,)).fetchone()
75
+ log_read(
76
+ conn, args, skill=SKILL, command="appt-query", table_name="store_appointments",
77
+ entity_id=args.appt_id, duration_ms=int((time.perf_counter() - t0) * 1000),
78
+ )
79
+ emit_ok({"appointments": [dict(row)] if row else [], "count": 1 if row else 0})
80
+ return 0
81
+
82
+ if args.date and (args.date_from or args.date_to):
83
+ emit_error("invalid_query", {"hint": "use --date or --date-from/--date-to, not both"})
84
+
85
+ clauses: List[str] = ["1=1"]
86
+ params: List[Any] = []
87
+ if args.member_id:
88
+ clauses.append("member_id=?")
89
+ params.append(args.member_id)
90
+ if args.technician_id:
91
+ clauses.append("technician_id=?")
92
+ params.append(args.technician_id)
93
+ if args.date:
94
+ clauses.append("substr(start_dt,1,10)=?")
95
+ params.append(args.date)
96
+ elif args.date_from:
97
+ clauses.append("substr(start_dt,1,10)>=?")
98
+ params.append(args.date_from)
99
+ if args.date_to:
100
+ clauses.append("substr(start_dt,1,10)<=?")
101
+ params.append(args.date_to)
102
+ if args.status:
103
+ clauses.append("status=?")
104
+ params.append(args.status)
105
+
106
+ q = f"SELECT * FROM store_appointments WHERE {' AND '.join(clauses)} ORDER BY start_dt"
107
+ if args.limit:
108
+ q += " LIMIT ?"
109
+ params.append(args.limit)
110
+ elif not args.date and not args.date_from and not args.member_id:
111
+ q += " LIMIT ?"
112
+ params.append(200)
113
+ rows = [dict(r) for r in conn.execute(q, params)]
114
+ log_read(
115
+ conn, args, skill=SKILL, command="appt-query", table_name="store_appointments",
116
+ duration_ms=int((time.perf_counter() - t0) * 1000),
117
+ )
118
+ emit_ok({"appointments": rows, "count": len(rows)})
119
+ return 0
120
+
121
+
122
+ def cmd_appt_update(args: argparse.Namespace) -> int:
123
+ t0 = time.perf_counter()
124
+ conn = connect()
125
+ row = conn.execute("SELECT * FROM store_appointments WHERE id=?", (args.appt_id,)).fetchone()
126
+ if not row or row["status"] != "scheduled":
127
+ emit_error("invalid_appt", {"appt_id": args.appt_id})
128
+
129
+ new_dt = args.datetime or row["start_dt"]
130
+ new_tech = args.technician_id or row["technician_id"]
131
+ new_service = args.service or row["service_name"]
132
+
133
+ if not args.datetime and not args.technician_id and not args.service:
134
+ emit_error("invalid_query", {"hint": "provide at least one of --datetime, --technician-id, --service"})
135
+
136
+ if args.service and args.service != row["service_name"]:
137
+ svc = conn.execute(
138
+ "SELECT name FROM catalog_services WHERE name=? AND status='active'",
139
+ (args.service,),
140
+ ).fetchone()
141
+ if not svc:
142
+ emit_error("service_not_found", {"name": args.service})
143
+
144
+ _, dur = query_service_price_and_duration(conn, new_service)
145
+ duration_min = int(args.duration) if args.duration else int(row["duration_min"] or dur)
146
+
147
+ ok, err = validate_appt_create(
148
+ conn,
149
+ technician_id=new_tech,
150
+ datetime_str=new_dt,
151
+ duration_min=duration_min,
152
+ exclude_appt_id=args.appt_id,
153
+ )
154
+ if not ok:
155
+ code = err.split(":", 1)[0] if err.startswith("schedule_conflict") else err
156
+ detail = {}
157
+ if ":" in err:
158
+ detail = {"conflict_id": err.split(":", 1)[1]}
159
+ emit_error(code, detail if detail else None)
160
+
161
+ conn.execute(
162
+ """UPDATE store_appointments SET start_dt=?, technician_id=?, service_name=?,
163
+ duration_min=?, updated_at=datetime('now','localtime') WHERE id=?""",
164
+ (new_dt, new_tech, new_service, duration_min, args.appt_id),
165
+ )
166
+ commit_with_log(
167
+ conn, args, skill=SKILL, command="appt-update", op="update", table_name="store_appointments",
168
+ entity_id=args.appt_id, duration_ms=int((time.perf_counter() - t0) * 1000),
169
+ )
170
+ emit_ok({"appt_id": args.appt_id})
171
+ return 0
172
+
173
+
174
+ def cmd_appt_cancel(args: argparse.Namespace) -> int:
175
+ if not args.yes:
176
+ emit_need_confirm({"action": "appt-cancel", "appt_id": args.appt_id})
177
+ return 0
178
+ t0 = time.perf_counter()
179
+ conn = connect()
180
+ row = conn.execute("SELECT status FROM store_appointments WHERE id=?", (args.appt_id,)).fetchone()
181
+ if not row:
182
+ emit_error("not_found", {"appt_id": args.appt_id})
183
+ conn.execute(
184
+ """UPDATE store_appointments SET status='cancelled', updated_at=datetime('now','localtime')
185
+ WHERE id=?""",
186
+ (args.appt_id,),
187
+ )
188
+ commit_with_log(
189
+ conn, args, skill=SKILL, command="appt-cancel", op="update", table_name="store_appointments",
190
+ entity_id=args.appt_id, duration_ms=int((time.perf_counter() - t0) * 1000),
191
+ )
192
+ emit_ok({"appt_id": args.appt_id, "status": "cancelled"})
193
+ return 0
194
+
195
+
196
+ def cmd_appt_complete(args: argparse.Namespace) -> int:
197
+ t0 = time.perf_counter()
198
+ conn = connect()
199
+ row = conn.execute("SELECT * FROM store_appointments WHERE id=?", (args.appt_id,)).fetchone()
200
+ if not row:
201
+ emit_error("not_found", {"appt_id": args.appt_id})
202
+ price, _ = query_service_price_and_duration(conn, row["service_name"])
203
+ if not args.yes:
204
+ emit_need_confirm(
205
+ {
206
+ "action": "appt-complete",
207
+ "appt_id": args.appt_id,
208
+ "member_id": row["member_id"],
209
+ "service": row["service_name"],
210
+ "pay": args.pay,
211
+ "total_amount": price,
212
+ }
213
+ )
214
+ return 0
215
+ audit = {
216
+ "session_id": getattr(args, "session_id", None),
217
+ "skill": SKILL,
218
+ "command": "appt-complete",
219
+ "request_json": request_dict(args),
220
+ "duration_ms": int((time.perf_counter() - t0) * 1000),
221
+ }
222
+ try:
223
+ summary = complete_appointment(
224
+ conn,
225
+ appt_id=args.appt_id,
226
+ pay=args.pay,
227
+ lines_json=args.lines_json,
228
+ staff_lines_json=args.staff_lines_json,
229
+ card_name=args.card_name,
230
+ cash_amount=args.cash_amount,
231
+ audit=audit,
232
+ )
233
+ except SettlementError as e:
234
+ emit_error(e.code, e.detail if e.detail else None)
235
+ emit_ok(summary)
236
+ return 0
237
+
238
+
239
+ def cmd_tech_recommend(args: argparse.Namespace) -> int:
240
+ t0 = time.perf_counter()
241
+ conn = connect()
242
+ _, dur = query_service_price_and_duration(conn, args.service)
243
+ candidates = staff_list_for_skill(conn, args.service)
244
+ date_part = args.datetime[:10]
245
+ good: List[Dict[str, Any]] = []
246
+ for st in candidates:
247
+ sid = st["id"]
248
+ sch = staff_schedule_query(conn, sid, date_part)
249
+ if sch.get("status") == "off":
250
+ continue
251
+ if not within_slots(args.datetime, sch.get("slots")):
252
+ continue
253
+ clash = appt_interval(conn, sid, args.datetime, dur)
254
+ if clash:
255
+ continue
256
+ good.append({"staff_id": sid, "name": st.get("name"), "fields": st.get("fields")})
257
+ log_read(
258
+ conn, args, skill=SKILL, command="tech-recommend", table_name="staff_staff",
259
+ duration_ms=int((time.perf_counter() - t0) * 1000),
260
+ )
261
+ emit_ok({"technicians": good, "count": len(good)})
262
+ return 0
263
+
264
+
265
+ def cmd_tech_auto_assign(args: argparse.Namespace) -> int:
266
+ t0 = time.perf_counter()
267
+ conn = connect()
268
+ _, dur = query_service_price_and_duration(conn, args.service)
269
+ candidates = staff_list_for_skill(conn, args.service)
270
+ date_part = args.datetime[:10]
271
+ for st in candidates:
272
+ sid = st["id"]
273
+ sch = staff_schedule_query(conn, sid, date_part)
274
+ if sch.get("status") == "off":
275
+ continue
276
+ if not within_slots(args.datetime, sch.get("slots")):
277
+ continue
278
+ clash = appt_interval(conn, sid, args.datetime, dur)
279
+ if clash:
280
+ continue
281
+ log_read(
282
+ conn, args, skill=SKILL, command="tech-auto-assign", table_name="staff_staff",
283
+ entity_id=sid, duration_ms=int((time.perf_counter() - t0) * 1000),
284
+ )
285
+ emit_ok({"assigned": {"staff_id": sid, "name": st.get("name")}})
286
+ return 0
287
+ emit_error("no_technician_available")
288
+ return 0
289
+
290
+
291
+ def cmd_appt_suggest_slots(args: argparse.Namespace) -> int:
292
+ t0 = time.perf_counter()
293
+ conn = connect()
294
+ slots = suggest_available_slots(
295
+ conn,
296
+ service=args.service,
297
+ date_yyyy_mm_dd=args.date,
298
+ technician_id=args.technician_id,
299
+ limit=int(args.limit or 5),
300
+ )
301
+ log_read(
302
+ conn, args, skill=SKILL, command="appt-suggest-slots", table_name="store_appointments",
303
+ duration_ms=int((time.perf_counter() - t0) * 1000),
304
+ )
305
+ emit_ok({"slots": slots, "count": len(slots)})
306
+ return 0
307
+
308
+
309
+ def build_parser() -> argparse.ArgumentParser:
310
+ p = argparse.ArgumentParser(description="Store appointment CLI")
311
+ sub = p.add_subparsers(dest="cmd", required=True)
312
+
313
+ s = sub.add_parser("appt-create")
314
+ s.add_argument("--member-id", required=True)
315
+ s.add_argument("--service", "--project", dest="service", required=True)
316
+ s.add_argument("--technician-id", required=True)
317
+ s.add_argument("--datetime", required=True)
318
+ s.add_argument("--duration", type=int)
319
+ s.add_argument("--appt-id")
320
+ s.add_argument("--remark", default="")
321
+ s.add_argument("--session-id")
322
+ s.set_defaults(func=cmd_appt_create)
323
+
324
+ s = sub.add_parser("appt-query")
325
+ s.add_argument("--appt-id")
326
+ s.add_argument("--member-id")
327
+ s.add_argument("--date")
328
+ s.add_argument("--date-from")
329
+ s.add_argument("--date-to")
330
+ s.add_argument("--technician-id")
331
+ s.add_argument("--status")
332
+ s.add_argument("--limit", type=int)
333
+ s.add_argument("--session-id")
334
+ s.set_defaults(func=cmd_appt_query)
335
+
336
+ s = sub.add_parser("appt-update")
337
+ s.add_argument("--appt-id", required=True)
338
+ s.add_argument("--datetime")
339
+ s.add_argument("--technician-id")
340
+ s.add_argument("--service")
341
+ s.add_argument("--duration", type=int)
342
+ s.add_argument("--session-id")
343
+ s.set_defaults(func=cmd_appt_update)
344
+
345
+ s = sub.add_parser("appt-cancel")
346
+ s.add_argument("--appt-id", required=True)
347
+ s.add_argument("--yes", action="store_true")
348
+ s.add_argument("--session-id")
349
+ s.set_defaults(func=cmd_appt_cancel)
350
+
351
+ s = sub.add_parser("appt-complete")
352
+ s.add_argument("--appt-id", required=True)
353
+ s.add_argument("--pay", default="stored_value", choices=list(PAY_MODES))
354
+ s.add_argument("--lines-json")
355
+ s.add_argument("--staff-lines-json")
356
+ s.add_argument("--card-name")
357
+ s.add_argument("--cash-amount", type=float)
358
+ s.add_argument("--yes", action="store_true")
359
+ s.add_argument("--session-id")
360
+ s.set_defaults(func=cmd_appt_complete)
361
+
362
+ s = sub.add_parser("tech-recommend")
363
+ s.add_argument("--service", "--project", dest="service", required=True)
364
+ s.add_argument("--datetime", required=True)
365
+ s.add_argument("--session-id")
366
+ s.set_defaults(func=cmd_tech_recommend)
367
+
368
+ s = sub.add_parser("tech-auto-assign")
369
+ s.add_argument("--service", "--project", dest="service", required=True)
370
+ s.add_argument("--datetime", required=True)
371
+ s.add_argument("--session-id")
372
+ s.set_defaults(func=cmd_tech_auto_assign)
373
+
374
+ s = sub.add_parser("appt-suggest-slots")
375
+ s.add_argument("--service", "--project", dest="service", required=True)
376
+ s.add_argument("--date", required=True, help="YYYY-MM-DD")
377
+ s.add_argument("--technician-id")
378
+ s.add_argument("--limit", type=int, default=5)
379
+ s.add_argument("--session-id")
380
+ s.set_defaults(func=cmd_appt_suggest_slots)
381
+
382
+ return p
383
+
384
+
385
+ def main() -> int:
386
+ args = build_parser().parse_args()
387
+ try:
388
+ return int(args.func(args))
389
+ except SettlementError as e:
390
+ emit_error(e.code, e.detail if e.detail else None)
391
+ return 1
392
+
393
+
394
+ if __name__ == "__main__":
395
+ sys.exit(main())
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from datetime import datetime, timedelta
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ from store_settlement.catalog import query_service_price_and_duration # noqa: F401
9
+ from store_settlement.errors import SettlementError # noqa: F401
10
+
11
+ DT_FMT = "%Y-%m-%d %H:%M"
12
+ DATE_FMT = "%Y-%m-%d"
13
+
14
+
15
+ def parse_dt(s: str) -> datetime:
16
+ return datetime.strptime(s.strip(), DT_FMT)
17
+
18
+
19
+ def staff_schedule_query(conn: sqlite3.Connection, staff_id: str, date_yyyy_mm_dd: str) -> Dict[str, Any]:
20
+ row = conn.execute(
21
+ "SELECT status, slots_json FROM staff_schedules WHERE staff_id=? AND date=?",
22
+ (staff_id, date_yyyy_mm_dd),
23
+ ).fetchone()
24
+ if not row:
25
+ return {"status": "work", "slots": None}
26
+ try:
27
+ slots = json.loads(row["slots_json"] or "null")
28
+ except json.JSONDecodeError:
29
+ slots = None
30
+ return {"status": row["status"], "slots": slots}
31
+
32
+
33
+ def staff_list_for_skill(conn: sqlite3.Connection, skill_keyword: str) -> List[Dict[str, Any]]:
34
+ needle = skill_keyword or ""
35
+ out: List[Dict[str, Any]] = []
36
+ for r in conn.execute("SELECT * FROM staff_staff WHERE status='active' ORDER BY id").fetchall():
37
+ d = dict(r)
38
+ try:
39
+ fj = json.loads(d.get("fields_json") or "{}")
40
+ except json.JSONDecodeError:
41
+ fj = {}
42
+ blob = json.dumps(fj, ensure_ascii=False)
43
+ if needle in blob or needle in (d.get("name") or ""):
44
+ d["fields"] = fj
45
+ out.append(d)
46
+ return out
47
+
48
+
49
+ def within_slots(start_dt: str, slots: Optional[List]) -> bool:
50
+ if not slots:
51
+ return True
52
+ t = start_dt.split()[1][:5]
53
+ for slot in slots:
54
+ if "-" not in slot:
55
+ continue
56
+ a, b = slot.split("-", 1)
57
+ a, b = a.strip()[:5], b.strip()[:5]
58
+ if a <= t <= b:
59
+ return True
60
+ return False
61
+
62
+
63
+ def appt_interval(
64
+ conn: sqlite3.Connection,
65
+ technician_id: str,
66
+ start_dt: str,
67
+ duration_min: int,
68
+ exclude_appt_id: Optional[str] = None,
69
+ ) -> Optional[str]:
70
+ s = parse_dt(start_dt)
71
+ e = s + timedelta(minutes=duration_min)
72
+ q = """SELECT id, start_dt, duration_min FROM store_appointments
73
+ WHERE technician_id=? AND status='scheduled'"""
74
+ params: List[Any] = [technician_id]
75
+ if exclude_appt_id:
76
+ q += " AND id!=?"
77
+ params.append(exclude_appt_id)
78
+ for row in conn.execute(q, params):
79
+ os = parse_dt(row["start_dt"])
80
+ oe = os + timedelta(minutes=int(row["duration_min"]))
81
+ if s < oe and os < e:
82
+ return str(row["id"])
83
+ return None
84
+
85
+
86
+ def validate_appt_create(
87
+ conn: sqlite3.Connection,
88
+ *,
89
+ technician_id: str,
90
+ datetime_str: str,
91
+ duration_min: int,
92
+ exclude_appt_id: Optional[str] = None,
93
+ ) -> Tuple[bool, str]:
94
+ date_part = datetime_str[:10]
95
+ sch = staff_schedule_query(conn, technician_id, date_part)
96
+ if sch.get("status") == "off":
97
+ return False, "technician_off"
98
+ if not within_slots(datetime_str, sch.get("slots")):
99
+ return False, "outside_work_slots"
100
+ clash = appt_interval(
101
+ conn, technician_id, datetime_str, duration_min, exclude_appt_id=exclude_appt_id
102
+ )
103
+ if clash:
104
+ return False, f"schedule_conflict:{clash}"
105
+ return True, ""
106
+
107
+
108
+ def _slot_datetimes(date_yyyy_mm_dd: str, slots: Optional[List], duration_min: int) -> List[str]:
109
+ out: List[str] = []
110
+ if not slots:
111
+ for hour in range(9, 19):
112
+ for minute in (0, 30):
113
+ out.append(f"{date_yyyy_mm_dd} {hour:02d}:{minute:02d}")
114
+ return out
115
+ for slot in slots:
116
+ if "-" not in slot:
117
+ continue
118
+ a, b = slot.split("-", 1)
119
+ a, b = a.strip()[:5], b.strip()[:5]
120
+ cur = datetime.strptime(f"{date_yyyy_mm_dd} {a}", DT_FMT)
121
+ end = datetime.strptime(f"{date_yyyy_mm_dd} {b}", DT_FMT)
122
+ step = timedelta(minutes=30)
123
+ while cur + timedelta(minutes=duration_min) <= end:
124
+ out.append(cur.strftime(DT_FMT))
125
+ cur += step
126
+ return out
127
+
128
+
129
+ def suggest_available_slots(
130
+ conn: sqlite3.Connection,
131
+ *,
132
+ service: str,
133
+ date_yyyy_mm_dd: str,
134
+ technician_id: Optional[str] = None,
135
+ limit: int = 5,
136
+ ) -> List[Dict[str, Any]]:
137
+ _, dur = query_service_price_and_duration(conn, service)
138
+ duration_min = int(dur or 60)
139
+ if technician_id:
140
+ staff_ids = [technician_id]
141
+ else:
142
+ staff_ids = [s["id"] for s in staff_list_for_skill(conn, service)]
143
+ suggestions: List[Dict[str, Any]] = []
144
+ for sid in staff_ids:
145
+ sch = staff_schedule_query(conn, sid, date_yyyy_mm_dd)
146
+ if sch.get("status") == "off":
147
+ continue
148
+ name_row = conn.execute("SELECT name FROM staff_staff WHERE id=?", (sid,)).fetchone()
149
+ staff_name = name_row["name"] if name_row else sid
150
+ for dt in _slot_datetimes(date_yyyy_mm_dd, sch.get("slots"), duration_min):
151
+ if not within_slots(dt, sch.get("slots")):
152
+ continue
153
+ if appt_interval(conn, sid, dt, duration_min):
154
+ continue
155
+ suggestions.append(
156
+ {"datetime": dt, "technician_id": sid, "technician_name": staff_name, "service": service}
157
+ )
158
+ if len(suggestions) >= limit:
159
+ return suggestions
160
+ return suggestions