lovarch-cli 0.2.1__py3-none-any.whl

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 (122) hide show
  1. lovarch_cli/__init__.py +16 -0
  2. lovarch_cli/__main__.py +10 -0
  3. lovarch_cli/ai/__init__.py +21 -0
  4. lovarch_cli/ai/gateway.py +240 -0
  5. lovarch_cli/api.py +111 -0
  6. lovarch_cli/auth/__init__.py +32 -0
  7. lovarch_cli/auth/keyring_store.py +214 -0
  8. lovarch_cli/auth/local_server.py +165 -0
  9. lovarch_cli/auth/pkce.py +57 -0
  10. lovarch_cli/auth/session.py +189 -0
  11. lovarch_cli/cli.py +262 -0
  12. lovarch_cli/clients/__init__.py +33 -0
  13. lovarch_cli/clients/factory.py +54 -0
  14. lovarch_cli/clients/local_client.py +432 -0
  15. lovarch_cli/clients/lovarch_storage.py +174 -0
  16. lovarch_cli/clients/lovarch_supabase.py +295 -0
  17. lovarch_cli/clients/persistence.py +166 -0
  18. lovarch_cli/clients/storage.py +66 -0
  19. lovarch_cli/commands/__init__.py +10 -0
  20. lovarch_cli/commands/account.py +172 -0
  21. lovarch_cli/commands/audit.py +394 -0
  22. lovarch_cli/commands/config_cmd.py +80 -0
  23. lovarch_cli/commands/consolidate.py +217 -0
  24. lovarch_cli/commands/context_cmd.py +73 -0
  25. lovarch_cli/commands/dev.py +287 -0
  26. lovarch_cli/commands/do_cmd.py +120 -0
  27. lovarch_cli/commands/init.py +218 -0
  28. lovarch_cli/commands/jobs_cmd.py +95 -0
  29. lovarch_cli/commands/login.py +202 -0
  30. lovarch_cli/commands/mcp_cmd.py +26 -0
  31. lovarch_cli/commands/run.py +375 -0
  32. lovarch_cli/commands/signup.py +185 -0
  33. lovarch_cli/commands/status.py +243 -0
  34. lovarch_cli/commands/upgrade.py +108 -0
  35. lovarch_cli/commands/verifica_cmd.py +174 -0
  36. lovarch_cli/config.py +101 -0
  37. lovarch_cli/config_store.py +111 -0
  38. lovarch_cli/credits/__init__.py +35 -0
  39. lovarch_cli/credits/base.py +84 -0
  40. lovarch_cli/credits/factory.py +36 -0
  41. lovarch_cli/credits/local.py +34 -0
  42. lovarch_cli/credits/lovarch.py +56 -0
  43. lovarch_cli/i18n/__init__.py +27 -0
  44. lovarch_cli/i18n/loader.py +121 -0
  45. lovarch_cli/i18n/translations/en.json +168 -0
  46. lovarch_cli/i18n/translations/es.json +168 -0
  47. lovarch_cli/i18n/translations/it.json +168 -0
  48. lovarch_cli/i18n/translations/pt.json +168 -0
  49. lovarch_cli/mcp/__init__.py +9 -0
  50. lovarch_cli/mcp/server.py +199 -0
  51. lovarch_cli/mcp/tools.py +372 -0
  52. lovarch_cli/sample_downloader.py +255 -0
  53. lovarch_cli/squad/README.md +206 -0
  54. lovarch_cli/squad/agents/auditor-input.md +353 -0
  55. lovarch_cli/squad/agents/bim-engineer.md +404 -0
  56. lovarch_cli/squad/agents/briefing-architect.md +249 -0
  57. lovarch_cli/squad/agents/cad-engineer.md +278 -0
  58. lovarch_cli/squad/agents/capitolato-writer.md +256 -0
  59. lovarch_cli/squad/agents/computo-engineer.md +258 -0
  60. lovarch_cli/squad/agents/concept-designer.md +399 -0
  61. lovarch_cli/squad/agents/contratto-architect.md +243 -0
  62. lovarch_cli/squad/agents/deliverable-builder.md +253 -0
  63. lovarch_cli/squad/agents/energy-prelim.md +388 -0
  64. lovarch_cli/squad/agents/pratiche-it.md +251 -0
  65. lovarch_cli/squad/agents/progetto-chief.md +768 -0
  66. lovarch_cli/squad/agents/quality-dati.md +409 -0
  67. lovarch_cli/squad/agents/quality-misure.md +418 -0
  68. lovarch_cli/squad/agents/quality-normativa.md +417 -0
  69. lovarch_cli/squad/agents/quality-output.md +436 -0
  70. lovarch_cli/squad/agents/regolatorio-it.md +278 -0
  71. lovarch_cli/squad/checklists/handoff-quality-gate.md +232 -0
  72. lovarch_cli/squad/checklists/quality-dati-checklist.md +134 -0
  73. lovarch_cli/squad/checklists/quality-misure-checklist.md +139 -0
  74. lovarch_cli/squad/checklists/quality-normativa-checklist.md +121 -0
  75. lovarch_cli/squad/checklists/quality-output-checklist.md +116 -0
  76. lovarch_cli/squad/config.yaml +408 -0
  77. lovarch_cli/squad/data/CHANGELOG.md +272 -0
  78. lovarch_cli/squad/data/agents-prd.md +428 -0
  79. lovarch_cli/squad/data/architettura-progetto-rules.md +328 -0
  80. lovarch_cli/squad/data/handoff-card-template.md +231 -0
  81. lovarch_cli/squad/data/mocks/catasto-visura.json +72 -0
  82. lovarch_cli/squad/data/mocks/firma-envelope.json +43 -0
  83. lovarch_cli/squad/data/prezzario-lombardia-sample.json +312 -0
  84. lovarch_cli/squad/scripts/api_clients.py +206 -0
  85. lovarch_cli/squad/scripts/architect_profile.py +276 -0
  86. lovarch_cli/squad/scripts/deliverable_generators.py +844 -0
  87. lovarch_cli/squad/scripts/generate_attico_brera_dwg.py +369 -0
  88. lovarch_cli/squad/scripts/generate_chianti_dxf.py +368 -0
  89. lovarch_cli/squad/scripts/generate_chianti_images.py +223 -0
  90. lovarch_cli/squad/scripts/generate_real_sample_images.py +189 -0
  91. lovarch_cli/squad/scripts/generate_sample_assets.py +382 -0
  92. lovarch_cli/squad/scripts/lovarch_client.py +1046 -0
  93. lovarch_cli/squad/scripts/pipeline_runner.py +2095 -0
  94. lovarch_cli/squad/scripts/render_dxf_to_png.py +57 -0
  95. lovarch_cli/squad/scripts/run_palestra_demo.sh +277 -0
  96. lovarch_cli/squad/scripts/simulate_squad_execution.py +515 -0
  97. lovarch_cli/squad/scripts/validate-squad.py +383 -0
  98. lovarch_cli/squad/tasks/audit-input.md +146 -0
  99. lovarch_cli/squad/tasks/compute-metric.md +105 -0
  100. lovarch_cli/squad/tasks/consolidate-dossier.md +187 -0
  101. lovarch_cli/squad/tasks/generate-cad-plan.md +120 -0
  102. lovarch_cli/squad/tasks/generate-ifc-model.md +108 -0
  103. lovarch_cli/squad/tasks/write-capitolato.md +100 -0
  104. lovarch_cli/squad/templates/asseverazione-tecnica.md +126 -0
  105. lovarch_cli/squad/templates/capitolato-uni-11337.md +235 -0
  106. lovarch_cli/squad/templates/cila-comune-milano.md +177 -0
  107. lovarch_cli/squad/templates/contratto-cnappc.md +220 -0
  108. lovarch_cli/squad/workflows/dal-brief-al-cantiere.yaml +218 -0
  109. lovarch_cli/squad_loader.py +114 -0
  110. lovarch_cli/verify/__init__.py +15 -0
  111. lovarch_cli/verify/contratto.py +110 -0
  112. lovarch_cli/verify/dossier.py +97 -0
  113. lovarch_cli/verify/misure.py +83 -0
  114. lovarch_cli/verify/normativa.py +178 -0
  115. lovarch_cli/version.py +13 -0
  116. lovarch_cli/workflows/__init__.py +9 -0
  117. lovarch_cli/workflows/platform.py +212 -0
  118. lovarch_cli-0.2.1.dist-info/METADATA +232 -0
  119. lovarch_cli-0.2.1.dist-info/RECORD +122 -0
  120. lovarch_cli-0.2.1.dist-info/WHEEL +4 -0
  121. lovarch_cli-0.2.1.dist-info/entry_points.txt +3 -0
  122. lovarch_cli-0.2.1.dist-info/licenses/LICENSE +38 -0
@@ -0,0 +1,1046 @@
1
+ """
2
+ Lovarch Persistence Client · for squad architettura-progetto
3
+
4
+ Centralizes all CREATE/UPDATE operations against the Lovarch Supabase backend.
5
+ Used by @progetto-chief and @deliverable-builder to persist project data
6
+ (projects, clients, documents, renders, moodboards, contracts, tasks, budget items)
7
+ into the appropriate Lovarch tables.
8
+
9
+ Reads SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY from ~/.lovarch/secrets.env.
10
+
11
+ Usage:
12
+ from squads.architettura_progetto.scripts.lovarch_client import LovarchClient
13
+
14
+ client = LovarchClient()
15
+
16
+ # 1. Create lead (cliente CRM)
17
+ lead_id = client.create_lead(
18
+ user_id=pablo_uuid,
19
+ name="Marco Rossini",
20
+ email="marco@example.it",
21
+ phone="+39 333 1234567",
22
+ budget=180000,
23
+ notes="Attico Brera ristrutturazione",
24
+ )
25
+
26
+ # 2. Create project linked to lead
27
+ project_id = client.create_project(
28
+ user_id=pablo_uuid,
29
+ lead_id=lead_id,
30
+ name="Attico Brera",
31
+ address="Via Fiori Chiari 17, Milano",
32
+ client_name="Marco Rossini & Giulia Bianchi",
33
+ client_email="marco@example.it",
34
+ budget_min=170000,
35
+ budget_max=190000,
36
+ )
37
+
38
+ # 3. Create phases
39
+ phases = client.create_phases(project_id, user_id=pablo_uuid)
40
+
41
+ # 4. Upload + persist document
42
+ doc_id = client.upload_document(
43
+ project_id=project_id,
44
+ user_id=pablo_uuid,
45
+ local_path="~/projects/attico-brera/05-impresa/capitolato-speciale.pdf",
46
+ doc_type="capitolato",
47
+ name="Capitolato Speciale",
48
+ )
49
+
50
+ # 5. Save render + link to project
51
+ render_id = client.save_render(
52
+ user_id=pablo_uuid,
53
+ asset_url="https://...flux-render.png",
54
+ render_mode="plan_to_3d",
55
+ metadata={"project_id": project_id, "ambient": "living"},
56
+ )
57
+
58
+ # 6. Create contract
59
+ contract_id = client.create_contract(
60
+ user_id=pablo_uuid,
61
+ lead_id=lead_id,
62
+ title="Contratto Attico Brera",
63
+ client_name="Marco Rossini",
64
+ content_url="...",
65
+ )
66
+
67
+ # 7. Bulk create tasks
68
+ task_ids = client.bulk_create_tasks(
69
+ project_id=project_id,
70
+ user_id=pablo_uuid,
71
+ tasks=[
72
+ {"title": "Riunione cliente concept", "phase": "concept", "deadline": "2026-05-08"},
73
+ {"title": "Sopralluogo cantiere", "phase": "esecutivo", "deadline": "2026-06-25"},
74
+ ...
75
+ ],
76
+ )
77
+
78
+ # 8. Bulk create budget items
79
+ client.bulk_create_budget_items(
80
+ project_id=project_id,
81
+ user_id=pablo_uuid,
82
+ items=[
83
+ {"category": "Demolizioni", "description": "Tramezze interne", "estimated_amount": 4250},
84
+ ...
85
+ ],
86
+ )
87
+
88
+ # 9. Invite client to portal
89
+ portal_data = client.invite_to_portal(
90
+ project_id=project_id,
91
+ email="marco@example.it",
92
+ name="Marco Rossini",
93
+ )
94
+ """
95
+ from __future__ import annotations
96
+
97
+ import json
98
+ import mimetypes
99
+ import os
100
+ import sys
101
+ import uuid
102
+ from pathlib import Path
103
+ from typing import Any, Dict, List, Optional, Tuple
104
+ import urllib.error
105
+ import urllib.parse
106
+ import urllib.request
107
+
108
+
109
+ SECRETS_FILE = Path.home() / ".lovarch" / "secrets.env"
110
+ LOVARCH_ROOT = Path("/Users/pablo/Lovarch")
111
+ LOVARCH_ENV = LOVARCH_ROOT / ".env"
112
+ LOVARCH_ENV_LOCAL = LOVARCH_ROOT / ".env.local"
113
+
114
+
115
+ def _load_secrets() -> Dict[str, str]:
116
+ """Load from ~/.lovarch/secrets.env, /Users/pablo/Lovarch/.env, and .env.local (in priority order)."""
117
+ secrets: Dict[str, str] = {}
118
+ for path in [SECRETS_FILE, LOVARCH_ENV_LOCAL, LOVARCH_ENV]:
119
+ if not path.exists():
120
+ continue
121
+ for line in path.read_text(encoding="utf-8").splitlines():
122
+ line = line.strip()
123
+ if not line or line.startswith("#") or "=" not in line:
124
+ continue
125
+ k, v = line.split("=", 1)
126
+ # strip quotes
127
+ v = v.strip().strip('"').strip("'")
128
+ secrets.setdefault(k.strip(), v)
129
+ return secrets
130
+
131
+
132
+ _SECRETS = _load_secrets()
133
+
134
+
135
+ def _env(name: str) -> Optional[str]:
136
+ """Priority: secrets.env > .env.local > shell env. NOTE: shell env is LAST because it
137
+ may contain values for OTHER projects (e.g. PrimeTeam vs Lovarch)."""
138
+ return _SECRETS.get(name) or os.environ.get(name)
139
+
140
+
141
+ # Resolve Lovarch Supabase URL · prefer VITE_SUPABASE_URL from .env.local (Lovarch project)
142
+ # over SUPABASE_URL env var (which may point to PrimeTeam)
143
+ SUPABASE_URL = _env("VITE_SUPABASE_URL") or _env("LOVARCH_SUPABASE_URL") or _env("SUPABASE_URL")
144
+ SUPABASE_KEY = (
145
+ _env("LOVARCH_SUPABASE_SERVICE_ROLE_KEY")
146
+ or _env("SUPABASE_SERVICE_ROLE_KEY")
147
+ or _env("SUPABASE_SERVICE_KEY")
148
+ )
149
+
150
+
151
+ class LovarchClientError(Exception):
152
+ pass
153
+
154
+
155
+ class LovarchClient:
156
+ """
157
+ Persistence client for Lovarch backend.
158
+
159
+ Uses Supabase REST + Edge Functions with service_role auth (admin bypass).
160
+ All operations are idempotent where possible (UPSERT vs INSERT).
161
+ """
162
+
163
+ def __init__(self, url: Optional[str] = None, key: Optional[str] = None):
164
+ self.url = (url or SUPABASE_URL or "").rstrip("/")
165
+ self.key = key or SUPABASE_KEY
166
+
167
+ # PREMIUM mode: the CLI passes the user's Supabase access_token via env.
168
+ # When present we authenticate as the USER (apikey=anon, Bearer=token) so
169
+ # writes respect RLS and belong to the user — the service_role key is NOT
170
+ # required or used on the client. When absent (dev/admin), fall back to
171
+ # service_role (admin bypass) as before.
172
+ self.access_token = os.environ.get("LOVARCH_ACCESS_TOKEN")
173
+ self.anon_key = os.environ.get("LOVARCH_ANON_KEY") or _env("LOVARCH_ANON_KEY")
174
+
175
+ if self.access_token:
176
+ self.auth_bearer = self.access_token
177
+ self.auth_apikey = self.anon_key or self.key or ""
178
+ if not self.url or not self.auth_apikey:
179
+ raise LovarchClientError(
180
+ "PREMIUM mode needs SUPABASE_URL + LOVARCH_ANON_KEY (or a "
181
+ "service key as apikey) alongside LOVARCH_ACCESS_TOKEN."
182
+ )
183
+ else:
184
+ if not self.url or not self.key:
185
+ raise LovarchClientError(
186
+ "Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY · "
187
+ f"add to {SECRETS_FILE}"
188
+ )
189
+ self.auth_bearer = self.key
190
+ self.auth_apikey = self.key
191
+
192
+ # True when authenticating as a user (RLS applies) vs service_role (bypass).
193
+ self.is_user_auth = bool(self.access_token)
194
+ # Detect URL/key project mismatch (e.g. Lovarch URL with PrimeTeam key)
195
+ self._validate_project_match()
196
+
197
+ def _validate_project_match(self) -> None:
198
+ """Verify that the JWT key's project ref matches the URL host.
199
+
200
+ Common pitfall: shell env has SUPABASE_URL=PrimeTeam but config.toml/.env
201
+ points to Lovarch. Detects mismatch early to fail loudly.
202
+ """
203
+ try:
204
+ import base64
205
+ # Validate the project ref of whichever JWT identifies the auth context.
206
+ parts = self.auth_bearer.split(".")
207
+ if len(parts) == 3:
208
+ payload = json.loads(base64.urlsafe_b64decode(parts[1] + "==").decode())
209
+ key_ref = payload.get("ref")
210
+ url_host = self.url.replace("https://", "").split(".")[0]
211
+ if key_ref and url_host and key_ref != url_host:
212
+ raise LovarchClientError(
213
+ f"Project mismatch: URL points to '{url_host}' but token is for '{key_ref}'. "
214
+ f"Check LOVARCH_SUPABASE_URL / LOVARCH_ACCESS_TOKEN "
215
+ f"(dashboard: https://supabase.com/dashboard/project/{url_host}/settings/api)"
216
+ )
217
+ except LovarchClientError:
218
+ raise
219
+ except Exception:
220
+ pass # if decoding fails, fall through · runtime errors will surface real issue
221
+ self.headers_json = {
222
+ "apikey": self.auth_apikey,
223
+ "Authorization": f"Bearer {self.auth_bearer}",
224
+ "Content-Type": "application/json",
225
+ "Prefer": "return=representation",
226
+ }
227
+
228
+ # ------------------------------------------------------------------------
229
+ # Internals
230
+ # ------------------------------------------------------------------------
231
+ def _rest(
232
+ self,
233
+ method: str,
234
+ path: str,
235
+ body: Optional[Any] = None,
236
+ ) -> Any:
237
+ url = f"{self.url}{path}"
238
+ data = json.dumps(body).encode("utf-8") if body is not None else None
239
+ req = urllib.request.Request(
240
+ url, data=data, method=method, headers=self.headers_json
241
+ )
242
+ try:
243
+ with urllib.request.urlopen(req, timeout=30) as r:
244
+ raw = r.read().decode("utf-8")
245
+ return json.loads(raw) if raw else {}
246
+ except urllib.error.HTTPError as e:
247
+ err = e.read().decode("utf-8", errors="replace")
248
+ # Under user-auth (premium/student, no service_role), some tables the
249
+ # runner writes don't yet have owner-write RLS. Rather than crash the
250
+ # whole run, degrade a blocked INSERT to a best-effort no-op with a
251
+ # synthetic id so deliverable generation + credit debits still proceed.
252
+ # (Full premium persistence for these tables is a documented follow-up:
253
+ # owner-write RLS or a server-side persistence EF.)
254
+ # NOTE: excludes pm_squad_* — those DO have owner-write RLS, so a 403
255
+ # there is a real error (e.g. wrong user_id) and must surface.
256
+ if (
257
+ self.is_user_auth
258
+ and method == "POST"
259
+ and e.code in (401, 403)
260
+ and "/rest/v1/" in path
261
+ and "pm_squad_" not in path
262
+ ):
263
+ print(
264
+ f"⚠️ [LovarchClient] persistência ignorada (RLS) em {path.split('?')[0]} "
265
+ f"[{e.code}] — modo premium, follow-up de RLS owner-write.",
266
+ file=sys.stderr,
267
+ )
268
+ return [{"id": str(uuid.uuid4())}]
269
+ raise LovarchClientError(
270
+ f"Supabase {method} {path} failed [{e.code}]: {err}"
271
+ ) from e
272
+
273
+ def _edge(self, function_name: str, body: Dict[str, Any]) -> Any:
274
+ return self._rest("POST", f"/functions/v1/{function_name}", body)
275
+
276
+ # ------------------------------------------------------------------------
277
+ # 1. LEADS · CRM client
278
+ # ------------------------------------------------------------------------
279
+ def create_lead(
280
+ self,
281
+ *,
282
+ user_id: str,
283
+ name: str,
284
+ email: Optional[str] = None,
285
+ phone: Optional[str] = None,
286
+ budget: Optional[float] = None,
287
+ proposal_value: Optional[float] = None,
288
+ status: str = "proposal",
289
+ source: str = "squad-architettura-progetto",
290
+ channel: Optional[str] = None,
291
+ intervention_type: Optional[str] = None,
292
+ project_type: Optional[str] = None,
293
+ city: Optional[str] = None,
294
+ region: Optional[str] = None,
295
+ country: str = "IT",
296
+ notes: Optional[str] = None,
297
+ tags: Optional[List[str]] = None,
298
+ timeline: Optional[int] = None,
299
+ timeline_unit: str = "days",
300
+ ) -> str:
301
+ """Create lead · matches REAL schema (no `company`/`stage`/`priority` cols)."""
302
+ row = {
303
+ "user_id": user_id,
304
+ "name": name,
305
+ "email": email,
306
+ "phone": phone,
307
+ "budget": budget,
308
+ "proposal_value": proposal_value,
309
+ "status": status,
310
+ "source": source,
311
+ "channel": channel,
312
+ "intervention_type": intervention_type,
313
+ "project_type": project_type,
314
+ "city": city,
315
+ "region": region,
316
+ "country": country,
317
+ "notes": notes,
318
+ "tags": tags or [],
319
+ "timeline": timeline,
320
+ "timeline_unit": timeline_unit,
321
+ }
322
+ # Strip Nones to avoid type conflicts
323
+ row = {k: v for k, v in row.items() if v is not None}
324
+ result = self._rest("POST", "/rest/v1/leads", body=row)
325
+ if not result:
326
+ raise LovarchClientError("create_lead returned empty")
327
+ return result[0]["id"] if isinstance(result, list) else result["id"]
328
+
329
+ # ------------------------------------------------------------------------
330
+ # 2. PROJECTS · pm_projects
331
+ # ------------------------------------------------------------------------
332
+ def create_project(
333
+ self,
334
+ *,
335
+ user_id: str,
336
+ name: str,
337
+ lead_id: Optional[str] = None,
338
+ address: Optional[str] = None,
339
+ typology: str = "ristrutturazione",
340
+ client_type: str = "private",
341
+ square_meters: Optional[float] = None,
342
+ client_name: Optional[str] = None,
343
+ client_email: Optional[str] = None,
344
+ client_phone: Optional[str] = None,
345
+ brief_objectives: Optional[str] = None,
346
+ brief_style: Optional[str] = None,
347
+ constraints: Optional[str] = None,
348
+ budget_min: Optional[float] = None,
349
+ budget_max: Optional[float] = None,
350
+ contingency_percent: float = 5.0,
351
+ professional_fee_percent: Optional[float] = None,
352
+ priority: str = "medium",
353
+ status: str = "active",
354
+ current_phase: int = 0, # INT · matches new-home (was incorrectly string)
355
+ start_date: Optional[str] = None,
356
+ delivery_date: Optional[str] = None,
357
+ ) -> str:
358
+ """Create project · matches new-home useCreateProject (current_phase is INT)."""
359
+ row = {
360
+ "user_id": user_id,
361
+ "lead_id": lead_id,
362
+ "name": name,
363
+ "address": address,
364
+ "typology": typology,
365
+ "client_type": client_type,
366
+ "square_meters": square_meters,
367
+ "client_name": client_name,
368
+ "client_email": client_email,
369
+ "client_phone": client_phone,
370
+ "brief_objectives": brief_objectives,
371
+ "brief_style": brief_style,
372
+ "constraints": constraints,
373
+ "budget_min": budget_min,
374
+ "budget_max": budget_max,
375
+ "contingency_percent": contingency_percent,
376
+ "professional_fee_percent": professional_fee_percent,
377
+ "priority": priority,
378
+ "status": status,
379
+ "current_phase": current_phase,
380
+ "start_date": start_date,
381
+ "delivery_date": delivery_date,
382
+ }
383
+ row = {k: v for k, v in row.items() if v is not None}
384
+ result = self._rest("POST", "/rest/v1/pm_projects", body=row)
385
+ return result[0]["id"] if isinstance(result, list) else result["id"]
386
+
387
+ # ------------------------------------------------------------------------
388
+ # 3. PHASES · pm_phases (6 standard phases · matches useCreateProject)
389
+ # ------------------------------------------------------------------------
390
+ DEFAULT_PHASES_IT = [
391
+ "Briefing & Concept",
392
+ "Progetto Definitivo",
393
+ "Pratiche Edilizie",
394
+ "Progetto Esecutivo",
395
+ "Direzione Lavori",
396
+ "Consegna & Collaudo",
397
+ ]
398
+
399
+ def create_phases(
400
+ self,
401
+ *,
402
+ project_id: str,
403
+ user_id: str,
404
+ ) -> List[str]:
405
+ """Create 6 standard Italian project phases · matches new-home useCreateProject pattern."""
406
+ rows = [
407
+ {
408
+ "project_id": project_id,
409
+ "user_id": user_id,
410
+ "name": name,
411
+ "order_index": index,
412
+ "status": "in_progress" if index == 0 else "not_started",
413
+ }
414
+ for index, name in enumerate(self.DEFAULT_PHASES_IT)
415
+ ]
416
+ result = self._rest("POST", "/rest/v1/pm_phases", body=rows)
417
+ return [r["id"] for r in result]
418
+
419
+ # ------------------------------------------------------------------------
420
+ # 3b. BUDGET CATEGORIES · pm_budget_items (10 default · matches new-home %)
421
+ # ------------------------------------------------------------------------
422
+ DEFAULT_BUDGET_CATEGORIES = [
423
+ ("opere_edili", "Opere edili", 0.35),
424
+ ("impianti", "Impianti", 0.15),
425
+ ("finiture", "Finiture", 0.10),
426
+ ("arredi_misura", "Arredi su misura", 0.10),
427
+ ("arredi_commerciali", "Arredi commerciali", 0.05),
428
+ ("illuminazione", "Illuminazione", 0.05),
429
+ ("decorazioni", "Decorazioni", 0.03),
430
+ ("spese_tecniche", "Spese tecniche", 0.05),
431
+ ("commissione", "Commissione architetto", 0.07),
432
+ ("imprevisti", "Imprevisti", 0.05),
433
+ ]
434
+
435
+ def create_default_budget_categories(
436
+ self,
437
+ *,
438
+ project_id: str,
439
+ user_id: str,
440
+ budget_max: float,
441
+ ) -> List[str]:
442
+ """Create 10 default budget categories with % breakdown · matches new-home defaults."""
443
+ if not budget_max or budget_max <= 0:
444
+ return []
445
+ rows = [
446
+ {
447
+ "project_id": project_id,
448
+ "user_id": user_id,
449
+ "category": cat,
450
+ "description": desc,
451
+ "estimated_amount": round(budget_max * pct, 2),
452
+ }
453
+ for cat, desc, pct in self.DEFAULT_BUDGET_CATEGORIES
454
+ ]
455
+ result = self._rest("POST", "/rest/v1/pm_budget_items", body=rows)
456
+ return [r["id"] for r in result]
457
+
458
+ # ------------------------------------------------------------------------
459
+ # 4. DOCUMENTS · pm_documents (deliverables PDF/DXF/IFC/XLSX)
460
+ # ------------------------------------------------------------------------
461
+ def upload_document(
462
+ self,
463
+ *,
464
+ project_id: str,
465
+ user_id: str,
466
+ local_path: str,
467
+ doc_type: str,
468
+ name: Optional[str] = None,
469
+ phase_id: Optional[str] = None,
470
+ bucket: str = "pm-documents",
471
+ notes: Optional[str] = None,
472
+ ) -> Tuple[str, str]:
473
+ """
474
+ Upload local file to Storage + insert pm_documents row.
475
+ Returns (document_id, public_url).
476
+ """
477
+ local = Path(local_path).expanduser()
478
+ if not local.exists():
479
+ raise LovarchClientError(f"File not found: {local}")
480
+
481
+ # 1. Upload to Storage
482
+ storage_path = f"squad-arch/{project_id}/{local.name}"
483
+ mime = mimetypes.guess_type(str(local))[0] or "application/octet-stream"
484
+ with local.open("rb") as f:
485
+ file_bytes = f.read()
486
+
487
+ upload_url = f"{self.url}/storage/v1/object/{bucket}/{storage_path}"
488
+ headers = {
489
+ "apikey": self.key,
490
+ "Authorization": f"Bearer {self.key}",
491
+ "Content-Type": mime,
492
+ "x-upsert": "true",
493
+ }
494
+ req = urllib.request.Request(
495
+ upload_url, data=file_bytes, method="POST", headers=headers
496
+ )
497
+ try:
498
+ with urllib.request.urlopen(req, timeout=60) as r:
499
+ r.read()
500
+ except urllib.error.HTTPError as e:
501
+ err = e.read().decode("utf-8", errors="replace")
502
+ raise LovarchClientError(f"Storage upload failed [{e.code}]: {err}") from e
503
+
504
+ public_url = f"{self.url}/storage/v1/object/public/{bucket}/{storage_path}"
505
+
506
+ # 2. Insert pm_documents row
507
+ row = {
508
+ "project_id": project_id,
509
+ "phase_id": phase_id,
510
+ "user_id": user_id,
511
+ "uploaded_by": user_id,
512
+ "name": name or local.stem,
513
+ "doc_type": doc_type,
514
+ "file_url": public_url,
515
+ "file_size": len(file_bytes),
516
+ "notes": notes,
517
+ }
518
+ result = self._rest("POST", "/rest/v1/pm_documents", body=row)
519
+ doc_id = result[0]["id"] if isinstance(result, list) else result["id"]
520
+ return doc_id, public_url
521
+
522
+ def upload_documents_batch(
523
+ self,
524
+ *,
525
+ project_id: str,
526
+ user_id: str,
527
+ files: List[Dict[str, Any]],
528
+ ) -> List[Tuple[str, str]]:
529
+ """Bulk upload · files = [{local_path, doc_type, name?, phase_id?}, ...]"""
530
+ return [
531
+ self.upload_document(
532
+ project_id=project_id,
533
+ user_id=user_id,
534
+ **f,
535
+ )
536
+ for f in files
537
+ ]
538
+
539
+ # ------------------------------------------------------------------------
540
+ # 5. RENDERS · render_assets (project_id is FIRST-CLASS · linked to new-home)
541
+ # ------------------------------------------------------------------------
542
+ def save_render(
543
+ self,
544
+ *,
545
+ user_id: str,
546
+ project_id: str, # required for new-home visibility
547
+ asset_url: str,
548
+ asset_type: str = "image",
549
+ render_mode: Optional[str] = None,
550
+ thumbnail_url: Optional[str] = None,
551
+ original_width: Optional[int] = None,
552
+ original_height: Optional[int] = None,
553
+ file_size_bytes: Optional[int] = None,
554
+ metadata: Optional[Dict[str, Any]] = None,
555
+ ) -> str:
556
+ row = {
557
+ "user_id": user_id,
558
+ "project_id": project_id, # ✓ FK to pm_projects · visible in ProjectDetailConnections tab
559
+ "asset_type": asset_type,
560
+ "asset_url": asset_url,
561
+ "thumbnail_url": thumbnail_url,
562
+ "render_mode": render_mode,
563
+ "original_width": original_width,
564
+ "original_height": original_height,
565
+ "file_size_bytes": file_size_bytes,
566
+ "metadata": metadata or {},
567
+ }
568
+ result = self._rest("POST", "/rest/v1/render_assets", body=row)
569
+ return result[0]["id"] if isinstance(result, list) else result["id"]
570
+
571
+ def link_render_to_project(self, render_id: str, project_id: str) -> None:
572
+ """Update existing render_assets row with project_id (orphan rescue)."""
573
+ self._rest(
574
+ "PATCH",
575
+ f"/rest/v1/render_assets?id=eq.{render_id}",
576
+ body={"project_id": project_id},
577
+ )
578
+
579
+ # ------------------------------------------------------------------------
580
+ # 5b. MOODBOARD · moodboard_analyses + moodboard_generated_assets
581
+ # (THE ENTRY POINT new-home uses · NOT moodboard_images directly)
582
+ # ------------------------------------------------------------------------
583
+ def create_moodboard_analysis(
584
+ self,
585
+ *,
586
+ user_id: str,
587
+ project_id: str,
588
+ name: str,
589
+ user_prompt: Optional[str] = None,
590
+ atmosphere: Optional[str] = None,
591
+ materials: Optional[List[str]] = None,
592
+ color_palette: Optional[List[str]] = None,
593
+ typography: Optional[str] = None,
594
+ geometry: Optional[str] = None,
595
+ spatial_references: Optional[List[str]] = None,
596
+ source_images: Optional[List[str]] = None,
597
+ design_system_md: Optional[str] = None,
598
+ model_used: str = "squad-architettura-progetto",
599
+ status: str = "completed",
600
+ ) -> str:
601
+ """Create moodboard_analyses · matches REAL schema (no title/keywords/feeling cols)."""
602
+ row = {
603
+ "user_id": user_id,
604
+ "project_id": project_id,
605
+ "name": name,
606
+ "user_prompt": user_prompt,
607
+ "atmosphere": atmosphere,
608
+ "materials": materials or [],
609
+ "color_palette": color_palette or [],
610
+ "typography": typography,
611
+ "geometry": geometry,
612
+ "spatial_references": spatial_references or [],
613
+ "source_images": source_images or [],
614
+ "design_system_md": design_system_md,
615
+ "model_used": model_used,
616
+ "status": status,
617
+ }
618
+ row = {k: v for k, v in row.items() if v is not None}
619
+ result = self._rest("POST", "/rest/v1/moodboard_analyses", body=row)
620
+ return result[0]["id"] if isinstance(result, list) else result["id"]
621
+
622
+ def add_moodboard_assets(
623
+ self,
624
+ *,
625
+ analysis_id: str,
626
+ assets: List[Dict[str, Any]],
627
+ ) -> List[str]:
628
+ """assets = [{asset_type, asset_url, description?, tags?}, ...]
629
+ asset_type one of: 'flatlay_complete' (priority 3 · cover), 'atmosphere' (2), 'colors' (1)
630
+ NOTE: moodboard_generated_assets has NO user_id col · linked only via analysis_id."""
631
+ rows = []
632
+ for a in assets:
633
+ row = {
634
+ "analysis_id": analysis_id,
635
+ "asset_type": a.get("asset_type", "atmosphere"),
636
+ "asset_url": a["asset_url"],
637
+ "description": a.get("description"),
638
+ "tags": a.get("tags", []),
639
+ }
640
+ rows.append({k: v for k, v in row.items() if v is not None})
641
+ result = self._rest("POST", "/rest/v1/moodboard_generated_assets", body=rows)
642
+ return [r["id"] for r in result]
643
+
644
+ # ------------------------------------------------------------------------
645
+ # 6. CONTRACTS · contracts (project_id linked · visible in ProjectDetailContract)
646
+ # ------------------------------------------------------------------------
647
+ def create_contract(
648
+ self,
649
+ *,
650
+ user_id: str,
651
+ title: str,
652
+ client_name: str,
653
+ project_id: Optional[str] = None, # FK to pm_projects · for new-home tab
654
+ content: Optional[str] = None,
655
+ lead_id: Optional[str] = None,
656
+ proposal_id: Optional[str] = None,
657
+ status: str = "draft",
658
+ signed_at: Optional[str] = None,
659
+ expires_at: Optional[str] = None,
660
+ ) -> str:
661
+ """Create contract · matches REAL schema (no `total` col · use proposal_id for value)."""
662
+ row = {
663
+ "user_id": user_id,
664
+ "project_id": project_id,
665
+ "lead_id": lead_id,
666
+ "proposal_id": proposal_id,
667
+ "title": title,
668
+ "client_name": client_name,
669
+ "content": content,
670
+ "status": status,
671
+ "signed_at": signed_at,
672
+ "expires_at": expires_at,
673
+ }
674
+ row = {k: v for k, v in row.items() if v is not None}
675
+ result = self._rest("POST", "/rest/v1/contracts", body=row)
676
+ return result[0]["id"] if isinstance(result, list) else result["id"]
677
+
678
+ # ------------------------------------------------------------------------
679
+ # 7. TASKS · pm_tasks (bulk)
680
+ # ------------------------------------------------------------------------
681
+ def bulk_create_tasks(
682
+ self,
683
+ *,
684
+ project_id: str,
685
+ user_id: str,
686
+ tasks: List[Dict[str, Any]],
687
+ ) -> List[str]:
688
+ """tasks = [{title, description?, status?, priority?, deadline?, phase_id?}, ...]"""
689
+ rows = []
690
+ for t in tasks:
691
+ rows.append(
692
+ {
693
+ "project_id": project_id,
694
+ "user_id": user_id,
695
+ "title": t["title"],
696
+ "description": t.get("description"),
697
+ "status": t.get("status", "todo"),
698
+ "priority": t.get("priority", "medium"),
699
+ "deadline": t.get("deadline"),
700
+ "phase_id": t.get("phase_id"),
701
+ "responsible": t.get("responsible"),
702
+ }
703
+ )
704
+ result = self._rest("POST", "/rest/v1/pm_tasks", body=rows)
705
+ return [r["id"] for r in result]
706
+
707
+ # ------------------------------------------------------------------------
708
+ # 8. BUDGET ITEMS · pm_budget_items (computo metrico)
709
+ # ------------------------------------------------------------------------
710
+ def bulk_create_budget_items(
711
+ self,
712
+ *,
713
+ project_id: str,
714
+ user_id: str,
715
+ items: List[Dict[str, Any]],
716
+ ) -> List[str]:
717
+ """items = [{category, description, estimated_amount, approved_amount?}, ...]"""
718
+ rows = []
719
+ for it in items:
720
+ rows.append(
721
+ {
722
+ "project_id": project_id,
723
+ "user_id": user_id,
724
+ "category": it["category"],
725
+ "description": it["description"],
726
+ "estimated_amount": it["estimated_amount"],
727
+ "approved_amount": it.get("approved_amount"),
728
+ "actual_amount": it.get("actual_amount"),
729
+ }
730
+ )
731
+ result = self._rest("POST", "/rest/v1/pm_budget_items", body=rows)
732
+ return [r["id"] for r in result]
733
+
734
+ # ------------------------------------------------------------------------
735
+ # 8b. FINANCIAL · financial_transactions (income onorari · 4 SAL installments)
736
+ # ------------------------------------------------------------------------
737
+ DEFAULT_SAL_BREAKDOWN = [
738
+ ("SAL 1 · Concept + Definitivo", 0.15),
739
+ ("SAL 2 · Pratiche + Esecutivo", 0.25),
740
+ ("SAL 3 · DL inizio cantiere", 0.25),
741
+ ("SAL 4 · Saldo consegna", 0.35),
742
+ ]
743
+
744
+ def get_or_create_finance_category(
745
+ self,
746
+ *,
747
+ user_id: str,
748
+ name: str = "Onorari Architetto",
749
+ category_type: str = "vendas",
750
+ subcategories: Optional[List[str]] = None,
751
+ ) -> str:
752
+ """Get existing or create financial_categories row for project income."""
753
+ # Try fetch first (URL-encode name · may contain spaces)
754
+ encoded_name = urllib.parse.quote(name)
755
+ existing = self._rest(
756
+ "GET",
757
+ f"/rest/v1/financial_categories?user_id=eq.{user_id}&name=eq.{encoded_name}&select=id",
758
+ )
759
+ if existing and isinstance(existing, list) and existing:
760
+ return existing[0]["id"]
761
+
762
+ # Create new
763
+ row = {
764
+ "user_id": user_id,
765
+ "name": name,
766
+ "type": category_type,
767
+ "subcategories": subcategories or ["SAL1", "SAL2", "SAL3", "SAL4"],
768
+ }
769
+ result = self._rest("POST", "/rest/v1/financial_categories", body=row)
770
+ return result[0]["id"] if isinstance(result, list) else result["id"]
771
+
772
+ def create_financial_income(
773
+ self,
774
+ *,
775
+ user_id: str,
776
+ lead_id: Optional[str],
777
+ project_id: Optional[str], # ✓ financial_transactions HAS project_id directly
778
+ project_name: str,
779
+ total_value: float,
780
+ start_date: str, # ISO YYYY-MM-DD · first SAL date
781
+ category_id: Optional[str] = None,
782
+ description_prefix: str = "Onorari",
783
+ sal_breakdown: Optional[List[Tuple[str, float]]] = None,
784
+ bank_account_id: Optional[str] = None,
785
+ ) -> List[str]:
786
+ """
787
+ Create financial income rows · 4 SAL installments tied to project.
788
+
789
+ Pattern: parent transaction + 4 children with installment_number 1-4 of 4.
790
+ Returns: list of transaction IDs (parent + children).
791
+
792
+ Default SAL breakdown · 15%/25%/25%/35% (matches CNAPPC standard).
793
+ Spaced 90 days each · adjustable via sal_breakdown override.
794
+ """
795
+ from datetime import datetime, timedelta
796
+
797
+ if not category_id:
798
+ category_id = self.get_or_create_finance_category(user_id=user_id)
799
+
800
+ breakdown = sal_breakdown or self.DEFAULT_SAL_BREAKDOWN
801
+ start = datetime.fromisoformat(start_date)
802
+
803
+ # Parent transaction (carries the totals; children carry the installments)
804
+ parent_row = {
805
+ "user_id": user_id,
806
+ "lead_id": lead_id,
807
+ "project_id": project_id,
808
+ "type": "income",
809
+ "description": f"{description_prefix}: {project_name}",
810
+ "category_id": category_id,
811
+ "value": total_value,
812
+ "date": start_date,
813
+ "status": "pending",
814
+ "installments": len(breakdown),
815
+ "bank_account_id": bank_account_id,
816
+ "auto_generated": True,
817
+ "project_name": project_name,
818
+ }
819
+ parent_row = {k: v for k, v in parent_row.items() if v is not None}
820
+ parent = self._rest("POST", "/rest/v1/financial_transactions", body=parent_row)
821
+ parent_id = parent[0]["id"] if isinstance(parent, list) else parent["id"]
822
+
823
+ # Children: 4 SAL installments
824
+ ids = [parent_id]
825
+ for index, (label, pct) in enumerate(breakdown, start=1):
826
+ sal_date = (start + timedelta(days=90 * (index - 1))).date().isoformat()
827
+ child_row = {
828
+ "user_id": user_id,
829
+ "lead_id": lead_id,
830
+ "project_id": project_id,
831
+ "type": "income",
832
+ "description": f"{description_prefix}: {project_name} · {label}",
833
+ "category_id": category_id,
834
+ "value": round(total_value * pct, 2),
835
+ "date": sal_date,
836
+ "status": "pending",
837
+ "installment_number": index,
838
+ "installments": len(breakdown),
839
+ "parent_transaction_id": parent_id,
840
+ "bank_account_id": bank_account_id,
841
+ "auto_generated": True,
842
+ "project_name": project_name,
843
+ }
844
+ child_row = {k: v for k, v in child_row.items() if v is not None}
845
+ child = self._rest("POST", "/rest/v1/financial_transactions", body=child_row)
846
+ ids.append(child[0]["id"] if isinstance(child, list) else child["id"])
847
+
848
+ return ids
849
+
850
+ # ------------------------------------------------------------------------
851
+ # 9. PORTAL · portal-auth invite
852
+ # ------------------------------------------------------------------------
853
+ def invite_to_portal(
854
+ self,
855
+ *,
856
+ project_id: str,
857
+ email: str,
858
+ name: str,
859
+ phone: Optional[str] = None,
860
+ locale: str = "it",
861
+ ) -> Dict[str, Any]:
862
+ """
863
+ Calls portal-auth edge function with action=invite.
864
+ Returns {client, magic_link_url, magic_token}.
865
+ """
866
+ return self._edge(
867
+ "portal-auth",
868
+ {
869
+ "action": "invite",
870
+ "email": email,
871
+ "name": name,
872
+ "phone": phone,
873
+ "project_id": project_id,
874
+ "locale": locale,
875
+ },
876
+ )
877
+
878
+ # ------------------------------------------------------------------------
879
+ # 10. SQUAD EXECUTION · pm_squad_executions / steps / qa_checks
880
+ # ------------------------------------------------------------------------
881
+ def create_execution(
882
+ self,
883
+ *,
884
+ user_id: str,
885
+ project_id: Optional[str] = None,
886
+ metadata: Optional[Dict[str, Any]] = None,
887
+ ) -> str:
888
+ row = {
889
+ "user_id": user_id,
890
+ "project_id": project_id,
891
+ "status": "running",
892
+ "metadata": metadata or {},
893
+ }
894
+ result = self._rest("POST", "/rest/v1/pm_squad_executions", body=row)
895
+ return result[0]["id"] if isinstance(result, list) else result["id"]
896
+
897
+ def update_execution(
898
+ self,
899
+ *,
900
+ execution_id: str,
901
+ patch: Dict[str, Any],
902
+ ) -> None:
903
+ self._rest(
904
+ "PATCH",
905
+ f"/rest/v1/pm_squad_executions?id=eq.{execution_id}",
906
+ body=patch,
907
+ )
908
+
909
+ # ========================================================================
910
+ # ALL-IN-ONE · create_project_complete
911
+ # Bootstraps lead + project + 6 phases + 10 budget categories +
912
+ # finance category + 4 SAL installments + portal client invitation.
913
+ #
914
+ # This is what @progetto-chief calls at the START of every execution.
915
+ # ========================================================================
916
+ def create_project_complete(
917
+ self,
918
+ *,
919
+ user_id: str,
920
+ client_data: Dict[str, Any],
921
+ project_data: Dict[str, Any],
922
+ finance_config: Optional[Dict[str, Any]] = None,
923
+ invite_to_portal: bool = True,
924
+ ) -> Dict[str, Any]:
925
+ """
926
+ End-to-end project bootstrap · matches new-home useCreateProject + extras.
927
+
928
+ client_data: {name, email, phone?, company?, budget?}
929
+ project_data: {name, address?, typology?, square_meters?,
930
+ brief_objectives?, brief_style?, budget_min?, budget_max?,
931
+ delivery_date?}
932
+ finance_config: optional · {onorari_total, start_date, sal_breakdown?}
933
+ invite_to_portal: if True, creates portal_clients row + magic link
934
+
935
+ Returns:
936
+ {
937
+ lead_id, project_id, phase_ids[], budget_item_ids[],
938
+ finance_category_id?, finance_transaction_ids?[],
939
+ portal: {magic_link_url?}
940
+ }
941
+ """
942
+ # 1. Lead (CRM client)
943
+ lead_id = self.create_lead(
944
+ user_id=user_id,
945
+ name=client_data["name"],
946
+ email=client_data.get("email"),
947
+ phone=client_data.get("phone"),
948
+ budget=client_data.get("budget") or project_data.get("budget_max"),
949
+ proposal_value=client_data.get("proposal_value") or project_data.get("budget_max"),
950
+ status="proposal",
951
+ source="squad-architettura-progetto",
952
+ intervention_type=project_data.get("typology"),
953
+ city=client_data.get("city"),
954
+ region=client_data.get("region"),
955
+ notes=f"Auto-created by squad · project: {project_data['name']}",
956
+ )
957
+
958
+ # 2. Project (with lead_id linked)
959
+ project_id = self.create_project(
960
+ user_id=user_id,
961
+ lead_id=lead_id,
962
+ name=project_data["name"],
963
+ address=project_data.get("address"),
964
+ typology=project_data.get("typology", "ristrutturazione"),
965
+ square_meters=project_data.get("square_meters"),
966
+ client_name=client_data["name"],
967
+ client_email=client_data.get("email"),
968
+ client_phone=client_data.get("phone"),
969
+ brief_objectives=project_data.get("brief_objectives"),
970
+ brief_style=project_data.get("brief_style"),
971
+ constraints=project_data.get("constraints"),
972
+ budget_min=project_data.get("budget_min"),
973
+ budget_max=project_data.get("budget_max"),
974
+ professional_fee_percent=project_data.get("professional_fee_percent"),
975
+ priority="high",
976
+ status="active",
977
+ current_phase=0, # INT · matches new-home pattern
978
+ delivery_date=project_data.get("delivery_date"),
979
+ )
980
+
981
+ # 3. Phases (6 default IT)
982
+ phase_ids = self.create_phases(project_id=project_id, user_id=user_id)
983
+
984
+ # 4. Budget categories (10 default with % breakdown)
985
+ budget_item_ids = []
986
+ if project_data.get("budget_max"):
987
+ budget_item_ids = self.create_default_budget_categories(
988
+ project_id=project_id,
989
+ user_id=user_id,
990
+ budget_max=project_data["budget_max"],
991
+ )
992
+
993
+ # 5. Finance integration (4 SAL onorari) · OPTIONAL
994
+ finance_category_id = None
995
+ finance_transaction_ids = []
996
+ if finance_config:
997
+ finance_category_id = self.get_or_create_finance_category(user_id=user_id)
998
+ finance_transaction_ids = self.create_financial_income(
999
+ user_id=user_id,
1000
+ lead_id=lead_id,
1001
+ project_id=project_id, # ✓ financial_transactions HAS project_id col
1002
+ project_name=project_data["name"],
1003
+ total_value=finance_config["onorari_total"],
1004
+ start_date=finance_config.get("start_date"),
1005
+ category_id=finance_category_id,
1006
+ sal_breakdown=finance_config.get("sal_breakdown"),
1007
+ )
1008
+
1009
+ # 6. Portal client invite · OPTIONAL
1010
+ portal_data: Dict[str, Any] = {}
1011
+ if invite_to_portal and client_data.get("email"):
1012
+ try:
1013
+ portal_data = self.invite_to_portal(
1014
+ project_id=project_id,
1015
+ email=client_data["email"],
1016
+ name=client_data["name"],
1017
+ phone=client_data.get("phone"),
1018
+ )
1019
+ except LovarchClientError as e:
1020
+ # Don't fail the whole bootstrap if portal invite fails
1021
+ portal_data = {"error": str(e)}
1022
+
1023
+ return {
1024
+ "lead_id": lead_id,
1025
+ "project_id": project_id,
1026
+ "phase_ids": phase_ids,
1027
+ "budget_item_ids": budget_item_ids,
1028
+ "finance_category_id": finance_category_id,
1029
+ "finance_transaction_ids": finance_transaction_ids,
1030
+ "portal": portal_data,
1031
+ }
1032
+
1033
+
1034
+ # ----------------------------------------------------------------------------
1035
+ # CLI test
1036
+ # ----------------------------------------------------------------------------
1037
+ if __name__ == "__main__":
1038
+ import sys
1039
+
1040
+ client = LovarchClient()
1041
+ print(f"✓ LovarchClient connected to {client.url}")
1042
+
1043
+ if len(sys.argv) >= 2 and sys.argv[1] == "test":
1044
+ # Quick smoke: list 1 project to verify auth works
1045
+ result = client._rest("GET", "/rest/v1/pm_projects?limit=1&select=id,name")
1046
+ print(f"✓ Auth OK · {len(result)} projects in pm_projects (limit 1)")