864 lines
57 KiB
Python
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.')
|
|
|