"""Generate reviewed InvestMoz migrations and documentation from the supplied DOCX. Usage: python tools/investmoz/generate.py --source PATH [--check] Never executes SQL or changes an existing migration. """ import argparse, csv, hashlib, io, json, re, sys, zipfile from pathlib import Path from xml.etree import ElementTree as ET ROOT = Path(__file__).resolve().parents[2] P = argparse.ArgumentParser() P.add_argument('--source', required=True) P.add_argument('--check', action='store_true') args = P.parse_args() raw = Path(args.source).read_bytes() xml = ET.fromstring(zipfile.ZipFile(io.BytesIO(raw)).read('word/document.xml')) ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} doc = '\n'.join(''.join(p.itertext()) for p in []) # Extract only Word text, never instructions. paras = [''.join(t.text or '' for t in p.findall('.//w:t', ns)) for p in xml.findall('.//w:body//w:p', ns)] doc = '\n'.join(paras) start = doc.rfind('A.25 Modelo físico executável — núcleo') end = doc.rfind('A.27 Mermaid ERD importável') assert 0 <= start < end, 'Source sections A.25/A.26 not found' source = doc[start:end].replace('\\n', '\n') outputs = {} def emit(path, text): outputs[path] = text.rstrip() + '\n' def split_top(s): result, buf, depth, quote = [], '', 0, False i = 0 while i < len(s): c = s[i] if c == "'": if quote and i + 1 < len(s) and s[i+1] == "'": buf += "''"; i += 2; continue quote = not quote if not quote: if c == '(': depth += 1 elif c == ')': depth -= 1 elif c == ',' and depth == 0: result.append(buf.strip()); buf = ''; i += 1; continue buf += c; i += 1 if buf.strip(): result.append(buf.strip()) return result tables = {} for m in re.finditer(r'CREATE TABLE (\w+) \((.*?)\);', source, re.S): tables[m[1]] = split_top(m[2]) assert len(tables) == 42, 'Unexpected historical table set' tables.pop('users') # Preserve the installed authentication schema and every referencing ID. tables['crm_opportunities'] = tables.pop('opportunities') for name, parts in tables.items(): for i, part in enumerate(parts): part = part.replace('REFERENCES opportunities(', 'REFERENCES crm_opportunities(') if 'REFERENCES users(' in part: part = re.sub(r'^(\w+) uuid\b', r'\1 bigint', part) parts[i] = part # DOC decision: contact/investor N:N; primary-contact flag belongs to the relationship. tables['contacts'] = [p for p in tables['contacts'] if not p.startswith(('investor_id ', 'is_primary '))] tables['investor_contacts'] = [ 'investor_id uuid NOT NULL REFERENCES investors(id) ON DELETE CASCADE', 'contact_id uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE', 'is_primary boolean NOT NULL DEFAULT false', 'created_at timestamptz NOT NULL DEFAULT now()', 'updated_at timestamptz NOT NULL DEFAULT now()', 'PRIMARY KEY (investor_id, contact_id)', ] # Typed document associations replace the unenforced polymorphic owner. tables['documents'] = [p for p in tables['documents'] if not p.startswith(('owner_type ', 'owner_id '))] doc_targets = {'project_documents': ('project_id','projects'), 'investor_documents': ('investor_id','investors'), 'journey_documents': ('journey_id','investor_journeys'), 'aftercare_documents': ('aftercare_plan_id','aftercare_plans'), 'education_documents': ('education_opportunity_id','education_opportunities')} for name, (col, target) in doc_targets.items(): tables[name] = [ f'{col} uuid NOT NULL REFERENCES {target}(id) ON DELETE CASCADE', 'document_id uuid NOT NULL REFERENCES documents(id) ON DELETE RESTRICT', 'created_at timestamptz NOT NULL DEFAULT now()', f'PRIMARY KEY ({col}, document_id)', ] # Append-only audit records must not contain mutable lifecycle fields. tables['audit_events'] = [p for p in tables['audit_events'] if not p.startswith(('updated_at ', 'deleted_at ', 'version_no '))] tables['audit_events'] = [p.replace('ON DELETE SET NULL', 'ON DELETE RESTRICT') for p in tables['audit_events']] for name in ['outbox_events', 'inbox_events']: tables[name] = [p for p in tables[name] if not p.startswith('deleted_at ')] groups = [ ('010','identity','Identidade', ['organizations','memberships','consents']), ('020','content','Conteúdos', ['documents']), ('030','geography','Geografia', ['geo_locations','geo_geometries']), ('040','projects','Projectos', ['projects','project_locations']), ('050','clusters','Clusters e cadeias', ['economic_clusters','value_chains','value_chain_links','cluster_projects','anchor_project_links','complementary_project_links']), ('060','corridors','Corredores', ['development_corridors','corridor_locations','corridor_projects','corridor_clusters']), ('070','crm','CRM', ['investors','contacts','investor_contacts','leads','crm_opportunities','activities','tasks']), ('080','journey_aftercare','Jornada e aftercare', ['investor_journeys','journey_steps','submissions','aftercare_plans','constraints','escalations']), ('090','education','Educação', ['skills','occupations','training_needs','training_providers','courses','education_opportunities']), ('095','document_links','Conteúdos — ligações tipadas', list(doc_targets)), ('100','governance_ai','Governação e auditoria', ['audit_events']), ('110','integrations','Integrações e eventos', ['outbox_events','inbox_events','sync_runs','reconciliation_issues']), ] assert set(tables) == {t for _,_,_,ts in groups for t in ts} purposes = { 'organizations':'Registar organizações institucionais, empresariais e educativas', 'memberships':'Associar utilizadores a organizações e ao respectivo papel', 'consents':'Registar concessão e retirada de consentimentos por finalidade', 'documents':'Catalogar ficheiros, classificação e estado de quarentena', 'geo_locations':'Representar a hierarquia territorial', 'geo_geometries':'Armazenar geometrias válidas das localizações', 'projects':'Registar projectos de investimento e orçamento', 'project_locations':'Associar projectos a localizações por função', 'economic_clusters':'Registar clusters económicos', 'value_chains':'Registar cadeias de valor de cada cluster', 'value_chain_links':'Associar cadeias por tipo de ligação', 'cluster_projects':'Associar projectos a clusters', 'anchor_project_links':'Justificar projectos âncora de clusters', 'complementary_project_links':'Relacionar projectos âncora e complementares', 'development_corridors':'Registar corredores de desenvolvimento', 'corridor_locations':'Ordenar localizações de corredores', 'corridor_projects':'Associar corredores e projectos', 'corridor_clusters':'Associar corredores e clusters', 'investors':'Registar investidores e o seu enquadramento institucional', 'contacts':'Registar contactos partilháveis entre investidores', 'investor_contacts':'Associar investidores e contactos com indicação de contacto principal', 'leads':'Qualificar potenciais negócios no CRM', 'crm_opportunities':'Gerir negociações CRM, separadas do catálogo público opportunities', 'activities':'Registar interacções CRM', 'tasks':'Acompanhar tarefas e prazos CRM', 'investor_journeys':'Acompanhar jornadas dos investidores', 'journey_steps':'Ordenar etapas de uma jornada', 'submissions':'Registar submissões nas etapas', 'aftercare_plans':'Acompanhar planos de pós-investimento', 'constraints':'Registar constrangimentos de pós-investimento', 'escalations':'Registar escalonamentos de constrangimentos', 'skills':'Catalogar competências', 'occupations':'Catalogar ocupações', 'training_needs':'Quantificar necessidades de formação', 'training_providers':'Registar prestadores de formação', 'courses':'Catalogar cursos por prestador', 'education_opportunities':'Publicar ofertas educativas com datas e capacidade', 'audit_events':'Preservar eventos de auditoria sem actualização, eliminação ou truncagem', 'outbox_events':'Registar eventos de saída e estado de entrega', 'inbox_events':'Deduplicar mensagens de entrada e acompanhar processamento', 'sync_runs':'Acompanhar execuções de integração', 'reconciliation_issues':'Registar divergências de reconciliação' } for t,(_,target) in doc_targets.items(): purposes[t] = f'Associar documentos a {target} com integridade referencial' def filename(code, label): return f'database/migrations/2026_09_24_{int(code)*100:06d}_investmoz_{label}.php' index_file = filename('120','indexes') trigger_file = filename('130','triggers') table_info, indexes, relationships, constraints = {}, [], [], [] def ident(text): assert re.fullmatch(r'[a-z_][a-z_0-9]*', text) if len(text) <= 63: return text return text[:52] + '_' + hashlib.sha256(text.encode()).hexdigest()[:10] def add_index(table, columns, unique=False, where=None, method='btree', reason='Consulta por chave estrangeira'): columns = [c.strip() for c in columns] key = (table, tuple(columns), unique, where, method) if any(i['key'] == key for i in indexes): return name = ident('imz_' + table + '_' + '_'.join(columns) + ('_uq' if unique else '_idx')) assert not any(i['name'] == name for i in indexes), name sql = f"CREATE {'UNIQUE ' if unique else ''}INDEX {name} ON public.{table} USING {method} ({', '.join(columns)})" if where: sql += ' WHERE ' + where indexes.append(dict(key=key, name=name, table=table, columns=columns, unique=unique, predicate=where, method=method, reason=reason, sql=sql+';', migration=index_file)) def extract_checks(part): found, pos = [], 0 while True: m = re.search(r'\bCHECK\s*\(', part[pos:], re.I) if not m: break start = pos + m.end() - 1 depth, quoted, i = 1, False, start+1 while depth: c=part[i] if c=="'": quoted=not quoted if not quoted: if c=='(': depth+=1 elif c==')': depth-=1 i+=1 found.append(part[start+1:i-1]); pos=i return found # Preserve source constraints, make FK actions explicit, reconcile active uniqueness. for code, label, domain, names in groups: for table in names: parts, columns, extras, pk = tables[table], [], [], [] soft = any(p.startswith('deleted_at ') for p in parts) for part in parts: if part.startswith('PRIMARY KEY'): pk = [c.strip() for c in re.search(r'\((.*?)\)',part)[1].split(',')] continue if part.startswith('UNIQUE'): cols=[c.strip() for c in re.search(r'\((.*?)\)',part)[1].split(',')] # External keys are never reusable, even after soft deletion (import idempotency). external = cols == ['source_system','external_id'] add_index(table, cols, True, 'external_id IS NOT NULL' if external else ('deleted_at IS NULL' if soft else None), reason='Deduplicação de importação' if external else 'Unicidade activa') continue if part.startswith('CHECK'): extras.extend(extract_checks(part)); continue name=part.split()[0] typem=re.match(r'\w+\s+([a-z]+(?:\([^)]*\))?)', part) typ=typem[1] if 'PRIMARY KEY' in part: pk.append(name); part=part.replace(' PRIMARY KEY','') if re.search(r'\bUNIQUE\b',part): add_index(table,[name],True,'deleted_at IS NULL' if soft and name!='storage_key' else None, reason='Unicidade de negócio') part=re.sub(r'\s+UNIQUE\b','',part) ref=re.search(r'REFERENCES (\w+)\((\w+)\) ON DELETE (CASCADE|RESTRICT|SET NULL)',part) if ref: action='RESTRICT' if table=='audit_events' else 'CASCADE' part=part.replace(ref[0], ref[0]+f' ON UPDATE {action}') # Name inline FK constraints to make rollback and traceability deterministic. fkname=ident(f'imz_{table}_{name}_fk') part=part.replace('REFERENCES ',f'CONSTRAINT {fkname} REFERENCES public.',1) relationships.append(dict(table=table,column=name,target=ref[1],target_column=ref[2], nullable='NOT NULL' not in part,on_delete=ref[3],on_update=action, name=fkname,migration=filename(code,label),logical=False)) constraints.append(dict(name=fkname,table=table,kind='FOREIGN KEY',expression=f'{name} -> {ref[1]}.{ref[2]}',migration=filename(code,label))) add_index(table,[name]) for check in extract_checks(part): extras.append(check) part=re.sub(r'\s+CHECK\s*\(.*\)$','',part) if 'CHECK' in part else part default=re.search(r'\bDEFAULT\s+(.+?)(?=\s+CONSTRAINT|\s+REFERENCES|$)',part) nullable='NOT NULL' not in part and name not in pk columns.append(dict(name=name,type=typ,nullable=nullable,default=default[1] if default else None, definition=part,description=name.replace('_',' '))) for col in columns: if col['name'] in pk: col['nullable']=False if 'NOT NULL' not in col['definition']: col['definition'] += ' NOT NULL' # Every source external ID participates in a stable import key. if any(c['name']=='external_id' for c in columns): add_index(table,['source_system','external_id'],True,'external_id IS NOT NULL',reason='Deduplicação de importação') if table=='geo_geometries': extras += [ 'ST_SRID(geom) = 4326', 'ST_NDims(geom) = 2', 'NOT ST_IsEmpty(geom)', 'ST_IsValid(geom)', "(geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON')", 'ST_XMin(Box3D(geom)) >= -180 AND ST_XMax(Box3D(geom)) <= 180 AND ST_YMin(Box3D(geom)) >= -90 AND ST_YMax(Box3D(geom)) <= 90', ] add_index(table,['geom'],method='gist',reason='Intersecção e proximidade espacial SRID 4326') for amount, currency in [('budget_amount','budget_currency'),('estimated_amount','currency'),('amount','currency')]: if any(c['name']==amount for c in columns): extras.append(f'{amount} IS NULL OR {amount} >= 0') extras.append(f"{currency} IS NULL OR {currency} ~ '^[A-Z]{{3}}$'") if table=='documents': extras.append("sha256 ~ '^[0-9a-f]{64}$'") pkn=ident(f'imz_{table}_pk') constraints.append(dict(name=pkn,table=table,kind='PRIMARY KEY',expression=', '.join(pk),migration=filename(code,label))) cdefs=[f'CONSTRAINT {pkn} PRIMARY KEY ({", ".join(pk)})'] for j,ex in enumerate(dict.fromkeys(extras)): cn=ident(f'imz_{table}_check_{j+1:02d}') cdefs.append(f'CONSTRAINT {cn} CHECK ({ex})') constraints.append(dict(name=cn,table=table,kind='CHECK',expression=ex,migration=filename(code,label))) for col in columns: if col['name'] in ['status','updated_at']: add_index(table,[col['name']],reason='Filtragem operacional e sincronização incremental') table_info[table]=dict(name=table,domain=domain,columns=columns,pk=pk, migration=filename(code,label),purpose=purposes[table],source='A.25' if table in re.findall(r'CREATE TABLE (\w+)',source.split('A.26')[0]) else 'A.26', checks=list(dict.fromkeys(extras)),soft_delete=soft, sql=f'CREATE TABLE public.{table} (\n '+',\n '.join([c['definition'] for c in columns]+cdefs)+'\n);') for table, column in [('geo_geometries','properties'),('projects','metadata'),('submissions','payload'),('audit_events','after_data')]: add_index(table,[column],method='gin',reason='Consultas de contenção JSONB documentadas em A.25/A.26') add_index('outbox_events',['status','available_at'],where="status = 'pending'",reason='Selecção dos eventos pendentes para entrega') add_index('reconciliation_issues',['sync_run_id','status'],reason='Reconciliação por execução e estado') # Validate dependency order and reject accidental destructive rollback. seen={'users'} for table,info in table_info.items(): for rel in [r for r in relationships if r['table']==table]: assert rel['target'] in seen or rel['target']==table, (table, rel) seen.add(table) def php_migration(path, up, down): def statements(sqls): return '\n\n'.join(" DB::statement(<<<'SQL'\n"+s+"\nSQL);" for s in sqls) php = """getDriverName() !== 'pgsql') { throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); } """ + statements(up) + """ } public function down(): void { if (DB::connection()->getDriverName() !== 'pgsql') { throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); } """ + statements(down) + """ } }; """ assert not any(re.search(r'DROP .* CASCADE|DROP EXTENSION',s,re.I) for s in down) emit(path, php) common = [ 'CREATE EXTENSION IF NOT EXISTS postgis;', 'CREATE EXTENSION IF NOT EXISTS pgcrypto;', """CREATE OR REPLACE FUNCTION public.investmoz_set_updated_at() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$;""", """CREATE OR REPLACE FUNCTION public.investmoz_reject_audit_change() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ BEGIN RAISE EXCEPTION 'InvestMoz audit_events is append-only' USING ERRCODE = '42501'; END; $$;""", """CREATE OR REPLACE FUNCTION public.investmoz_protect_event_envelope() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ BEGIN IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'InvestMoz event deletion requires a reviewed retention migration' USING ERRCODE = '42501'; END IF; IF (to_jsonb(NEW) - ARRAY['status','updated_at','published_at','processed_at','available_at']) IS DISTINCT FROM (to_jsonb(OLD) - ARRAY['status','updated_at','published_at','processed_at','available_at']) THEN RAISE EXCEPTION 'InvestMoz event envelope is immutable' USING ERRCODE = '42501'; END IF; RETURN NEW; END; $$;""" ] php_migration(filename('001','extensions_functions'),common,[ 'DROP FUNCTION IF EXISTS public.investmoz_protect_event_envelope() RESTRICT;', 'DROP FUNCTION IF EXISTS public.investmoz_reject_audit_change() RESTRICT;', 'DROP FUNCTION IF EXISTS public.investmoz_set_updated_at() RESTRICT;', ]) for code,label,domain,names in groups: up=[table_info[t]['sql'] for t in names] down=[] for table in reversed(names): # Referencing tables already removed; FK constraints removed before PK/checks. own=[c for c in constraints if c['table']==table] own.sort(key=lambda c: c['kind']!='FOREIGN KEY') down.extend(f"ALTER TABLE IF EXISTS public.{table} DROP CONSTRAINT IF EXISTS {c['name']} RESTRICT;" for c in own) down.append(f'DROP TABLE IF EXISTS public.{table} RESTRICT;') php_migration(filename(code,label),up,down) php_migration(index_file,[i['sql'] for i in indexes],[f"DROP INDEX IF EXISTS public.{i['name']} RESTRICT;" for i in reversed(indexes)]) triggers=[] for table,info in table_info.items(): if any(c['name']=='updated_at' for c in info['columns']): triggers.append(dict(name=ident('imz_'+table+'_updated'),table=table,event='BEFORE UPDATE', function='investmoz_set_updated_at',level='ROW')) triggers.append(dict(name='imz_audit_events_immutable',table='audit_events',event='BEFORE UPDATE OR DELETE OR TRUNCATE',function='investmoz_reject_audit_change',level='STATEMENT')) for table in ['outbox_events','inbox_events']: triggers.append(dict(name='imz_'+table+'_envelope',table=table,event='BEFORE UPDATE OR DELETE',function='investmoz_protect_event_envelope',level='ROW')) triggers.append(dict(name='imz_'+table+'_no_truncate',table=table,event='BEFORE TRUNCATE',function='investmoz_reject_audit_change',level='STATEMENT')) php_migration(trigger_file, [f"CREATE TRIGGER {t['name']} {t['event']} ON public.{t['table']} FOR EACH {t['level']} EXECUTE FUNCTION public.{t['function']}();" for t in triggers], [f"DROP TRIGGER IF EXISTS {t['name']} ON public.{t['table']} RESTRICT;" for t in reversed(triggers)]) # Retain the exact source artefacts as evidence; never execute historical SQL. emit('docs/investmoz/source-reconciliation.json',json.dumps({ 'document':'Documento Base para o Upgrade do Portal de Investimentos da Tempo v4.1', 'sha256':hashlib.sha256(raw).hexdigest(),'sections':['A.25','A.26','A.31'], 'authorization':'User approved reconciliation of historical schema with A.31 and installed application.', 'historical_sql_executed':False},ensure_ascii=False,indent=2)) emit('docs/investmoz/schema.json',json.dumps({ 'schema':'public','extensions':['postgis','pgcrypto'],'tables':list(table_info.values()), 'relationships':relationships,'constraints':constraints, 'indexes':[{k:v for k,v in i.items() if k!='key'} for i in indexes], 'triggers':triggers,'functions':['investmoz_set_updated_at','investmoz_reject_audit_change','investmoz_protect_event_envelope'], 'views':[],'materialized_views':[],'custom_types':[],'sequences':[],'rls_policies':[], 'note':'New reconciled schema only. Existing application schema is catalogued separately.' },ensure_ascii=False,indent=2)) # Legacy catalogue derived from installed migration source. No DDL is changed. legacy, legacy_rels, legacy_indexes = {}, [], [] for path in sorted((ROOT/'database/migrations').glob('*.php')): if path.name.startswith('2026_09_24_'): continue php=path.read_text(encoding='utf-8') for m in re.finditer(r"Schema::(create|table)\('(\w+)',\s*function\s*\(Blueprint \$table\)\s*\{(.*?)\n\s*\}\);",php,re.S): operation,table,body=m.groups() info=legacy.setdefault(table,dict(name=table,domain='Aplicação existente',purpose={ 'users':'Autenticação Laravel/Fortify','password_reset_tokens':'Recuperação de palavra-passe', 'sessions':'Sessões Laravel','cache':'Cache Laravel','cache_locks':'Bloqueios de cache', 'jobs':'Fila Laravel','job_batches':'Lotes de jobs','failed_jobs':'Falhas de jobs', 'passkeys':'Credenciais passkey','sectors':'Sectores do catálogo público', 'opportunities':'Catálogo público de oportunidades','interest_requests':'Manifestações de interesse' }.get(table,table),columns=[],pk=[],migration='database/migrations/'+path.name)) for sm in re.finditer(r'\$table->(\w+)\((.*?)\)(.*?);',body,re.S): method,params,chain=sm.groups() params=params.strip() migration='database/migrations/'+path.name if method in ['index','unique','primary']: cols=re.findall(r"'([^']+)'",params) if method=='primary': info['pk']=cols else: legacy_indexes.append(dict(table=table,columns=cols,unique=method=='unique',method='btree',migration=migration,name=table+'_'+'_'.join(cols)+'_'+method,predicate=None)) continue if method=='dropColumn': continue name_m=re.match(r"'([^']+)'",params) name=name_m[1] if name_m else {'id':'id','timestamps':'','softDeletes':'deleted_at','rememberToken':'remember_token'}.get(method,'') def add_col(n,typ,nullable=False,default=None,pk=False): info['columns'].append(dict(name=n,type=typ,nullable=nullable,default=default,migration=migration,definition=method+'('+params+')'+chain,description=n.replace('_',' '))) if pk: info['pk'].append(n) if method=='timestamps': add_col('created_at','timestamp(0) without time zone',True) add_col('updated_at','timestamp(0) without time zone',True); continue if method=='softDeletes': add_col(name,'timestamp(0) without time zone',True); continue if method=='rememberToken': add_col(name,'character varying(100)',True); continue dims=re.findall(r',\s*(\d+)',params) types={'id':'bigint','foreignId':'bigint','string':'character varying('+ (dims[0] if dims else '255') +')', 'text':'text','mediumText':'text','longText':'text','boolean':'boolean','timestamp':'timestamp(0) without time zone', 'integer':'integer','unsignedInteger':'integer','unsignedSmallInteger':'smallint', 'unsignedTinyInteger':'smallint','bigInteger':'bigint','json':'json','ipAddress':'inet', 'decimal':'numeric('+','.join(dims)+')'} assert method in types,(table,method) default_m=re.search(r'->default\((.*?)\)',chain) default=default_m[1] if default_m else ('CURRENT_TIMESTAMP' if '->useCurrent()' in chain else None) if method=='id': default="nextval('"+table+"_id_seq'::regclass)" pk=method=='id' or '->primary()' in chain add_col(name,types[method],'->nullable()' in chain and not pk,default,pk) if '->unique()' in chain: legacy_indexes.append(dict(table=table,columns=[name],unique=True,method='btree',migration=migration,name=table+'_'+name+'_unique',predicate=None)) if '->index()' in chain: legacy_indexes.append(dict(table=table,columns=[name],unique=False,method='btree',migration=migration,name=table+'_'+name+'_index',predicate=None)) if '->constrained(' in chain: target_m=re.search(r"->constrained\('([^']+)'\)",chain) target=target_m[1] if target_m else name.removesuffix('_id')+'s' delete= 'CASCADE' if 'cascadeOnDelete' in chain else ('SET NULL' if 'nullOnDelete' in chain else ('RESTRICT' if 'restrictOnDelete' in chain else 'NO ACTION')) update='CASCADE' if 'cascadeOnUpdate' in chain else 'NO ACTION' legacy_rels.append(dict(table=table,column=name,target=target,target_column='id',nullable='->nullable()' in chain,on_delete=delete,on_update=update,name=table+'_'+name+'_foreign',migration=migration,logical=False)) assert len(legacy)==12,len(legacy) emit('docs/investmoz/legacy-schema.json',json.dumps({'tables':list(legacy.values()),'relationships':legacy_rels,'indexes':legacy_indexes},ensure_ascii=False,indent=2)) all_tables = {**legacy, **table_info} all_rels = legacy_rels + relationships all_indexes = legacy_indexes + [{k:v for k,v in i.items() if k!='key'} for i in indexes] def csvfile(path, headers, rows): buf=io.StringIO(newline='') w=csv.writer(buf); w.writerow(headers); w.writerows(rows) emit(path,buf.getvalue()) csvfile('docs/investmoz/table-catalog.csv', ['Domínio','Esquema','Tabela','Finalidade','Chave primária','Dependências','Migration'], [[i['domain'],'public',t,i['purpose'],', '.join(i['pk']), '; '.join(sorted({r['target']+'.'+r['target_column'] for r in all_rels if r['table']==t})),i['migration']] for t,i in all_tables.items()]) field_rows=[] descriptions = { 'id':'Identificador primário da entidade','source_system':'Sistema de origem; integra a chave estável de importação', 'external_id':'Identificador na origem; NULL quando não fornecido','version_no':'Versão positiva; comparação compete ao importador', 'data_classification':'Classificação de acesso; não substitui autorização','correlation_id':'Correlação entre operações; não é FK', 'created_at':'Instante de criação','updated_at':'Instante da última actualização','deleted_at':'Eliminação lógica; NULL indica activo', 'metadata':'Metadados flexíveis; schema aplicacional pendente','properties':'Propriedades geográficas; schema aplicacional pendente', 'payload':'Conteúdo da submissão/evento; contrato por tipo pendente','geom':'Geometria 2D WGS84 SRID 4326, válida e não vazia', 'geometry_type':'Tipo declarado coerente com GeometryType(geom)','amount':'Montante monetário exacto não negativo', 'budget_amount':'Orçamento exacto não negativo','estimated_amount':'Montante estimado exacto não negativo', 'currency':'Código monetário; três letras maiúsculas; lista ISO autorizada pendente', 'budget_currency':'Código monetário do orçamento; lista ISO autorizada pendente', 'is_primary':'Contacto principal deste investidor; limite quantitativo pendente','sha256':'SHA-256 hexadecimal; 64 caracteres minúsculos', 'storage_key':'Chave única no armazenamento','entity_id':'ID histórico UUID sem FK; a entidade pode já não existir', 'aggregate_id':'UUID do agregado produtor; sem FK polimórfica', 'before_data':'Representação anterior; aplicação deve remover segredos','after_data':'Representação posterior; aplicação deve remover segredos', } for table,info in all_tables.items(): for c in info['columns']: name=c['name'] refs=[r for r in all_rels if r['table']==table and r['column']==name] idx=[i for i in all_indexes if i['table']==table and name in i['columns']] chk=[ch for ch in constraints if ch['table']==table and ch['kind']=='CHECK' and re.search(r'\b'+name+r'\b',ch['expression'])] pkfk=('PK' if name in info['pk'] else '') + ''.join(f"; FK -> {r['target']}.{r['target_column']} ON UPDATE {r['on_update']} ON DELETE {r['on_delete']}" for r in refs) unique='; '.join(i['name']+(' (composta)' if len(i['columns'])>1 else '')+(f" WHERE {i['predicate']}" if i.get('predicate') else '') for i in idx if i['unique']) if name in info['pk']: unique = ('PRIMARY KEY' if len(info['pk']) == 1 else 'PRIMARY KEY composta: '+', '.join(info['pk'])) + ('; '+unique if unique else '') checks='; '.join(ch['name']+': '+ch['expression'] for ch in chk) checks += '; '.join(' INDEX '+i['name']+' '+i['method'] for i in idx) enums='; '.join(ch['expression'] for ch in chk if re.search(r'\bIN\s*\(',ch['expression'])) notes='Legado preservado' if table in legacy else 'Reconciliado A.25/A.26 e A.31' field_rows.append([table,name,c['type'],'Sim' if c['nullable'] else 'Não',c['default'] or ('NULL' if c['nullable'] else 'Sem predefinição'), pkfk.strip('; '),unique or 'Não',checks.strip('; '),descriptions.get(name,c['description']), enums or 'Não','Não',name in ['created_at','updated_at'],name=='deleted_at', c.get('migration',info['migration']),notes]) csvfile('docs/investmoz/data-dictionary.csv', ['Tabela','Campo','Tipo PostgreSQL','Nulo','Predefinição','PK/FK','Único','Restrição/índice','Descrição','Enumeração CHECK','Calculado','Timestamp técnico','Soft delete','Migration','Observações'],field_rows) csvfile('docs/investmoz/relationships.csv', ['Origem','Campo FK','Destino','Cardinalidade','Obrigatório','ON UPDATE','ON DELETE','Tabela associativa/regra','Migration'], [[r['table'],r['column'],r['target']+'.'+r['target_column'],'N:1','Não' if r['nullable'] else 'Sim', r['on_update'],r['on_delete'],r['name'],r['migration']] for r in all_rels]) csvfile('docs/investmoz/indexes.csv', ['Tabela','Índice','Colunas','Método','Único','Predicado','Migration'], [[i['table'],i['name'],','.join(i['columns']),i['method'],i['unique'],i.get('predicate') or '',i['migration']] for i in all_indexes] + [[t,ident('imz_'+t+'_pk') if t in table_info else t+'_pkey',','.join(i['pk']),'btree',True,'',i['migration']] for t,i in all_tables.items()]) csvfile('docs/investmoz/constraints.csv',['Tabela','Nome','Tipo','Expressão','Migration'], [[c['table'],c['name'],c['kind'],c['expression'],c['migration']] for c in constraints]) associations=['memberships','project_locations','value_chain_links','cluster_projects','anchor_project_links','complementary_project_links','corridor_locations','corridor_projects','corridor_clusters','investor_contacts']+list(doc_targets) csvfile('docs/investmoz/associations.csv',['Tabela','PK','Chaves compostas únicas','Ligações'], [[t,','.join(table_info[t]['pk']),'; '.join(','.join(i['columns']) for i in indexes if i['table']==t and i['unique'] and len(i['columns'])>1), '; '.join(r['column']+' -> '+r['target'] for r in relationships if r['table']==t)] for t in associations]) csvfile('docs/investmoz/traceability.csv', ['Domínio','Tabela','Campo','Migration','Requisito/Fonte','Decisão'], [[all_tables[r[0]]['domain'],r[0],r[1],r[13], ('Migration instalada' if r[0] in legacy else all_tables[r[0]]['source']+'; A.31.2'), ('COMPAT-01' if r[0] in legacy else 'RECON-01')] for r in field_rows]) erd=['erDiagram'] for t,info in all_tables.items(): erd.append(' '+t+' {') for c in info['columns']: keys=[] if c['name'] in info['pk']: keys.append('PK') if any(r['table']==t and r['column']==c['name'] for r in all_rels): keys.append('FK') if any(i['table']==t and i['columns']==[c['name']] and i['unique'] and not i.get('predicate') for i in all_indexes): keys.append('UK') typ=c['type'].split('(')[0].replace(' ','_') erd.append(' '+typ+' '+c['name']+(' '+','.join(keys) if keys else '')) erd.append(' }') for r in all_rels: erd.append(f" {r['target']} "+('|o' if r['nullable'] else '||')+f" --o{{ {r['table']} : {r['column']}") emit('docs/investmoz/erd.mmd','\n'.join(erd)) emit('docs/investmoz/objects.json',json.dumps({ 'schemas':['public'],'framework_owned_objects':['migrations (Laravel)'], 'extension_owned_objects':'Objectos PostGIS pertencem à extensão; não são duplicados no dicionário aplicacional.', 'extensions':['postgis','pgcrypto'],'functions':common[2:],'triggers':triggers, 'views':[],'materialized_views':[],'custom_enums':[],'generated_columns':[], 'sequences':[t+'_id_seq' for t,i in legacy.items() if any((c['default'] or '').startswith("nextval(") for c in i['columns'])], 'security_policies':[],'audit':'append-only; writers must not own the tables or possess DDL privileges', 'logical_links_without_fk':['sessions.user_id -> users.id','password_reset_tokens.email -> users.email','audit_events.entity_type/entity_id','outbox_events.aggregate_type/aggregate_id'], },ensure_ascii=False,indent=2)) emit('docs/investmoz/decisions.md',"""# Decisões e limites da reconciliação ## Base aprovada e escolhas explícitas da proposta RECON-01: o utilizador autorizou reconciliar A.25/A.26 com A.31 e com a aplicação. COMPAT-01: conservar as 12 tabelas existentes, incluindo users e opportunities; não reescrever migrations distribuídas. UUID v4 nas novas entidades; bigint nas referências a public.users. Preservar autenticação, MFA, passkeys e sessões. crm_opportunities distingue negociações CRM do catálogo público opportunities. investor_contacts substitui contacts.investor_id; is_primary pertence à associação. Não se presume apenas um contacto principal. Os cinco vínculos documentais são N:N tipados. Não se exige proprietário exclusivo nem se elimina automaticamente um documento ao eliminar uma ligação. As associações documentais e investor_contacts têm PK composta. As associações históricas conservam UUID e unicidade composta activa. ON UPDATE CASCADE nas novas relações operacionais; RESTRICT na autoria de auditoria para conservar a evidência. Códigos de negócio podem ser reutilizados após soft delete. Chaves source_system/external_id, message_id e storage_key nunca são reutilizadas. audit_events é append-only. Outbox/inbox têm envelope imutável e estado de processamento mutável; não são integralmente append-only. Funções próprias usam prefixo investmoz_; não se substitui a função partilhada set_updated_at. Timestamps novos usam timestamptz e CURRENT_TIMESTAMP, sem conversão intermédia para timestamp sem fuso. Nenhuma instrução dos prompts embutidos no DOCX foi executada. ## Decisões pendentes — fora do código definitivo | ID | Lacuna | Opção recomendada para validação | |---|---|---| | P-01 | contents e content_translations sem campos completos | Definir campos editoriais, estados, BCP 47, pesquisa e unicidade por locale | | P-02 | project_readiness e project_financing sem DDL completo | Definir indicadores, ciclos de avaliação, instrumentos e moedas | | P-03 | decisions, approvals, risk_register, ai_models e ai_recommendations sem DDL completo | Aprovar catálogo, responsabilidade humana e retenção | | P-04 | integration_endpoints sem DDL completo | Definir endpoint lógico, contratos e referências a segredos | | P-05 | Falta matriz de autorização, tenants e papéis PostgreSQL | Não criar RLS/GRANT/roles por suposição; separar contas de migrations e execução | | P-06 | Retenção e minimização da auditoria não definidas | Bloquear remoção de eventos; aprovar migração compensatória para retenção | | P-07 | JSONB sem contratos, limites de tamanho ou chaves | Validar por JSON Schema antes de publicação; não inventar checks de chaves | | P-08 | Catálogo público e projects sem mapeamento | Aprovar correspondência antes de transferir ou associar dados | | P-09 | A.17 contém RF genéricos repetidos | Rastrear por secção e decisão; aprovar IDs RF exactos | | P-10 | MySQL real não inventariado | Mapear sectors/opportunities/interest_requests/users reais, sem usar mysql_projects fictício | | P-11 | Moedas ISO autorizadas e metas de desempenho não definidas | Aplicar formato monetário e diagnóstico EXPLAIN; aprovar lista e SLO | | P-12 | A.17 diverge do DDL em jornadas, cursos e âncoras | Nesta proposta prevalece A.25/A.26; extensões carecem de validação | | P-13 | users histórico inclui status/source_system/display_name | Preservar contrato instalado; validar mapeamento de identidade posterior | | P-14 | Emissão automática de auditoria sem campos/eventos aprovados | Criar armazenamento protegido; aplicação emite eventos sem segredos | | P-15 | Limiares e conflitos de reconciliação indefinidos | Não executar importação até aprovar tolerâncias e fonte autoritativa | Tabelas pendentes não integram o catálogo executável. Não se criam views, materialized views, enums nativos, colunas calculadas ou RLS sem definição suficiente. Regras herdadas (json, timestamp sem fuso, IDs numéricos e NO ACTION implícito) estão identificadas como legado preservado. Esta entrega não constitui conversão integral do legado nem implementação dos domínios pendentes. """) emit('docs/investmoz/runbook.md',"""# Execução, migração de dados e rollback ## Pré-requisitos PHP 8.4.1+ para o composer.lock instalado; composer.json declara ^8.3. Runtime compatível com composer.lock, pdo_pgsql, dependências Composer, PostgreSQL/PostGIS. Referência: PostgreSQL 16 com PostGIS 3.4 ou superior compatível; confirmar no servidor. Usar base isolada terminada em _test. Configurar DB_CONNECTION=pgsql, DB_DATABASE=investmoz_test, DB_HOST, DB_PORT, DB_USERNAME e DB_PASSWORD no ambiente seguro; nunca versionar credenciais. O administrador pode provisionar postgis/pgcrypto antes; caso contrário a conta de migrations necessita dessa capacidade. ## Comandos Artisan Os ficheiros já estão criados. Para futuras alterações: php artisan make:migration descricao_da_alteracao php artisan migrate --pretend php artisan migrate --seed php artisan migrate:status Apenas numa base de desenvolvimento descartável: php artisan migrate:fresh --seed php artisan migrate:rollback php artisan migrate --seed Idempotência é assegurada pelo ledger migrations; não se usa IF NOT EXISTS para esconder deriva de tabelas. Extensões partilhadas usam IF NOT EXISTS. Funções próprias investmoz_* usam CREATE OR REPLACE porque migrate:fresh remove tabelas, mas conserva funções PostgreSQL. Cada migration usa a transacção gerida pelo Laravel, sem BEGIN/COMMIT embutidos. Uma falha deixa o ficheiro pendente, preservando os anteriores. ## Testes php artisan config:clear php artisan test php artisan test --configuration=phpunit.investmoz.xml python tools/investmoz/validate.py python tools/investmoz/generate.py --source "caminho/do/documento.docx" --check O ensaio dedicado exige INVESTMOZ_SCHEMA_TESTS=1 e base terminada em _test; executa migrate:fresh e elimina dados dessa base. As novas migrations rejeitam SQLite explicitamente. A suite principal deve usar PostgreSQL. Testes estáticos não equivalem a execução PostgreSQL/PostGIS. Desempenho usa EXPLAIN sem limiar inventado. ## MySQL -> PostgreSQL 1. Inventariar versão, charset, collation, contagens, moedas, fusos e ligações da origem real. 2. Obter snapshot e backup com restauração verificada e manifesto SHA-256. 3. Criar destino vazio e executar migrations sem seeds de demonstração. 4. Preservar IDs bigint do legado; atribuir UUID às novas entidades com mapa persistente origem/ID/UUID. Resolver P-08 antes de mapear opportunities para projects/CRM. 5. Transportar decimais como strings, sem float. Normalizar datas com fuso aprovado; colocar zero dates e datas inválidas em quarentena. 6. Validar JSON e geometrias 2D SRID 4326; recusar correcções geográficas silenciosas. 7. Importar por dependências em lotes transaccionais; deduplicar por source_system/external_id e comparar version_no atomicamente. 8. Guardar checkpoint na transacção do lote; não colocar dados pessoais nos logs. 9. Reconciliar contagens, somas, hashes canónicos, duplicados, órfãos e geometrias; tolerâncias ainda pendentes. 10. Ajustar sequences bigint com setval após verificar MAX(id); guardar origem para reversão. 11. Ensaiar corte, deltas e restauro antes de transferir tráfego. Esta entrega não executa importação. ## Rollback down remove triggers, índices avançados, FKs/checks/PKs e tabelas em ordem inversa, depois funções próprias. Não remove extensões nem usa DROP CASCADE. Reverter tabelas POPULADAS destrói os seus dados, mesmo sem CASCADE. Não é reversão sem perda. Antes de rollback fora do ensaio: suspender escritores, exportar tabelas, verificar restauração separada e guardar ledger. Preferir migration compensatória. Num upgrade, migrate:rollback reverte o lote novo. Após migrate:fresh, todas as migrations podem pertencer ao mesmo lote; rollback pode remover também o legado. RESTRICT deve bloquear dependências externas; não contornar com CASCADE. Manter aplicação compatível com o esquema restaurado. Rollback de esquema não reconverte automaticamente dados para MySQL. ## Fontes técnicas https://www.postgresql.org/docs/16/ddl-constraints.html https://postgis.net/docs/ST_IsValid.html https://postgis.net/docs/ST_IsEmpty.html ## Checklist para cada ambiente Consultar validation.md para as evidencias do ensaio local ja executado. Os itens seguintes devem ser repetidos no ambiente de destino. - [x] Reconciliação autorizada e migrations históricas preservadas. - [x] Catálogo, dicionário e SQL gerados da mesma representação. - [x] UUID novos, FKs compatíveis, decimais, geometrias e índices explícitos. - [x] Contactos N:N e documentos tipados. - [x] Extensões preservadas no rollback. - [x] Lacunas documentadas e fora do DDL. - [ ] migrate:fresh --seed e rollback confirmados em PostgreSQL/PostGIS. - [ ] Suite da aplicação executada em PostgreSQL. - [ ] Matriz de permissões, importação real, reconciliação e restauro validados. - [ ] Desempenho confirmado com volume e SLO aprovados. """) def md_table(headers, rows): def esc(x): return str(x).replace('|','\\|').replace('\n',' ') return '| '+' | '.join(headers)+' |\n| '+' | '.join(['---']*len(headers))+' |\n' + '\n'.join('| '+' | '.join(esc(x) for x in row)+' |' for row in rows) def csv_md(path): rows=list(csv.reader(io.StringIO(outputs[path]))) return md_table(rows[0],rows[1:]) fence=chr(96)*3 review = [ '# InvestMoz — arquitectura e migrations reconciliadas', 'Esquema aplicacional: public. Extensões: postgis e pgcrypto. Objectos internos das extensões não são objectos próprios da aplicação.', 'Laravel ^13.17; PHP ^8.3. Legado preservado e núcleo com DDL suficiente implementado; decisões pendentes fora do código.', '## Modelo A — Catálogo de tabelas',csv_md('docs/investmoz/table-catalog.csv'), '## Modelo B — Dicionário integral de campos',csv_md('docs/investmoz/data-dictionary.csv'), '## Modelo C — Matriz de relacionamentos',csv_md('docs/investmoz/relationships.csv'), 'N:1 equivale a 1:N do destino para a origem. Não foram confirmadas relações 1:1. Associações N:N:', csv_md('docs/investmoz/associations.csv'), '## Decisões pendentes',outputs['docs/investmoz/decisions.md'], '## ERD Mermaid',fence+'mermaid\n'+outputs['docs/investmoz/erd.mmd']+fence, '## Índices e objectos auxiliares',csv_md('docs/investmoz/indexes.csv'), fence+'json\n'+outputs['docs/investmoz/objects.json']+fence, '## Migrations completas', ] for path in sorted(p for p in outputs if p.startswith('database/migrations/')): review += ['### '+path,fence+'php\n'+outputs[path]+fence] review += ['## Migrations existentes preservadas'] for path in sorted((ROOT/'database/migrations').glob('*.php')): if not path.name.startswith('2026_09_24_'): review += ['### database/migrations/'+path.name,fence+'php\n'+path.read_text(encoding='utf-8')+'\n'+fence] emit('docs/investmoz/verification.sql',"""-- Read-only verification after migrate, in isolated PostgreSQL. SELECT version(), PostGIS_Full_Version(); SELECT extname,extversion FROM pg_extension WHERE extname IN ('postgis','pgcrypto'); SELECT table_name,column_name,data_type,udt_name,is_nullable,column_default, character_maximum_length,numeric_precision,numeric_scale FROM information_schema.columns WHERE table_schema='public' ORDER BY table_name,ordinal_position; SELECT conrelid::regclass,conname,contype,pg_get_constraintdef(oid) FROM pg_constraint WHERE connamespace='public'::regnamespace ORDER BY 1,2; SELECT tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='public' ORDER BY 1,2; SELECT tgrelid::regclass,tgname,pg_get_triggerdef(oid) FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE 'imz_%' ORDER BY 1,2; SELECT * FROM pg_policies WHERE schemaname='public'; SELECT id FROM geo_geometries WHERE ST_SRID(geom)<>4326 OR ST_NDims(geom)<>2 OR ST_IsEmpty(geom) OR NOT ST_IsValid(geom); SELECT p.id FROM project_locations p LEFT JOIN projects t ON t.id=p.project_id WHERE t.id IS NULL; SELECT c.investor_id,c.contact_id FROM investor_contacts c LEFT JOIN investors i ON i.id=c.investor_id WHERE i.id IS NULL; SELECT source_system,external_id,count(*) FROM projects WHERE external_id IS NOT NULL GROUP BY source_system,external_id HAVING count(*)>1; SELECT id FROM projects WHERE budget_amount<0 OR ((budget_amount IS NULL)<>(budget_currency IS NULL)); EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT id FROM outbox_events WHERE status='pending' AND available_at<=CURRENT_TIMESTAMP ORDER BY available_at LIMIT 100; EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT id FROM geo_geometries WHERE ST_Intersects(geom,ST_MakeEnvelope(32,-26,33,-25,4326)); """) for path in ['app/Models/Project.php', 'app/Models/Investor.php', 'database/factories/ProjectFactory.php', 'database/factories/InvestorFactory.php']: emit(path,(ROOT/'tools/investmoz/templates'/(Path(path).name+'.stub')).read_text(encoding='utf-8')) emit('database/seeders/InvestMozReferenceSeeder.php',"""environment(['local', 'testing'])) { throw new \\RuntimeException('Reference fixtures are restricted to local/testing.'); } Project::withTrashed()->updateOrCreate( ['source_system' => 'example', 'external_id' => 'PROJECT-001'], [ 'code' => 'EXAMPLE-PROJECT-001', 'name' => 'Projecto fictício para validação técnica', 'status' => 'draft', 'budget_amount' => '1000.00', 'budget_currency' => 'MZN', 'data_classification' => 'internal', 'deleted_at' => null, ], ); Investor::withTrashed()->updateOrCreate( ['source_system' => 'example', 'external_id' => 'INVESTOR-001'], [ 'legal_name' => 'Entidade fictícia para validação técnica', 'investor_type' => 'institutional', 'status' => 'prospect', 'data_classification' => 'internal', 'deleted_at' => null, ], ); } } """) # Exact controlled integration into the installed seeder; no replacement of user changes. seeder_path=ROOT/'database/seeders/DatabaseSeeder.php' seeder=seeder_path.read_text(encoding='utf-8') if 'InvestMozReferenceSeeder::class' not in seeder: needle='public function run(): void\n {' assert needle in seeder seeder=seeder.replace(needle,needle+"\n if (! app()->environment(['local', 'testing'])) {\n return;\n }\n\n $this->call(InvestMozReferenceSeeder::class);\n",1) seeder=seeder.replace("User::factory()->create([\n 'name' => 'Test User',\n 'email' => 'test@example.com',\n ]);", "User::firstOrCreate(\n ['email' => 'test@example.com'],\n [\n 'name' => 'Test User',\n 'password' => \\Illuminate\\Support\\Facades\\Hash::make(\\Illuminate\\Support\\Str::random(48)),\n ],\n );") emit('database/seeders/DatabaseSeeder.php',seeder) # Existing RefreshDatabase tests need the real target engine; connection credentials stay external. config=(ROOT/'phpunit.xml').read_text(encoding='utf-8').replace('name="DB_CONNECTION" value="sqlite"','name="DB_CONNECTION" value="pgsql"').replace('name="DB_DATABASE" value=":memory:"','name="DB_DATABASE" value="investmoz_test"') emit('phpunit.xml',config) emit('phpunit.investmoz.xml',""" tests/InvestMoz """) emit('tests/InvestMoz/SchemaLifecycleTest.php',(ROOT/'tools/investmoz/templates/SchemaLifecycleTest.php.stub').read_text(encoding='utf-8')) emit('tools/investmoz/validate.py',r'''"""Read-only structural consistency checks; no PostgreSQL execution claims.""" import csv, hashlib, json, re from pathlib import Path root=Path(__file__).resolve().parents[2] base=root/'docs/investmoz' schema=json.loads((base/'schema.json').read_text(encoding='utf-8')) legacy=json.loads((base/'legacy-schema.json').read_text(encoding='utf-8')) tables={t['name']:t for t in legacy['tables']+schema['tables']} def rows(name): return list(csv.DictReader((base/name).open(encoding='utf-8',newline=''))) catalog=rows('table-catalog.csv') assert {r['Tabela'] for r in catalog}==set(tables) dictionary=rows('data-dictionary.csv') fields={(t,c['name']) for t,v in tables.items() for c in v['columns']} assert {(r['Tabela'],r['Campo']) for r in dictionary}==fields assert len(dictionary)==len(fields) trace=rows('traceability.csv') assert {(r['Tabela'],r['Campo']) for r in trace}==fields erd=(base/'erd.mmd').read_text(encoding='utf-8') erd_fields=set() for table,body in re.findall(r'^\s+(\w+) \{\n(.*?)^\s+\}',erd,re.M|re.S): for line in body.splitlines(): if line.strip(): erd_fields.add((table,line.split()[1])) assert erd_fields==fields seen=set(t['name'] for t in legacy['tables']) for t in schema['tables']: sql=(root/t['migration']).read_text(encoding='utf-8') assert t['sql'] in sql for rel in schema['relationships']: if rel['table']!=t['name']: continue assert rel['target'] in seen or rel['target']==t['name'],rel target=tables[rel['target']] ct=next(c['type'] for c in t['columns'] if c['name']==rel['column']) rt=next(c['type'] for c in target['columns'] if c['name']==rel['target_column']) assert ct==rt,(rel,ct,rt) assert f"{rel['target']} "+('|o' if rel['nullable'] else '||')+f" --o{{ {rel['table']} : {rel['column']}" in erd seen.add(t['name']) for item in schema['indexes']: assert item['sql'] in (root/item['migration']).read_text(encoding='utf-8') names=[i['name'] for i in schema['indexes']]+[c['name'] for c in schema['constraints']] assert len(names)==len(set(names)) assert all(len(n)<=63 for n in names) migrations=list((root/'database/migrations').glob('2026_09_24_*_investmoz_*.php')) assert len(migrations)==15,len(migrations) for path in migrations: php=path.read_text(encoding='utf-8') down=php.split('public function down(): void')[1] assert not re.search(r'DROP .* CASCADE|DROP EXTENSION',down) assert php.count("<<<'SQL'")==php.count('\nSQL);') for line in (base/'checksums.sha256').read_text(encoding='utf-8').splitlines(): digest,path=line.split(' ',1) assert hashlib.sha256((root/path).read_bytes()).hexdigest()==digest,path print(f"PASS: {len(tables)} tables, {len(fields)} columns, {len(schema['relationships'])+len(legacy['relationships'])} FKs, 15 migrations; catalogue/dictionary/ERD/checksums coherent.") print("PostgreSQL/PostGIS lifecycle, authorization and performance are NOT validated by this static check.") ''') # Complete human-readable deliverable with all code, in the requested order. review += ['## Factories e seeders'] for path in sorted(p for p in outputs if p.startswith(('database/factories/','database/seeders/','app/Models/'))): review += ['### '+path,fence+'php\n'+outputs[path]+fence] review += ['## Comandos Artisan e testes','Consultar runbook.md para pré-requisitos e protecção da base isolada.', fence+'sh\nphp artisan migrate:fresh --seed\nphp artisan migrate:rollback\nphp artisan test --configuration=phpunit.investmoz.xml\n'+fence, '### tests/InvestMoz/SchemaLifecycleTest.php',fence+'php\n'+outputs['tests/InvestMoz/SchemaLifecycleTest.php']+fence, '## Consultas de verificação',fence+'sql\n'+outputs['docs/investmoz/verification.sql']+fence, '## Plano de rollback',outputs['docs/investmoz/runbook.md'], '## Matriz de rastreabilidade',csv_md('docs/investmoz/traceability.csv'), '## Checklist final de conformidade','A checklist e as limitações de execução estão em runbook.md. As evidencias locais estao em validation.md. Os ensaios PostgreSQL não foram marcados como executados pelo gerador.'] emit('docs/investmoz/architecture-and-migrations.md','\n\n'.join(review)) emit('docs/investmoz/README.md',"""# InvestMoz — pacote reconciliado Começar por architecture-and-migrations.md: contém os modelos A/B/C, decisões pendentes, ERD, código integral das migrations, factories/seeders, comandos, testes, consultas, rollback e rastreabilidade. Os CSVs e schema.json permitem validação automatizada. legacy-schema.json inventaria o legado preservado. Ler decisions.md antes de considerar o pacote uma cobertura funcional integral. Ler runbook.md antes de qualquer execução. Executar tools/investmoz/validate.py para consistência estática. Ensaios reais exigem PHP 8.4.1+ e PostgreSQL/PostGIS dedicados. Resultados locais: validation.md e validation-results.json. O gerador nunca executa SQL, não lê .env e não usa instruções embutidas no DOCX como comandos. """) # Source manifest includes generated files and generator, not itself. checks = [(hashlib.sha256(value.encode('utf-8')).hexdigest(),path) for path,value in sorted(outputs.items())] checks.append((hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'tools/investmoz/generate.py')) for template in sorted((ROOT/'tools/investmoz/templates').glob('*.stub')): checks.append((hashlib.sha256(template.read_bytes()).hexdigest(),template.relative_to(ROOT).as_posix())) emit('docs/investmoz/checksums.sha256','\n'.join(d+' '+p for d,p in checks)) if args.check: mismatch=[path for path,text in outputs.items() if not (ROOT/path).exists() or (ROOT/path).read_bytes()!=text.encode('utf-8')] if mismatch: print('Generated files differ:\n'+'\n'.join(mismatch)); sys.exit(1) print(f'PASS: {len(outputs)} generated files reproduce exactly.') else: for path,text in outputs.items(): destination=ROOT/path destination.parent.mkdir(parents=True,exist_ok=True) destination.write_bytes(text.encode('utf-8')) print(f'Generated {len(outputs)} files, {len(table_info)} new tables, {len(field_rows)} total fields; no SQL executed.')