Files
Investidor-V2/tools/investmoz/generate.py
T
nala 250ac35fd1
tests / ci (push) Canceled after 0s
first commit
2026-09-28 10:01:09 +02:00

864 lines
57 KiB
Python

"""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 = """<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Support\\Facades\\DB;
return new class extends Migration
{
// Laravel's migration ledger provides idempotency; object drift fails explicitly.
public function up(): void
{
if (DB::connection()->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',"""<?php
namespace Database\\Seeders;
use App\\Models\\Investor;
use App\\Models\\Project;
use Illuminate\\Database\\Seeder;
class InvestMozReferenceSeeder extends Seeder
{
public function run(): void
{
if (! app()->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',"""<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="InvestMoz PostgreSQL PostGIS">
<directory>tests/InvestMoz</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing" force="true"/>
<env name="DB_CONNECTION" value="pgsql" force="true"/>
<env name="DB_DATABASE" value="investmoz_test"/>
<env name="DB_URL" value="" force="true"/>
<env name="CACHE_STORE" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="BCRYPT_ROUNDS" value="4"/>
</php>
</phpunit>
""")
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.')