@@ -0,0 +1,18 @@
|
||||
{
|
||||
"source_document_attached": true,
|
||||
"source_document_reconciled": true,
|
||||
"source_document_title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_document_note": "Documento do utilizador reconciliado com fontes institucionais e primárias acessíveis. Alegações promocionais não são tratadas como actividade autorizada, operação ou disponibilidade.",
|
||||
"consulted_at": "2026-10-04",
|
||||
"records": [
|
||||
{"code":"MOZ-TOPUITO-PARK","name":"Parque Industrial de Topuito","category":"industrial_park","physical_category":"industrial_park","legal_regime":"pending","development_status":"in_development","availability_status":"reported_unverified","territory_note":"The source names Topuito in Larde; the master dataset has no district row for Larde. This candidate is linked only at Nampula province until the territorial mapping is reviewed.","territories":[{"name":"NAMPULA","type":"province","scope_type":"administrative"}],"source":{"title":"Construction begins on SME Village at Topuito Industrial Park","url":"https://mozparks.co.mz/construction-begins-on-sme-village-at-topuito-industrial-park/","source_type":"promoter","supports":"Project and development activity, not operational availability."},"areas":[{"type":"total","value":215,"unit":"ha","status":"disputed","source_reference":"MozParks brochure 2023, source scope to review"},{"type":"total","value":200,"unit":"ha","status":"disputed","source_reference":"MozParks brochure 2025, source scope to review"},{"type":"developed","value":30,"unit":"ha","status":"reported","source_reference":"MozParks brochure 2025; not a land availability statement"}]},
|
||||
{"code":"MOZ-TOPUITO-ZFI","name":"Zona Franca Industrial de Topuito","category":"zfi","physical_category":"industrial_free_zone","legal_regime":"zfi","development_status":"planned","availability_status":"not_informed","territory_note":"The decree names Topuito in Larde; the master dataset has no district row for Larde. This candidate is linked only at Nampula province until the territorial mapping is reviewed.","territories":[{"name":"NAMPULA","type":"province","scope_type":"administrative"}],"source":{"title":"Decreto n.º 57/2023 — criação da ZFI de Topuito","url":"https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-190-de-031023-boletim-da-rep%C3%BAblica-i-s%C3%A9rie-p%C3%A1g-2349","source_type":"government","supports":"Legal creation and reported legal area; primary review remains required."},"areas":[{"type":"legal","value":65,"unit":"ha","status":"reported","source_reference":"Decreto n.º 57/2023, artigo 1.º — confirm against retained official text"}]},
|
||||
{"code":"MOZ-MUNHAVA-PARK","name":"Parque Industrial e Logístico de Munhava","category":"industrial_park","physical_category":"industrial_logistics_park","legal_regime":"pending","development_status":"in_development","availability_status":"not_informed","territories":[{"name":"CIDADE DA BEIRA","type":"district","scope_type":"administrative"}],"source":{"title":"Parque Industrial e Logístico — SDUBeira","url":"https://sdubeira.co.mz/project/parque-industrial-logistico/","source_type":"promoter","supports":"Project existence, location and reported development status."},"areas":[{"type":"total","value":900,"unit":"ha","status":"disputed","source_reference":"SDUBeira project page, total area scope needs confirmation"},{"type":"total","value":990,"unit":"ha","status":"disputed","source_reference":"SDUBeira project headline, conflicts with page summary"},{"type":"pilot","value":50,"unit":"ha","status":"reported","source_reference":"SDUBeira project page; not available-land confirmation"}]},
|
||||
{"code":"MOZ-CDP-MONTEPUEZ","name":"Parque Industrial de Montepuez","category":"industrial_park","physical_category":"industrial_park","legal_regime":"pending","development_status":"planned","availability_status":"not_informed","territories":[{"name":"MONTEPUEZ","type":"district","scope_type":"administrative"}],"source":{"title":"MozParks — Cabo Delgado parks initiative","url":"https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/","source_type":"promoter","supports":"District association within a provincial parks initiative; not an operating facility claim."},"areas":[]},
|
||||
{"code":"MOZ-CDP-BALAMA","name":"Parque Industrial de Balama","category":"industrial_park","physical_category":"industrial_park","legal_regime":"pending","development_status":"planned","availability_status":"not_informed","territories":[{"name":"BALAMA","type":"district","scope_type":"administrative"}],"source":{"title":"MozParks — Cabo Delgado parks initiative","url":"https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/","source_type":"promoter","supports":"District association within a provincial parks initiative; not an operating facility claim."},"areas":[]},
|
||||
{"code":"MOZ-CDP-ANCUABE","name":"Parque Industrial de Ancuabe","category":"industrial_park","physical_category":"industrial_park","legal_regime":"pending","development_status":"planned","availability_status":"not_informed","territories":[{"name":"ANCUABE","type":"district","scope_type":"administrative"}],"source":{"title":"MozParks — Cabo Delgado parks initiative","url":"https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/","source_type":"promoter","supports":"District association within a provincial parks initiative; not an operating facility claim."},"areas":[]},
|
||||
{"code":"MOZ-CDP-PALMA","name":"Parque Industrial de Palma","category":"industrial_park","physical_category":"industrial_park","legal_regime":"pending","development_status":"planned","availability_status":"not_informed","territories":[{"name":"PALMA","type":"district","scope_type":"administrative"}],"source":{"title":"MozParks — Cabo Delgado parks initiative","url":"https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/","source_type":"promoter","supports":"District association within a provincial parks initiative; not an operating facility claim."},"areas":[]},
|
||||
{"code":"MOZ-REVUBUE-ZFI","name":"Zona Franca Industrial de Revúbuè","category":"zfi","physical_category":"industrial_free_zone","legal_regime":"zfi","development_status":"planned","availability_status":"not_informed","territories":[{"name":"CHIUTA","type":"district","scope_type":"legal_scope"},{"name":"MOATIZE","type":"district","scope_type":"legal_scope"}],"source":{"title":"Revúbuè Free Industrial Zone — APIEX","url":"https://apiex.gov.mz/revubue-free-industrial-zone/","source_type":"institutional","supports":"APIEX-reported identity, district scope and area; primary legal text pending."},"areas":[{"type":"legal","value":4456,"unit":"ha","status":"reported","source_reference":"APIEX page; confirm perimeter against Decreto 41/2016"}]},
|
||||
{"code":"MOZ-MINHEWENE-ZFI","name":"Zona Franca Industrial de Minhewene","category":"zfi","physical_category":"industrial_free_zone","legal_regime":"zfi","development_status":"planned","availability_status":"not_informed","territories":[{"name":"NAMPULA","type":"province","scope_type":"administrative"}],"source":{"title":"Zona Franca Industrial de Minhewene — APIEX","url":"https://apiex.gov.mz/zona-franca-industrial-de-minhewene/","source_type":"institutional","supports":"APIEX-reported identity, province and area; primary legal text and site precision pending."},"areas":[{"type":"legal","value":350,"unit":"ha","status":"reported","source_reference":"APIEX page; confirm perimeter against Decreto 51/2011"}]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
{
|
||||
"source_document": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"provided": true,
|
||||
"received_at": "2026-10-04",
|
||||
"classification": "documento fornecido pelo utilizador; referncias citadas sem URLs/ficheiros primrios anexos; claims no so por si s prova oficial",
|
||||
"note": "As afirmaes do documento foram comparadas com pginas institucionais/promotoras e referncias oficiais acessveis. Manter alegaes promocionais separadas de existncia jurdica, autorizao de actividade, operao e disponibilidade."
|
||||
},
|
||||
"consulted_at": "2026-10-04",
|
||||
"candidates": [
|
||||
{
|
||||
"name": "Beluluane",
|
||||
"outcome": "reconcile_existing",
|
||||
"prepared_codes": [
|
||||
"MOZ-BELULUANE-PARK",
|
||||
"MOZ-BELULUANE-ZFI"
|
||||
],
|
||||
"source": "https://apiex.gov.mz/beluluane-industrial-park-free-zone/",
|
||||
"known": "MozParks apresenta 700 ha prontos para arrendamento; APIEX apresenta complexo de cerca de 780 ha: parque 600 ha, Mozal 180 ha e ZFI 55 ha. So mbitos que no podem ser somados nem equiparados a disponibilidade.",
|
||||
"pending": "Confirmar limites e sobreposio cartogrfica/documental, sobretudo relao entre 700 ha e parque de 600 ha. No publicar actividades como autorizadas nem declarar lotes disponveis.",
|
||||
"user_document_claims": {
|
||||
"status": "operacional e em expanso (alegao documental/promotora)",
|
||||
"area": "700 ha (alegao MozParks no portal actual); actividades/enfoque: manufactura, alumnio, cabos, agro-processamento, tecnologia, logstica e servios",
|
||||
"assessment": "MozParks apresenta 700 ha prontos para arrendamento; APIEX descreve um complexo de cerca de 780 ha composto por parque de 600 ha e Mozal 180 ha, e uma ZFI de 55 ha. Escopos no reconciliados; nenhum nmero prova disponibilidade actual."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Topuito",
|
||||
"outcome": "prepare_separate_park_and_zfi",
|
||||
"prepared_codes": [
|
||||
"MOZ-TOPUITO-PARK",
|
||||
"MOZ-TOPUITO-ZFI"
|
||||
],
|
||||
"source": "https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-190-de-031023-boletim-da-rep%C3%BAblica-i-s%C3%A9rie-p%C3%A1g-2349",
|
||||
"known": "MozParks apresenta Topuito como parque e oferta de 215 ha; notcia de Agosto de 2026 confirma incio da construo do SME Village. Decreto 57/2023 cria separadamente ZFI com rea legal de 65 ha.",
|
||||
"pending": "O parque, a obra do SME Village e a ZFI so escopos distintos. Verificar limites, operao e disponibilidade actual. Base territorial no contm distrito Larde; manter associao apenas provincial at reviso.",
|
||||
"user_document_claims": {
|
||||
"status": "operacional/em expanso (alegao documental/promotora)",
|
||||
"area": "215 ha (documento e perfil institucional MozParks)",
|
||||
"assessment": "MozParks identifica Topuito Industrial Park e notcia de Agosto de 2026 informa incio de construo do SME Village. A ZFI legal de 65 ha entidade/permetro distinto. No inferir que toda a rea esteja pronta ou disponvel."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Munhava",
|
||||
"outcome": "prepare_informational_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-MUNHAVA-PARK"
|
||||
],
|
||||
"source": "https://sdubeira.co.mz/project/parque-industrial-logistico/",
|
||||
"known": "SDUBeira descreve o projecto em fases, rea total de 900 ha no resumo e piloto de 50 ha; o destaque de 990 ha na mesma pgina cria divergncia.",
|
||||
"pending": "Documento de entrada acrescenta faixa aproximada de 44-60 ha para piloto; no corresponde a metragem confirmada. Obter permetro/plano primrio e no declarar terreno disponvel.",
|
||||
"user_document_claims": {
|
||||
"status": "desenvolvimento faseado",
|
||||
"area": "900 ha total e piloto de cerca de 44-60 ha (alegao documental); actividades/enfoque: logstica porturia, armazenagem, manufactura, agro-indstria e pescas",
|
||||
"assessment": "SDUBeira descreve 900 ha no resumo e 50 ha piloto; a mesma pgina contm destaque de 990 ha. Conservar divergncia editorial e no tratar piloto como terreno livre."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Montepuez",
|
||||
"outcome": "prepare_district_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-CDP-MONTEPUEZ"
|
||||
],
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/",
|
||||
"known": "A iniciativa MozParks menciona parque/rede de Cabo Delgado no distrito de Montepuez. MozParks confirma a rede/parques em desenvolvimento; actividades do documento so apenas enfoque promocional.",
|
||||
"pending": "Regime jurdico, reas, actividades efectivamente autorizadas/instaladas e disponibilidade permanecem por confirmar.",
|
||||
"user_document_claims": {
|
||||
"status": "planeado/em desenvolvimento",
|
||||
"activities": "fornecedores mineiros, transformao, servios e PME (enfoque alegado no documento)",
|
||||
"assessment": "MozParks confirma quatro parques em desenvolvimento nos distritos; actividades so foco de promoo, no licena ou operao confirmada."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Balama",
|
||||
"outcome": "prepare_district_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-CDP-BALAMA"
|
||||
],
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/",
|
||||
"known": "A iniciativa MozParks menciona parque/rede de Cabo Delgado no distrito de Balama. MozParks confirma a rede/parques em desenvolvimento; actividades do documento so apenas enfoque promocional.",
|
||||
"pending": "Regime jurdico, reas, actividades efectivamente autorizadas/instaladas e disponibilidade permanecem por confirmar.",
|
||||
"user_document_claims": {
|
||||
"status": "planeado/em desenvolvimento",
|
||||
"activities": "minerao, servios industriais, fornecedores e PME (enfoque alegado no documento)",
|
||||
"assessment": "MozParks confirma a iniciativa/distrito; actividades permanecem promocionais e no so autorizaes."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Ancuabe",
|
||||
"outcome": "prepare_district_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-CDP-ANCUABE"
|
||||
],
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/",
|
||||
"known": "A iniciativa MozParks menciona parque/rede de Cabo Delgado no distrito de Ancuabe. MozParks confirma a rede/parques em desenvolvimento; actividades do documento so apenas enfoque promocional.",
|
||||
"pending": "Regime jurdico, reas, actividades efectivamente autorizadas/instaladas e disponibilidade permanecem por confirmar.",
|
||||
"user_document_claims": {
|
||||
"status": "planeado/em desenvolvimento",
|
||||
"activities": "transformao, contedo local, servios industriais e PME (enfoque alegado no documento)",
|
||||
"assessment": "MozParks confirma a iniciativa/distrito; actividades permanecem promocionais e no so autorizaes."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Palma",
|
||||
"outcome": "prepare_district_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-CDP-PALMA"
|
||||
],
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/",
|
||||
"known": "A iniciativa MozParks menciona parque/rede de Cabo Delgado no distrito de Palma. MozParks confirma a rede/parques em desenvolvimento; actividades do documento so apenas enfoque promocional.",
|
||||
"pending": "Regime jurdico, reas, actividades efectivamente autorizadas/instaladas e disponibilidade permanecem por confirmar.",
|
||||
"user_document_claims": {
|
||||
"status": "planeado/em desenvolvimento",
|
||||
"activities": "petrleo e gs, energia, logstica e cadeias locais (enfoque alegado no documento)",
|
||||
"assessment": "MozParks confirma a iniciativa/distrito; actividades permanecem promocionais e no so autorizaes."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Selva/Manica Parks",
|
||||
"outcome": "keep_unresolved_program_and_site_distinct",
|
||||
"prepared_codes": [],
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-manica-parks-a-new-industrial-platform-for-manica-province/",
|
||||
"known": "MozParks confirma Manica Parks como iniciativa provincial lanada por memorando em Setembro de 2026. O documento fornecido associa a iniciativa a Selva/Vanduzi, mas no confirma instalao individual.",
|
||||
"pending": "No criar parque individual nem relao territorial de Selva sem fonte especfica. Actividades listadas so enfoque declarado, no autorizao.",
|
||||
"user_document_claims": {
|
||||
"status": "Manica Parks lanado por memorando em Setembro de 2026; alegao de localizao em Selva/Vanduzi permanece por confirmar",
|
||||
"activities": "agro-processamento, metalomecnica, combustveis, hotelaria e restaurao (enfoque alegado no documento)",
|
||||
"assessment": "MozParks confirma iniciativa provincial e memorando, no uma instalao individual em Selva nem incio de construo/operao."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Mocuba ZFI",
|
||||
"outcome": "reconcile_existing",
|
||||
"prepared_codes": [
|
||||
"MOZ-MOCUBA-ZFI",
|
||||
"MOZ-MOCUBA-ZEE"
|
||||
],
|
||||
"source": "https://apiex.gov.mz/zif/mocuba-free-industrial-zone/",
|
||||
"known": "APIEX confirma a ZFI de 50,58 ha e fase de mobilizao de recursos e operador. A lista de actividades do documento enfoque declarado, no autorizao.",
|
||||
"pending": "Confirmar diploma primrio, limites e actividades; manter ZFI e ZEE de Mocuba separadas.",
|
||||
"user_document_claims": {
|
||||
"status": "mobilizao de recursos e operador",
|
||||
"area": "50,58 ha segundo APIEX",
|
||||
"activities": "agro-florestal, pescas, txteis, calcrio, pedra e minerais (enfoque alegado no documento)",
|
||||
"assessment": "APIEX confirma 50,58 ha e mobilizao; confirmar actividades com fonte prpria antes de atribuir ao registo. Manter ZFI distinta da ZEE."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Revúbuè ZFI",
|
||||
"outcome": "prepare_one_record_two_districts",
|
||||
"prepared_codes": [
|
||||
"MOZ-REVUBUE-ZFI"
|
||||
],
|
||||
"source": "https://apiex.gov.mz/revubue-free-industrial-zone/",
|
||||
"known": "APIEX reporta a ZFI abrangendo Chiuta e Moatize, 4.456 ha, sem operador identificado.",
|
||||
"pending": "Obter e validar texto primário do Decreto 41/2016 e as relações territoriais. Um único registo, duas localizações.",
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Mocambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
},
|
||||
"user_document_claims": {
|
||||
"status": "sem operador; um projecto aprovado segundo APIEX",
|
||||
"area": "4 456 ha segundo APIEX",
|
||||
"activities": "industria ligada a recursos e transformacao (enfoque alegado no documento)",
|
||||
"assessment": "APIEX informa um projecto aprovado e potenciais sectores; isto nao transforma o projecto em oportunidade publicada nem comprova actividades autorizadas."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Minhewene ZFI",
|
||||
"outcome": "prepare_province_precision_candidate",
|
||||
"prepared_codes": [
|
||||
"MOZ-MINHEWENE-ZFI"
|
||||
],
|
||||
"source": "https://apiex.gov.mz/zona-franca-industrial-de-minhewene/",
|
||||
"known": "Pgina APIEX actualmente acessvel reporta Decreto 51/2011, Nampula/Cidade de Nacala, 350 ha e mobilizao de recursos/operador. O documento fornecido diz que a ficha no estava acessvel; esta afirmao diverge da consulta actual.",
|
||||
"pending": "Validar Decreto 51/2011 e localizao cadastral exacta; pgina institucional no substitui fonte legal primria nem comprova operacionalizao actual.",
|
||||
"user_document_claims": {
|
||||
"status": "mobilizao de recursos e operador segundo pgina APIEX acessvel na verificao actual",
|
||||
"area": "350 ha segundo APIEX",
|
||||
"assessment": "A alegao do documento de que no havia ficha acessvel diverge da pgina institucional APIEX actualmente acessvel, que reporta Decreto 51/2011, Nampula/Cidade de Nacala, 350 ha e mobilizao de recursos/operador. Acessibilidade da pgina no substitui validao do diploma primrio ou do estado actual."
|
||||
},
|
||||
"claim_source": {
|
||||
"title": "Parques Industriais e Zonas Francas Industriais em Moçambique",
|
||||
"source_type": "user_supplied_secondary_document",
|
||||
"verified_as_primary": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"legislative_review": {
|
||||
"decree_57_2023": "Texto oficial localizado; criação da ZFI de Topuito e 65 ha confirmados. Não implica que operador, actividade operacional ou terrenos disponíveis estejam confirmados.",
|
||||
"decree_12_2026": "A página oficial consultada apresenta o sumário de aprovação do regulamento ZEE/ZFI e revogação do Decreto 75/99. O texto integral e regras transitórias/entrada em vigor ainda não foram revistos; não parametrizar simulador fiscal a partir do sumário."
|
||||
},
|
||||
"official_checks": [
|
||||
{
|
||||
"topic": "Beluluane",
|
||||
"source": "https://mozparks.co.mz/",
|
||||
"finding": "MozParks apresenta 700 ha prontos para arrendamento; APIEX reporta complexo de cerca de 780 ha, parque 600 ha, Mozal 180 ha, ZFI 55 ha. Escopos divergentes."
|
||||
},
|
||||
{
|
||||
"topic": "Topuito",
|
||||
"source": "https://mozparks.co.mz/",
|
||||
"finding": "MozParks apresenta 215 ha; notcia de 19-08-2026 diz SME Village comeou construo. No declara a totalidade da rea operacional/disponvel."
|
||||
},
|
||||
{
|
||||
"topic": "Munhava",
|
||||
"source": "https://sdubeira.co.mz/project/parque-industrial-logistico/",
|
||||
"finding": "A pgina da SDUBeira contm total 900 ha no resumo e 990 ha em destaque; piloto 50 ha. A faixa 44-60 ha no foi corroborada."
|
||||
},
|
||||
{
|
||||
"topic": "Manica Parks",
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-manica-parks-a-new-industrial-platform-for-manica-province/",
|
||||
"finding": "Memorando e iniciativa provincial confirmados; Selva/Vanduzi no aparece como instalao individual confirmada."
|
||||
},
|
||||
{
|
||||
"topic": "Cabo Delgado Parks",
|
||||
"source": "https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/",
|
||||
"finding": "MozParks identifica parques em desenvolvimento nos quatro distritos. Sectores do documento permanecem promocionais."
|
||||
},
|
||||
{
|
||||
"topic": "Minhewene",
|
||||
"source": "https://apiex.gov.mz/zona-franca-industrial-de-minhewene/",
|
||||
"finding": "Pgina APIEX est acessvel nesta consulta e reporta Decreto 51/2011, Nacala, 350 ha e mobilizao de recursos/operador; a alegao documental de indisponibilidade da ficha no corresponde ao acesso actual."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"source_prompt": "docs/investmoz/source-staging/zone-integration-2026-10-04/Pasted text.txt",
|
||||
"consulted_at": "2026-10-04",
|
||||
"records": [
|
||||
{
|
||||
"code": "MOZ-BELULUANE-PARK",
|
||||
"name": "Parque Industrial de Beluluane",
|
||||
"name_en": "Beluluane Industrial Park",
|
||||
"category": "industrial_park",
|
||||
"physical_category": "industrial_park",
|
||||
"legal_regime": "general",
|
||||
"operational_status": "unknown",
|
||||
"area_value": null,
|
||||
"area_unit": null,
|
||||
"area_components": [
|
||||
{"type":"total","value":780,"unit":"ha","status":"disputed","source_reference":"https://apiex.gov.mz/beluluane-industrial-park-free-zone/ — APIEX reports the complex; scope and overlap need confirmation","source_date":null},
|
||||
{"type":"total","value":700,"unit":"ha","status":"disputed","source_reference":"Document cited in the integration prompt was not attached; claim retained as unresolved, not imported as verified data","source_date":null},
|
||||
{"type":"developed","value":600,"unit":"ha","status":"reported","source_reference":"https://apiex.gov.mz/beluluane-industrial-park-free-zone/ — APIEX describes the industrial park component","source_date":null},
|
||||
{"type":"developed","value":180,"unit":"ha","status":"reported","source_reference":"https://apiex.gov.mz/beluluane-industrial-park-free-zone/ — APIEX describes the Mozal site component; do not add as available land","source_date":null},
|
||||
{"type":"legal","value":55,"unit":"ha","status":"reported","source_reference":"https://apiex.gov.mz/beluluane-industrial-park-free-zone/ — separate ZFI component; do not merge with park","source_date":null}
|
||||
],
|
||||
"description": "A APIEX apresenta aproximadamente 780 ha para o conjunto referido como Parque Industrial de Beluluane–Zona Franca, incluindo componentes com perímetros distintos. O valor não representa área livre ou disponível. A fonte identifica historicamente Matola-Rio, Boane, Província de Maputo. Situação operacional e área disponível não confirmadas por esta referência.",
|
||||
"description_en": "APIEX reports approximately 780 ha for the Beluluane Industrial Park–Free Zone complex, including distinct components. This is not an available-land figure. The source historically identifies Matola-Rio, Boane, Maputo Province. Operational status and available area are not confirmed by this reference.",
|
||||
"aliases": ["PIB-ZF", "Parque Industrial de Beluluane–Zona Franca"],
|
||||
"activities": [],
|
||||
"historical_territory": {"name": "Matola-Rio", "district": "Boane", "province": "Maputo", "source_reference": "APIEX page; current territorial equivalence requires review"},
|
||||
"source": {"title": "Beluluane Industrial Park – Free Zone", "url": "https://apiex.gov.mz/beluluane-industrial-park-free-zone/", "diploma": "Resolução Interna do Conselho de Ministros n.º 15/99, de 12 de Outubro", "article": "Act of creation; article and current legal effect pending review", "valid_from": "1999-10-12", "competent_entity": "Conselho de Ministros / APIEX source page"},
|
||||
"territories": [{"name": "MATOLA RIO", "type": "admin_post", "scope_type": "administrative"}]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-BELULUANE-ZFI",
|
||||
"name": "Zona Franca Industrial de Beluluane",
|
||||
"name_en": "Beluluane Industrial Free Zone",
|
||||
"category": "zfi",
|
||||
"physical_category": "industrial_free_zone",
|
||||
"legal_regime": "zfi",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 55,
|
||||
"area_unit": "ha",
|
||||
"description": "Componente ZFI distinta dentro do complexo Beluluane. A APIEX indica cerca de 55 ha. Este registo não atribui elegibilidade ou incentivos a qualquer empresa.",
|
||||
"description_en": "A distinct IFZ component within the Beluluane complex. APIEX reports approximately 55 ha. This record does not establish eligibility or incentives for any company.",
|
||||
"aliases": ["Beluluane IFZ"],
|
||||
"activities": [],
|
||||
"parent_code": "MOZ-BELULUANE-PARK",
|
||||
"historical_territory": {"name": "Matola-Rio", "district": "Boane", "province": "Maputo", "source_reference": "APIEX page; current territorial equivalence requires review"},
|
||||
"source": {"title": "Beluluane Industrial Park – Free Zone", "url": "https://apiex.gov.mz/beluluane-industrial-park-free-zone/", "diploma": "Resolução Interna do Conselho de Ministros n.º 15/99, de 12 de Outubro", "article": "Act of creation; article and current legal effect pending review", "valid_from": "1999-10-12", "competent_entity": "Conselho de Ministros / APIEX source page"},
|
||||
"territories": [{"name": "MATOLA RIO", "type": "admin_post", "scope_type": "administrative"}]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-CRUSSE-JAMALI-ZEE",
|
||||
"name": "Zona de Estância de Turismo Integrado de Crusse e Jamali",
|
||||
"name_en": "Crusse and Jamali Integrated Tourism Resort Area",
|
||||
"category": "zee",
|
||||
"physical_category": "integrated_tourism_area",
|
||||
"legal_regime": "zee",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 1750,
|
||||
"area_unit": "ha",
|
||||
"description": "Criada em regime de ZEE pelo Decreto n.º 47/2013, de 30 de Agosto. A fonte oficial indica 1.750 ha no Posto Administrativo de Matibane, Distrito de Mossuril, Nampula. A criação da zona não confirma que infra-estruturas ou oportunidades previstas estejam actualmente disponíveis.",
|
||||
"description_en": "Created under the SEZ regime by Decree no. 47/2013 of 30 August. The official source states 1,750 ha in Matibane Administrative Post, Mossuril District, Nampula. Establishment of the zone does not confirm that planned infrastructure or opportunities are currently available.",
|
||||
"aliases": ["Crusse-Jamali", "Crusse & Jamali"],
|
||||
"activities": [{"name": "Turismo integrado", "status": "potential", "source_reference": "Decreto n.º 47/2013; tourism scope in the creation description"}],
|
||||
"source": {"title": "Conselho de Ministros — BR n.º 70 de 30.08.13", "url": "https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-70-de-300813-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-571", "diploma": "Decreto n.º 47/2013, de 30 de Agosto", "article": "Act of creation; article reference pending review", "valid_from": "2013-08-30", "competent_entity": "Conselho de Ministros / Imprensa Nacional de Moçambique"},
|
||||
"territories": [{"name": "MATIBANE", "type": "admin_post", "scope_type": "legal_scope"}]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-MANGA-MUNGASSA-ZEE",
|
||||
"name": "Zona Económica Especial de Manga-Mungassa",
|
||||
"name_en": "Manga-Mungassa Special Economic Zone",
|
||||
"category": "zee",
|
||||
"physical_category": "economic_zone",
|
||||
"legal_regime": "zee",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 217,
|
||||
"area_unit": "ha",
|
||||
"description": "A APIEX indica criação pelo Decreto n.º 22/2012, de 6 de Junho, localização na Cidade da Beira, Sofala, e área de 217 ha. A fonte refere sectores de potencial atracção; esses sectores não são tratados como actividades autorizadas nem como oportunidades cadastradas.",
|
||||
"description_en": "APIEX reports establishment by Decree no. 22/2012 of 6 June, location in Beira City, Sofala, and an area of 217 ha. Sectors described as investment potential are not treated as authorized activities or registered opportunities.",
|
||||
"aliases": ["Manga Mungassa ZEE"],
|
||||
"activities": [
|
||||
{"name": "Indústria", "status": "potential", "source_reference": "APIEX Manga-Mungassa page; potential sectors"},
|
||||
{"name": "Logística e transporte", "status": "potential", "source_reference": "APIEX Manga-Mungassa page; potential sectors"},
|
||||
{"name": "Hotelaria e serviços", "status": "potential", "source_reference": "APIEX Manga-Mungassa page; potential sectors"}
|
||||
],
|
||||
"source": {"title": "Manga-Mungassa Special Economic Zone – APIEX", "url": "https://apiex.gov.mz/zee/manga-mungassa-special-economic-zone/", "diploma": "Decreto n.º 22/2012, de 6 de Junho", "article": "Act of creation; article reference pending review", "valid_from": "2012-06-06", "competent_entity": "Conselho de Ministros / APIEX"},
|
||||
"territories": [{"name": "CIDADE DA BEIRA", "type": "district", "scope_type": "administrative"}]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-MOCUBA-ZEE",
|
||||
"name": "Zona Económica Especial de Mocuba",
|
||||
"name_en": "Mocuba Special Economic Zone",
|
||||
"category": "zee",
|
||||
"physical_category": "economic_zone",
|
||||
"legal_regime": "zee",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 10727,
|
||||
"area_unit": "km2",
|
||||
"description": "Criada pelo Decreto n.º 28/2014, de 6 de Junho. O INM e a APIEX indicam que abrange todo o Distrito de Mocuba e o Posto Administrativo de Munhamade, no Distrito de Lugela, Zambézia. A APIEX indica 10.727 km²; a unidade e a abrangência são preservadas sem as reduzir à cidade de Mocuba.",
|
||||
"description_en": "Created by Decree no. 28/2014 of 6 June. INM and APIEX state that it covers all of Mocuba District and Munhamade Administrative Post in Lugela District, Zambézia. APIEX reports 10,727 km²; the unit and scope are preserved without reducing it to Mocuba City.",
|
||||
"aliases": ["Mocuba ZEE"],
|
||||
"activities": [
|
||||
{"name": "Produção agrícola, pecuária, florestal e processamento", "status": "potential", "source_reference": "APIEX Mocuba SEZ page; stated attraction objectives"}
|
||||
],
|
||||
"source": {"title": "BR n.º 46 de 06.06.14 — Decreto n.º 28/2014", "url": "https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-46-de-060614-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-1259", "diploma": "Decreto n.º 28/2014, de 6 de Junho", "article": "Act of creation; article reference pending review", "valid_from": "2014-06-06", "competent_entity": "Conselho de Ministros / Imprensa Nacional de Moçambique"},
|
||||
"territories": [
|
||||
{"name": "MOCUBA", "type": "district", "scope_type": "legal_scope"},
|
||||
{"name": "MUNHAMADE", "type": "admin_post", "scope_type": "legal_scope"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-MOCUBA-ZFI",
|
||||
"name": "Zona Franca Industrial de Mocuba",
|
||||
"name_en": "Mocuba Industrial Free Zone",
|
||||
"category": "zfi",
|
||||
"physical_category": "industrial_free_zone",
|
||||
"legal_regime": "zfi",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 50.58,
|
||||
"area_unit": "ha",
|
||||
"description": "Criada pelo Decreto n.º 29/2014, de 6 de Junho, e localizada na Cidade de Mocuba. A APIEX descreve esta ZFI como estando na fase de mobilização de recursos e do operador. É distinta da ZEE de Mocuba e não implica elegibilidade fiscal de empresas.",
|
||||
"description_en": "Created by Decree no. 29/2014 of 6 June and located in Mocuba City. APIEX describes this IFZ as mobilizing resources and an operator. It is distinct from Mocuba SEZ and does not imply tax eligibility for companies.",
|
||||
"aliases": ["Mocuba IFZ", "ZFI de Mocuba"],
|
||||
"activities": [],
|
||||
"source": {"title": "Mocuba IFZ – APIEX", "url": "https://apiex.gov.mz/zif/mocuba-free-industrial-zone/", "diploma": "Decreto n.º 29/2014, de 6 de Junho", "article": "Act of creation; article reference pending review", "valid_from": "2014-06-06", "competent_entity": "Conselho de Ministros / APIEX"},
|
||||
"territories": [{"name": "CIDADE DE MOCUBA", "type": "admin_post", "scope_type": "administrative"}]
|
||||
},
|
||||
{
|
||||
"code": "MOZ-NACALA-ZEE",
|
||||
"name": "Zona Económica Especial de Nacala",
|
||||
"name_en": "Nacala Special Economic Zone",
|
||||
"category": "zee",
|
||||
"physical_category": "economic_zone",
|
||||
"legal_regime": "zee",
|
||||
"operational_status": "unknown",
|
||||
"area_value": 1539,
|
||||
"area_unit": "km2",
|
||||
"description": "A APIEX indica criação pelo Decreto n.º 76/2007, de 18 de Dezembro, abrangência dos distritos de Nacala e Nacala-à-Velha e área de 1.539 km². Não se confunde com o Corredor de Nacala, porto ou outras zonas francas. Os números de projectos e empregos publicados na página não são importados como actuais por não terem data de referência confirmada.",
|
||||
"description_en": "APIEX reports establishment by Decree no. 76/2007 of 18 December, covering Nacala and Nacala-à-Velha districts, with an area of 1,539 km². It is distinct from the Nacala Corridor, port, and other free zones. Project and employment counts on the page are not imported as current because their reference date is not confirmed.",
|
||||
"aliases": ["ZEEN", "Nacala SEZ"],
|
||||
"activities": [
|
||||
{"name": "Logística e indústria", "status": "potential", "source_reference": "APIEX Nacala SEZ page; sectors identified as potential"},
|
||||
{"name": "Agro-processamento e indústria química", "status": "potential", "source_reference": "APIEX Nacala SEZ page; sectors identified as potential"}
|
||||
],
|
||||
"source": {"title": "Nacala Special Economic Zone – APIEX", "url": "https://apiex.gov.mz/nacala-special-economic-zone/", "diploma": "Decreto n.º 76/2007, de 18 de Dezembro", "article": "Act of creation; article reference pending review", "valid_from": "2007-12-18", "competent_entity": "Conselho de Ministros / APIEX"},
|
||||
"territories": [
|
||||
{"name": "NACALA-PORTO", "type": "district", "scope_type": "legal_scope"},
|
||||
{"name": "NACALA-VELHA", "source_name": "Nacala-à-Velha", "type": "district", "scope_type": "legal_scope"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Simulation form draft RLS requires PostgreSQL.');
|
||||
}
|
||||
|
||||
DB::statement(<<<'SQL'
|
||||
ALTER POLICY simulation_form_drafts_read ON public.simulation_form_drafts
|
||||
USING (
|
||||
(owner_user_id IS NOT NULL AND owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint)
|
||||
OR (anonymous_token_hash IS NOT NULL AND anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), ''))
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Simulation form draft RLS requires PostgreSQL.');
|
||||
}
|
||||
|
||||
DB::statement(<<<'SQL'
|
||||
ALTER POLICY simulation_form_drafts_read ON public.simulation_form_drafts
|
||||
USING (
|
||||
deleted_at IS NULL AND (
|
||||
(owner_user_id IS NOT NULL AND owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint)
|
||||
OR (anonymous_token_hash IS NOT NULL AND anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), ''))
|
||||
)
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const ORIGINAL = [
|
||||
'company_sale', 'participation_sale', 'capital_increase', 'financial_investor', 'strategic_partner',
|
||||
'buyer_search', 'position_assignment', 'franchise', 'asset_sale', 'asset_lease', 'business_unit_transfer',
|
||||
'joint_venture', 'succession', 'recovery', 'expansion', 'internationalization', 'real_estate',
|
||||
'agricultural_investment', 'land_right', 'mining_license', 'forestry_license',
|
||||
];
|
||||
|
||||
private const ADDED = [
|
||||
'investment_project', 'business_partnership', 'financing_request', 'concession_opportunity', 'supply_opportunity',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$this->replaceConstraint([...self::ORIGINAL, ...self::ADDED]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$used = DB::table('existing_businesses')->whereIn('modality', self::ADDED)->exists();
|
||||
if ($used) {
|
||||
throw new RuntimeException('Cannot remove opportunity types while records use them.');
|
||||
}
|
||||
|
||||
$this->replaceConstraint(self::ORIGINAL);
|
||||
}
|
||||
|
||||
/** @param list<string> $values */
|
||||
private function replaceConstraint(array $values): void
|
||||
{
|
||||
if (DB::getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('This additive constraint migration requires PostgreSQL.');
|
||||
}
|
||||
|
||||
$constraints = DB::select("SELECT conname FROM pg_constraint WHERE conrelid = 'existing_businesses'::regclass AND contype = 'c' AND conkey @> ARRAY[(SELECT attnum FROM pg_attribute WHERE attrelid = 'existing_businesses'::regclass AND attname = 'modality')]::smallint[] AND pg_get_constraintdef(oid) ILIKE '%company_sale%' AND pg_get_constraintdef(oid) ILIKE '%forestry_license%'");
|
||||
if (count($constraints) !== 1) {
|
||||
throw new RuntimeException('Expected exactly one existing modality constraint; inspect the live schema before migrating.');
|
||||
}
|
||||
|
||||
$oldName = str_replace('"', '""', $constraints[0]->conname);
|
||||
DB::statement('ALTER TABLE existing_businesses DROP CONSTRAINT "'.$oldName.'"');
|
||||
|
||||
$quotedValues = implode(',', array_map(fn (string $value): string => "'".$value."'", $values));
|
||||
DB::statement('ALTER TABLE existing_businesses ADD CONSTRAINT imz_existing_businesses_modality_check CHECK (modality IN ('.$quotedValues.'))');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Tax rule governance requires PostgreSQL.');
|
||||
}
|
||||
|
||||
Schema::create('simulation_tax_rule_versions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('rule_code', 64);
|
||||
$table->unsignedInteger('rule_version');
|
||||
$table->string('component', 24);
|
||||
$table->string('scope_key', 160);
|
||||
$table->date('valid_from');
|
||||
$table->date('valid_until')->nullable();
|
||||
$table->string('status', 24)->default('draft');
|
||||
$table->jsonb('rule_definition');
|
||||
$table->jsonb('parameters');
|
||||
$table->jsonb('rounding_policy');
|
||||
$table->foreignUuid('legal_source_id')->constrained('investment_legal_sources')->restrictOnDelete();
|
||||
$table->unsignedInteger('source_version_no');
|
||||
$table->char('source_sha256', 64);
|
||||
$table->foreignId('author_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('reviewer_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('approver_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('publisher_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->text('review_evidence')->nullable();
|
||||
$table->text('approval_evidence')->nullable();
|
||||
$table->text('publication_evidence')->nullable();
|
||||
$table->text('closure_evidence')->nullable();
|
||||
$table->timestampTz('submitted_at')->nullable();
|
||||
$table->timestampTz('reviewed_at')->nullable();
|
||||
$table->timestampTz('approved_at')->nullable();
|
||||
$table->timestampTz('published_at')->nullable();
|
||||
$table->timestampTz('suspended_at')->nullable();
|
||||
$table->timestampTz('archived_at')->nullable();
|
||||
$table->unsignedInteger('version_no')->default(1);
|
||||
$table->timestampsTz();
|
||||
$table->unique(['rule_code', 'scope_key', 'rule_version']);
|
||||
$table->index(['component', 'scope_key', 'status', 'valid_from'], 'tax_rule_resolution_idx');
|
||||
});
|
||||
DB::statement("ALTER TABLE simulation_tax_rule_versions ADD CONSTRAINT tax_rule_component_check CHECK(component IN ('IRPC','IVA','ISPC','IRPS','INSS','RETENCOES','DIREITOS_ADUANEIROS','ICE')), ADD CONSTRAINT tax_rule_status_check CHECK(status IN ('draft','submitted','reviewed','rejected','approved','published','suspended','archived')), ADD CONSTRAINT tax_rule_dates_check CHECK(valid_until IS NULL OR valid_until>=valid_from), ADD CONSTRAINT tax_rule_version_check CHECK(rule_version>0 AND version_no>0), ADD CONSTRAINT tax_rule_hash_check CHECK(source_sha256 ~ '^[0-9a-f]{64}$'), ADD CONSTRAINT tax_rule_stage_evidence_check CHECK(status NOT IN ('reviewed','rejected','approved','published','suspended','archived') OR (reviewer_id IS NOT NULL AND reviewer_id<>author_id AND submitted_at IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_evidence))>=20)), ADD CONSTRAINT tax_rule_approval_check CHECK(status NOT IN ('approved','published','suspended','archived') OR (approver_id IS NOT NULL AND approver_id<>author_id AND approver_id<>reviewer_id AND approved_at IS NOT NULL AND length(trim(approval_evidence))>=20)), ADD CONSTRAINT tax_rule_publication_check CHECK(status NOT IN ('published','suspended','archived') OR (publisher_id IS NOT NULL AND publisher_id<>author_id AND publisher_id<>reviewer_id AND publisher_id<>approver_id AND published_at IS NOT NULL AND length(trim(publication_evidence))>=20))");
|
||||
|
||||
Schema::create('simulation_tax_rule_events', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('rule_version_id')->constrained('simulation_tax_rule_versions')->restrictOnDelete();
|
||||
$table->unsignedInteger('version_no');
|
||||
$table->foreignId('actor_user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->string('action', 32);
|
||||
$table->jsonb('snapshot');
|
||||
$table->char('sha256', 64);
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->unique(['rule_version_id', 'version_no']);
|
||||
});
|
||||
DB::statement("ALTER TABLE simulation_tax_rule_events ADD CONSTRAINT tax_rule_event_hash_check CHECK(sha256 ~ '^[0-9a-f]{64}$')");
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION public.investmoz_reject_tax_rule_event_change() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$
|
||||
BEGIN RAISE EXCEPTION 'Tax rule governance events are append-only' USING ERRCODE = '42501'; END;
|
||||
$$;
|
||||
CREATE TRIGGER simulation_tax_rule_events_immutable BEFORE UPDATE OR DELETE ON public.simulation_tax_rule_events
|
||||
FOR EACH ROW EXECUTE FUNCTION public.investmoz_reject_tax_rule_event_change();
|
||||
CREATE OR REPLACE FUNCTION public.investmoz_check_tax_rule_published_overlap() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'published' THEN
|
||||
PERFORM pg_advisory_xact_lock(hashtext(NEW.rule_code), hashtext(NEW.scope_key));
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.simulation_tax_rule_versions existing
|
||||
WHERE existing.id <> NEW.id AND existing.status = 'published'
|
||||
AND existing.component = NEW.component AND existing.rule_code = NEW.rule_code
|
||||
AND existing.scope_key = NEW.scope_key
|
||||
AND existing.valid_from <= COALESCE(NEW.valid_until, 'infinity'::date)
|
||||
AND COALESCE(existing.valid_until, 'infinity'::date) >= NEW.valid_from
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Published tax rule effective period overlaps an existing version' USING ERRCODE = '23P01';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER simulation_tax_rule_no_published_overlap BEFORE INSERT OR UPDATE ON public.simulation_tax_rule_versions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.investmoz_check_tax_rule_published_overlap();
|
||||
ALTER TABLE public.simulation_tax_rule_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.simulation_tax_rule_versions FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY simulation_tax_rule_versions_governance ON public.simulation_tax_rule_versions
|
||||
FOR ALL USING (current_setting('app.simulation_tax_governance', true) = 'on'
|
||||
AND NULLIF(current_setting('app.actor_user_id', true), '') IS NOT NULL)
|
||||
WITH CHECK (current_setting('app.simulation_tax_governance', true) = 'on'
|
||||
AND NULLIF(current_setting('app.actor_user_id', true), '') IS NOT NULL);
|
||||
ALTER TABLE public.simulation_tax_rule_events ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.simulation_tax_rule_events FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY simulation_tax_rule_events_governance ON public.simulation_tax_rule_events
|
||||
FOR SELECT USING (current_setting('app.simulation_tax_governance', true) = 'on'
|
||||
AND NULLIF(current_setting('app.actor_user_id', true), '') IS NOT NULL);
|
||||
CREATE POLICY simulation_tax_rule_events_insert ON public.simulation_tax_rule_events
|
||||
FOR INSERT WITH CHECK (current_setting('app.simulation_tax_governance', true) = 'on'
|
||||
AND actor_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint);
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::select("select set_config('app.actor_user_id', '-1', true), set_config('app.simulation_tax_governance', 'on', true)");
|
||||
try {
|
||||
if (Schema::hasTable('simulation_tax_rule_events') && DB::table('simulation_tax_rule_events')->exists()) {
|
||||
throw new RuntimeException('Tax rule governance history exists; preserve records before rollback.');
|
||||
}
|
||||
if (Schema::hasTable('simulation_tax_rule_versions') && DB::table('simulation_tax_rule_versions')->exists()) {
|
||||
throw new RuntimeException('Tax rule versions exist; preserve records before rollback.');
|
||||
}
|
||||
} finally {
|
||||
DB::select("select set_config('app.actor_user_id', '', true), set_config('app.simulation_tax_governance', '', true)");
|
||||
}
|
||||
|
||||
Schema::dropIfExists('simulation_tax_rule_events');
|
||||
Schema::dropIfExists('simulation_tax_rule_versions');
|
||||
DB::statement('DROP FUNCTION IF EXISTS public.investmoz_check_tax_rule_published_overlap() RESTRICT');
|
||||
DB::statement('DROP FUNCTION IF EXISTS public.investmoz_reject_tax_rule_event_change() RESTRICT');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS.');
|
||||
}
|
||||
|
||||
foreach (['cluster_projects', 'corridor_projects', 'corridor_clusters'] as $table) {
|
||||
DB::statement("ALTER TABLE public.{$table}
|
||||
ADD COLUMN association_type text NOT NULL DEFAULT 'potential',
|
||||
ADD COLUMN source_reference text,
|
||||
ADD COLUMN evidence_reference text,
|
||||
ADD COLUMN valid_from date,
|
||||
ADD COLUMN valid_until date,
|
||||
ADD COLUMN validation_status text NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN editor_id bigint,
|
||||
ADD COLUMN validator_id bigint,
|
||||
ADD CONSTRAINT imz_{$table}_association_type_ck CHECK (association_type IN ('official','influence','proximity','potential','historical')),
|
||||
ADD CONSTRAINT imz_{$table}_validation_status_ck CHECK (validation_status IN ('pending','validated','revoked')),
|
||||
ADD CONSTRAINT imz_{$table}_validity_ck CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until >= valid_from),
|
||||
ADD CONSTRAINT imz_{$table}_editor_fk FOREIGN KEY (editor_id) REFERENCES public.users(id) ON DELETE RESTRICT,
|
||||
ADD CONSTRAINT imz_{$table}_validator_fk FOREIGN KEY (validator_id) REFERENCES public.users(id) ON DELETE RESTRICT");
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('InvestMoz rollback requires PostgreSQL.');
|
||||
}
|
||||
|
||||
foreach (['cluster_projects', 'corridor_projects', 'corridor_clusters'] as $table) {
|
||||
$governedRows = DB::table($table)->where(function ($query): void {
|
||||
$query->whereNotNull('source_reference')->orWhereNotNull('evidence_reference')->orWhereNotNull('editor_id')
|
||||
->orWhereNotNull('validator_id')->orWhereNotNull('valid_from')->orWhereNotNull('valid_until')
|
||||
->orWhere('association_type', '!=', 'potential')->orWhere('validation_status', '!=', 'pending');
|
||||
})->count();
|
||||
if ($governedRows > 0) {
|
||||
throw new RuntimeException("Cannot roll back {$table} governance while {$governedRows} relation(s) contain review metadata.");
|
||||
}
|
||||
DB::statement("ALTER TABLE public.{$table}
|
||||
DROP CONSTRAINT imz_{$table}_validator_fk,
|
||||
DROP CONSTRAINT imz_{$table}_editor_fk,
|
||||
DROP CONSTRAINT imz_{$table}_validity_ck,
|
||||
DROP CONSTRAINT imz_{$table}_validation_status_ck,
|
||||
DROP CONSTRAINT imz_{$table}_association_type_ck,
|
||||
DROP COLUMN validator_id,
|
||||
DROP COLUMN editor_id,
|
||||
DROP COLUMN validation_status,
|
||||
DROP COLUMN valid_until,
|
||||
DROP COLUMN valid_from,
|
||||
DROP COLUMN evidence_reference,
|
||||
DROP COLUMN source_reference,
|
||||
DROP COLUMN association_type");
|
||||
}
|
||||
}
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Published tax parameter reads require PostgreSQL RLS.');
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE POLICY simulation_tax_rule_versions_public_read
|
||||
ON public.simulation_tax_rule_versions
|
||||
FOR SELECT
|
||||
USING (
|
||||
status = 'published'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.investment_legal_sources AS source
|
||||
WHERE source.id = simulation_tax_rule_versions.legal_source_id
|
||||
AND source.validation_status = 'validated'
|
||||
AND source.version_no = simulation_tax_rule_versions.source_version_no
|
||||
AND source.valid_from <= simulation_tax_rule_versions.valid_from
|
||||
AND (
|
||||
source.valid_until IS NULL
|
||||
OR (
|
||||
simulation_tax_rule_versions.valid_until IS NOT NULL
|
||||
AND source.valid_until >= simulation_tax_rule_versions.valid_until
|
||||
)
|
||||
)
|
||||
AND (
|
||||
simulation_tax_rule_versions.valid_until IS NOT NULL
|
||||
OR source.valid_until IS NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP POLICY IF EXISTS simulation_tax_rule_versions_public_read ON public.simulation_tax_rule_versions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Land assistance requires PostgreSQL.');
|
||||
}
|
||||
|
||||
Schema::create('land_assistance_cases', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->string('reference', 40)->unique();
|
||||
$table->string('status', 32)->default('draft');
|
||||
$table->unsignedInteger('version')->default(1);
|
||||
$table->text('payload');
|
||||
$table->uuid('crm_submission_key')->unique();
|
||||
$table->foreignId('interest_request_id')->nullable()->unique()->constrained('interest_requests')->restrictOnDelete();
|
||||
$table->foreignId('assigned_to')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->timestampTz('support_requested_at')->nullable();
|
||||
$table->timestampsTz();
|
||||
$table->index(['user_id', 'status', 'updated_at']);
|
||||
$table->index(['status', 'updated_at']);
|
||||
});
|
||||
DB::statement("ALTER TABLE land_assistance_cases ADD CONSTRAINT land_assistance_status_check CHECK (status IN ('draft','support_requested','internal_review','waiting_applicant','referral_prepared','closed')), ADD CONSTRAINT land_assistance_version_check CHECK (version > 0)");
|
||||
|
||||
Schema::create('land_assistance_revisions', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignUuid('case_id')->constrained('land_assistance_cases')->restrictOnDelete();
|
||||
$table->unsignedInteger('version');
|
||||
$table->text('snapshot');
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->unique(['case_id', 'version']);
|
||||
});
|
||||
|
||||
Schema::create('land_assistance_events', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignUuid('case_id')->constrained('land_assistance_cases')->restrictOnDelete();
|
||||
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$table->string('action', 40);
|
||||
$table->unsignedInteger('version');
|
||||
$table->jsonb('metadata')->default('{}');
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->index(['case_id', 'created_at']);
|
||||
});
|
||||
DB::statement("ALTER TABLE land_assistance_events ADD CONSTRAINT land_assistance_event_action_check CHECK (action IN ('draft_created','draft_updated','support_requested','status_changed','attachment_added','applicant_update_recorded'))");
|
||||
|
||||
Schema::create('land_assistance_documents', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('case_id')->constrained('land_assistance_cases')->restrictOnDelete();
|
||||
$table->foreignUuid('document_id')->constrained('documents')->restrictOnDelete();
|
||||
$table->string('category', 40);
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->unique(['case_id', 'document_id']);
|
||||
});
|
||||
DB::statement("ALTER TABLE land_assistance_documents ADD CONSTRAINT land_assistance_document_category_check CHECK (category IN ('sketch','prior_right','exploration_plan','official_receipt','consultation_minutes','photo','other'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Land assistance rollback requires PostgreSQL.');
|
||||
}
|
||||
foreach (['land_assistance_documents', 'land_assistance_events', 'land_assistance_revisions', 'land_assistance_cases'] as $table) {
|
||||
if (DB::table($table)->exists()) {
|
||||
throw new RuntimeException('Preserve land assistance records before rollback.');
|
||||
}
|
||||
}
|
||||
Schema::dropIfExists('land_assistance_documents');
|
||||
Schema::dropIfExists('land_assistance_events');
|
||||
Schema::dropIfExists('land_assistance_revisions');
|
||||
Schema::dropIfExists('land_assistance_cases');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interest_requests', function ($table): void {
|
||||
$table->string('email', 150)->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('interest_requests')->whereNull('email')->exists()) {
|
||||
throw new RuntimeException('Cannot restore the required email field while phone-only interest requests exist.');
|
||||
}
|
||||
|
||||
Schema::table('interest_requests', function ($table): void {
|
||||
$table->string('email', 150)->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('opportunities', function (Blueprint $table): void {
|
||||
$table->string('geography_scope', 20)->default('unspecified');
|
||||
});
|
||||
DB::statement("ALTER TABLE opportunities ADD CONSTRAINT opportunities_geography_scope_check CHECK (geography_scope IN ('unspecified','specific','multiple','national','digital'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('opportunities')->where('geography_scope', '!=', 'unspecified')->exists()) {
|
||||
throw new RuntimeException('Preserve explicitly classified opportunity geography before rollback.');
|
||||
}
|
||||
DB::statement('ALTER TABLE opportunities DROP CONSTRAINT IF EXISTS opportunities_geography_scope_check');
|
||||
Schema::table('opportunities', function (Blueprint $table): void {
|
||||
$table->dropColumn('geography_scope');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Simulation draft continuity requires PostgreSQL.');
|
||||
}
|
||||
|
||||
Schema::table('simulation_form_drafts', function ($table): void {
|
||||
$table->timestampTz('expires_at')->nullable();
|
||||
$table->char('claimed_token_hash', 64)->nullable();
|
||||
});
|
||||
DB::table('simulation_form_drafts')->whereNull('owner_user_id')->whereNull('expires_at')->update([
|
||||
'expires_at' => DB::raw("GREATEST(created_at + interval '30 days', now() + interval '30 days')"),
|
||||
]);
|
||||
DB::statement('DROP INDEX IF EXISTS simulation_form_drafts_owner_active_unique');
|
||||
DB::statement('CREATE INDEX simulation_form_drafts_owner_active_idx ON public.simulation_form_drafts(owner_user_id, updated_at DESC) WHERE owner_user_id IS NOT NULL AND deleted_at IS NULL');
|
||||
DB::statement("CREATE UNIQUE INDEX simulation_form_drafts_claimed_token_unique ON public.simulation_form_drafts(claimed_token_hash) WHERE claimed_token_hash IS NOT NULL");
|
||||
|
||||
Schema::create('simulation_draft_business_links', function ($table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('draft_id')->constrained('simulation_form_drafts')->restrictOnDelete();
|
||||
$table->foreignUuid('business_id')->constrained('existing_businesses')->restrictOnDelete();
|
||||
$table->foreignId('owner_user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('linked_by')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('unlinked_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->timestampTz('linked_at');
|
||||
$table->timestampTz('unlinked_at')->nullable();
|
||||
$table->timestampsTz();
|
||||
});
|
||||
DB::statement('CREATE UNIQUE INDEX simulation_draft_business_links_active_unique ON public.simulation_draft_business_links(draft_id) WHERE unlinked_at IS NULL');
|
||||
DB::statement('CREATE INDEX simulation_draft_business_links_owner_idx ON public.simulation_draft_business_links(owner_user_id, linked_at DESC)');
|
||||
DB::unprepared(<<<'SQL'
|
||||
ALTER TABLE public.simulation_draft_business_links ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.simulation_draft_business_links FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY simulation_draft_business_links_read ON public.simulation_draft_business_links FOR SELECT
|
||||
USING (owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND EXISTS (SELECT 1 FROM public.simulation_form_drafts d WHERE d.id = draft_id AND d.owner_user_id = owner_user_id AND d.deleted_at IS NULL));
|
||||
CREATE POLICY simulation_draft_business_links_insert ON public.simulation_draft_business_links FOR INSERT
|
||||
WITH CHECK (owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND linked_by = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND EXISTS (SELECT 1 FROM public.simulation_form_drafts d WHERE d.id = draft_id AND d.owner_user_id = owner_user_id AND d.deleted_at IS NULL)
|
||||
AND EXISTS (SELECT 1 FROM public.existing_businesses b WHERE b.id = business_id AND b.owner_user_id = owner_user_id AND b.deleted_at IS NULL));
|
||||
CREATE POLICY simulation_draft_business_links_update ON public.simulation_draft_business_links FOR UPDATE
|
||||
USING (owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND EXISTS (SELECT 1 FROM public.simulation_form_drafts d WHERE d.id = draft_id AND d.owner_user_id = owner_user_id AND d.deleted_at IS NULL))
|
||||
WITH CHECK (owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND linked_by = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND EXISTS (SELECT 1 FROM public.simulation_form_drafts d WHERE d.id = draft_id AND d.owner_user_id = owner_user_id AND d.deleted_at IS NULL)
|
||||
AND (unlinked_at IS NOT NULL OR EXISTS (SELECT 1 FROM public.existing_businesses b WHERE b.id = business_id AND b.owner_user_id = owner_user_id AND b.deleted_at IS NULL)));
|
||||
CREATE POLICY simulation_draft_business_links_delete ON public.simulation_draft_business_links FOR DELETE
|
||||
USING (owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint
|
||||
AND EXISTS (SELECT 1 FROM public.simulation_form_drafts d WHERE d.id = draft_id AND d.owner_user_id = owner_user_id AND d.deleted_at IS NULL));
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('Simulation draft continuity rollback requires PostgreSQL.');
|
||||
}
|
||||
if (DB::table('simulation_draft_business_links')->exists()) {
|
||||
throw new RuntimeException('Cannot roll back while simulation-to-business link history exists.');
|
||||
}
|
||||
$hasMultipleOwnerDrafts = DB::table('simulation_form_drafts')->whereNotNull('owner_user_id')
|
||||
->whereNull('deleted_at')->select('owner_user_id')->groupBy('owner_user_id')->havingRaw('COUNT(*) > 1')->exists();
|
||||
if ($hasMultipleOwnerDrafts) {
|
||||
throw new RuntimeException('Cannot restore the single-draft-per-owner index while an account has multiple drafts.');
|
||||
}
|
||||
DB::statement('DROP TABLE public.simulation_draft_business_links');
|
||||
DB::statement('DROP INDEX IF EXISTS simulation_form_drafts_owner_active_idx');
|
||||
DB::statement('CREATE UNIQUE INDEX simulation_form_drafts_owner_active_unique ON public.simulation_form_drafts(owner_user_id) WHERE owner_user_id IS NOT NULL AND deleted_at IS NULL');
|
||||
Schema::table('simulation_form_drafts', function ($table): void {
|
||||
$table->dropColumn(['expires_at', 'claimed_token_hash']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('investment_zones', function (Blueprint $table): void {
|
||||
$table->string('physical_category')->nullable();
|
||||
$table->string('legal_regime')->nullable();
|
||||
$table->string('operational_status')->default('unknown');
|
||||
$table->decimal('area_value', 18, 4)->nullable();
|
||||
$table->string('area_unit', 24)->nullable();
|
||||
$table->jsonb('aliases')->default(DB::raw("'[]'::jsonb"));
|
||||
$table->jsonb('activities')->default(DB::raw("'[]'::jsonb"));
|
||||
$table->jsonb('resources')->default(DB::raw("'[]'::jsonb"));
|
||||
$table->jsonb('historical_territory')->nullable();
|
||||
$table->date('data_as_of')->nullable();
|
||||
$table->date('sources_consulted_at')->nullable();
|
||||
$table->foreignUuid('parent_zone_id')->nullable()->constrained('investment_zones')->restrictOnDelete();
|
||||
});
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_physical_category_check CHECK (physical_category IS NULL OR physical_category IN ('economic_zone','industrial_park','industrial_free_zone','integrated_tourism_area','other','unknown'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_legal_regime_check CHECK (legal_regime IS NULL OR legal_regime IN ('zee','zfi','general','pending','not_applicable'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_operational_status_check CHECK (operational_status IN ('operational','implementation','planned','suspended','unknown'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_area_check CHECK (area_value IS NULL OR area_value >= 0)");
|
||||
|
||||
Schema::create('investment_zone_locations', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('zone_id')->constrained('investment_zones')->restrictOnDelete();
|
||||
$table->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete();
|
||||
$table->string('scope_type')->default('administrative');
|
||||
$table->text('source_reference');
|
||||
$table->foreignUuid('legal_source_id')->nullable()->constrained('investment_legal_sources')->restrictOnDelete();
|
||||
$table->date('valid_from')->nullable();
|
||||
$table->date('valid_until')->nullable();
|
||||
$table->string('validation_status')->default('pending');
|
||||
$table->foreignId('editor_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('reviewer_id')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->unsignedInteger('version_no')->default(1);
|
||||
$table->timestampsTz();
|
||||
$table->unique(['zone_id', 'geo_location_id', 'scope_type'], 'investment_zone_locations_identity_uq');
|
||||
});
|
||||
DB::statement("ALTER TABLE investment_zone_locations ADD CONSTRAINT investment_zone_locations_scope_check CHECK(scope_type IN ('legal_scope','administrative','site','influence')), ADD CONSTRAINT investment_zone_locations_status_check CHECK(validation_status IN ('pending','validated','revoked')), ADD CONSTRAINT investment_zone_locations_dates_check CHECK(valid_until IS NULL OR valid_from IS NULL OR valid_until>=valid_from), ADD CONSTRAINT investment_zone_locations_review_check CHECK(validation_status<>'validated' OR (reviewer_id IS NOT NULL AND reviewer_id<>editor_id))");
|
||||
DB::statement('CREATE INDEX investment_zone_locations_geo_status_idx ON investment_zone_locations(geo_location_id, validation_status, zone_id)');
|
||||
|
||||
// Backfill only the already-recorded primary location; no location is inferred.
|
||||
DB::statement("INSERT INTO investment_zone_locations(id,zone_id,geo_location_id,scope_type,source_reference,validation_status,editor_id,reviewer_id,version_no,created_at,updated_at) SELECT gen_random_uuid(),id,primary_territory_id,'administrative','Legacy primary territory; source review required','pending',editor_id,NULL,1,now(),now() FROM investment_zones WHERE primary_territory_id IS NOT NULL ON CONFLICT(zone_id,geo_location_id,scope_type) DO NOTHING");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_zone_locations')->exists() || DB::table('investment_zones')->where(function ($query): void {
|
||||
$query->whereNotNull('physical_category')->orWhereNotNull('legal_regime')->orWhere('operational_status', '<>', 'unknown')
|
||||
->orWhereNotNull('area_value')->orWhereNotNull('area_unit')->orWhereNotNull('parent_zone_id')->orWhereNotNull('historical_territory')
|
||||
->orWhereNotNull('data_as_of')->orWhereNotNull('sources_consulted_at')
|
||||
->orWhereRaw("aliases <> '[]'::jsonb OR activities <> '[]'::jsonb OR resources <> '[]'::jsonb");
|
||||
})->exists()) {
|
||||
throw new RuntimeException('Zone integration data exists; export and reconcile it before rollback.');
|
||||
}
|
||||
Schema::dropIfExists('investment_zone_locations');
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT IF EXISTS investment_zones_area_check, DROP CONSTRAINT IF EXISTS investment_zones_operational_status_check, DROP CONSTRAINT IF EXISTS investment_zones_legal_regime_check, DROP CONSTRAINT IF EXISTS investment_zones_physical_category_check');
|
||||
Schema::table('investment_zones', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('parent_zone_id');
|
||||
$table->dropColumn(['physical_category','legal_regime','operational_status','area_value','area_unit','aliases','activities','resources','historical_territory','data_as_of','sources_consulted_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('investment_zone_attachments', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('zone_id')->constrained('investment_zones')->restrictOnDelete();
|
||||
$table->foreignUuid('document_id')->unique()->constrained('documents')->restrictOnDelete();
|
||||
$table->string('kind', 32);
|
||||
$table->string('caption', 255);
|
||||
$table->text('source_reference');
|
||||
$table->date('source_date')->nullable();
|
||||
$table->string('rights_status', 24)->default('pending');
|
||||
$table->text('rights_basis')->nullable();
|
||||
$table->string('personal_data_status', 24)->default('unknown');
|
||||
$table->string('review_status', 24)->default('pending');
|
||||
$table->foreignId('uploaded_by')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->timestampTz('reviewed_at')->nullable();
|
||||
$table->boolean('independent_review')->default(false);
|
||||
$table->boolean('publicly_selected')->default(false);
|
||||
$table->timestampTz('publicly_selected_at')->nullable();
|
||||
$table->foreignId('publicly_selected_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->unsignedInteger('version_no')->default(1);
|
||||
$table->timestampsTz();
|
||||
$table->index(['zone_id', 'review_status', 'publicly_selected']);
|
||||
});
|
||||
DB::statement("ALTER TABLE investment_zone_attachments ADD CONSTRAINT investment_zone_attachments_kind_check CHECK (kind IN ('legal_instrument','map','photo','study','other')), ADD CONSTRAINT investment_zone_attachments_rights_check CHECK (rights_status IN ('pending','confirmed','restricted')), ADD CONSTRAINT investment_zone_attachments_personal_data_check CHECK (personal_data_status IN ('unknown','none','present')), ADD CONSTRAINT investment_zone_attachments_review_check CHECK (review_status IN ('pending','reviewed','rejected')), ADD CONSTRAINT investment_zone_attachments_review_metadata_check CHECK ((review_status='pending' AND reviewed_by IS NULL AND reviewed_at IS NULL) OR (review_status<>'pending' AND reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL)), ADD CONSTRAINT investment_zone_attachments_public_metadata_check CHECK ((NOT publicly_selected AND publicly_selected_at IS NULL AND publicly_selected_by IS NULL) OR (publicly_selected AND publicly_selected_at IS NOT NULL AND publicly_selected_by IS NOT NULL AND rights_status='confirmed' AND length(trim(coalesce(rights_basis,'')))>0 AND personal_data_status='none' AND review_status='reviewed'))");
|
||||
|
||||
Schema::create('investment_zone_attachment_events', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('attachment_id')->constrained('investment_zone_attachments')->restrictOnDelete();
|
||||
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$table->string('action', 40);
|
||||
$table->unsignedInteger('version_no');
|
||||
$table->jsonb('metadata')->default(DB::raw("'{}'::jsonb"));
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->index(['attachment_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_zone_attachments')->exists() || DB::table('investment_zone_attachment_events')->exists()) {
|
||||
throw new RuntimeException('Investment zone attachment data exists; export and reconcile it before rollback.');
|
||||
}
|
||||
|
||||
Schema::dropIfExists('investment_zone_attachment_events');
|
||||
Schema::dropIfExists('investment_zone_attachments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_check');
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_publication_requirements_check CHECK (publication_status <> 'published' OR (validation_status='validated' AND category <> 'pending' AND legal_source_id IS NOT NULL AND reviewer_id IS NOT NULL AND reviewer_id <> editor_id AND published_at IS NOT NULL AND length(trim(name)) > 0))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_zones')->where('publication_status', 'published')->where(function ($query): void {
|
||||
$query->whereNull('operator_id')->orWhereNull('name_en')->orWhereNull('description_en');
|
||||
})->exists()) {
|
||||
throw new RuntimeException('Published zone data relies on the relaxed operator/language requirements; reconcile it before rollback.');
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_publication_requirements_check');
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_check CHECK (publication_status <> 'published' OR (validation_status='validated' AND category <> 'pending' AND legal_source_id IS NOT NULL AND operator_id IS NOT NULL AND reviewer_id IS NOT NULL AND reviewer_id <> editor_id AND published_at IS NOT NULL AND name_en IS NOT NULL AND description_en IS NOT NULL))");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('investment_zones', function (Blueprint $table): void {
|
||||
$table->string('development_status', 32)->default('unknown');
|
||||
$table->string('availability_status', 32)->default('not_informed');
|
||||
$table->jsonb('area_components')->default(DB::raw("'[]'::jsonb"));
|
||||
$table->jsonb('source_evidence')->default(DB::raw("'[]'::jsonb"));
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_physical_category_check');
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_physical_category_check CHECK (physical_category IS NULL OR physical_category IN ('economic_zone','industrial_park','agro_industrial_park','industrial_logistics_park','industrial_free_zone','integrated_tourism_area','other','unknown'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_development_status_check CHECK (development_status IN ('announced','planned','in_development','partially_operational','operational','suspended','unknown'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_availability_status_check CHECK (availability_status IN ('confirmed','unavailable','reported_unverified','not_informed'))");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_area_components_check CHECK (jsonb_typeof(area_components)='array')");
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_source_evidence_check CHECK (jsonb_typeof(source_evidence)='array')");
|
||||
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_publication_requirements_check');
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_publication_requirements_check CHECK (publication_status <> 'published' OR (validation_status='validated' AND category <> 'pending' AND reviewer_id IS NOT NULL AND reviewer_id <> editor_id AND published_at IS NOT NULL AND length(trim(name)) > 0 AND physical_category IS NOT NULL AND physical_category <> 'unknown' AND ((category IN ('zee','zfi') AND legal_source_id IS NOT NULL) OR jsonb_array_length(source_evidence)>0)))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_zones')->where(function ($query): void {
|
||||
$query->where('development_status', '<>', 'unknown')->orWhere('availability_status', '<>', 'not_informed')
|
||||
->orWhereRaw("area_components <> '[]'::jsonb OR source_evidence <> '[]'::jsonb");
|
||||
})->exists()) {
|
||||
throw new RuntimeException('Zone development or source evidence data exists; export and reconcile it before rollback.');
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_publication_requirements_check');
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_publication_requirements_check CHECK (publication_status <> 'published' OR (validation_status='validated' AND category <> 'pending' AND legal_source_id IS NOT NULL AND reviewer_id IS NOT NULL AND reviewer_id <> editor_id AND published_at IS NOT NULL AND length(trim(name)) > 0))");
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_source_evidence_check, DROP CONSTRAINT investment_zones_area_components_check, DROP CONSTRAINT investment_zones_availability_status_check, DROP CONSTRAINT investment_zones_development_status_check');
|
||||
DB::statement('ALTER TABLE investment_zones DROP CONSTRAINT investment_zones_physical_category_check');
|
||||
DB::statement("ALTER TABLE investment_zones ADD CONSTRAINT investment_zones_physical_category_check CHECK (physical_category IS NULL OR physical_category IN ('economic_zone','industrial_park','industrial_free_zone','integrated_tourism_area','other','unknown'))");
|
||||
Schema::table('investment_zones', function (Blueprint $table): void {
|
||||
$table->dropColumn(['development_status', 'availability_status', 'area_components', 'source_evidence']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
throw new RuntimeException('The document catalogue requires PostgreSQL.');
|
||||
}
|
||||
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->text('description')->nullable();
|
||||
$table->string('document_type', 40)->default('unknown');
|
||||
$table->string('document_number', 120)->nullable();
|
||||
$table->date('document_date')->nullable();
|
||||
$table->text('source_url')->nullable();
|
||||
$table->string('language', 12)->default('unknown');
|
||||
$table->string('version_label', 120)->nullable();
|
||||
$table->uuid('document_group_id')->nullable();
|
||||
$table->string('territorial_scope', 24)->default('unknown');
|
||||
$table->foreignUuid('territory_id')->nullable()->constrained('geo_locations')->restrictOnDelete();
|
||||
$table->string('source_confirmation', 24)->default('unconfirmed');
|
||||
$table->string('legal_status', 24)->default('unknown');
|
||||
$table->string('rights_status', 24)->default('unconfirmed');
|
||||
$table->timestampTz('last_reviewed_at')->nullable();
|
||||
$table->string('document_identity_key', 64)->nullable();
|
||||
$table->foreignId('duplicate_of_resource_id')->nullable()->constrained('portal_resources')->restrictOnDelete();
|
||||
$table->unsignedInteger('metadata_version')->default(1);
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('last_reviewed_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->boolean('independent_review')->default(false);
|
||||
$table->index(['document_type', 'language', 'published'], 'portal_resources_library_filters_idx');
|
||||
$table->index(['territory_id', 'territorial_scope'], 'portal_resources_library_territory_idx');
|
||||
});
|
||||
|
||||
Schema::table('documents', function (Blueprint $table): void {
|
||||
$table->string('rights_status', 24)->default('unconfirmed');
|
||||
$table->string('original_source', 2000)->nullable();
|
||||
$table->index(['status', 'data_classification', 'sha256'], 'documents_library_visibility_hash_idx');
|
||||
});
|
||||
|
||||
Schema::create('portal_resource_links', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('resource_id')->constrained('portal_resources')->cascadeOnDelete();
|
||||
$table->string('target_type', 32);
|
||||
$table->string('target_key', 180);
|
||||
$table->string('relationship', 24)->default('applies_to');
|
||||
$table->string('applicability', 24)->default('support');
|
||||
$table->text('condition_text')->nullable();
|
||||
$table->text('basis_reference')->nullable();
|
||||
$table->timestampsTz();
|
||||
$table->unique(['resource_id', 'target_type', 'target_key', 'relationship'], 'portal_resource_links_identity_uq');
|
||||
$table->index(['target_type', 'target_key', 'applicability'], 'portal_resource_links_lookup_idx');
|
||||
});
|
||||
|
||||
Schema::create('portal_resource_revisions', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('resource_id')->constrained('portal_resources')->cascadeOnDelete();
|
||||
$table->unsignedInteger('revision_no');
|
||||
$table->string('change_type', 24);
|
||||
$table->jsonb('snapshot');
|
||||
$table->foreignId('actor_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->unique(['resource_id', 'revision_no'], 'portal_resource_revisions_identity_uq');
|
||||
});
|
||||
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_document_type_check CHECK (document_type IN ('legislation','procedure','official_form','digital_adaptation','guidance_template','guide','study','reference_data','unknown'))");
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_scope_check CHECK (territorial_scope IN ('national','provincial','district','locality','multiple','unknown'))");
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_source_confirmation_check CHECK (source_confirmation IN ('unconfirmed','identified','official_confirmed'))");
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_legal_status_check CHECK (legal_status IN ('not_applicable','in_force','historical','proposed','repealed','amended','unknown'))");
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_rights_status_check CHECK (rights_status IN ('unconfirmed','authorized','restricted'))");
|
||||
DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_target_type_check CHECK (target_type IN ('theme','module','activity','stage','procedure','entity','territory','document','investment_type','legal_source'))");
|
||||
DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_relationship_check CHECK (relationship IN ('applies_to','supports','version_of','translation_of','amends','repeals','implements','cites','related_to'))");
|
||||
DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_applicability_check CHECK (applicability IN ('required','conditional','support','optional_template','evidence'))");
|
||||
DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_basis_check CHECK ((applicability NOT IN ('required','conditional') OR length(trim(coalesce(basis_reference,''))) >= 5) AND (applicability <> 'conditional' OR length(trim(coalesce(condition_text,''))) >= 5))");
|
||||
DB::statement("ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_metadata_version_check CHECK (metadata_version > 0)");
|
||||
DB::statement("ALTER TABLE portal_resource_revisions ADD CONSTRAINT portal_resource_revisions_change_type_check CHECK (change_type IN ('created','metadata_updated','published','unpublished'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('portal_resource_links')->exists() || DB::table('portal_resource_revisions')->exists()
|
||||
|| DB::table('portal_resources')->whereNotNull('document_identity_key')->orWhereNotNull('duplicate_of_resource_id')
|
||||
->orWhereNotNull('description')->orWhereNotNull('source_url')->orWhereNotNull('document_group_id')->exists()
|
||||
|| DB::table('documents')->where('rights_status', '!=', 'unconfirmed')->orWhereNotNull('original_source')->exists()) {
|
||||
throw new RuntimeException('Preserve document catalogue relationships and duplicate review data before rollback.');
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE portal_resource_links DROP CONSTRAINT portal_resource_links_target_type_check, DROP CONSTRAINT portal_resource_links_relationship_check, DROP CONSTRAINT portal_resource_links_applicability_check, DROP CONSTRAINT portal_resource_links_basis_check');
|
||||
DB::statement('ALTER TABLE portal_resource_revisions DROP CONSTRAINT portal_resource_revisions_change_type_check');
|
||||
DB::statement('ALTER TABLE portal_resources DROP CONSTRAINT portal_resources_metadata_version_check');
|
||||
Schema::dropIfExists('portal_resource_revisions');
|
||||
Schema::dropIfExists('portal_resource_links');
|
||||
Schema::table('documents', function (Blueprint $table): void {
|
||||
$table->dropIndex('documents_library_visibility_hash_idx');
|
||||
$table->dropColumn(['rights_status', 'original_source']);
|
||||
});
|
||||
DB::statement('ALTER TABLE portal_resources DROP CONSTRAINT portal_resources_document_type_check, DROP CONSTRAINT portal_resources_scope_check, DROP CONSTRAINT portal_resources_source_confirmation_check, DROP CONSTRAINT portal_resources_legal_status_check, DROP CONSTRAINT portal_resources_rights_status_check');
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->dropForeign(['territory_id']);
|
||||
$table->dropForeign(['duplicate_of_resource_id']);
|
||||
$table->dropForeign(['created_by']);
|
||||
$table->dropForeign(['last_reviewed_by']);
|
||||
$table->dropIndex('portal_resources_library_filters_idx');
|
||||
$table->dropIndex('portal_resources_library_territory_idx');
|
||||
$table->dropColumn(['description', 'document_type', 'document_number', 'document_date', 'source_url', 'language', 'version_label',
|
||||
'document_group_id', 'territorial_scope', 'territory_id', 'source_confirmation', 'legal_status', 'rights_status', 'last_reviewed_at',
|
||||
'document_identity_key', 'duplicate_of_resource_id', 'metadata_version', 'created_by', 'last_reviewed_by', 'independent_review']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('documents', function (Blueprint $table): void {
|
||||
$table->string('malware_scan_status', 20)->default('pending');
|
||||
$table->char('malware_scanned_sha256', 64)->nullable();
|
||||
$table->string('malware_engine', 80)->nullable();
|
||||
$table->string('malware_engine_version', 80)->nullable();
|
||||
$table->timestampTz('malware_signatures_at')->nullable();
|
||||
$table->timestampTz('malware_scanned_at')->nullable();
|
||||
$table->string('malware_failure_reason', 80)->nullable();
|
||||
$table->unsignedSmallInteger('malware_scan_attempts')->default(0);
|
||||
$table->index(['malware_scan_status', 'updated_at'], 'documents_malware_queue_idx');
|
||||
});
|
||||
DB::statement("ALTER TABLE documents ADD CONSTRAINT documents_malware_scan_status_check CHECK (malware_scan_status IN ('pending','scanning','clean','infected','inconclusive','failed'))");
|
||||
|
||||
Schema::create('document_malware_scans', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('document_id')->constrained('documents')->restrictOnDelete();
|
||||
$table->char('file_sha256', 64);
|
||||
$table->string('result', 20);
|
||||
$table->string('reason_code', 80)->nullable();
|
||||
$table->string('engine', 80);
|
||||
$table->string('engine_version', 80);
|
||||
$table->timestampTz('signatures_at')->nullable();
|
||||
$table->timestampTz('started_at');
|
||||
$table->timestampTz('finished_at')->nullable();
|
||||
$table->unsignedInteger('duration_ms')->nullable();
|
||||
$table->unsignedInteger('file_size_bytes')->nullable();
|
||||
$table->timestampsTz();
|
||||
$table->index(['document_id', 'created_at'], 'document_malware_scans_history_idx');
|
||||
$table->index(['result', 'created_at'], 'document_malware_scans_result_idx');
|
||||
});
|
||||
DB::statement("ALTER TABLE document_malware_scans ADD CONSTRAINT document_malware_scans_result_check CHECK (result IN ('clean','infected','inconclusive','failed'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('document_malware_scans')->exists()) {
|
||||
throw new RuntimeException('Malware scan history exists; export and reconcile it before rollback.');
|
||||
}
|
||||
|
||||
Schema::dropIfExists('document_malware_scans');
|
||||
DB::statement('ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_malware_scan_status_check');
|
||||
Schema::table('documents', function (Blueprint $table): void {
|
||||
$table->dropIndex('documents_malware_queue_idx');
|
||||
$table->dropColumn([
|
||||
'malware_scan_status', 'malware_scanned_sha256', 'malware_engine', 'malware_engine_version',
|
||||
'malware_signatures_at', 'malware_scanned_at', 'malware_failure_reason', 'malware_scan_attempts',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->foreignId('approved_by')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('portal_resources')->whereNull('approved_by')->exists()) {
|
||||
throw new RuntimeException('Private document drafts without an approver exist; preserve them before rollback.');
|
||||
}
|
||||
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->foreignId('approved_by')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->date('applicable_from')->nullable();
|
||||
$table->date('applicable_until')->nullable();
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE portal_resources ADD CONSTRAINT portal_resources_applicable_period_check CHECK (applicable_until IS NULL OR applicable_from IS NULL OR applicable_until >= applicable_from)');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE portal_resources DROP CONSTRAINT IF EXISTS portal_resources_applicable_period_check');
|
||||
Schema::table('portal_resources', function (Blueprint $table): void {
|
||||
$table->dropColumn(['applicable_from', 'applicable_until']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('portal_resource_links', function (Blueprint $table): void {
|
||||
$table->string('review_status', 16)->default('pending');
|
||||
});
|
||||
DB::statement("ALTER TABLE portal_resource_links DROP CONSTRAINT portal_resource_links_target_type_check");
|
||||
DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_target_type_check CHECK (target_type IN ('theme','module','activity','stage','procedure','entity','territory','document','investment_type','legal_source','requirement'))");
|
||||
DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_review_status_check CHECK (review_status IN ('pending','confirmed'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('portal_resource_links')->where('target_type', 'requirement')->exists()) {
|
||||
throw new RuntimeException('Requirement evidence links exist; reconcile them before rollback.');
|
||||
}
|
||||
DB::statement('ALTER TABLE portal_resource_links DROP CONSTRAINT portal_resource_links_target_type_check, DROP CONSTRAINT portal_resource_links_review_status_check');
|
||||
DB::statement("ALTER TABLE portal_resource_links ADD CONSTRAINT portal_resource_links_target_type_check CHECK (target_type IN ('theme','module','activity','stage','procedure','entity','territory','document','investment_type','legal_source'))");
|
||||
Schema::table('portal_resource_links', fn (Blueprint $table) => $table->dropColumn('review_status'));
|
||||
}
|
||||
};
|
||||
@@ -22,5 +22,6 @@ class DatabaseSeeder extends Seeder
|
||||
|
||||
$this->call(InvestMozReferenceSeeder::class);
|
||||
$this->call(LocalTestUserSeeder::class);
|
||||
$this->call(SampleManagerAccountsSeeder::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Creates local-only account examples for management UI and access-control exercises.
|
||||
* These accounts are deliberately unverified, have no MFA, and are not added to the
|
||||
* configured portal manager allowlist.
|
||||
*/
|
||||
class SampleManagerAccountsSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
if (! app()->environment(['local', 'testing'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (range(1, 10) as $number) {
|
||||
$suffix = str_pad((string) $number, 2, '0', STR_PAD_LEFT);
|
||||
|
||||
$user = User::firstOrCreate(
|
||||
['email' => "gestor{$suffix}@example.test"],
|
||||
[
|
||||
'name' => "Gestor de Exemplo {$suffix}",
|
||||
'password' => Str::random(48),
|
||||
],
|
||||
);
|
||||
|
||||
if ($user->wasRecentlyCreated) {
|
||||
$user->forceFill([
|
||||
'email_verified_at' => null,
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class TerritoryReferenceSeeder extends Seeder
|
||||
|
||||
$records = $data['records'];
|
||||
|
||||
$actualCounts = is_array($records) ? array_count_values(array_column($records, 'location_type')) : [];
|
||||
$actualCounts = array_count_values(array_column($records, 'location_type'));
|
||||
ksort($actualCounts);
|
||||
$expectedCounts = self::EXPECTED_COUNTS;
|
||||
ksort($expectedCounts);
|
||||
@@ -41,7 +41,6 @@ class TerritoryReferenceSeeder extends Seeder
|
||||
|| ! is_string($data['records_sha256'] ?? null)
|
||||
|| ! hash_equals(self::RECORDS_CHECKSUM, $data['records_sha256'])
|
||||
|| ! hash_equals(self::RECORDS_CHECKSUM, hash('sha256', json_encode($records, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)))
|
||||
|| ! is_array($records)
|
||||
|| $actualCounts !== $expectedCounts) {
|
||||
throw new RuntimeException('Territory reference dataset failed its source or completeness checks.');
|
||||
}
|
||||
@@ -113,7 +112,7 @@ class TerritoryReferenceSeeder extends Seeder
|
||||
return $counts;
|
||||
});
|
||||
|
||||
$this->command?->info(sprintf(
|
||||
$this->command->info(sprintf(
|
||||
'Referências territoriais: %d criadas, %d já existentes, %d classificadas como públicas.',
|
||||
$counts['created'],
|
||||
$counts['unchanged'],
|
||||
@@ -124,9 +123,16 @@ class TerritoryReferenceSeeder extends Seeder
|
||||
/** @param array<string, mixed> $record
|
||||
* @param array<string, bool> $seen
|
||||
* @param array<string, string> $types
|
||||
*
|
||||
* @param-out array<string, bool> $seen
|
||||
* @param-out array<string, string> $types
|
||||
*/
|
||||
private function validateRecord(array $record, array &$seen, array &$types): void
|
||||
{
|
||||
$id = $record['id'] ?? null;
|
||||
if (! is_string($id) || $id === '') {
|
||||
throw new RuntimeException('Territory reference is missing a valid id.');
|
||||
}
|
||||
foreach (['id', 'name', 'location_type', 'code', 'source_code'] as $field) {
|
||||
if (! isset($record[$field]) || $record[$field] === '') {
|
||||
throw new RuntimeException('Territory reference is missing '.$field.'.');
|
||||
@@ -142,7 +148,7 @@ class TerritoryReferenceSeeder extends Seeder
|
||||
'locality' => 'admin_post',
|
||||
];
|
||||
|
||||
if (! array_key_exists($type, $parentTypes) || isset($seen[$record['id']])) {
|
||||
if (! is_string($type) || ! array_key_exists($type, $parentTypes) || isset($seen[$id])) {
|
||||
throw new RuntimeException('Territory reference has an invalid type or duplicate ID.');
|
||||
}
|
||||
|
||||
@@ -152,11 +158,11 @@ class TerritoryReferenceSeeder extends Seeder
|
||||
if ($record['parent_id'] !== null) {
|
||||
throw new RuntimeException('Country reference cannot have a parent.');
|
||||
}
|
||||
} elseif (! $record['parent_id'] || ($types[$record['parent_id']] ?? null) !== $expectedParent) {
|
||||
throw new RuntimeException('Territory reference has a missing or invalid parent: '.$record['id']);
|
||||
} elseif (! is_string($record['parent_id'] ?? null) || ($types[$record['parent_id']] ?? null) !== $expectedParent) {
|
||||
throw new RuntimeException('Territory reference has a missing or invalid parent: '.$id);
|
||||
}
|
||||
|
||||
$seen[$record['id']] = true;
|
||||
$types[$record['id']] = $type;
|
||||
$seen[$id] = true;
|
||||
$types[$id] = $type;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user