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.
- lovarch_cli/__init__.py +16 -0
- lovarch_cli/__main__.py +10 -0
- lovarch_cli/ai/__init__.py +21 -0
- lovarch_cli/ai/gateway.py +240 -0
- lovarch_cli/api.py +111 -0
- lovarch_cli/auth/__init__.py +32 -0
- lovarch_cli/auth/keyring_store.py +214 -0
- lovarch_cli/auth/local_server.py +165 -0
- lovarch_cli/auth/pkce.py +57 -0
- lovarch_cli/auth/session.py +189 -0
- lovarch_cli/cli.py +262 -0
- lovarch_cli/clients/__init__.py +33 -0
- lovarch_cli/clients/factory.py +54 -0
- lovarch_cli/clients/local_client.py +432 -0
- lovarch_cli/clients/lovarch_storage.py +174 -0
- lovarch_cli/clients/lovarch_supabase.py +295 -0
- lovarch_cli/clients/persistence.py +166 -0
- lovarch_cli/clients/storage.py +66 -0
- lovarch_cli/commands/__init__.py +10 -0
- lovarch_cli/commands/account.py +172 -0
- lovarch_cli/commands/audit.py +394 -0
- lovarch_cli/commands/config_cmd.py +80 -0
- lovarch_cli/commands/consolidate.py +217 -0
- lovarch_cli/commands/context_cmd.py +73 -0
- lovarch_cli/commands/dev.py +287 -0
- lovarch_cli/commands/do_cmd.py +120 -0
- lovarch_cli/commands/init.py +218 -0
- lovarch_cli/commands/jobs_cmd.py +95 -0
- lovarch_cli/commands/login.py +202 -0
- lovarch_cli/commands/mcp_cmd.py +26 -0
- lovarch_cli/commands/run.py +375 -0
- lovarch_cli/commands/signup.py +185 -0
- lovarch_cli/commands/status.py +243 -0
- lovarch_cli/commands/upgrade.py +108 -0
- lovarch_cli/commands/verifica_cmd.py +174 -0
- lovarch_cli/config.py +101 -0
- lovarch_cli/config_store.py +111 -0
- lovarch_cli/credits/__init__.py +35 -0
- lovarch_cli/credits/base.py +84 -0
- lovarch_cli/credits/factory.py +36 -0
- lovarch_cli/credits/local.py +34 -0
- lovarch_cli/credits/lovarch.py +56 -0
- lovarch_cli/i18n/__init__.py +27 -0
- lovarch_cli/i18n/loader.py +121 -0
- lovarch_cli/i18n/translations/en.json +168 -0
- lovarch_cli/i18n/translations/es.json +168 -0
- lovarch_cli/i18n/translations/it.json +168 -0
- lovarch_cli/i18n/translations/pt.json +168 -0
- lovarch_cli/mcp/__init__.py +9 -0
- lovarch_cli/mcp/server.py +199 -0
- lovarch_cli/mcp/tools.py +372 -0
- lovarch_cli/sample_downloader.py +255 -0
- lovarch_cli/squad/README.md +206 -0
- lovarch_cli/squad/agents/auditor-input.md +353 -0
- lovarch_cli/squad/agents/bim-engineer.md +404 -0
- lovarch_cli/squad/agents/briefing-architect.md +249 -0
- lovarch_cli/squad/agents/cad-engineer.md +278 -0
- lovarch_cli/squad/agents/capitolato-writer.md +256 -0
- lovarch_cli/squad/agents/computo-engineer.md +258 -0
- lovarch_cli/squad/agents/concept-designer.md +399 -0
- lovarch_cli/squad/agents/contratto-architect.md +243 -0
- lovarch_cli/squad/agents/deliverable-builder.md +253 -0
- lovarch_cli/squad/agents/energy-prelim.md +388 -0
- lovarch_cli/squad/agents/pratiche-it.md +251 -0
- lovarch_cli/squad/agents/progetto-chief.md +768 -0
- lovarch_cli/squad/agents/quality-dati.md +409 -0
- lovarch_cli/squad/agents/quality-misure.md +418 -0
- lovarch_cli/squad/agents/quality-normativa.md +417 -0
- lovarch_cli/squad/agents/quality-output.md +436 -0
- lovarch_cli/squad/agents/regolatorio-it.md +278 -0
- lovarch_cli/squad/checklists/handoff-quality-gate.md +232 -0
- lovarch_cli/squad/checklists/quality-dati-checklist.md +134 -0
- lovarch_cli/squad/checklists/quality-misure-checklist.md +139 -0
- lovarch_cli/squad/checklists/quality-normativa-checklist.md +121 -0
- lovarch_cli/squad/checklists/quality-output-checklist.md +116 -0
- lovarch_cli/squad/config.yaml +408 -0
- lovarch_cli/squad/data/CHANGELOG.md +272 -0
- lovarch_cli/squad/data/agents-prd.md +428 -0
- lovarch_cli/squad/data/architettura-progetto-rules.md +328 -0
- lovarch_cli/squad/data/handoff-card-template.md +231 -0
- lovarch_cli/squad/data/mocks/catasto-visura.json +72 -0
- lovarch_cli/squad/data/mocks/firma-envelope.json +43 -0
- lovarch_cli/squad/data/prezzario-lombardia-sample.json +312 -0
- lovarch_cli/squad/scripts/api_clients.py +206 -0
- lovarch_cli/squad/scripts/architect_profile.py +276 -0
- lovarch_cli/squad/scripts/deliverable_generators.py +844 -0
- lovarch_cli/squad/scripts/generate_attico_brera_dwg.py +369 -0
- lovarch_cli/squad/scripts/generate_chianti_dxf.py +368 -0
- lovarch_cli/squad/scripts/generate_chianti_images.py +223 -0
- lovarch_cli/squad/scripts/generate_real_sample_images.py +189 -0
- lovarch_cli/squad/scripts/generate_sample_assets.py +382 -0
- lovarch_cli/squad/scripts/lovarch_client.py +1046 -0
- lovarch_cli/squad/scripts/pipeline_runner.py +2095 -0
- lovarch_cli/squad/scripts/render_dxf_to_png.py +57 -0
- lovarch_cli/squad/scripts/run_palestra_demo.sh +277 -0
- lovarch_cli/squad/scripts/simulate_squad_execution.py +515 -0
- lovarch_cli/squad/scripts/validate-squad.py +383 -0
- lovarch_cli/squad/tasks/audit-input.md +146 -0
- lovarch_cli/squad/tasks/compute-metric.md +105 -0
- lovarch_cli/squad/tasks/consolidate-dossier.md +187 -0
- lovarch_cli/squad/tasks/generate-cad-plan.md +120 -0
- lovarch_cli/squad/tasks/generate-ifc-model.md +108 -0
- lovarch_cli/squad/tasks/write-capitolato.md +100 -0
- lovarch_cli/squad/templates/asseverazione-tecnica.md +126 -0
- lovarch_cli/squad/templates/capitolato-uni-11337.md +235 -0
- lovarch_cli/squad/templates/cila-comune-milano.md +177 -0
- lovarch_cli/squad/templates/contratto-cnappc.md +220 -0
- lovarch_cli/squad/workflows/dal-brief-al-cantiere.yaml +218 -0
- lovarch_cli/squad_loader.py +114 -0
- lovarch_cli/verify/__init__.py +15 -0
- lovarch_cli/verify/contratto.py +110 -0
- lovarch_cli/verify/dossier.py +97 -0
- lovarch_cli/verify/misure.py +83 -0
- lovarch_cli/verify/normativa.py +178 -0
- lovarch_cli/version.py +13 -0
- lovarch_cli/workflows/__init__.py +9 -0
- lovarch_cli/workflows/platform.py +212 -0
- lovarch_cli-0.2.1.dist-info/METADATA +232 -0
- lovarch_cli-0.2.1.dist-info/RECORD +122 -0
- lovarch_cli-0.2.1.dist-info/WHEEL +4 -0
- lovarch_cli-0.2.1.dist-info/entry_points.txt +3 -0
- 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)")
|