Fase {number} · {label}
{path.steps
- .filter((step) => step.phase === Number(number))
+ .filter((step) => step.phase === Number(number) && step.preparation_allowed)
.map((step) => (
().props;
+ const { auth, contentManagementHref } = usePage<{ auth: Auth; contentManagementHref?: string | null }>().props;
const user = auth.user;
const action =
'inline-flex rounded-lg bg-slate-900 px-5 py-3 font-medium text-white';
@@ -29,7 +29,7 @@ export default function ManagementAccess({
{!user
? 'Preparar a sua conta'
- : auth.canManagePortal
+ : contentManagementHref || auth.canManagePortal
? 'Acesso disponível'
: !user.email_verified_at
? 'Verificar o seu email'
@@ -53,6 +53,11 @@ export default function ManagementAccess({
)}
>
+ ) : contentManagementHref ? (
+ <>
+ Abra a entrada de gestão para cadastrar oportunidades e gerir documentos e legislação, conforme as funções já atribuídas à sua conta.
+ Abrir gestão de conteúdos e oportunidades
+ >
) : auth.canManagePortal ? (
<>
diff --git a/resources/js/pages/management/content-hub.tsx b/resources/js/pages/management/content-hub.tsx
new file mode 100644
index 00000000..40fa915a
--- /dev/null
+++ b/resources/js/pages/management/content-hub.tsx
@@ -0,0 +1,42 @@
+import { Link } from '@inertiajs/react';
+import { Frame, card, button } from '@/pages/investment/shared';
+import { states } from './shared';
+
+type Access = {
+ procurement?: {index:string;create:string};
+ opportunities?: {index: string; create: string | null; profileWorkflow: boolean};
+ documents?: {index: string; edit: boolean};
+};
+export default function ContentHub({access, counts}: {access: Access; counts: Record>}) {
+ return
+ Use os cadastros e a biblioteca existentes. Guardar um rascunho, analisar um ficheiro e publicar são operações distintas. Cada acção respeita a sua autorização actual.
+
+ {access.procurement &&
Concursos e anúncios Cadastre o procedimento e os lotes, guarde e recupere a preparação, reveja a prévia e publique os actos autorizados. Esclarecimentos, prorrogações, resultados e cancelamentos conservam versões e histórico. Divulgação de oportunidade e abertura de concurso são operações distintas.
Novo concurso/anúncio Gerir concursos e anúncios Catálogo público}
+ {access.opportunities &&
+ Oportunidades
+ Escolha a modalidade, o sector e a localização; preencha, guarde o rascunho e siga as acções de submissão, revisão e publicação disponíveis no seu perfil.
+ Sector económico e modalidade são escolhas distintas. Pode guardar preparação incompleta; o editor identifica os campos e variantes ainda sem suporte de submissão.
+ {access.opportunities.create && Nova oportunidade}
+ Gerir oportunidades e histórico
+ {access.opportunities.profileWorkflow && Rever o lote das 34 oportunidades de Agronegócio}
+ Para divulgar um resumo ainda em preparação, abra a ficha e escolha Divulgação promocional : prepare o texto público, reveja a prévia e confirme divulgar, actualizar ou retirar. Esta operação não conclui a validação do investimento.
+ {Object.entries(counts.opportunities ?? {}).map(([state, total]) => {states[state] ?? state}: {total} )}
+ As etapas e a independência já definidas para oportunidades mantêm-se. Esta entrada não atribui autorização para rever ou publicar.
+ {!access.opportunities.profileWorkflow && O seu acesso actual abre o cadastro legado de rascunhos. A submissão, revisão e publicação de novas oportunidades utilizam os perfis operacionais já existentes; a administração de conteúdos não substitui essa designação. Esta entrada não apresenta o rascunho legado como percurso completo de publicação.
}
+ }
+ {access.documents &&
+ Documentos e legislação
+ Carregue o original ou reutilize um ficheiro existente; acompanhe a análise, complete os metadados e associações e defina o acesso. Depois publique explicitamente, se autorizado.
+ Novo documento
+ {access.documents.edit && Nova legislação}
+ Gerir conteúdos, versões e histórico
+ {!access.documents.edit && O seu acesso permite carregar e analisar ficheiros próprios; não inclui cadastro editorial nem publicação.
}
+ Publicados: {counts.documents?.published ?? 0} · Não publicados: {counts.documents?.unpublished ?? 0}. A lista distingue rascunhos de conteúdos retirados através do histórico.
+ Publicar legislação permite consultá-la. Não a activa como regra executável no simulador.
+ }
+
+ Como concluir sem apoio técnico
+ Abra a acção pretendida e use os campos do editor existente. Guarde a preparação. As mensagens indicam o estado e o que precisa de corrigir. Reabra pela gestão, pesquise o título e consulte o histórico. Use as acções autorizadas de publicação ou retirada. Os originais e as revisões são preservados.
+
+ ;
+}
diff --git a/resources/js/pages/management/corridor-agribusiness.tsx b/resources/js/pages/management/corridor-agribusiness.tsx
new file mode 100644
index 00000000..15b10766
--- /dev/null
+++ b/resources/js/pages/management/corridor-agribusiness.tsx
@@ -0,0 +1,25 @@
+import { Link, router } from '@inertiajs/react';
+import { useState } from 'react';
+import { profileJson } from '@/lib/profile-json';
+import { createOperationKey } from '@/lib/operation-key';
+import { Management, button, control } from './shared';
+
+type Entry = {key:string;id:number|null;title:string;summary:string;description:string;corridor:string;status:string|null;href:string|null};
+type Preview = {id:number;token:string;projection:{title:string;summary:string;description:string};operation_key:string};
+export default function AgribusinessBatch({entries,manifestHash,canRegister}:{entries:Entry[];manifestHash:string;canRegister:boolean}) {
+ const [previews,setPreviews]=useState([]); const [reason,setReason]=useState(''); const [confirmed,setConfirmed]=useState(false); const [busy,setBusy]=useState(false); const [message,setMessage]=useState('');
+ const base='/minhas-oportunidades/lotes/agronegocio';
+ async function run(action:'register'|'preview'|'disclose') {setBusy(true);setMessage('');try {
+ if(action==='register'){await profileJson(base+'/cadastrar',{manifest_hash:manifestHash,confirm:true});router.reload();}
+ if(action==='preview'){const result=await profileJson(base+'/previa',{}) as {previews:Preview[]};setPreviews(result.previews.map(p=>({...p,operation_key:createOperationKey()})));setConfirmed(false);}
+ if(action==='disclose'){await profileJson(base+'/divulgar',{previews:previews.map(({id,token,operation_key})=>({id,token,operation_key})),reason,confirm:confirmed});setPreviews([]);setMessage('As 34 fichas foram divulgadas como Em preparação; a execução não foi autorizada.');router.reload();}
+ }catch(e){setMessage(e instanceof Error?e.message:'Operação não concluída. A versão anterior foi preservada.');}finally{setBusy(false);}}
+ return Reveja as oportunidades, complete a informação disponível e confira a prévia antes de divulgar.
+ {entries.filter(e=>e.id).length} cadastradas · {entries.filter(e=>e.status==='published').length} divulgadas
+ {canRegister && void run('register')}>Cadastrar ou recuperar as 34 fichas privadamente }
+ {entries.every(e=>e.id) && void run('preview')}>Pré-visualizar divulgação das 34 fichas }
+
+ {!!previews.length && }
+ {message&&{message}
} Consultar catálogo de Agronegócio
+ ;
+}
diff --git a/resources/js/pages/management/opportunities.tsx b/resources/js/pages/management/opportunities.tsx
index faa2bbbb..958cd32b 100644
--- a/resources/js/pages/management/opportunities.tsx
+++ b/resources/js/pages/management/opportunities.tsx
@@ -17,6 +17,7 @@ type Entry = {
updated_at: string;
deleted_at: string | null;
can_edit?: boolean;
+ can_promote?: boolean;
working_status?: string;
draft_title?: string;
publication?: { scope: string; label: string; public_catalog: boolean };
@@ -232,6 +233,7 @@ export default function Opportunities({
Atualizado: {r.updated_at}
Ver
+ {r.can_promote &&
Divulgação promocional}
{r.can_edit !== false && (
{r.deleted_at || r.status === 'archived'
diff --git a/resources/js/pages/management/opportunity-draft.tsx b/resources/js/pages/management/opportunity-draft.tsx
index 23eb017f..c095b53c 100644
--- a/resources/js/pages/management/opportunity-draft.tsx
+++ b/resources/js/pages/management/opportunity-draft.tsx
@@ -1,6 +1,7 @@
import { ClassificationReview } from '@/components/opportunity-classification';
import { createOperationKey } from '@/lib/operation-key';
import { valueText } from '@/lib/value-text';
+import { modalityLabels } from '@/components/opportunity-search-filters';
import OpportunityProfileActions from '@/components/opportunity-profile-actions';
import OpportunityContactForm from '@/components/opportunity-contact-form';
import OpportunityCanonicalFields, {
@@ -74,9 +75,11 @@ export default function OpportunityDraft({
workflowParticipants,
publicationPreview,
publicationState,
+ promotionHref,
auth,
canEditDraft = false,
} = usePage<{
+ promotionHref?: string | null;
basePath?: string;
profileMode?: boolean;
profileOrganization?: string | null;
@@ -245,6 +248,15 @@ export default function OpportunityDraft({
{profileMode ? 'Minhas oportunidades' : 'Voltar à gestão'}
+ {promotionHref &&
Divulgação promocional — Em preparação}
+ {!!profileContext?.corridor_agribusiness && (
+
+ Origem e enquadramento da oportunidade
+ {valueText((profileContext.corridor_agribusiness as Record).corridor)}
+ Fonte: {valueText((profileContext.corridor_agribusiness as Record).source)}
+ Em preparação. O corredor indicado na fonte não confirma implantação, promotor, viabilidade ou financiamento. Complete a ficha e reveja a prévia antes de divulgar.
+
+ )}
{record && (
))}
diff --git a/resources/js/pages/management/opportunity-promotion.tsx b/resources/js/pages/management/opportunity-promotion.tsx
new file mode 100644
index 00000000..da16a2f1
--- /dev/null
+++ b/resources/js/pages/management/opportunity-promotion.tsx
@@ -0,0 +1,77 @@
+import { Link, router } from '@inertiajs/react';
+import { useState } from 'react';
+import { createOperationKey } from '@/lib/operation-key';
+import { profileJson, ProfileValidationError } from '@/lib/profile-json';
+import { Management, Field, control, button } from './shared';
+
+type Draft = {title: string; summary: string; description: string; limitations: string; source_reference: string; edition: string; source_date: string; sector_id: number | null; territory_ids: string[]};
+type Projection = {title: string; summary: string; description: string; sector_id: number | null; maturity_label: string; territories: {id: string; label: string}[]};
+type Preview = {token: string; action: 'disclose' | 'withdraw'; projection: Projection; current: {title: string; summary: string; description: string; status: string}};
+
+export default function OpportunityPromotion({record, initial, etag, publicNow, reauthenticationRequired, sectors, territories, history}: {
+ record: {id: number; reference: string}; initial: Draft; etag: string; publicNow: boolean; reauthenticationRequired: boolean;
+ sectors: {id: number; name: string}[]; territories: {id: string; name: string; location_type: string}[];
+ history: {id: string; action: string; actor_user_id: number; occurred_at: string}[];
+}) {
+ const [draft, setDraft] = useState
({...initial, source_date: initial.source_date ?? ''});
+ const [preview, setPreview] = useState(null);
+ const [operationKey, setOperationKey] = useState(createOperationKey);
+ const [reason, setReason] = useState('');
+ const [confirmed, setConfirmed] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [message, setMessage] = useState('');
+ const [errors, setErrors] = useState>({});
+ const [needsMfa, setNeedsMfa] = useState(reauthenticationRequired);
+ function change(key: K, value: Draft[K]) { setDraft(current => ({...current, [key]:value})); setPreview(null); setConfirmed(false); }
+ function report(error: unknown) {setMessage(error instanceof Error ? error.message : 'Não foi possível concluir. A ficha anterior foi preservada.'); setErrors(error instanceof ProfileValidationError ? error.fields : {}); if(error instanceof ProfileValidationError && error.reauthenticate)setNeedsMfa(true);}
+ function renewAuthentication() { router.post('/logout', {}, {onSuccess:()=>router.visit(`/minhas-oportunidades/${record.id}/divulgacao`)}); }
+ async function prepare(action: 'disclose' | 'withdraw') {
+ setBusy(true); setMessage(''); setErrors({}); setPreview(null); setConfirmed(false);
+ try { const result = await profileJson(`/minhas-oportunidades/${record.id}/divulgacao/previa`, {...draft, etag, action}); setPreview(result); setOperationKey(createOperationKey()); }
+ catch (error) { report(error); } finally { setBusy(false); }
+ }
+ async function publish() {
+ if (!preview || !confirmed) return;
+ setBusy(true); setErrors({});
+ try {const result = await profileJson(`/minhas-oportunidades/${record.id}/divulgacao/confirmar`, {token:preview.token, operation_key:operationKey, reason, confirm:confirmed});
+ setMessage(result.message); setPreview(null); setConfirmed(false); router.reload();
+ } catch (error) { report(error); } finally {setBusy(false);}
+ }
+ const labels: Record = {promotional_summary_published:'Resumo divulgado', promotional_summary_updated:'Resumo actualizado', promotional_summary_withdrawn:'Resumo retirado'};
+ return
+ Voltar à oportunidade
+ Prepare o resumo público e reveja a prévia antes de confirmar. A divulgação fica identificada como Em preparação e não autoriza a execução do investimento. Documentos e contactos do cadastro continuam privados.
+ {publicNow ? 'Resumo actualmente divulgado.' : 'Resumo não divulgado.'} {message}
+ {needsMfa && Entre novamente com a sua conta e MFA para confirmar esta operação. O cadastro guardado permanece disponível; a prévia ainda não confirmada terá de ser preparada novamente. Renovar entrada com MFA
}
+ {Object.entries(errors).map(([key,value]) => {key}: {value}
)}
+
+ {preview &&
+ {preview.action === 'withdraw' ? 'Rever retirada do catálogo' : 'Prévia do conteúdo público'}
+ {preview.projection.maturity_label}
+ {preview.projection.title} {preview.projection.summary}
{preview.projection.description}
+ Sector: {sectors.find(s=>s.id===preview.projection.sector_id)?.name ?? 'Por indicar'}
+ {preview.projection.territories.map(t=>{t.label} )}
+ {!preview.projection.territories.length && Abrangência territorial por indicar.
}
+ Esta prévia ainda não alterou o portal público. {preview.action === 'withdraw' ? 'A confirmação retira o resumo da consulta pública e preserva o cadastro.' : 'Só este texto e o enquadramento apresentado serão divulgados; os anexos e contactos não são incluídos.'}
+ setReason(e.target.value)}/>
+ setConfirmed(e.target.checked)}/> Confirmo o conteúdo público desta prévia e a operação indicada.
+ void publish()}>{preview.action === 'withdraw' ? 'Confirmar retirada do resumo' : 'Confirmar divulgação do resumo'}
+ {setPreview(null);setConfirmed(false);}}>Cancelar prévia
+ }
+ Histórico da divulgação {history.map(h=>{labels[h.action] ?? h.action} · {h.occurred_at} · responsável {h.actor_user_id} )}
+ ;
+}
diff --git a/resources/js/pages/management/shared.tsx b/resources/js/pages/management/shared.tsx
index 3f3e8981..fd8392e8 100644
--- a/resources/js/pages/management/shared.tsx
+++ b/resources/js/pages/management/shared.tsx
@@ -1,4 +1,5 @@
import ProfileNavigation from '@/components/profile-navigation';
+import ContentManagementEntry from '@/components/content-management-entry';
import { Head, Link, usePage } from '@inertiajs/react';
import type { ReactNode } from 'react';
import { cloneElement, isValidElement, useId } from 'react';
@@ -60,6 +61,7 @@ export function Management({
return (
+
{auth?.canManagePortal && basePath !== '/minhas-oportunidades' ? (
({
body: content?.body ?? '',
image_resource_id: content?.image_resource_id
? String(content.image_resource_id)
: '',
+ image_mode:
+ content?.image_mode ??
+ (content?.image_resource_id ? 'selected' : 'none'),
metadata_version: resource.metadata_version,
});
+ const form = useForm(initial());
+ const [preview, setPreview] = useState(null);
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+ useEffect(() => {
+ form.setData(initial());
+ setPreview(null);
+ setError('');
+ }, [resource.id, resource.metadata_version]);
+ useEffect(() => {
+ setPreview(null);
+ }, [form.data.body, form.data.image_mode, form.data.image_resource_id]);
+ const showPreview = async () => {
+ setBusy(true);
+ setError('');
+ try {
+ const token = document.cookie
+ .split('; ')
+ .find((v) => v.startsWith('XSRF-TOKEN='))
+ ?.slice(11);
+ const response = await fetch(
+ `/management/portal-services/resources/${resource.id}/article/preview`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ 'X-XSRF-TOKEN': decodeURIComponent(token ?? ''),
+ },
+ body: JSON.stringify(form.data),
+ },
+ );
+ const data = await response
+ .json()
+ .catch(() => ({
+ message:
+ 'Não foi possível abrir a prévia. Recupere o editor e confirme a sessão e o MFA existentes.',
+ }));
+ if (!response.ok)
+ throw new Error(
+ Object.values(data.errors ?? {})
+ .flat()
+ .join(' ') ||
+ data.message ||
+ 'Não foi possível preparar a prévia.',
+ );
+ setPreview(data.article);
+ } catch (e) {
+ setError(
+ e instanceof Error
+ ? e.message
+ : 'Não foi possível preparar a prévia.',
+ );
+ } finally {
+ setBusy(false);
+ }
+ };
return (
- O original permanece intacto na biblioteca. Guardar o corpo
- retira a publicação até à sua ação expressa no acervo.
+ O original, a origem e as versões permanecem na biblioteca.
+ Guardar cria uma revisão editorial e retira a publicação até à
+ sua acção expressa no acervo.
+
+ Revisão guardada {resource.metadata_version} ·{' '}
+ {resource.published ? 'Publicado' : 'Rascunho privado'}
+
+ {source && (
+
+ Original {source.legacy_id} · publicado em{' '}
+ {source.published_at} · modificado em {source.modified_at}.
+ A imagem de origem mantém a sua referência mesmo quando não
+ existe exemplar disponível.
+
+ )}
{
e.preventDefault();
+ setError('');
form.put(
`/management/portal-services/resources/${resource.id}/article`,
);
}}
>
+ {!!Object.keys(form.errors).length && (
+
+ {Object.values(form.errors).join(' ')}
+
+ )}
form.setData('body', e.target.value)}
/>
- Texto simples. Separe os parágrafos com uma linha em
- branco.
+ Texto simples, com uma linha em branco entre parágrafos.
+ Se mantiver o texto original, a leitura conserva também
+ a sua formatação; as correcções ficam numa edição
+ separada.
- form.setData('image_resource_id', e.target.value)
+ form.setData('image_mode', e.target.value)
}
>
- Sem imagem
- {images.map((i) => (
-
- {i.title}
-
- ))}
+
+ Manter referência e imagem de origem disponível
+
+
+ Escolher imagem pública da biblioteca
+
+
+ Não mostrar imagem nesta edição
+
-
- Guardar artigo como rascunho
- {' '}
-
- Voltar ao acervo
-
+ {form.data.image_mode === 'selected' && (
+
+
+ form.setData(
+ 'image_resource_id',
+ e.target.value,
+ )
+ }
+ >
+ Seleccione uma imagem
+ {images.map((i) => (
+
+ {i.title}
+
+ ))}
+
+
+ )}
+
+
+ Guardar artigo como rascunho
+
+ void showPreview()}
+ >
+ Pré-visualizar alterações
+
+
+ Ler revisão guardada
+
+
+ Voltar ao acervo para metadados e publicação
+
+
+ {error && {error}
}
+ {preview && (
+
+ )}
Histórico
{revisions.map((r) => (
@@ -86,8 +243,10 @@ export default function ArticleEditor({
))}
- Título, descrição, autor e fonte são editados em Editar
- metadados no acervo.
+ Título, descrição, autor, fonte, direitos e acesso são
+ corrigidos em Editar metadados no acervo. Carregue e analise
+ imagens na biblioteca antes de as seleccionar. Publicar continua
+ a ser uma acção separada.
);
diff --git a/resources/js/pages/portal/article.tsx b/resources/js/pages/portal/article.tsx
index d0a03d47..66d11248 100644
--- a/resources/js/pages/portal/article.tsx
+++ b/resources/js/pages/portal/article.tsx
@@ -1,42 +1,27 @@
import { Head, Link } from '@inertiajs/react';
import { PortalMenu } from '@/layouts/portal-navigation';
-export default function Article({
- article,
-}: {
- article: {
- id: number;
- title: string;
- description: string | null;
- author: string | null;
- body: string;
- image_url: string | null;
- original_url: string;
- };
-}) {
+import EditorialReading, {
+ type EditorialArticle,
+} from '@/components/editorial-reading';
+export default function Article({ article }: { article: EditorialArticle }) {
return (
<>
- {article.title}
- {article.description && {article.description}
}
- {article.author && Autor / entidade: {article.author}
}
- {article.image_url && (
-
- )}
-
- {article.body.split(/\n\s*\n/).map((p, i) => (
-
- {p}
-
- ))}
-
-
- Consultar documento original
-
+
-
- Voltar à biblioteca
+
+ {article.private_preview
+ ? 'Voltar ao editor'
+ : 'Voltar à biblioteca'}
diff --git a/resources/js/pages/portal/legacy.tsx b/resources/js/pages/portal/legacy.tsx
index 0546ba69..bd112efd 100644
--- a/resources/js/pages/portal/legacy.tsx
+++ b/resources/js/pages/portal/legacy.tsx
@@ -105,6 +105,7 @@ export default function LegacySector({
? article.group.heading
: article.title}
+ {article.sector?.slug === 'agronegocio' && Explorar oportunidades por corredor}
Explore o setor e prepare a sua ideia com o contexto
guardado no dossier.
diff --git a/resources/js/pages/portal/manage.tsx b/resources/js/pages/portal/manage.tsx
index 1395ed83..32230fc1 100644
--- a/resources/js/pages/portal/manage.tsx
+++ b/resources/js/pages/portal/manage.tsx
@@ -1,5 +1,5 @@
-import { router, useForm } from '@inertiajs/react';
-import { useState } from 'react';
+import { router, useForm, usePage } from '@inertiajs/react';
+import { useEffect, useState } from 'react';
import { Management, Field, control, button } from '../management/shared';
type DocumentFile = {
@@ -164,7 +164,11 @@ export default function Manage({
legacyContentEnabled = false,
documentAccessOnly = false,
editorialScopeOnly = false,
+ managementFilters = {},
+ associationOptions = {},
}: {
+ managementFilters?: {q?: string; state?: string; intent?: string};
+ associationOptions?: Record;
legacyContentEnabled?: boolean;
documentAccessOnly?: boolean;
editorialScopeOnly?: boolean;
@@ -206,12 +210,26 @@ export default function Manage({
}) {
const [editingId, setEditingId] = useState(null);
const [actionError, setActionError] = useState('');
+ const [query, setQuery] = useState(managementFilters.q ?? '');
+ const [stateFilter, setStateFilter] = useState(managementFilters.state ?? '');
+ const { flash } = usePage<{flash?: {uploaded_document_id?: string}}>().props;
const upload = useForm<{
file: File | null;
original_source: string;
issuer: string;
- }>({ file: null, original_source: '', issuer: '' });
- const form = useForm(emptyResource());
+ intent: string;
+ }>({ file: null, original_source: '', issuer: '', intent: managementFilters.intent ?? 'document' });
+ const form = useForm({...emptyResource(), ...(managementFilters.intent === 'legislation' ? {document_type: 'legislation', legislation: true} : {})});
+ const chooseDocument = (id: string) => {
+ const document = documents.find((item) => item.id === id);
+ form.setData((current) => ({...current, document_id: id,
+ issuer: current.issuer || document?.issuer || '',
+ title: current.title || document?.file_name || '',
+ }));
+ };
+ useEffect(() => {
+ if (flash?.uploaded_document_id && !editingId && !form.data.document_id) chooseDocument(flash.uploaded_document_id);
+ }, [flash?.uploaded_document_id]);
const setAssociation = (
index: number,
key: keyof Association,
@@ -361,6 +379,7 @@ export default function Manage({
))}
{
e.preventDefault();
@@ -420,6 +439,7 @@ export default function Manage({
{!documentAccessOnly && <>
+ {managementFilters.intent === 'legislation' && Nova legislação: reutilize o original, complete diploma/tipo, entidade emitente, número, datas e âmbito. Registe alterações e revogações nas associações. Publicar para consulta não activa cálculos no simulador.
}
{
@@ -458,9 +478,7 @@ export default function Manage({
required
className={control}
value={form.data.document_id}
- onChange={(e) =>
- form.setData('document_id', e.target.value)
- }
+ onChange={(e) => chooseDocument(e.target.value)}
>
Seleccionar ficheiro ou registo existente
@@ -925,6 +943,8 @@ export default function Manage({
'document',
'investment_type',
'legal_source',
+ 'sector',
+ 'opportunity',
'requirement',
]
.filter(
@@ -934,8 +954,8 @@ export default function Manage({
requirementAssociationsEnabled,
)
.map((v) => (
-
- {v}
+
+ {({theme:'Tema', module:'Módulo',activity:'Actividade',stage:'Etapa',procedure:'Procedimento',entity:'Entidade',territory:'Território',document:'Documento / diploma',investment_type:'Modalidade de investimento',legal_source:'Fonte legal',requirement:'Requisito',sector:'Sector',opportunity:'Oportunidade'} as Record)[v] ?? v}
))}
@@ -974,6 +994,12 @@ export default function Manage({
),
)}
+ ) : associationOptions[association.target_type] ? (
+ setAssociation(index, 'target_key', e.target.value)}>
+ Seleccione um registo existente
+ {association.target_key && !associationOptions[association.target_type].some((o) => String(o.value) === association.target_key) && Referência histórica guardada }
+ {associationOptions[association.target_type].map((o) => {o.label} )}
+
) : (
)}
-
+
Registos, pendências e duplicados por rever
+ {e.preventDefault(); router.get('/management/portal-services', {q:query,state:stateFilter}, {preserveState:true,preserveScroll:true});}}>
+ setQuery(e.target.value)} />
+ setStateFilter(e.target.value)}>Todos Rascunhos Publicados Retirados
+ Pesquisar conteúdos
+
+ Mostram-se até 200 registos por pesquisa. Refine o título ou o estado para localizar conteúdos anteriores.
+ {!resources.length && Sem conteúdos correspondentes no seu âmbito de acesso.
}
{resources.map((r) => (
- {r.title} · {r.published ? 'Publicado' : 'Rascunho'}
+ {r.title} · {r.published ? 'Publicado' : r.revisions.some((v) => ['unpublished', 'withdrawn'].includes(v.change_type)) ? 'Retirado' : 'Rascunho'}
Ficheiro: {r.file_name} · {r.file_status} ·{' '}
@@ -1352,6 +1385,7 @@ export default function Manage({
>
Editar artigo
+ Ler / pré-visualizar artigo
;
+ acts: Record;
+ organizations: Choice[];
+ sectors: Choice[];
+ territories: Choice[];
+ opportunities: Choice[];
+ projects: Choice[];
+ documents: Choice[];
+};
+type RecordData = {
+ id: string;
+ version: number;
+ draft: Draft;
+ published: boolean;
+};
+type Plan = {
+ id: string;
+ version: number;
+ act: string;
+ payload: Payload;
+ hash: string;
+};
+const dates = [
+ 'origin_published_at',
+ 'clarifications_at',
+ 'deadline',
+ 'opening_at',
+];
+const groups: [string, string[]][] = [
+ [
+ 'Identificação e objecto',
+ ['title', 'reference', 'modality', 'object', 'quantities'],
+ ],
+ [
+ 'Regime e financiamento',
+ [
+ 'financier',
+ 'instrument',
+ 'edition',
+ 'rule_source',
+ 'financing_agreement',
+ ],
+ ],
+ ['Participação', ['eligibility', 'requirements', 'proposal_validity']],
+ [
+ 'Calendário e entrega',
+ [
+ 'origin_published_at',
+ 'clarifications_at',
+ 'deadline',
+ 'opening_at',
+ 'delivery_channel',
+ 'delivery_address',
+ 'delivery_url',
+ ],
+ ],
+ ['Divulgação e contacto', ['origin_url', 'dissemination']],
+];
+export default function Edit({
+ record,
+ references,
+ history,
+}: {
+ record: RecordData | null;
+ references: References;
+ history: {
+ version: number;
+ act: string;
+ reason: string;
+ actor_id: number;
+ published_at: string | null;
+ created_at: string;
+ }[];
+}) {
+ const empty: Draft = {
+ timezone: 'Africa/Maputo',
+ lots: [],
+ attachments: [],
+ contact_authorized: false,
+ };
+ const form = useForm({
+ version: record?.version ?? 0,
+ reason: '',
+ draft: record?.draft ?? empty,
+ });
+ const [act, setAct] = useState(
+ record?.published ? 'clarification' : 'initial',
+ );
+ const [preview, setPreview] = useState(null);
+ const [message, setMessage] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [saved, setSaved] = useState(true);
+ useEffect(() => {
+ form.setData({
+ version: record?.version ?? 0,
+ reason: '',
+ draft: record?.draft ?? empty,
+ });
+ setPreview(null);
+ setSaved(true);
+ }, [record?.id, record?.version]); // A server version is the recovery boundary.
+ const change = (key: string, value: Draft[string]) => {
+ form.setData('draft', { ...form.data.draft, [key]: value });
+ setPreview(null);
+ setSaved(false);
+ };
+ const value = (key: string) => String(form.data.draft[key] ?? '');
+ const select = (key: string, label: string, choices: Choice[]) => (
+
+
+ change(
+ key,
+ e.target.value
+ ? key === 'sector_id' || key === 'opportunity_id'
+ ? Number(e.target.value)
+ : e.target.value
+ : null,
+ )
+ }
+ >
+ Por indicar
+ {choices.map((o) => (
+
+ {o.name ?? o.title}
+ {o.location_type ? ` · ${o.location_type}` : ''}
+
+ ))}
+
+
+ );
+ const input = (key: string, label?: string) => (
+
+ {dates.includes(key) ? (
+ change(key, e.target.value)}
+ />
+ ) : (
+ change(key, e.target.value)}
+ />
+ )}
+
+ );
+ const lots = (form.data.draft.lots ?? []) as Lot[];
+ const attachments = (form.data.draft.attachments ?? []) as Attachment[];
+ const makePreview = async () => {
+ setBusy(true);
+ setMessage('');
+ try {
+ const token = document.cookie
+ .split('; ')
+ .find((v) => v.startsWith('XSRF-TOKEN='))
+ ?.slice(11);
+ const response = await fetch(
+ `/management/concursos/${record!.id}/previa`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ 'X-XSRF-TOKEN': decodeURIComponent(token ?? ''),
+ },
+ body: JSON.stringify({ act }),
+ },
+ );
+ const body = await response.json();
+ if (!response.ok)
+ throw new Error(
+ Object.values(body.errors ?? {})
+ .flat()
+ .join(' ') ||
+ body.message ||
+ 'Não foi possível preparar a prévia.',
+ );
+ setPreview(body);
+ } catch (error) {
+ setMessage(
+ error instanceof Error ? error.message : 'Erro de ligação.',
+ );
+ } finally {
+ setBusy(false);
+ }
+ };
+ return (
+
+
+
+ Os campos destinam-se à divulgação após revisão da prévia,
+ excepto as notas internas, os anexos não públicos e o contacto
+ sem autorização. O vínculo a um cadastro privado não publica os
+ seus dados.
+
+
+ O rascunho pode ficar incompleto. Alterações guardadas não
+ substituem o acto público; reveja a prévia e publique
+ explicitamente. Publicar concurso não publica a oportunidade nem
+ recebe propostas.
+
+
+ {record
+ ? `Versão guardada ${record.version}. ${saved ? 'Preparação recuperada do servidor.' : 'Há alterações por guardar.'}`
+ : 'Preparação ainda não guardada.'}
+
+ {!!Object.keys(form.errors).length && (
+
+ {Object.values(form.errors).map((error, i) => (
+
{error}
+ ))}
+
+ )}
+ {
+ e.preventDefault();
+ setPreview(null);
+ record
+ ? form.put(`/management/concursos/${record.id}`)
+ : form.post('/management/concursos');
+ }}
+ >
+
+ {select(
+ 'organization_id',
+ 'Entidade contratante existente',
+ references.organizations,
+ )}
+ {select(
+ 'opportunity_id',
+ 'Oportunidade relacionada existente',
+ references.opportunities,
+ )}
+ {select(
+ 'project_id',
+ 'Projecto existente',
+ references.projects,
+ )}
+ {select('sector_id', 'Sector', references.sectors)}
+ {select(
+ 'territory_id',
+ 'Localização existente',
+ references.territories,
+ )}
+
+ Os vínculos reutilizam os cadastros; não criam nova
+ entidade, oportunidade, projecto ou limite. Referências
+ privadas não são transformadas em conteúdo público.
+
+
+
+
+ Regime do procedimento
+
+
+ change('regime', e.target.value)}
+ >
+ Por indicar
+ {Object.entries(references.regimes).map(
+ ([v, l]) => (
+
+ {l}
+
+ ),
+ )}
+
+
+ {value('regime') === 'state' ? (
+ <>
+
+ Contratação do Estado: identifique a modalidade,
+ o instrumento e a edição aplicáveis.{' '}
+
+ UFSA — Decreto n.º 79/2022
+
+ . A referência não atribui automaticamente
+ requisitos ou prazos a todos os procedimentos.
+
+ {input(
+ 'responsible_unit',
+ 'UGEA / unidade responsável quando aplicável',
+ )}
+ >
+ ) : (
+ <>
+
+ {value('regime') === 'external' ? (
+ <>
+
+ Quadro e documentos normalizados do
+ Banco Mundial
+ {' '}
+ são referências; indique a edição e o
+ acordo efectivamente aplicáveis.
+ >
+ ) : (
+ 'Use as regras documentadas da entidade e deste procedimento.'
+ )}{' '}
+ UGEA, Cadastro Único, garantias e abertura
+ pública não são impostos a este regime.
+
+ {input(
+ 'responsible_unit',
+ 'Unidade responsável (quando aplicável)',
+ )}
+ >
+ )}
+
+
+ change('object_kind', e.target.value)
+ }
+ >
+ Por indicar
+ {Object.entries({
+ works: 'Obras',
+ goods: 'Bens',
+ services: 'Serviços',
+ mixed: 'Misto',
+ other: 'Outro',
+ }).map(([v, l]) => (
+
+ {l}
+
+ ))}
+
+
+
+ {groups.map(([title, fields]) => (
+
+ {title}
+
+ {fields.map((key) =>
+ input(
+ key,
+ key === 'title'
+ ? 'Título do concurso/anúncio'
+ : undefined,
+ ),
+ )}
+
+
+ ))}
+
+
+ Garantias e prazo mínimo documentado
+
+
+
+ change('guarantee_status', e.target.value)
+ }
+ >
+
+ Por determinar nos documentos
+
+
+ Exigidas nos documentos
+
+ Não exigidas
+
+
+ {value('guarantee_status') === 'required' &&
+ input('guarantees')}
+
+ change('timezone', e.target.value)}
+ placeholder="Africa/Maputo"
+ />
+
+
+ As datas e horas introduzidas pertencem a este fuso.
+ Publicação no InvesteMoz é registada separadamente pelo
+ servidor; não reinicia o prazo.
+
+
+
+ change(
+ 'minimum_days',
+ e.target.value === ''
+ ? null
+ : Number(e.target.value),
+ )
+ }
+ />
+
+ {form.data.draft.minimum_days !== null &&
+ form.data.draft.minimum_days !== undefined && (
+
+ {input('minimum_source')}
+ {input('minimum_edition')}
+ {input('minimum_modality')}
+
+
+ change(
+ 'minimum_day_basis',
+ e.target.value,
+ )
+ }
+ >
+ Por indicar
+
+ Dias corridos
+
+
+ Dias úteis — calendário documentado
+
+
+
+ {value('minimum_day_basis') === 'business' && (
+ <>
+ {input(
+ 'minimum_calendar_source',
+ 'Fonte do calendário aplicável e abrangência de feriados',
+ )}
+ {input(
+ 'minimum_working_days',
+ 'Dias de trabalho documentados: 1=segunda a 7=domingo, separados por vírgula',
+ )}
+ {input(
+ 'minimum_holidays',
+ 'Feriados / exclusões documentados (AAAA-MM-DD, separados por espaço ou vírgula)',
+ )}
+ >
+ )}
+
+ )}
+
+ Sem prazo parametrizado não é presumido um mínimo geral.
+ A contagem em dias úteis usa exclusivamente os dias de
+ trabalho e exclusões do calendário documentado neste
+ registo; não adopta um calendário nacional por
+ suposição.
+
+
+
+ Lotes
+ {lots.map((lot, i) => (
+
+ Lote {i + 1}
+ {(
+ ['code', 'description', 'quantities'] as const
+ ).map((key) => (
+
+
+ change(
+ 'lots',
+ lots.map((r, n) =>
+ n === i
+ ? {
+ ...r,
+ [key]: e.target
+ .value,
+ }
+ : r,
+ ),
+ )
+ }
+ />
+
+ ))}
+
+ change(
+ 'lots',
+ lots.filter((_, n) => n !== i),
+ )
+ }
+ >
+ Retirar lote do rascunho
+
+
+ ))}
+
+ change('lots', [
+ ...lots,
+ { code: '', description: '', quantities: '' },
+ ])
+ }
+ >
+ Acrescentar lote
+
+
+
+
+ Documentos e anexos
+
+
+ Carregue e classifique os originais na biblioteca
+ existente pelo link acima. Reutilize-os aqui: anúncio,
+ caderno, termos de referência, formulários, anexos ou
+ comprovativos. Publicar exige que cada anexo marcado
+ público já esteja autorizado, publicado e com scan limpo
+ na biblioteca. Os restantes ficam privados no cadastro.
+
+ {attachments.map((item, i) => (
+
+ Anexo {i + 1}
+
+
+ change(
+ 'attachments',
+ attachments.map((r, n) =>
+ n === i
+ ? {
+ ...r,
+ resource_id: Number(
+ e.target.value,
+ ),
+ }
+ : r,
+ ),
+ )
+ }
+ >
+
+ Seleccione da biblioteca
+
+ {references.documents.map((d) => (
+
+ {d.title} · {d.malware_scan_status}{' '}
+ ·{' '}
+ {d.published
+ ? 'publicado'
+ : 'privado / rascunho'}
+
+ ))}
+
+
+
+
+ change(
+ 'attachments',
+ attachments.map((r, n) =>
+ n === i
+ ? {
+ ...r,
+ purpose:
+ e.target.value,
+ }
+ : r,
+ ),
+ )
+ }
+ />
+
+
+
+ change(
+ 'attachments',
+ attachments.map((r, n) =>
+ n === i
+ ? {
+ ...r,
+ public: e.target
+ .checked,
+ }
+ : r,
+ ),
+ )
+ }
+ />{' '}
+ Incluir este anexo na divulgação pública
+
+
+ change(
+ 'attachments',
+ attachments.filter((_, n) => n !== i),
+ )
+ }
+ >
+ Retirar anexo do rascunho
+
+
+ ))}
+
+ change('attachments', [
+ ...attachments,
+ { resource_id: 0, purpose: '', public: false },
+ ])
+ }
+ >
+ Reutilizar documento
+
+
+
+
+ {record && (
+
+
+ Pré-visualizar e publicar
+
+
+ {
+ setAct(e.target.value);
+ setPreview(null);
+ }}
+ >
+ {Object.entries(references.acts).map(([v, l]) => (
+
+ {l}
+
+ ))}
+
+
+
+ Preparar prévia pública
+
+
+ Guarde antes da prévia. Uma prorrogação identifica o
+ prazo anterior e o novo; o fim do prazo não é
+ adjudicação.
+
+ {message && {message}
}
+ {preview && (
+
+
+ Prévia do conteúdo público — {acts[preview.act]}
+
+
+ Motivo público:{' '}
+ {String(preview.payload.public_reason)}
+
+
+
+ router.post(
+ `/management/concursos/${record.id}/publicar`,
+ {
+ act: preview.act,
+ hash: preview.hash,
+ reason: String(
+ preview.payload.public_reason,
+ ),
+ },
+ { onSuccess: () => setPreview(null) },
+ )
+ }
+ >
+ Publicar este acto
+
+
+ )}
+ {record.published && (
+
+ Consultar anúncio público
+
+ )}
+
+ )}
+
+
+ Histórico de preparação e publicação
+
+ {history.map((v) => (
+
+ Versão {v.version} ·{' '}
+ {v.act === 'draft' ? 'Rascunho' : acts[v.act]} ·
+ responsável {v.actor_id} ·{' '}
+ {v.published_at ?? v.created_at} · {v.reason}
+
+ ))}
+
+
+ );
+}
diff --git a/resources/js/pages/procurement/index.tsx b/resources/js/pages/procurement/index.tsx
new file mode 100644
index 00000000..76b519d4
--- /dev/null
+++ b/resources/js/pages/procurement/index.tsx
@@ -0,0 +1,196 @@
+import { Link, useForm } from '@inertiajs/react';
+import { Frame, card, button } from '@/pages/investment/shared';
+import { Field, control, states, type Publication } from './shared';
+type Choice = { id: string; name: string };
+export default function Index({
+ items,
+ filters,
+ options,
+ regimes,
+ management,
+}: {
+ items: {
+ data: { id: string; publication: Publication; state: string }[];
+ current_page: number;
+ links: { url: string | null; label: string; active: boolean }[];
+ };
+ filters: Record;
+ options: Record;
+ regimes: Record;
+ management: boolean;
+}) {
+ const form = useForm({
+ q: filters.q ?? '',
+ entity: filters.entity ?? '',
+ sector: filters.sector ?? '',
+ territory: filters.territory ?? '',
+ modality: filters.modality ?? '',
+ financier: filters.financier ?? '',
+ state: filters.state ?? '',
+ deadline_before: filters.deadline_before ?? '',
+ regime: filters.regime ?? '',
+ });
+ const query = new URLSearchParams(
+ Object.entries({ ...filters, page: String(items.current_page) }).filter(
+ ([, v]) => v != null && v !== '',
+ ),
+ ).toString();
+ return (
+
+
+ Procedimentos e actos divulgados pelas entidades responsáveis.
+ Uma oportunidade divulgada não é, por si, um concurso aberto.
+
+
+
+ Os concursos que acompanho
+
+ {management && (
+
+ Gerir concursos e anúncios
+
+ )}
+
+ {
+ e.preventDefault();
+ form.get('/concursos', { preserveState: true });
+ }}
+ >
+
+ form.setData('q', e.target.value)}
+ />
+
+ {(
+ [
+ 'entity',
+ 'sector',
+ 'territory',
+ 'modality',
+ 'financier',
+ ] as const
+ ).map((key) => (
+
+ form.setData(key, e.target.value)}
+ >
+ Todos
+ {(options[key] ?? []).map((o) => (
+
+ {o.name}
+
+ ))}
+
+
+ ))}
+
+ form.setData('regime', e.target.value)}
+ >
+ Todos
+ {Object.entries(regimes).map(([v, l]) => (
+
+ {l}
+
+ ))}
+
+
+
+ form.setData('state', e.target.value)}
+ >
+ Todos
+ {Object.entries(states).map(([v, l]) => (
+
+ {l}
+
+ ))}
+
+
+
+
+ form.setData('deadline_before', e.target.value)
+ }
+ />
+
+ Pesquisar
+
+ {!items.data.length && (
+ Nenhum anúncio corresponde aos filtros.
+ )}
+
+ {items.data.map((item) => (
+
+
+
+ {String(item.publication.payload.title)}
+
+
+
+ {String(item.publication.payload.organization_name)}{' '}
+ · {states[item.state]}
+
+
+ Entrega:{' '}
+ {String(item.publication.payload.deadline).replace(
+ 'T',
+ ' ',
+ )}{' '}
+ · {String(item.publication.payload.timezone)}
+
+ {String(item.publication.payload.object)}
+
+ ))}
+
+
+ {items.links.map((link, i) =>
+ link.url ? (
+
+ {link.label
+ .replace('«', '«')
+ .replace('»', '»')}
+
+ ) : (
+
+ {link.label
+ .replace('«', '«')
+ .replace('»', '»')}
+
+ ),
+ )}
+
+
+ );
+}
diff --git a/resources/js/pages/procurement/manage.tsx b/resources/js/pages/procurement/manage.tsx
new file mode 100644
index 00000000..05992ebf
--- /dev/null
+++ b/resources/js/pages/procurement/manage.tsx
@@ -0,0 +1,106 @@
+import { Link } from '@inertiajs/react';
+import { Frame, card, button } from '@/pages/investment/shared';
+import { states, type Publication } from './shared';
+export default function Manage({
+ items,
+ watched = false,
+}: {
+ items: {
+ id: string;
+ title?: string;
+ version?: number;
+ published?: boolean;
+ publication?: Publication;
+ state?: string;
+ changed?: boolean;
+ }[];
+ watched?: boolean;
+}) {
+ return (
+
+
+ Catálogo público
+
+ {!watched && (
+ <>
+
+ Novo concurso/anúncio
+
+
+ Entrada única de gestão
+
+
+ Guardar → recuperar → pré-visualizar → publicar →
+ actualizar/prorrogar → publicar resultado ou
+ cancelamento. Carregue documentos na biblioteca
+ existente, acompanhe o scan e autorize separadamente os
+ anexos públicos. O rascunho pode ficar incompleto.
+
+
+ Para actualizar, reabra o concurso, guarde as
+ alterações, escolha o acto e confira a prévia. A data de
+ publicação no portal não substitui a data de origem.
+ Acompanhar não apresenta proposta.
+
+ >
+ )}
+ {!items.length && Sem registos.
}
+ {items.map((item) => (
+
+
+ {item.title ?? String(item.publication?.payload.title)}
+
+ {watched ? (
+ <>
+
+ {states[item.state ?? '']} ·{' '}
+ {item.changed
+ ? 'Há novos actos desde que começou a acompanhar'
+ : 'Sem novos actos desde o registo de acompanhamento'}
+
+
+ O acompanhamento é consultado nesta área; não
+ afirma envio de alertas por email.
+
+
+ Consultar concurso
+
+ >
+ ) : (
+ <>
+
+ Versão {item.version} ·{' '}
+ {item.published
+ ? 'Com acto publicado; alterações em preparação são separadas'
+ : 'Rascunho privado'}
+
+
+ Recuperar e editar
+
+ {item.published && (
+
+ Consultar publicação
+
+ )}
+ >
+ )}
+
+ ))}
+
+ );
+}
diff --git a/resources/js/pages/procurement/shared.tsx b/resources/js/pages/procurement/shared.tsx
new file mode 100644
index 00000000..a86a89e8
--- /dev/null
+++ b/resources/js/pages/procurement/shared.tsx
@@ -0,0 +1,253 @@
+import type { ReactNode } from 'react';
+
+export type Lot = { code: string; description: string; quantities: string };
+export type Attachment = {
+ resource_id: number;
+ public: boolean;
+ purpose: string;
+ title?: string;
+ available?: boolean;
+};
+export type Draft = {
+ [key: string]: string | number | boolean | null | Lot[] | Attachment[];
+};
+export type Payload = Draft & {
+ opportunity?: { title: string; url: string } | null;
+};
+export type Publication = {
+ id: string;
+ version: number;
+ act: string;
+ payload: Payload;
+ published_at: string;
+ reason: string;
+};
+export const states: Record = {
+ open: 'Aberto',
+ extended: 'Prorrogado',
+ deadline_ended: 'Prazo de propostas terminado',
+ cancelled: 'Cancelado',
+ results: 'Resultados publicados',
+};
+export const acts: Record = {
+ initial: 'Anúncio inicial',
+ clarification: 'Esclarecimento',
+ correction: 'Rectificação',
+ extension: 'Prorrogação',
+ award: 'Resultado / adjudicação',
+ cancellation: 'Cancelamento',
+ other: 'Outro acto',
+};
+export const labels: Record = {
+ reference: 'Referência',
+ object: 'Objecto',
+ responsible_unit: 'Unidade responsável / UGEA quando aplicável',
+ modality: 'Modalidade',
+ object_kind: 'Natureza do objecto',
+ quantities: 'Quantidades essenciais',
+ financier: 'Financiador',
+ instrument: 'Instrumento aplicável',
+ edition: 'Edição aplicável',
+ rule_source: 'Fonte das regras',
+ financing_agreement: 'Acordo de financiamento',
+ eligibility: 'Elegibilidade',
+ requirements: 'Requisitos de participação',
+ proposal_validity: 'Validade da proposta',
+ guarantee_status: 'Aplicabilidade de garantias',
+ guarantees: 'Garantias documentadas',
+ delivery_channel: 'Canal de entrega',
+ delivery_address: 'Endereço / instruções para apresentar proposta',
+ delivery_url: 'Plataforma de entrega',
+ public_contact: 'Contacto público autorizado',
+ dissemination: 'Meios de divulgação exigidos / adoptados e comprovativos',
+ origin_url: 'Anúncio de origem',
+ origin_published_at: 'Publicação de origem — referência do prazo',
+ clarifications_at: 'Limite para esclarecimentos',
+ deadline: 'Entrega de propostas',
+ opening_at: 'Abertura (quando aplicável)',
+ timezone: 'Fuso horário do procedimento',
+ minimum_days: 'Prazo mínimo documentado (dias)',
+ minimum_source: 'Fonte / citação do prazo mínimo',
+ minimum_edition: 'Edição da regra de prazo',
+ minimum_modality: 'Modalidade da regra de prazo',
+ minimum_day_basis: 'Base de contagem do prazo',
+ minimum_calendar_source: 'Fonte do calendário de dias úteis',
+ minimum_working_days: 'Dias de trabalho (1=segunda, 7=domingo)',
+ minimum_holidays: 'Feriados e exclusões documentados',
+ result: 'Acto ou resultado documentado',
+};
+export const control =
+ 'w-full rounded border border-slate-400 bg-background p-2 text-foreground';
+export function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: ReactNode;
+}) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+export function NoticeContent({ p }: { p: Payload }) {
+ const links = ['rule_source', 'origin_url', 'delivery_url'];
+ const names: Record = {
+ works: 'Obras',
+ goods: 'Bens',
+ services: 'Serviços',
+ mixed: 'Misto',
+ other: 'Outro',
+ required: 'Exigidas',
+ not_required: 'Não exigidas',
+ unknown: 'Por determinar',
+ calendar: 'Dias corridos',
+ business: 'Dias úteis',
+ };
+ return (
+
+
{String(p.title ?? '')}
+
+ Entidade: {' '}
+ {String(p.organization_name ?? 'Por indicar')}
+
+
+ Regime:{' '}
+ {(
+ {
+ state: 'Contratação pública do Estado',
+ external: 'Financiamento externo',
+ private: 'Concurso privado',
+ other: 'Outro regime documentado',
+ } as Record
+ )[String(p.regime)] ?? 'Por indicar'}
+
+
+
+ Objecto e como participar
+
+ {String(p.object ?? '')}
+
+ Entrega: {' '}
+ {String(p.deadline ?? 'Por indicar').replace('T', ' ')} ·{' '}
+ {String(p.timezone ?? '')}
+
+ {String(p.delivery_channel ?? '')} ·{' '}
+ {String(p.delivery_address ?? '')}
+
+
+ Sector: {String(p.sector_name ?? 'Por indicar')} ·
+ Localização: {String(p.territory_name ?? 'Por indicar')}
+
+ {p.project_name && (
+
+ Projecto relacionado: {String(p.project_name)}. Esta
+ relação não confirma execução ou contratação.
+
+ )}
+ {p.opportunity && (
+
+ Oportunidade relacionada: {p.opportunity.title}
+
+ )}
+
+ {p.deadline_previous && (
+
+ Prorrogação: prazo anterior{' '}
+ {String(p.deadline_previous).replace('T', ' ')} (
+ {String(p.timezone_previous)}) → novo prazo{' '}
+ {String(p.deadline).replace('T', ' ')} ({String(p.timezone)}
+ ).
+
+ )}
+
+ {Object.entries(labels)
+ .filter(
+ ([key]) =>
+ p[key] !== null &&
+ p[key] !== undefined &&
+ p[key] !== '',
+ )
+ .map(([key, label]) => (
+
+
{label}
+
+ {links.includes(key) ? (
+
+ {String(p[key])}
+
+ ) : (
+ (
+ names[String(p[key])] ?? String(p[key])
+ ).replace(/^(\d{4}-\d{2}-\d{2})T/, '$1 ')
+ )}
+
+
+ ))}
+
+
+ Lotes
+ {((p.lots ?? []) as Lot[]).length ? (
+
+ {(p.lots as Lot[]).map((lot, i) => (
+
+ {lot.code} — {lot.description} ·{' '}
+ {lot.quantities}
+
+ ))}
+
+ ) : (
+ Sem lotes indicados.
+ )}
+
+
+
+ Documentos do concurso
+
+
+ {!((p.attachments as Attachment[]) ?? []).length && (
+
+ Sem anexos públicos neste acto. Consulte o canal e o
+ anúncio de origem.
+
+ )}
+
+
+ O InvesteMoz divulga este acto e não recebe propostas nesta
+ secção. Apresente a proposta no canal indicado nos documentos.
+ Esta divulgação não substitui os meios oficiais exigidos,
+ não comprova envio à UFSA ou ao financiador e não equivale à
+ execução do investimento.
+
+
+ );
+}
diff --git a/resources/js/pages/procurement/show.tsx b/resources/js/pages/procurement/show.tsx
new file mode 100644
index 00000000..b4d5c454
--- /dev/null
+++ b/resources/js/pages/procurement/show.tsx
@@ -0,0 +1,89 @@
+import { Head, Link, router, usePage } from '@inertiajs/react';
+import { Frame, card, button } from '@/pages/investment/shared';
+import { NoticeContent, states, acts, type Publication } from './shared';
+export default function Show({
+ id,
+ publication,
+ state,
+ history,
+ watching,
+ backUrl,
+ printMode,
+}: {
+ id: string;
+ publication: Publication;
+ state: string;
+ history: Publication[];
+ watching: boolean;
+ backUrl: string;
+ printMode: boolean;
+}) {
+ const { auth } = usePage<{ auth: { user: unknown | null } }>().props;
+ return (
+
+
+
+
+
+ Voltar aos resultados
+
+
+ Relatório imprimível
+
+ {printMode && (
+ window.print()}>
+ Imprimir anúncio
+
+ )}
+ {auth?.user ? (
+
+ watching
+ ? router.delete(`/concursos/${id}/acompanhar`)
+ : router.put(`/concursos/${id}/acompanhar`)
+ }
+ >
+ {watching
+ ? 'Deixar de acompanhar'
+ : 'Acompanhar concurso'}
+
+ ) : (
+
+ Entrar para acompanhar — conta simples
+
+ )}
+
+
+ {states[state]} · {acts[publication.act]} ·
+ Versão {publication.version}
+
+
+ Publicação deste acto no InvesteMoz: {publication.published_at}{' '}
+ (UTC). Esta data não reinicia o prazo de origem.
+
+
+
+
+
+
+ Actos publicados e histórico
+
+ {history.map((v) => (
+
+
+ {acts[v.act]} · versão {v.version} ·{' '}
+ {v.published_at} (UTC)
+
+ Motivo público: {v.reason}
+
+
+ ))}
+
+
+ );
+}
diff --git a/routes/investor-path.php b/routes/investor-path.php
index cec18848..6df87e1b 100644
--- a/routes/investor-path.php
+++ b/routes/investor-path.php
@@ -13,8 +13,10 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, Virtual
Route::get('/contexto-atual', [InvestorContextController::class, 'current']);
Route::put('/contexto-atual', [InvestorContextController::class, 'focus']);
Route::get('/fontes-contexto', [InvestorContextController::class, 'options']);
+ Route::get('/opcoes-corredor', [InvestorContextController::class, 'corridorOptions']);
Route::get('/{id}/contexto', [InvestorContextController::class, 'show'])->whereUuid('id');
Route::get('/{id}/contexto/editar', [InvestorContextController::class, 'edit'])->whereUuid('id');
+ Route::post('/{id}/contexto/rascunho', [InvestorContextController::class, 'saveDraft'])->whereUuid('id');
Route::post('/{id}/contexto/previa', [InvestorContextController::class, 'preview'])->whereUuid('id');
Route::post('/{id}/contexto/confirmar', [InvestorContextController::class, 'commit'])->whereUuid('id');
Route::get('/a-partir-do-parque/{zoneId}', [Path::class, 'zoneStart'])->whereUuid('zoneId');
diff --git a/routes/procurement.php b/routes/procurement.php
new file mode 100644
index 00000000..60e51a34
--- /dev/null
+++ b/routes/procurement.php
@@ -0,0 +1,20 @@
+name('procurement.index');
+Route::get('/concursos/acompanhar', [Procurement::class, 'watched'])->middleware('auth');
+Route::get('/concursos/{id}', [Procurement::class, 'show'])->whereUuid('id')->name('procurement.show');
+Route::get('/concursos/{id}/imprimir', [Procurement::class, 'print'])->whereUuid('id');
+Route::put('/concursos/{id}/acompanhar', [Procurement::class, 'watch'])->whereUuid('id')->middleware(['auth', 'throttle:30,1']);
+Route::delete('/concursos/{id}/acompanhar', [Procurement::class, 'unwatch'])->whereUuid('id')->middleware(['auth', 'throttle:30,1']);
+Route::middleware(['auth', 'verified'])->prefix('management/concursos')->group(function () {
+ Route::get('/', [Procurement::class, 'manage']);
+ Route::get('/novo', [Procurement::class, 'edit']);
+ Route::post('/', [Procurement::class, 'save']);
+ Route::get('/{id}', [Procurement::class, 'edit'])->whereUuid('id');
+ Route::put('/{id}', [Procurement::class, 'save'])->whereUuid('id');
+ Route::post('/{id}/previa', [Procurement::class, 'preview'])->whereUuid('id');
+ Route::post('/{id}/publicar', [Procurement::class, 'publish'])->whereUuid('id');
+});
diff --git a/routes/web.php b/routes/web.php
index 48d94e1d..b99eb676 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,5 +1,7 @@
whereIn('legacySlug', array_keys(config('legacy_content.articles', [])));
Route::post('/apoio', [PortalServicesController::class, 'submit'])->middleware('throttle:10,1')->name('services.submit');
Route::middleware(['auth', 'verified'])->group(function () {
+ Route::get('/gestao-conteudos', fn (\Illuminate\Http\Request $request) => Inertia::render('management/content-hub', app(\App\Services\ContentManagement::class)->view($request->user())))->name('content-management');
Route::get('/balcao-virtual', [PortalServicesController::class, 'page'])->defaults('page', 'desk')->name('services.desk');
Route::post('/parcerias', [PortalServicesController::class, 'partnership'])->middleware('throttle:10,1')->name('services.partnership');
});
Route::middleware(['auth', 'verified', \App\Http\Middleware\EnsureScopedEditorial::class])->group(function () {
Route::get('/management/portal-services/resources/{id}/article', [\App\Http\Controllers\EditorialArticleController::class, 'edit'])->whereNumber('id')->name('editorial.edit');
Route::put('/management/portal-services/resources/{id}/article', [\App\Http\Controllers\EditorialArticleController::class, 'save'])->whereNumber('id')->name('editorial.save');
+ Route::get('/management/portal-services/resources/{id}/article/preview', [\App\Http\Controllers\EditorialArticleController::class, 'preview'])->whereNumber('id')->name('editorial.preview');
+ Route::post('/management/portal-services/resources/{id}/article/preview', [\App\Http\Controllers\EditorialArticleController::class, 'previewContent'])->whereNumber('id')->name('editorial.preview-content');
Route::get('/management/editorial-installation', [\App\Http\Controllers\EditorialArticleController::class, 'installation'])->name('editorial.installation');
Route::get('/management/editorial-installation/mfa', [\App\Http\Controllers\EditorialArticleController::class, 'showMfa'])->name('editorial.mfa');
Route::post('/management/editorial-installation/mfa', [\App\Http\Controllers\EditorialArticleController::class, 'confirmMfa'])->middleware('throttle:5,1');
@@ -162,6 +167,10 @@ Route::middleware(['auth', 'verified', EnsurePortalProfileAdministration::class]
Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(function () {
$profiles = PortalProfileController::class;
Route::get('/minhas-oportunidades', [$profiles, 'opportunities']);
+ Route::get('/minhas-oportunidades/lotes/agronegocio', [\App\Http\Controllers\CorridorAgribusinessController::class, 'show']);
+ Route::post('/minhas-oportunidades/lotes/agronegocio/cadastrar', [\App\Http\Controllers\CorridorAgribusinessController::class, 'register']);
+ Route::post('/minhas-oportunidades/lotes/agronegocio/previa', [\App\Http\Controllers\CorridorAgribusinessController::class, 'preview']);
+ Route::post('/minhas-oportunidades/lotes/agronegocio/divulgar', [\App\Http\Controllers\CorridorAgribusinessController::class, 'disclose']);
Route::get('/minhas-oportunidades/territory-options', [InvestmentTerritoryController::class, 'options']);
Route::get('/minhas-oportunidades/territory-geometries', [InvestmentTerritoryController::class, 'geometries']);
Route::get('/minhas-oportunidades/create', [$profiles, 'edit']);
@@ -173,6 +182,9 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
Route::get('/minhas-oportunidades/{id}/classification', [OpportunityClassificationController::class, 'index'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/classification/review', [OpportunityClassificationController::class, 'review'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/workflow', [$profiles, 'workflow'])->whereNumber('id');
+ Route::get('/minhas-oportunidades/{id}/divulgacao', [\App\Http\Controllers\OpportunityPromotionController::class, 'show'])->whereNumber('id');
+ Route::post('/minhas-oportunidades/{id}/divulgacao/previa', [\App\Http\Controllers\OpportunityPromotionController::class, 'preview'])->whereNumber('id');
+ Route::post('/minhas-oportunidades/{id}/divulgacao/confirmar', [\App\Http\Controllers\OpportunityPromotionController::class, 'confirm'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/contacts', [$profiles, 'contact'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/resources', [OpportunityResourceController::class, 'index'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/library-support', [OpportunityResourceController::class, 'reuseLibrary'])->whereNumber('id');
diff --git a/tests/Feature/ContentManagementEntryTest.php b/tests/Feature/ContentManagementEntryTest.php
new file mode 100644
index 00000000..57a7bed7
--- /dev/null
+++ b/tests/Feature/ContentManagementEntryTest.php
@@ -0,0 +1,124 @@
+assertSame('simulator_fiscal_delta_20261021_test', $db->name);
+ $this->assertSame(55441, (int) $db->port);
+ $this->withoutVite();
+ }
+
+ public function test_entry_does_not_grant_access_to_an_investor(): void
+ {
+ $user = User::factory()->create();
+ config(['portal.managers' => [], 'portal.document_managers' => []]);
+ $this->assertSame([], app(ContentManagement::class)->access($user));
+ $this->actingAs($user)->get('/gestao-conteudos')->assertForbidden();
+ }
+
+ public function test_existing_manager_gets_reused_destinations_and_document_intent_and_search(): void
+ {
+ $user = User::factory()->create();
+ config(['portal.managers' => [$user->email]]);
+ $this->actingAs($user)->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $page) => $page->component('management/content-hub')
+ ->where('access.documents.edit', true)->where('access.opportunities.create', '/management/opportunities/create'));
+ $this->get('/management/portal-services?intent=legislation&q=not-existing-qa-title')->assertOk()->assertInertia(fn (Assert $page) => $page
+ ->where('managementFilters.intent', 'legislation')->has('associationOptions.sector')->has('associationOptions.stage')->has('resources', 0));
+ $this->get('/management/portal-services?state=published')->assertOk();
+ $this->get('/management/portal-services?state=withdrawn')->assertOk();
+ }
+
+ public function test_team_promoter_uses_operational_editor_without_general_administration(): void
+ {
+ config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true, 'portal.managers' => []]);
+ $actor = User::factory()->create();
+ $actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
+ $org = (string) Str::uuid();
+ DB::table('organizations')->insert(['id' => $org, 'name' => 'Synthetic QA content team', 'organization_type' => 'private']);
+ DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $actor->id, 'organization_id' => $org, 'role' => 'officer']);
+ app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
+ 'user_id' => $actor->id, 'role' => 'promoter', 'organization_id' => $org,
+ 'opportunity_types' => OpportunityManagement::CATEGORIES, 'responsibilities' => [], 'source' => 'synthetic-content-entry-test',
+ ]);
+ $before = DB::table('portal_role_assignments')->count();
+ $this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
+ $this->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $page) => $page
+ ->where('access.opportunities.create', '/minhas-oportunidades/create')->where('access.opportunities.profileWorkflow', true)->missing('access.documents'));
+ $this->get('/minhas-oportunidades/create')->assertOk();
+ $this->get('/management/portal-services')->assertForbidden();
+ $this->assertSame($before, DB::table('portal_role_assignments')->count());
+ }
+
+ public function test_new_legislation_is_recovered_edited_published_and_withdrawn_with_history(): void
+ {
+ Storage::fake('local');
+ config(['queue.default' => 'sync', 'services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
+ $this->assertTrue(app(MalwareScanner::class)->health()['available'], 'Real ClamD required for this QA demonstration');
+ $user = User::factory()->create();
+ config(['portal.managers' => [$user->email]]);
+ $this->actingAs($user);
+ $name = 'synthetic-entry-'.Str::uuid().'.txt';
+ $this->post('/management/portal-services/upload', ['intent' => 'legislation', 'file' => UploadedFile::fake()->createWithContent($name, 'Synthetic QA legislation example, no legal value.')])
+ ->assertSessionHasNoErrors()->assertSessionHas('uploaded_document_id')->assertRedirect('/management/portal-services?intent=legislation');
+ $document = DB::table('documents')->where('file_name', $name)->first();
+ $this->assertSame('clean', $document->malware_scan_status);
+ $payload = ['document_id' => $document->id, 'title' => $name, 'category' => 'Synthetic QA', 'topic' => 'Synthetic QA',
+ 'document_type' => 'legislation', 'issuer' => 'Synthetic QA only', 'source_url' => 'https://example.invalid/synthetic-qa', 'language' => 'pt', 'version_label' => 'QA-1',
+ 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'proposed',
+ 'rights_status' => 'authorized', 'data_classification' => 'public', 'source_reference' => 'Synthetic QA example, no official provenance or legal value.',
+ 'legislation' => true, 'essential' => false, 'independent_review' => false, 'associations' => []];
+ $this->post('/management/portal-services/resources', $payload)->assertRedirect()->assertSessionHasNoErrors();
+ $resource = DB::table('portal_resources')->where('document_id', $document->id)->first();
+ $this->assertNotNull($resource);
+ $this->assertSame($name, $resource->title);
+ $this->get('/management/portal-services?q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 1)->where('resources.0.title', $name));
+ $this->put('/management/portal-services/resources/'.$resource->id, [...$payload, 'title' => $name.' edited', 'metadata_version' => $resource->metadata_version])->assertSessionHasNoErrors();
+ $this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
+ $this->get('/documentos/'.$resource->id.'/download')->assertOk()->assertDownload($name);
+ $this->post('/management/portal-services/resources/'.$resource->id, ['published' => false])->assertSessionHasNoErrors();
+ $this->get('/management/portal-services?state=withdrawn&q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 1));
+ $this->get('/management/portal-services?state=draft&q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 0));
+ $this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
+ $this->assertDatabaseHas('documents', ['id' => $document->id, 'sha256' => $document->sha256]);
+ $this->assertSame(4, DB::table('portal_resource_revisions')->where('resource_id', $resource->id)->count());
+ }
+
+ public function test_existing_named_editorial_designation_survives_removal_of_technical_reservation(): void
+ {
+ $actor = User::factory()->create();
+ $other = User::factory()->create();
+ $actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
+ config(['portal.homologation_only' => false, 'portal.profile_access_enabled' => true,
+ 'portal.managers' => [], 'homologation_editorial.accounts' => [$actor->email]]);
+ DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]);
+ $this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
+ $this->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $page) => $page->where('access.documents.edit', true));
+ $this->get('/management/portal-services?intent=legislation')->assertOk()->assertInertia(fn (Assert $page) => $page
+ ->where('editorialScopeOnly', true)->where('documentAccessOnly', false));
+ config(['homologation_editorial.accounts' => []]);
+ $this->get('/management/portal-services')->assertForbidden();
+ config(['homologation_editorial.accounts' => [$actor->email]]);
+ $this->withSession(['portal.mfa_at' => time() - 901]);
+ $this->assertFalse(app(\App\Services\DocumentManagementAccess::class)->editorial($actor));
+ }
+}
diff --git a/tests/Feature/CorridorAgribusinessBatchTest.php b/tests/Feature/CorridorAgribusinessBatchTest.php
new file mode 100644
index 00000000..751fd836
--- /dev/null
+++ b/tests/Feature/CorridorAgribusinessBatchTest.php
@@ -0,0 +1,47 @@
+assertSame('simulator_fiscal_delta_20261021_test', $target->name);
+ $this->assertSame(55441, (int) $target->port);
+ $this->withoutVite(); config(['portal.profile_access_enabled' => true]);
+ $entries = app(CorridorAgribusinessOpportunities::class)->entries();
+ $keys = array_column($entries, 'key');
+ $others = Opportunity::withTrashed()->whereNotIn('management_creation_key', $keys)->orWhereNull('management_creation_key')->orderBy('id')->get()->map(fn ($r) => $r->getRawOriginal())->all();
+ $count = Opportunity::withTrashed()->count();
+ $actor = User::findOrFail(295);
+ $this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
+ $base = '/minhas-oportunidades/lotes/agronegocio';
+ $this->get($base)->assertOk();
+ $this->postJson($base.'/cadastrar', ['manifest_hash' => str_repeat('0', 64), 'confirm' => true])->assertConflict();
+ $this->postJson($base.'/cadastrar', ['manifest_hash' => hash('sha256', json_encode($entries, JSON_THROW_ON_ERROR)), 'confirm' => true])->assertOk();
+ $this->assertSame($count, Opportunity::withTrashed()->count());
+ $previews = $this->postJson($base.'/previa')->assertOk()->json('previews');
+ $this->assertCount(34, $previews);
+ $input = ['confirm' => true, 'reason' => 'QA: divulgação das fichas expressamente seleccionadas no lote de Agronegócio.',
+ 'previews' => array_map(fn ($p) => ['id' => $p['id'], 'token' => $p['token'], 'operation_key' => (string) Str::uuid()], $previews)];
+ $bad = $input; array_pop($bad['previews']);
+ $this->postJson($base.'/divulgar', $bad)->assertUnprocessable();
+ $this->postJson($base.'/divulgar', $input)->assertOk();
+ $this->postJson($base.'/divulgar', $input)->assertOk();
+ $this->assertSame(34, Opportunity::whereIn('management_creation_key', $keys)->published()->count());
+ $this->assertSame($others, Opportunity::withTrashed()->whereNotIn('management_creation_key', $keys)->orWhereNull('management_creation_key')->orderBy('id')->get()->map(fn ($r) => $r->getRawOriginal())->all());
+ $this->withSession(['portal.mfa_at' => 0])->postJson($base.'/previa')->assertForbidden();
+ $this->actingAs(User::factory()->create())->get($base)->assertForbidden();
+ }
+}
diff --git a/tests/Feature/CorridorAgribusinessInvestorPathTest.php b/tests/Feature/CorridorAgribusinessInvestorPathTest.php
new file mode 100644
index 00000000..e434f498
--- /dev/null
+++ b/tests/Feature/CorridorAgribusinessInvestorPathTest.php
@@ -0,0 +1,57 @@
+assertSame('simulator_fiscal_delta_20261021_test', $target->name);
+ $this->assertSame(55441, (int) $target->port);
+ $this->withoutVite();
+ config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true,
+ 'investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
+ $actor = User::findOrFail(295);
+ $this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
+ $entry = app(CorridorAgribusinessOpportunities::class)->entries()[0];
+ $record = Opportunity::where('management_creation_key', $entry['key'])->firstOrFail();
+ $base = '/minhas-oportunidades/'.$record->id.'/divulgacao';
+ $preview = $this->postJson($base.'/previa', [...$record->workflow_context['promotional_summary']['input'],
+ 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])->assertOk()->json();
+ $this->postJson($base.'/confirmar', ['token' => $preview['token'], 'operation_key' => (string) Str::uuid(), 'confirm' => true,
+ 'reason' => 'QA: conferir o roteiro de um interessado sem atribuir qualquer autorização.'])->assertOk();
+
+ $investor = User::factory()->create();
+ $this->actingAs($investor);
+ $this->get('/oportunidades/'.$record->slug)->assertOk();
+ $id = $this->postJson('/investimentos', ['title' => 'QA: investimento a partir de agronegócio', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
+ $view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ $body = ['version' => $view['case']['version'], 'selection' => ['opportunity' => 'opportunity:'.$record->id]];
+ $plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'],
+ 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
+ $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()
+ ->assertJsonPath('context.selection.opportunity.key', 'opportunity:'.$record->id)
+ ->assertJsonPath('context.fields.project_title', 'QA: investimento a partir de agronegócio')
+ ->assertJsonPath('context.fields.project_summary', $record->summary)
+ ->assertJsonPath('context.fields.sector_id', '1');
+ $this->get('/investimentos/'.$id)->assertOk();
+ $interestId = $this->postJson('/investidor/manifestacoes', ['opportunity_id' => $record->id])->assertCreated()->json('id');
+ $this->getJson('/investidor/manifestacoes/'.$interestId)->assertOk();
+ $this->assertSame(1, DB::table('desk_cases')->where('id', $id)->count());
+ $this->assertSame('opportunity:'.$record->id, app(\App\Services\InvestorContext::class)->view($investor, $id)['context']['selection']['opportunity']['key']);
+ // All synthetic investor/path/disclosure writes roll back with this test.
+ }
+}
diff --git a/tests/Feature/CorridorAgribusinessOpportunitiesTest.php b/tests/Feature/CorridorAgribusinessOpportunitiesTest.php
new file mode 100644
index 00000000..f012c552
--- /dev/null
+++ b/tests/Feature/CorridorAgribusinessOpportunitiesTest.php
@@ -0,0 +1,92 @@
+assertSame('simulator_fiscal_delta_20261021_test', $target->name);
+ $this->assertSame(55441, (int) $target->port);
+ $this->withoutVite();
+ config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true, 'investor-path.enabled' => true]);
+ $actor = User::findOrFail(295); // Existing designated QA operator; no new role or account.
+ $this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
+ }
+
+ public function test_all_34_records_have_editable_drafts_and_individual_disclosure_and_path(): void
+ {
+ $service = app(CorridorAgribusinessOpportunities::class);
+ $entries = $service->entries();
+ $this->assertCount(34, $entries);
+ $masters = DB::table('development_corridors')->orderBy('id')->get()->toJson();
+ $this->assertSame([], $service->articleLinks());
+ foreach ($entries as $entry) {
+ $record = Opportunity::where('management_creation_key', $entry['key'])->firstOrFail();
+ $this->assertSame('draft', $record->status);
+ $this->assertNull($record->primary_territory_id);
+ $this->assertNull($record->promoter_name);
+ $this->assertNull($record->estimated_investment_amount);
+ $this->assertSame($entry['corridor'], $record->workflow_context['corridor_agribusiness']['corridor']);
+ $base = '/minhas-oportunidades/'.$record->id;
+ $this->get($base.'/edit')->assertOk();
+ $this->get($base.'/divulgacao')->assertOk();
+ $input = $record->workflow_context['promotional_summary']['input'];
+ $preview = $this->postJson($base.'/divulgacao/previa', [...$input, 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])->assertOk()->json();
+ $this->assertStringContainsString($entry['corridor'], $preview['projection']['description']);
+ $this->postJson($base.'/divulgacao/confirmar', ['token' => $preview['token'], 'operation_key' => (string) Str::uuid(), 'confirm' => true,
+ 'reason' => 'QA: conferir divulgação individual da fonte e ligação ao roteiro.'])->assertOk();
+ $this->getJson('/api/v1/opportunities/'.$record->slug)->assertOk()->assertJsonPath('data.maturity_label', 'Em preparação');
+ $this->get('/oportunidades/'.$record->slug)->assertOk();
+ $this->get('/investimentos/iniciar?source=opportunity:'.$record->id)->assertOk();
+ $this->assertSame($record->id, (int) app(\App\Services\InvestorContext::class)->reference('opportunity:'.$record->id)['id']);
+ }
+ $links = $service->articleLinks();
+ $this->assertSame([5, 6, 6, 7, 5, 5], array_map('count', $links));
+ $this->assertSame($masters, DB::table('development_corridors')->orderBy('id')->get()->toJson());
+ // DatabaseTransactions rolls back these QA disclosures; the 34 real-source drafts stay private.
+ }
+
+ public function test_edit_recover_preview_update_withdraw_and_idempotent_loading_preserve_editorial_work(): void
+ {
+ $record = Opportunity::where('management_creation_key', app(CorridorAgribusinessOpportunities::class)->entries()[0]['key'])->firstOrFail();
+ $base = '/minhas-oportunidades/'.$record->id;
+ $canonical = app(OpportunityManagement::class)->values($record)['canonical'];
+ $canonical['executive_summary'] .= ' Correcção editorial QA.';
+ $this->putJson($base.'/draft', ['etag' => app(OpportunityManagement::class)->etag($record), 'canonical' => $canonical,
+ 'reason' => 'QA: corrigir e recuperar os dados através da gestão.'])->assertOk();
+ $this->assertSame($canonical['executive_summary'], app(OpportunityManagement::class)->values($record->fresh())['canonical']['executive_summary']);
+ $this->get($base.'/edit')->assertOk();
+ $input = $record->fresh()->workflow_context['promotional_summary']['input'];
+ foreach (['disclose', 'disclose', 'withdraw'] as $index => $action) {
+ $record->refresh();
+ if ($index === 1) $input['summary'] .= ' Actualização pública QA.';
+ $preview = $this->postJson($base.'/divulgacao/previa', [...$input, 'action' => $action, 'etag' => app(OpportunityManagement::class)->etag($record)])->assertOk()->json();
+ $this->postJson($base.'/divulgacao/confirmar', ['token' => $preview['token'], 'operation_key' => (string) Str::uuid(), 'confirm' => true,
+ 'reason' => 'QA: conferir actualização ou retirada sem perder a preparação.'])->assertOk();
+ }
+ $this->getJson('/api/v1/opportunities/'.$record->slug)->assertNotFound();
+ $this->assertSame([], app(CorridorAgribusinessOpportunities::class)->articleLinks());
+ $this->assertSame($canonical['executive_summary'], app(OpportunityManagement::class)->values($record->fresh())['canonical']['executive_summary']);
+ $request = \Illuminate\Http\Request::create('/minhas-oportunidades/drafts', 'POST');
+ app()->instance('request', $request);
+ $repeat = app(CorridorAgribusinessOpportunities::class)->register(User::findOrFail(295), $record->organization_id);
+ $this->assertCount(34, $repeat);
+ $this->assertSame(0, count(array_filter($repeat, fn ($r) => $r['created'])));
+ $this->assertSame('suspended', $record->fresh()->status);
+ $this->assertSame($canonical['executive_summary'], app(OpportunityManagement::class)->values($record->fresh())['canonical']['executive_summary']);
+ }
+}
diff --git a/tests/Feature/InvestmentCorridorContextTest.php b/tests/Feature/InvestmentCorridorContextTest.php
new file mode 100644
index 00000000..df079128
--- /dev/null
+++ b/tests/Feature/InvestmentCorridorContextTest.php
@@ -0,0 +1,187 @@
+withoutVite();
+ $t=DB::selectOne('select current_database() as name,inet_server_port() as port');
+ $this->assertSame('simulator_fiscal_delta_20261021_test',$t->name);$this->assertSame(55441,(int)$t->port);
+ config(['investor-path.enabled'=>true,'investor-path.private_preparation'=>true,'suitecrm.enabled'=>false,'investmoz_ai.enabled'=>false]);
+ $this->investor=User::factory()->create();$this->actingAs($this->investor);
+ }
+ private function record(string $corridor): Opportunity
+ {
+ $entry=collect(app(CorridorAgribusinessOpportunities::class)->entries())->firstWhere('corridor',config('investment_corridors.'.$corridor.'.name'));
+ return Opportunity::where('management_creation_key',$entry['key'])->firstOrFail();
+ }
+ public function test_land_status_uses_the_real_decision_preserves_inactive_values_and_allows_incomplete_drafts(): void
+ {
+ $id=$this->postJson('/investimentos',['title'=>'QA situação do terreno','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $setNeed=function(string $need) use($id): array {
+ $version=$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
+ $this->postJson('/investimentos/'.$id.'/aplicabilidade',['version'=>$version,'land_needed'=>$need,'external_funding'=>'unknown','viability'=>'unknown','reason'=>'Cenário QA '.$need])->assertOk();
+ return $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ };
+ $view=$setNeed('yes');
+ $body=['context'=>$id,'personal_snapshot'=>[],'economic_snapshot'=>['purpose'=>'simulate_only','context_applicability_snapshot'=>$view['prefill']['simulator']['context_applicability_snapshot'],'land_status'=>'duat','selection_labels'=>['land_status'=>'DUAT']]];
+ $created=$this->postJson('/api/v1/simulation-drafts',$body,['Idempotency-Key'=>(string)Str::uuid()])->assertCreated();
+ $draft=$created->json('data.id');$etag=$created->headers->get('ETag');
+ foreach(['no','unknown'] as $need){
+ $view=$setNeed($need);
+ $this->assertSame($need,json_decode($view['prefill']['simulator']['context_applicability_snapshot'],true)['land_preparation']['need']);
+ $update=$this->putJson('/api/v1/simulation-drafts/'.$draft,['personal_snapshot'=>[],'economic_snapshot'=>['purpose'=>'simulate_only','context_applicability_snapshot'=>$body['economic_snapshot']['context_applicability_snapshot']]],['If-Match'=>$etag])->assertOk();
+ $recovered=$this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk();
+ // Omitting context cannot bypass the real dossier decision. Hidden values are retained.
+ $this->assertSame('duat',$recovered->json('data.economic_snapshot.land_status'));
+ $this->assertSame('DUAT',$recovered->json('data.economic_snapshot.selection_labels.land_status'));
+ $this->assertSame($need,json_decode($recovered->json('data.economic_snapshot.context_applicability_snapshot'),true)['land_preparation']['need']);
+ $etag=$update->headers->get('ETag');
+ }
+ $setNeed('yes');
+ $this->flushSession();$this->actingAs($this->investor);
+ $this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.land_status','duat');
+ $updated=$this->putJson('/api/v1/simulation-drafts/'.$draft,[...$body,'economic_snapshot'=>['purpose'=>'simulate_only','land_status'=>'leased']],['If-Match'=>$etag])->assertOk();
+ $recovered=$this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.land_status','leased');
+ $this->assertSame('yes',json_decode($recovered->json('data.economic_snapshot.context_applicability_snapshot'),true)['land_preparation']['need']);
+ $this->putJson('/api/v1/simulation-drafts/'.$draft,[...$body,'economic_snapshot'=>['purpose'=>'simulate_only','land_status'=>'invalid']],['If-Match'=>$updated->headers->get('ETag')])->assertUnprocessable();
+ $empty=app(\App\Services\InvestorContext::class)->prefill(['fields'=>['site_tenure'=>'lease'],'revision'=>0,'provenance'=>[],'land_preparation'=>['need'=>'yes','reason'=>'QA']]);
+ $this->assertSame('leased',$empty['simulator']['land_status']);
+ }
+ public function test_simulator_reuses_names_for_the_exact_chosen_territory_and_sector_without_creating_a_location(): void
+ {
+ $id=$this->postJson('/investimentos',['title'=>'QA continuidade simulador','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $province=collect(app(InvestmentCorridorContext::class)->provinces('beira'))->firstWhere('source_code','07')->id;
+ $district=app(\App\Services\InvestmentGeography::class)->publicReferences()->where('parent_id',$province)->where('location_type','district')->firstOrFail();
+ $record=$this->record('beira');
+ $plan=$this->postJson('/investimentos/'.$id.'/contexto/previa',['version'=>$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'),'selection'=>[],'opportunity_choices'=>[['source'=>'opportunity:'.$record->id,'corridor_key'=>'beira','territory'=>'territory:'.$district->id]]])->assertOk()->json();
+ $sim=app(\App\Services\InvestorContext::class)->prefill($plan)['simulator'];
+ $this->assertSame('investor',$sim['journey_type']);
+ $this->assertSame('simulate_only',$sim['purpose']);
+ $this->assertSame($province,$sim['province_id']);
+ $this->assertSame('Sofala',$sim['province_id_label']);
+ $this->assertSame($district->id,$sim['district_id']);
+ $this->assertSame($district->name,$sim['district_id_label']);
+ $this->assertSame((string)$record->sector_id,$sim['sector_id']);
+ $this->assertSame(\App\Models\Sector::findOrFail($record->sector_id)->name,$sim['sector_id_label']);
+ $this->assertArrayNotHasKey('locality_id_label',$sim);
+ $this->assertArrayNotHasKey('admin_post_id_label',$sim);
+ }
+ public function test_nonapplicable_land_hides_the_action_and_reopening_recovers_saved_evidence(): void
+ {
+ $id = $this->postJson('/investimentos', ['title' => 'QA adaptação sem perda', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
+ $view = $this->getJson('/investimentos/'.$id)->assertOk()->json();
+ $this->assertNotNull($view['tools']['land']);
+ $this->assertSame('undetermined', collect($view['steps'])->firstWhere('code', 'S10')['applicability']);
+ $this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $view['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Informação territorial já guardada'])->assertOk();
+ $view = $this->getJson('/investimentos/'.$id)->json();
+ $facts = ['land_needed' => 'no', 'external_funding' => 'no', 'viability' => 'unknown', 'reason' => 'Investimento sem necessidade de terra nem financiamento externo'];
+ $this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $view['case']['version'], ...$facts])->assertOk();
+ $view = $this->getJson('/investimentos/'.$id)->assertJsonPath('tools.land', null)->json();
+ $step = collect($view['steps'])->firstWhere('code', 'S10');
+ $this->assertFalse($step['preparation_allowed']);
+ $this->assertSame('preparing', $step['saved_state']);
+ $this->assertSame('Informação territorial já guardada', $step['detail']['reason']);
+ $this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $view['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Não deve executar etapa sem aplicação'])->assertUnprocessable();
+ $this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $view['case']['version'], ...$facts, 'land_needed' => 'yes'])->assertOk();
+ $view = $this->getJson('/investimentos/'.$id)->assertOk()->json();
+ $this->assertNotNull($view['tools']['land']);
+ $this->assertSame('preparing', collect($view['steps'])->firstWhere('code', 'S10')['state']);
+ $this->assertCount(19, $view['steps']);
+ }
+ public function test_tempo_configuration_uses_existing_ids_and_maputo_includes_both_units(): void
+ {
+ foreach(config('investment_corridors') as $key=>$entry){
+ $provinces=app(InvestmentCorridorContext::class)->provinces($key);
+ $this->assertEqualsCanonicalizing($entry['provinces'],array_column($provinces,'source_code'));
+ $response=$this->getJson('/api/v1/investment/territory-options?type=province&corridor='.$key)->assertOk();
+ $this->assertEqualsCanonicalizing(array_column($provinces,'id'),array_column($response->json('data'),'id'));
+ }
+ $this->assertEqualsCanonicalizing(['Maputo Província','Maputo Cidade'],array_column(app(InvestmentCorridorContext::class)->provinces('maputo'),'name'));
+ foreach(['nacala','limpopo'] as $key){
+ $choice=app(InvestmentOpportunityChoices::class)->resolve([['source'=>'opportunity:'.$this->record($key)->id]])[0];
+ $this->assertSame(app(InvestmentCorridorContext::class)->provinces($key)[0]->id,$choice['territory']['id']);
+ }
+ }
+ public function test_server_refuses_incompatible_location_in_draft_and_confirmation_preview(): void
+ {
+ $id=$this->postJson('/investimentos',['title'=>'QA corridor guard','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $version=$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
+ $gaza=app(InvestmentCorridorContext::class)->provinces('limpopo')[0]->id;
+ $body=['version'=>$version,'selection'=>[],'opportunity_choices'=>[['source'=>'opportunity:'.$this->record('beira')->id,'corridor_key'=>'beira','territory'=>'territory:'.$gaza]]];
+ $this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertUnprocessable();
+ $this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>1])->assertUnprocessable();
+ $this->getJson('/api/v1/investment/territory-options?type=district&corridor=beira&parent_id='.$gaza)->assertUnprocessable();
+ $this->getJson('/investimentos/'.$id.'/contexto')->assertJsonPath('context.revision',0);
+ }
+ public function test_encrypted_draft_survives_refresh_and_reauthentication_without_committing_context(): void
+ {
+ $id=$this->postJson('/investimentos',['title'=>'QA recovery','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $before=$this->getJson('/investimentos/'.$id.'/contexto')->json();
+ $record=$this->record('beira');$province=app(InvestmentCorridorContext::class)->provinces('beira')[0]->id;
+ $body=['version'=>$before['case']['version'],'selection'=>[],'declarations'=>['currency'=>'MZN'],'opportunity_choices'=>[['source'=>'opportunity:'.$record->id,'corridor_key'=>'beira','territory'=>'territory:'.$province]]];
+ $this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>10])->assertOk();
+ $this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>9])->assertConflict();
+ $encrypted=DB::table('desk_cases')->where('id',$id)->value('payload');$this->assertStringNotContainsString($province,$encrypted);
+ $this->flushSession();$this->actingAs($this->investor);
+ $this->get('/investimentos/'.$id.'/contexto/editar')->assertOk()->assertInertia(fn(\Inertia\Testing\AssertableInertia $page)=>$page
+ ->where('draft.declarations.currency','MZN')->where('requestedChoices.0.territory.id',$province)->where('requestedChoices.0.corridor_key','beira'));
+ $after=$this->getJson('/investimentos/'.$id.'/contexto')->json();
+ $this->assertSame($before['context'],$after['context']);$this->assertSame($before['case']['version'],$after['case']['version']);
+ $empty=[...$body,'opportunity_choices'=>[],'entry_sources'=>['opportunity:'.$record->id],'draft_sequence'=>11];
+ $this->postJson('/investimentos/'.$id.'/contexto/rascunho',$empty)->assertOk();
+ $this->get('/investimentos/'.$id.'/contexto/editar?source=opportunity:'.$record->id)->assertOk()->assertInertia(fn(\Inertia\Testing\AssertableInertia $page)=>$page->has('requestedChoices',0));
+ $new=$this->record('nacala');
+ $this->get('/investimentos/'.$id.'/contexto/editar?source=opportunity:'.$new->id)->assertOk()->assertInertia(fn(\Inertia\Testing\AssertableInertia $page)=>$page->where('requestedChoices.0.opportunity.key','opportunity:'.$new->id));
+ $this->actingAs(User::factory()->create());$this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>12])->assertForbidden();
+ }
+ public function test_zones_need_compatible_documented_territory_and_catalogue_presence_alone_is_insufficient(): void
+ {
+ $service=app(InvestmentCorridorContext::class);
+ $sofala=collect($service->provinces('beira'))->firstWhere('source_code','07')->id;
+ $gaza=$service->provinces('limpopo')[0]->id;
+ $base=app(\App\Services\InvestmentZones::class)->visible()->whereIn('category',['industrial_park','zee','zfi'])->firstOrFail();
+ $clones=[];
+ foreach([$sofala,$gaza,null] as $territory){$zone=$base->replicate();$zone->code='QA-'.Str::uuid();$zone->name='Synthetic corridor zone';$zone->primary_territory_id=$territory;$zone->save();$clones[]=$zone;}
+ $available=array_column($service->parks('beira',$sofala),'id');
+ $this->assertContains($clones[0]->id,$available);$this->assertNotContains($clones[1]->id,$available);$this->assertNotContains($clones[2]->id,$available);
+ $id=$this->postJson('/investimentos',['title'=>'QA zone guard','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $version=$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
+ $body=['version'=>$version,'selection'=>['park'=>'park:'.$clones[1]->id],'opportunity_choices'=>[['source'=>'opportunity:'.$this->record('beira')->id,'corridor_key'=>'beira','territory'=>'territory:'.$sofala]]];
+ $this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertUnprocessable();
+ $this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>1])->assertUnprocessable();
+ $this->getJson('/investimentos/opcoes-corredor?corridor=beira&territory='.$sofala)->assertOk()->assertJsonFragment(['id'=>$clones[0]->id]);
+ }
+ public function test_corridor_change_keeps_compatible_paths_and_removes_only_incompatible_geography(): void
+ {
+ $service=app(InvestmentCorridorContext::class);$source='opportunity:'.$this->record('beira')->id;
+ $sofala=collect($service->provinces('beira'))->firstWhere('source_code','07')->id;
+ $district=app(\App\Services\InvestmentGeography::class)->publicReferences()->where('parent_id',$sofala)->where('location_type','district')->firstOrFail();
+ $resolver=app(InvestmentOpportunityChoices::class);
+ $old=$resolver->resolve([['source'=>$source,'corridor_key'=>'beira','territory'=>'territory:'.$district->id]]);
+ $same=$resolver->resolve([['source'=>$source,'corridor_key'=>'beira','territory'=>'territory:'.$district->id]],$old);
+ $this->assertSame($old,$same);
+ $changed=$resolver->resolve([['source'=>$source,'corridor_key'=>'limpopo']],$old);
+ $this->assertSame($service->provinces('limpopo')[0]->id,$changed[0]['territory']['id']);
+ $this->assertCount(1,$changed[0]['territory_path']);
+ $this->assertNull($this->record('beira')->primary_territory_id);
+ $this->getJson('/api/v1/investment/territory-options?type=locality')->assertOk()->assertJsonPath('data',[]);
+ config(['investment_corridors.nacala.provinces'=>['03','99']]);
+ $this->getJson('/api/v1/investment/territory-options?type=province&corridor=nacala')->assertStatus(503);
+ }
+}
diff --git a/tests/Feature/InvestmentOpportunityChoicesTest.php b/tests/Feature/InvestmentOpportunityChoicesTest.php
new file mode 100644
index 00000000..f4de09ef
--- /dev/null
+++ b/tests/Feature/InvestmentOpportunityChoicesTest.php
@@ -0,0 +1,143 @@
+assertSame('simulator_fiscal_delta_20261021_test', $target->name);
+ $this->assertSame(55441, (int) $target->port);
+ config(['investor-path.enabled' => true, 'investor-path.private_preparation' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
+ $this->withoutVite();
+ $this->investor = User::factory()->create(); $this->actingAs($this->investor);
+ }
+
+ private function dossier(): string
+ {
+ return $this->postJson('/investimentos', ['title' => 'QA: investimento conjunto', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
+ }
+
+ public function test_edit_recovers_requested_opportunity_without_presenting_it_as_already_saved(): void
+ {
+ $record = Opportunity::where('management_creation_key', app(CorridorAgribusinessOpportunities::class)->entries()[0]['key'])->firstOrFail();
+ $id = $this->dossier();
+ $before = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json('context');
+ $this->get('/investimentos/'.$id.'/contexto/editar?source=opportunity:'.$record->id)->assertOk()
+ ->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page->component('investor-path/context')
+ ->where('source.key', 'opportunity:'.$record->id)
+ ->where('requestedChoices.0.opportunity.key', 'opportunity:'.$record->id)
+ ->where('requestedChoices.0.opportunity.title', $record->title));
+ $after = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json('context');
+ $this->assertSame($before, $after);
+ }
+
+ public function test_multiple_opportunities_keep_sites_sources_requirements_and_results_in_one_dossier(): void
+ {
+ $entries = array_slice(app(CorridorAgribusinessOpportunities::class)->entries(), 0, 2);
+ $records = Opportunity::whereIn('management_creation_key', array_column($entries, 'key'))->orderBy('id')->get();
+ $sites = collect(app(\App\Services\InvestmentCorridorContext::class)->provinces('pemba-lichinga'))->pluck('id');
+ $this->get('/investimentos/iniciar?opportunities[]='.$records[0]->id.'&opportunities[]='.$records[1]->id)->assertOk();
+ $id = $this->dossier();
+ $before = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ $input = ['version' => $before['case']['version'], 'selection' => [], 'opportunity_choices' => [
+ ['source' => 'opportunity:'.$records[0]->id, 'territory' => 'territory:'.$sites[0], 'activities' => ['production', 'processing']],
+ ['source' => 'opportunity:'.$records[1]->id, 'territory' => 'territory:'.$sites[1], 'activities' => ['trade']],
+ ]];
+ $plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertOk()->json();
+ $this->assertCount(2, $plan['opportunity_choices']);
+ $this->assertSame($sites[0], $plan['opportunity_choices'][0]['territory']['id']);
+ $this->assertSame($sites[1], $plan['opportunity_choices'][1]['territory']['id']);
+ $this->assertSame(CorridorAgribusinessOpportunities::SOURCE_HASH, $plan['opportunity_choices'][1]['source_sha256']);
+ $this->assertSame('QA: investimento conjunto', $plan['fields']['project_title']);
+ $this->assertSame($records[0]->summary, $plan['fields']['project_summary']);
+ $this->assertContains('processing', array_column($plan['requirements_assessment']['requirements'], 'code'));
+ $this->assertContains('trade', array_column($plan['requirements_assessment']['requirements'], 'code'));
+ $this->assertSame($before['case']['version'], $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'));
+ $control = [...$input, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true];
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk();
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk()->assertJsonPath('replayed', true);
+ $after = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ $this->assertCount(2, $after['context']['opportunity_choices']);
+ $snapshot = json_decode($after['prefill']['simulator']['context_applicability_snapshot'], true);
+ $this->assertSame($after['context']['opportunity_choices'], $snapshot['opportunity_choices']);
+ $this->assertSame('general', $snapshot['fields']['investment_regime']);
+ $this->assertArrayNotHasKey('tax_benefit', $snapshot['fields']);
+ $this->get('/investimentos/'.$id)->assertOk();
+
+ $desk = app(VirtualDesk::class); $case = $desk->case($this->investor, $id);
+ $payload = $desk->decode($case->payload); $resultId = (string) Str::uuid();
+ $payload['investor_path']['results'] = [['id' => $resultId, 'engine' => 'synthetic-qa-history', 'context_state' => 'current', 'value' => 'preserve']];
+ DB::table('desk_cases')->where('id', $id)->update(['payload' => $desk->encode($payload)]);
+ $next = ['version' => $after['case']['version'], 'selection' => [], 'opportunity_choices' => [
+ ['source' => 'opportunity:'.$records[0]->id, 'territory' => 'territory:'.$sites[0], 'activities' => ['production']],
+ ['source' => 'opportunity:'.$records[1]->id, 'territory' => 'territory:'.$sites[0], 'confirm_location_change' => true, 'location_reason' => 'Alternativa territorial escolhida expressamente para comparar o investimento.', 'activities' => ['trade']],
+ ]];
+ $impact = $this->postJson('/investimentos/'.$id.'/contexto/previa', $next)->assertOk()->json();
+ $this->assertSame($resultId, $impact['dependent_results'][0]['id']);
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$next, 'preview_hash' => $impact['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
+ $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('results.0.value', 'preserve')->assertJsonPath('results.0.context_state', 'review_required');
+ $this->actingAs(User::factory()->create())->getJson('/investimentos/'.$id.'/contexto')->assertForbidden();
+ }
+
+ public function test_documented_location_cannot_change_silently_and_stale_source_cannot_be_confirmed(): void
+ {
+ $record = Opportunity::where('management_creation_key', app(CorridorAgribusinessOpportunities::class)->entries()[0]['key'])->firstOrFail();
+ $sites = collect(app(\App\Services\InvestmentCorridorContext::class)->provinces('pemba-lichinga'))->pluck('id');
+ $record->primary_territory_id = $sites[0]; $record->save();
+ $id = $this->dossier(); $version = $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
+ $input = ['version' => $version, 'selection' => [], 'opportunity_choices' => [['source' => 'opportunity:'.$record->id, 'territory' => 'territory:'.$sites[1]]]];
+ $this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertUnprocessable();
+ $this->postJson('/investimentos/'.$id.'/contexto/previa', ['version' => $version, 'selection' => ['opportunity' => 'opportunity:'.$record->id, 'territory' => 'territory:'.$sites[1]]])->assertUnprocessable();
+ $input['opportunity_choices'][0]['confirm_location_change'] = true;
+ $input['opportunity_choices'][0]['location_reason'] = 'Escolha explícita de implantação alternativa, sem alterar a origem.';
+ $plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertOk()->json();
+ $this->assertSame($sites[0], $record->fresh()->primary_territory_id);
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$input, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
+ $input['version'] = $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
+ unset($input['opportunity_choices'][0]['confirm_location_change']);
+ $plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertOk()->assertJsonPath('dependency_changed', false)->json();
+ $record->title .= ' QA source changed'; $record->save();
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$input, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertConflict();
+ $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('context.revision', 1);
+ }
+
+ public function test_the_34_choices_and_sources_fit_the_existing_simulation_draft_without_reentry(): void
+ {
+ $records = Opportunity::whereIn('management_creation_key', array_column(app(CorridorAgribusinessOpportunities::class)->entries(), 'key'))->orderBy('id')->get();
+ $this->assertCount(34, $records);
+ $id = $this->dossier();
+ $view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ $input = ['version' => $view['case']['version'], 'selection' => [], 'opportunity_choices' => $records->map(fn ($r) => ['source' => 'opportunity:'.$r->id])->all()];
+ $plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertOk()->json();
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$input, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
+ $view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ $snapshot = $view['prefill']['simulator']['context_applicability_snapshot'];
+ $this->assertCount(34, json_decode($snapshot, true)['opportunity_choices']);
+ $this->assertLessThan(1000000, strlen($snapshot));
+ // Mirror the existing browser partition: labels are persisted under selection_labels.
+ $economic = [...$view['prefill']['simulator'], 'purpose' => 'simulate_only', 'currency' => 'MZN'];
+ foreach (array_keys($economic) as $field) if (str_ends_with($field, '_label')) {
+ $economic['selection_labels'][substr($field, 0, -6)] = $economic[$field]; unset($economic[$field]);
+ }
+ $draft = $this->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => [],
+ 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json();
+ $this->assertNotEmpty($draft);
+ $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('context.opportunity_choices.33.opportunity.key', 'opportunity:'.$records[33]->id);
+ }
+}
diff --git a/tests/Feature/InvestmentPortfolioContinuityTest.php b/tests/Feature/InvestmentPortfolioContinuityTest.php
new file mode 100644
index 00000000..6a7864c9
--- /dev/null
+++ b/tests/Feature/InvestmentPortfolioContinuityTest.php
@@ -0,0 +1,130 @@
+forceFill(['id'=>(string)Str::uuid(),'version_no'=>2]);
+ $service=app(\App\Services\SimulationFormDrafts::class);
+ $guard=new \ReflectionMethod($service,'requireMatch');
+ $tag=$service->etag($draft);
+ foreach (['W/'.$tag, str_replace('-v2','-v1',$tag)] as $invalid) {
+ $request=\Illuminate\Http\Request::create('/','PUT'); $request->headers->set('If-Match',$invalid);
+ try { $guard->invoke($service,$request,$draft); $this->fail('A mismatching version must be rejected'); }
+ catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) { $this->assertSame(412,$error->getStatusCode()); }
+ }
+ $request=\Illuminate\Http\Request::create('/','PUT'); $request->headers->set('If-Match',$tag);
+ $this->assertNull($guard->invoke($service,$request,$draft));
+ }
+
+ public function test_beira_documentary_membership_opens_the_same_published_opportunities_without_inferring_by_province(): void
+ {
+ $portfolio = InvestmentPortfolioController::visible()->where('content->code', 'COR-02')->firstOrFail();
+ $service = app(CorridorAgribusinessOpportunities::class);
+ $ids = $service->portfolioOpportunityIds($portfolio);
+ $this->assertCount(7, $ids);
+ $this->get('/carteira/'.$portfolio->id)->assertOk()->assertInertia(fn(Assert $page) => $page->has('opportunities', 7));
+ $catalogueIds=app(\App\Services\InvestmentCatalogue::class)->query(['corridor_id'=>$portfolio->corridor_id])->pluck('opportunities.id')->all();
+ $this->assertSame([], array_values(array_diff($ids,$catalogueIds)));
+ $opportunity=Opportunity::findOrFail($ids[0]);
+ $this->get('/oportunidades/'.$opportunity->slug.'?from_dossier='.$portfolio->id)->assertOk();
+ $origins=app(InvestmentOpportunityChoices::class)->portfolioOrigins([$portfolio->id],$opportunity->id);
+ $this->assertSame($portfolio->id,$origins[0]['id']);
+ $this->assertSame('corridor',$origins[0]['kind']);
+ $other=Opportunity::published()->whereNotIn('id',$ids)->firstOrFail();
+ $this->get('/oportunidades/'.$other->slug.'?from_dossier='.$portfolio->id)->assertNotFound();
+ $this->assertSame([], $service->portfolioOpportunityIds(InvestmentPortfolioController::visible()->whereNotNull('project_id')->firstOrFail()));
+ }
+ private User $actor;
+ protected function setUp(): void
+ {
+ parent::setUp(); $this->withoutVite();
+ $db=DB::selectOne('select current_database() as name,inet_server_port() as port');
+ $this->assertSame('simulator_fiscal_delta_20261021_test',$db->name); $this->assertSame(55441,(int)$db->port);
+ config(['investor-path.enabled'=>true,'suitecrm.enabled'=>false,'investmoz_ai.enabled'=>false]);
+ $this->actor=User::factory()->create(); $this->actingAs($this->actor);
+ }
+ private function records(): array
+ {
+ $entries=collect(app(CorridorAgribusinessOpportunities::class)->entries())->where('corridor',config('investment_corridors.beira.name'))->take(2);
+ $opportunities=Opportunity::whereIn('management_creation_key',$entries->pluck('key'))->orderBy('id')->get();
+ $project=InvestmentPortfolioController::visible()->whereNotNull('project_id')->firstOrFail();
+ $cluster=InvestmentPortfolioController::visible()->whereNotNull('cluster_id')->firstOrFail();
+ foreach($opportunities as $opportunity) {
+ // Synthetic links exist only inside this rolled-back QA transaction.
+ DB::table('opportunity_projects')->insertOrIgnore(['opportunity_id'=>$opportunity->id,'project_id'=>$project->project_id]);
+ DB::table('opportunity_clusters')->insertOrIgnore(['opportunity_id'=>$opportunity->id,'cluster_id'=>$cluster->cluster_id]);
+ }
+ return [$opportunities,$project,$cluster];
+ }
+ public function test_origin_and_location_survive_entry_draft_confirmation_and_simulator_without_resetting_deliberate_values(): void
+ {
+ [$records,$origin,$cluster]=$this->records(); $opportunity=$records[0];
+ $province=collect(app(InvestmentCorridorContext::class)->provinces('beira'))->firstWhere('source_code','07')->id;
+ $query='?source=opportunity:'.$opportunity->id.'&from_dossier='.$origin->id.'&province_id='.$province;
+ auth()->logout();
+ $this->get('/investimentos/iniciar'.$query)->assertRedirect(route('login'));
+ parse_str(parse_url(session('url.intended'),PHP_URL_QUERY),$intended);
+ $this->assertSame($origin->id,$intended['from_dossier']);$this->assertSame($province,$intended['province_id']);
+ $this->actingAs($this->actor);
+ $this->get('/investimentos/iniciar'.$query)->assertOk()->assertInertia(fn(Assert $p)=>$p->where('opportunityChoices.0.portfolio_origins.0.id',$origin->id)->where('opportunityChoices.0.territory.id',$province)->where('entryQuery.from_dossier',$origin->id));
+ $id=$this->postJson('/investimentos',['title'=>'QA título deliberado','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $body=['version'=>$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'),'selection'=>[],'opportunity_choices'=>[
+ ['source'=>'opportunity:'.$opportunity->id,'territory'=>'territory:'.$province,'portfolio_origins'=>[$origin->id],'activities'=>['trade']],
+ ['source'=>'opportunity:'.$records[1]->id,'territory'=>'territory:'.$province,'portfolio_origins'=>[$origin->id,$cluster->id]],
+ ]];
+ $this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>1])->assertOk();
+ $this->flushSession();$this->actingAs($this->actor);
+ $this->get('/investimentos/'.$id.'/contexto/editar')->assertOk()->assertInertia(fn(Assert $p)=>$p->where('requestedChoices.0.portfolio_origins.0.id',$origin->id)->where('requestedChoices.0.territory.id',$province));
+ $preview=$this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertOk()->json();
+ $this->assertGreaterThan(0,$preview['opportunity_choices'][0]['portfolio_origins'][0]['version']);
+ $this->assertSame('QA título deliberado',$preview['fields']['project_title']);
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar',[...$body,'preview_hash'=>$preview['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true])->assertOk();
+ $view=$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ $this->assertSame($view['context']['opportunity_choices'],json_decode($view['prefill']['simulator']['context_applicability_snapshot'],true)['opportunity_choices']);
+ $this->assertSame($province,$view['prefill']['simulator']['province_id']);
+ $this->assertSame('QA título deliberado',$view['prefill']['simulator']['project_title']);
+ $created=$this->postJson('/api/v1/simulation-drafts',['context'=>$id,'personal_snapshot'=>[],'economic_snapshot'=>['purpose'=>'simulate_only','project_title'=>'QA edição deliberada no simulador','context_applicability_snapshot'=>$view['prefill']['simulator']['context_applicability_snapshot']]],['Idempotency-Key'=>(string)Str::uuid()])->assertCreated();
+ $this->flushSession();$this->actingAs($this->actor);
+ $simulation=$this->getJson('/api/v1/simulation-drafts/'.$created->json('data.id').'?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.project_title','QA edição deliberada no simulador')->json('data.economic_snapshot');
+ $this->assertSame($view['context']['opportunity_choices'],json_decode($simulation['context_applicability_snapshot'],true)['opportunity_choices']);
+ $manica=collect(app(InvestmentCorridorContext::class)->provinces('beira'))->firstWhere('source_code','06')->id;
+ $this->get('/investimentos/'.$id.'/contexto/editar?source=opportunity:'.$opportunity->id.'&from_dossier='.$cluster->id.'&province_id='.$manica)
+ ->assertOk()->assertInertia(fn(Assert $p)=>$p->where('requestedChoices.0.territory.id',$province)->where('requestedChoices.0.activities.0','trade')->has('requestedChoices',2)->has('requestedChoices.0.portfolio_origins',2)->has('entryLocationDifferences',1));
+ $body['version']=$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
+ $body['opportunity_choices'][0]['portfolio_origins'][]=$cluster->id;
+ $associations=$this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertOk()->json();
+ $this->assertFalse($associations['calculation_inputs_changed']);$this->assertSame([],$associations['review_steps']);
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar',[...$body,'preview_hash'=>$associations['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true])->assertOk();
+ $this->getJson('/investimentos/'.$id.'/contexto')->assertJsonPath('context.dependency_hash',$view['context']['dependency_hash'])->assertJsonCount(2,'context.opportunity_choices.0.portfolio_origins');
+ }
+ public function test_unrelated_or_private_origins_are_rejected_in_entry_draft_and_preview(): void
+ {
+ [$records,$origin]=$this->records();
+ DB::table('opportunity_projects')->where('opportunity_id',$records[0]->id)->where('project_id',$origin->project_id)->delete();
+ $this->get('/investimentos/iniciar?source=opportunity:'.$records[0]->id.'&from_dossier='.$origin->id)->assertUnprocessable();
+ $id=$this->postJson('/investimentos',['title'=>'QA origem inválida','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->json('id');
+ $body=['version'=>$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'),'selection'=>[],'opportunity_choices'=>[['source'=>'opportunity:'.$records[0]->id,'portfolio_origins'=>[$origin->id]]]];
+ $this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>1])->assertUnprocessable();
+ $this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertUnprocessable();
+ $origin->publication_status='draft';$origin->save();
+ $this->get('/investimentos/iniciar?source=opportunity:'.$records[1]->id.'&from_dossier='.$origin->id)->assertNotFound();
+ }
+}
diff --git a/tests/Feature/LegacyEditorialWorkflowTest.php b/tests/Feature/LegacyEditorialWorkflowTest.php
new file mode 100644
index 00000000..6ad0bf27
--- /dev/null
+++ b/tests/Feature/LegacyEditorialWorkflowTest.php
@@ -0,0 +1,173 @@
+assertSame('simulator_fiscal_delta_20261021_test', $db->name);
+ $this->assertSame(55441, (int) $db->port);
+ $this->withoutVite();
+ $identities = json_decode(file_get_contents(config_path('legacy_editorial_resources.json')), true);
+ $this->resources = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereIn('d.external_id', array_column($identities, 'external_id'))->orderBy('r.id')->get(['r.*']);
+ $this->assertCount(28, $this->resources);
+ $this->editor = User::factory()->create();
+ config(['portal.managers' => [$this->editor->email]]);
+ $this->actingAs($this->editor);
+ }
+
+ private function payload(object $r, string $body): array
+ {
+ return ['metadata_version' => (int) $r->metadata_version, 'body' => $body, 'image_mode' => 'original', 'image_resource_id' => null];
+ }
+
+ public function test_all_28_open_from_existing_originals_and_private_previews_without_reimport_or_save(): void
+ {
+ $before = DB::table('portal_resource_revisions')->count();
+ $service = app(EditorialReading::class);
+ foreach ($this->resources as $r) {
+ $original = $service->original($r);
+ $this->assertNotEmpty($original['content_html']);
+ $text = fn (string $html) => preg_replace('/\s+/u', '', html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+ $this->assertSame($text($original['content_html']), $text(app(LegacyContent::class)->sanitize($original['content_html'], true)), 'Reader must preserve the original text, including inline spans and table cells.');
+ $this->assertStringStartsWith('https://investemoz.tempo.co.mz/', $service->reading($r, null, true)['image_source_url']);
+ $this->get('/management/portal-services/resources/'.$r->id.'/article')->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $p) => $p->where('source.legacy_id', $original['legacy_id'])->where('content.body', $service->content($r, $original)['body']));
+ $this->get('/management/portal-services/resources/'.$r->id.'/article/preview')->assertOk()->assertHeader('X-Robots-Tag', 'noindex, nofollow')->assertInertia(fn (Assert $p) => $p->where('article.private_preview', true)->where('article.source_url', $original['source_url'])->where('article.legacy_id', $original['legacy_id']));
+ $this->get('/artigos/'.$r->id)->assertNotFound();
+ }
+ $this->assertSame($before, DB::table('portal_resource_revisions')->count());
+ }
+
+ public function test_review_changes_save_recover_versions_and_stale_previews_preserve_original(): void
+ {
+ $r = $this->resources->first(fn ($item) => ! $item->editorial_content);
+ $original = app(EditorialReading::class)->original($r);
+ $sha = DB::table('documents')->where('id', $r->document_id)->value('sha256');
+ $payload = $this->payload($r, 'Correcção editorial sintética QA; preservar o original e não publicar.');
+ $this->postJson('/management/portal-services/resources/'.$r->id.'/article/preview', $payload)->assertOk()->assertJsonPath('article.body', $payload['body']);
+ $this->assertNull(DB::table('portal_resources')->where('id', $r->id)->value('editorial_content'));
+ $this->put('/management/portal-services/resources/'.$r->id.'/article', $payload)->assertSessionHasNoErrors()->assertRedirect();
+ $this->get('/management/portal-services/resources/'.$r->id.'/article')->assertInertia(fn (Assert $p) => $p->where('resource.metadata_version', (int) $r->metadata_version + 1)->where('content.body', $payload['body']));
+ $this->postJson('/management/portal-services/resources/'.$r->id.'/article/preview', $payload)->assertConflict();
+ $this->put('/management/portal-services/resources/'.$r->id.'/article', $payload)->assertConflict();
+ $payload['metadata_version']++;
+ $payload['body'] .= ' Segunda revisão.';
+ $this->put('/management/portal-services/resources/'.$r->id.'/article', $payload)->assertSessionHasNoErrors()->assertRedirect();
+ $this->get('/management/portal-services/resources/'.$r->id.'/article/preview')->assertInertia(fn (Assert $p) => $p->where('article.body', $payload['body'])->where('article.source_url', $original['source_url']));
+ $this->assertSame($sha, DB::table('documents')->where('id', $r->document_id)->value('sha256'));
+ $this->assertSame($original, app(EditorialReading::class)->original($r));
+ $this->assertFalse((bool) DB::table('portal_resources')->where('id', $r->id)->value('published'));
+ $this->assertGreaterThanOrEqual(2, DB::table('portal_resource_revisions')->where('resource_id', $r->id)->count());
+ }
+
+ public function test_publication_remains_explicit_and_reader_works_before_first_editorial_save(): void
+ {
+ $r = $this->resources->first(fn ($item) => ! $item->editorial_content);
+ $original = app(EditorialReading::class)->original($r);
+ $this->post('/management/portal-services/resources/'.$r->id, ['published' => true])->assertSessionHasErrors();
+ $document = DB::table('documents')->find($r->document_id);
+ $metadata = ['document_id' => $r->document_id, 'title' => $r->title, 'category' => $r->category, 'topic' => $r->topic, 'issuer' => $document->issuer,
+ 'document_type' => $r->document_type, 'language' => 'pt', 'description' => 'Arquivo editorial QA. Sem oportunidade concreta ou regra activa.',
+ 'source_url' => $r->source_url, 'source_reference' => $r->source_reference, 'source_confirmation' => 'identified', 'legal_status' => 'historical', 'rights_status' => 'authorized', 'data_classification' => 'public', 'metadata_version' => $r->metadata_version, 'associations' => [],
+ 'territorial_scope' => 'unknown', 'independent_review' => false, 'legislation' => false, 'essential' => false];
+ $this->put('/management/portal-services/resources/'.$r->id, $metadata)->assertSessionHasNoErrors()->assertRedirect();
+ $this->get('/artigos/'.$r->id)->assertNotFound();
+ $this->post('/management/portal-services/resources/'.$r->id, ['published' => true])->assertSessionHasNoErrors()->assertRedirect();
+ Auth::logout();
+ $this->app['auth']->forgetGuards();
+ $this->get('/artigos/'.$r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.legacy_id', $original['legacy_id'])->where('article.private_preview', false));
+ $this->get('/documentos/'.$r->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('articleUrl', '/artigos/'.$r->id));
+ $this->actingAs($this->editor);
+ $current = DB::table('portal_resources')->find($r->id);
+ $this->put('/management/portal-services/resources/'.$r->id.'/article', $this->payload($current, 'Edição sintética depois da publicação; revisão expressa necessária.'))->assertRedirect();
+ $this->get('/artigos/'.$r->id)->assertNotFound();
+ }
+
+ public function test_named_editorial_scope_reuses_28_exact_original_identities_and_mfa_without_global_grant(): void
+ {
+ $actor = $this->editor;
+ $other = User::factory()->create();
+ $actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
+ config(['portal.profile_access_enabled' => true, 'portal.managers' => [], 'homologation_editorial.accounts' => [$actor->email]]);
+ DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]);
+ $this->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
+ $this->get('/management/portal-services/resources/'.$this->resources->first()->id.'/article/preview')->assertOk();
+ $this->assertFalse($actor->can('manage-portal'));
+ DB::table('documents')->where('id', $this->resources->first()->document_id)->update(['external_id' => 'destination-upload-qa-identity', 'source_system' => 'portal-services']);
+ $this->get('/management/portal-services/resources/'.$this->resources->first()->id.'/article/preview')->assertOk();
+ $this->get('/management/portal-services/resources/38/article/preview')->assertForbidden();
+ $this->withSession(['portal.mfa_at' => time() - 901])->get('/management/portal-services/resources/'.$this->resources->first()->id.'/article/preview')->assertForbidden();
+ $this->actingAs($other)->get('/management/portal-services/resources/'.$this->resources->first()->id.'/article/preview')->assertForbidden();
+ }
+
+ public function test_structured_original_is_sanitized_and_unavailable_images_are_explicit(): void
+ {
+ $html = app(LegacyContent::class)->sanitize('', true);
+ $this->assertStringContainsString('', $html);
+ $this->assertStringContainsString('Conteúdo', $html);
+ $this->assertStringNotContainsString('evil', $html);
+ $this->assertStringNotContainsString('onclick', $html);
+ $r = $this->resources->first(fn ($item) => ! $item->editorial_content);
+ $data = app(EditorialReading::class)->reading($r, null, true);
+ if (! $data['image_url']) {
+ $this->assertNotEmpty($data['image_notice']);
+ }
+ $payload = $this->payload($r, app(EditorialReading::class)->content($r, app(EditorialReading::class)->original($r))['body']);
+ $payload['image_mode'] = 'selected';
+ $payload['image_resource_id'] = 999999;
+ $this->postJson('/management/portal-services/resources/'.$r->id.'/article/preview', $payload)->assertUnprocessable();
+ }
+
+ public function test_team_selects_a_real_scanned_image_and_withdrawal_never_exposes_private_bytes(): void
+ {
+ config(['queue.default' => 'sync', 'services.malware.scanner' => 'clamd', 'services.malware.socket' => null, 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
+ $this->assertTrue(app(MalwareScanner::class)->health()['available']);
+ $name = 'synthetic-editorial-'.Str::uuid().'.png';
+ $bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jVmkAAAAASUVORK5CYII=');
+ $this->post('/management/portal-services/upload', ['file' => UploadedFile::fake()->createWithContent($name, $bytes)])->assertSessionHasNoErrors();
+ $doc = DB::table('documents')->where('file_name', $name)->first();
+ $this->assertNotNull($doc);
+ try {
+ $this->assertSame('clean', $doc->malware_scan_status);
+ $metadata = ['document_id' => $doc->id, 'title' => 'Imagem sintética QA', 'issuer' => 'Synthetic QA', 'category' => 'education', 'topic' => 'legacy-archive', 'document_type' => 'unknown', 'language' => 'pt', 'source_url' => 'https://example.invalid/qa-image.png', 'source_reference' => 'Imagem sintética para ensaio transaccional, não é original do legado.', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'authorized', 'data_classification' => 'public', 'territorial_scope' => 'unknown', 'independent_review' => false, 'legislation' => false, 'essential' => false, 'associations' => []];
+ $this->post('/management/portal-services/resources', $metadata)->assertSessionHasNoErrors();
+ $image = DB::table('portal_resources')->where('document_id', $doc->id)->first();
+ $this->post('/management/portal-services/resources/'.$image->id, ['published' => true])->assertSessionHasNoErrors();
+ $r = $this->resources->first();
+ $payload = $this->payload($r, 'Texto sintético de prévia com selecção de imagem.');
+ $payload['image_mode'] = 'selected';
+ $payload['image_resource_id'] = $image->id;
+ $this->postJson('/management/portal-services/resources/'.$r->id.'/article/preview', $payload)->assertOk()->assertJsonPath('article.image_url', '/documentos/'.$image->id.'/view');
+ $this->post('/management/portal-services/resources/'.$image->id, ['published' => false])->assertSessionHasNoErrors();
+ $this->postJson('/management/portal-services/resources/'.$r->id.'/article/preview', $payload)->assertUnprocessable();
+ Auth::logout();
+ $this->app['auth']->forgetGuards();
+ $this->get('/documentos/'.$image->id.'/view')->assertNotFound();
+ } finally {
+ Storage::disk('local')->delete($doc->storage_key);
+ }
+ }
+}
diff --git a/tests/Feature/OpportunityCorridorExplorerTest.php b/tests/Feature/OpportunityCorridorExplorerTest.php
new file mode 100644
index 00000000..666a0588
--- /dev/null
+++ b/tests/Feature/OpportunityCorridorExplorerTest.php
@@ -0,0 +1,30 @@
+assertSame('simulator_fiscal_delta_20261021_test', $target->name);
+ $this->assertSame(55441, (int) $target->port);
+ $this->withoutVite();
+ $this->get('/oportunidades?sector=agronegocio&view=corridors')->assertOk()->assertInertia(fn (Assert $page) => $page
+ ->component('investment/catalogue')
+ ->has('corridorGroups', 6)
+ ->where('results.total', 34)
+ ->where('corridorGroups', function ($groups) {
+ $groups = collect($groups);
+ $ids = $groups->flatMap(fn ($g) => collect($g['opportunities'])->pluck('id'));
+ return $ids->count() === 34 && $ids->unique()->count() === 34
+ && $groups->pluck('count', 'name')->get('Corredor da Beira') === 7
+ && $groups->pluck('count', 'name')->get('Corredor de Maputo') === 5
+ && ! str_contains(json_encode($groups), 'source_sha256');
+ }));
+ }
+}
diff --git a/tests/Feature/OpportunityPromotionTest.php b/tests/Feature/OpportunityPromotionTest.php
new file mode 100644
index 00000000..cb15c2ae
--- /dev/null
+++ b/tests/Feature/OpportunityPromotionTest.php
@@ -0,0 +1,170 @@
+assertSame('simulator_fiscal_delta_20261021_test', $target->name);
+ $this->assertSame(55441, (int) $target->port);
+ $this->withoutVite();
+ config(['portal.profile_access_enabled' => true, 'portal.managers' => []]);
+ $this->actor = User::factory()->create();
+ $this->actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
+ $this->org = (string) Str::uuid();
+ DB::table('organizations')->insert(['id' => $this->org, 'name' => 'SYNTHETIC QA Tempo operator', 'external_id' => '000000000', 'organization_type' => 'private']);
+ DB::table('audit_events')->insert(['source_system' => 'portal-profiles-v1', 'entity_type' => 'authorization', 'action' => 'operator_organization_registered', 'data_classification' => 'restricted',
+ 'after_data' => json_encode(['organization_id' => $this->org, 'organization' => ['name' => 'SYNTHETIC QA Tempo operator', 'nuit' => '000000000'], 'source_reference' => 'Synthetic QA designation only'])]);
+ app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $this->actor->id,
+ 'role' => 'promoter', 'organization_id' => $this->org, 'opportunity_types' => OpportunityManagement::CATEGORIES, 'responsibilities' => [], 'source' => 'internal-portal-designation']);
+ $this->record = new Opportunity;
+ $this->record->forceFill(['title' => 'PRIVATE TITLE', 'slug' => 'qa-promo-'.Str::uuid(), 'reference' => 'QA-'.Str::uuid(), 'status' => 'draft',
+ 'opportunity_category' => 'investment_project', 'owner_user_id' => $this->actor->id, 'organization_id' => $this->org, 'management_version' => 1,
+ 'promoter_name' => 'PRIVATE CONTACT', 'description' => 'PRIVATE CONTENT', 'estimated_investment_amount' => '123456.00',
+ 'workflow_context' => ['operator_organization_id' => $this->org, 'contributors' => [$this->actor->id]],
+ 'management_draft' => json_encode(['canonical' => ['official_title' => 'PRIVATE TITLE', 'contact' => 'PRIVATE CONTACT', 'documents' => ['PRIVATE DOCUMENT']]])])->save();
+ $this->login($this->actor);
+ }
+
+ private function login(User $actor): void
+ {
+ $this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
+ }
+ private function base(): string { return '/minhas-oportunidades/'.$this->record->id.'/divulgacao'; }
+ private function data(array $overrides = []): array
+ {
+ return [...['etag' => app(OpportunityManagement::class)->etag($this->record), 'action' => 'disclose', 'title' => 'SYNTHETIC QA public summary',
+ 'summary' => 'Synthetic QA opportunity in preparation, no real investment.', 'description' => 'Only this deliberately selected public text.',
+ 'source_reference' => 'Synthetic source provided for QA', 'edition' => 'QA-1', 'source_date' => '2026-10-22',
+ 'limitations' => 'Financing and implementation not established.', 'territory_ids' => []], ...$overrides];
+ }
+ private function preview(array $overrides = []): array { return $this->postJson($this->base().'/previa', $this->data($overrides))->assertOk()->json(); }
+ private function confirm(array $preview, ?string $key = null): array
+ {
+ $data = ['token' => $preview['token'], 'operation_key' => $key ?? (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA operation reviewed in preview'];
+ $this->postJson($this->base().'/confirmar', $data)->assertOk();
+ return $data;
+ }
+ public function test_preview_publish_update_withdraw_and_redisclose_preserve_private_draft_and_audit(): void
+ {
+ $file = (string) Str::uuid();
+ $attachment = (string) Str::uuid();
+ DB::table('documents')->insert(['id' => $file, 'file_name' => 'PRIVATE-QA-DOCUMENT.txt', 'storage_key' => 'private-qa/'.$file,
+ 'media_type' => 'text/plain', 'sha256' => hash('sha256', 'private QA only'), 'size_bytes' => 15,
+ 'uploaded_by' => $this->actor->id, 'status' => 'quarantined', 'data_classification' => 'internal']);
+ DB::table('opportunity_documents')->insert(['id' => $attachment, 'opportunity_id' => $this->record->id, 'document_id' => $file,
+ 'version' => 1, 'created_by' => $this->actor->id, 'payload' => json_encode(['title' => 'PRIVATE DOCUMENT', 'visibility' => 'internal'])]);
+ $documentBefore = DB::table('documents')->where('id', $file)->first();
+ $attachmentBefore = DB::table('opportunity_documents')->where('id', $attachment)->first();
+ DB::table('opportunity_profiles')->insert(['opportunity_id' => $this->record->id, 'business_model' => 'PRIVATE PROFILE', 'source_reference' => 'PRIVATE SOURCE']);
+ $before = $this->record->refresh()->getRawOriginal();
+ $private = $before['management_draft'];
+ $preview = $this->preview();
+ $this->assertSame($before, $this->record->fresh()->getRawOriginal());
+ $this->assertFalse($preview['writes']);
+ $this->assertStringContainsString('Em preparação', $preview['projection']['description']);
+ $this->assertStringNotContainsString('PRIVATE', json_encode($preview['projection']));
+ $key = (string) Str::uuid();
+ $request = $this->confirm($preview, $key);
+ $this->postJson($this->base().'/confirmar', $request)->assertOk()->assertJsonPath('replayed', true);
+ $this->record->refresh();
+ $this->assertSame('draft', $this->record->workflow_context['working_state']);
+ $this->assertSame($private, $this->record->management_draft);
+ $this->assertSame('PRIVATE CONTACT', app(OpportunityManagement::class)->values($this->record)['promoter_name']);
+ $public = $this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk();
+ $this->assertStringNotContainsString('PRIVATE', $public->getContent());
+ $public->assertJsonPath('data.maturity_label', 'Em preparação')->assertJsonPath('data.estimated_investment_amount', null);
+ $context = app(\App\Services\InvestmentCatalogue::class)->context($this->record->id);
+ $this->assertNull($context['profile']);
+ $this->assertSame([], $context['source_evidence']);
+ $this->assertStringNotContainsString('PRIVATE', json_encode($context));
+ $this->confirm($this->preview(['summary' => 'Updated synthetic QA summary.']));
+ $this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk()->assertJsonPath('data.summary', 'Updated synthetic QA summary.');
+ $this->get($this->base())->assertOk();
+ $this->confirm($this->preview(['action' => 'withdraw']));
+ $this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertNotFound();
+ $this->get($this->base())->assertOk();
+ $this->confirm($this->preview());
+ $this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk();
+ $events = DB::table('audit_events')->where('source_system', 'tempo-preparation-publication')->where('after_data->record_id', $this->record->id)->get();
+ $this->assertCount(4, $events);
+ $this->assertContains('promotional_summary_withdrawn', $events->pluck('action')->all());
+ foreach ($events as $event) { $this->assertSame($this->actor->id, (int) $event->actor_user_id); $this->assertFalse(json_decode($event->after_data, true)['execution_approved']); }
+ $this->assertEquals($documentBefore, DB::table('documents')->where('id', $file)->first());
+ $this->assertEquals($attachmentBefore, DB::table('opportunity_documents')->where('id', $attachment)->first());
+ }
+ public function test_stale_tampered_expired_and_foreign_preview_do_not_write(): void
+ {
+ $preview = $this->preview();
+ $payload = ['token' => $preview['token'].'broken', 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA invalid confirmation'];
+ $this->postJson($this->base().'/confirmar', $payload)->assertUnprocessable();
+ $this->record->title = 'Concurrent private edit'; $this->record->save();
+ $this->postJson($this->base().'/confirmar', [...$payload, 'token' => $preview['token']])->assertConflict();
+ $fresh = $this->preview();
+ $this->travel(16)->minutes();
+ $this->withSession(['portal.mfa_at' => time()]);
+ $this->postJson($this->base().'/confirmar', [...$payload, 'token' => $fresh['token']])->assertConflict();
+ $this->travelBack();
+ $this->login(User::factory()->create());
+ $this->get($this->base())->assertForbidden();
+ $this->postJson($this->base().'/confirmar', [...$payload, 'token' => $fresh['token']])->assertForbidden();
+ $this->assertSame('draft', $this->record->fresh()->status);
+ }
+ public function test_external_promoter_and_unrelated_internal_actor_cannot_disclose_and_no_ordinary_publication_is_overwritten(): void
+ {
+ $other = User::factory()->create();
+ $this->login($other);
+ $this->postJson($this->base().'/previa', $this->data())->assertForbidden();
+ $this->login($this->actor);
+ DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'organization_id' => $this->org, 'user_id' => $this->actor->id, 'role' => 'officer']);
+ DB::table('portal_role_assignments')->where('user_id', $this->actor->id)->update(['source' => 'approved-external-promoter']);
+ $this->postJson($this->base().'/previa', $this->data())->assertForbidden();
+ DB::table('portal_role_assignments')->where('user_id', $this->actor->id)->update(['source' => 'internal-portal-designation']);
+ $this->withSession(['portal.mfa_at' => time() - 901]);
+ $this->postJson($this->base().'/previa', $this->data())->assertForbidden();
+ $this->login($this->actor);
+ $this->record->forceFill(['status' => 'published', 'published_at' => now(), 'workflow_context' => [...$this->record->workflow_context, 'publication' => ['visibility' => 'public']]])->save();
+ $this->get($this->base())->assertForbidden();
+ }
+ public function test_existing_publisher_scope_can_use_the_same_action_without_new_role_or_approval(): void
+ {
+ $publisher = User::factory()->create();
+ $publisher->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
+ app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->actor, ['user_id' => $publisher->id,
+ 'role' => 'publisher', 'organization_id' => $this->org, 'opportunity_types' => ['investment_project'], 'responsibilities' => [], 'source' => 'synthetic-approved-designation']);
+ $roles = DB::table('portal_role_assignments')->count();
+ $this->login($publisher);
+ $access = app(\App\Services\PortalProfileAccess::class);
+ $this->assertTrue($access->canView($publisher, $this->record), 'Publisher read scope');
+ $this->assertTrue($access->scoped($publisher, 'publisher', $this->org, 'investment_project'), 'Publisher scope');
+ $this->assertTrue(app(\App\Services\OpportunityPromotion::class)->allowed($publisher, $this->record), 'Promotional action scope');
+ $this->confirm($this->preview());
+ $this->assertSame($roles, DB::table('portal_role_assignments')->count());
+ $this->assertArrayNotHasKey('approval', $this->record->fresh()->workflow_context);
+ }
+ public function test_profile_directory_uses_existing_accounts_without_exposing_authentication_secrets(): void
+ {
+ $other = User::factory()->create();
+ DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $this->actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]);
+ $this->get('/perfis/gestao')->assertOk()->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page
+ ->has('staffAccounts')->where('staffAccounts', fn ($rows) => collect($rows)->contains(fn ($row) => $row['id'] === $this->actor->id && $row['email'] === $this->actor->email && $row['verified'] && $row['mfa'])
+ && collect($rows)->every(fn ($row) => !isset($row['password']) && !isset($row['two_factor_secret']))));
+ }
+}
diff --git a/tests/Feature/ProcurementTest.php b/tests/Feature/ProcurementTest.php
new file mode 100644
index 00000000..490fcab4
--- /dev/null
+++ b/tests/Feature/ProcurementTest.php
@@ -0,0 +1,217 @@
+assertSame('simulator_fiscal_delta_20261021_test', $identity->database);
+ $this->assertSame(55441, (int) $identity->port);
+ $this->withoutVite();
+ $this->editor = User::factory()->create();
+ config(['procurement.enabled' => true, 'portal.managers' => [$this->editor->email]]);
+ $this->organization = (string) Str::uuid();
+ DB::table('organizations')->insert(['id' => $this->organization, 'name' => 'SINTÉTICO QA — Entidade de ensaio', 'organization_type' => 'private']);
+ $this->actingAs($this->editor);
+ }
+
+ private function payload(string $regime = 'state'): array
+ {
+ return ['title' => 'SINTÉTICO QA — '.$regime, 'reference' => 'QA-'.Str::uuid(), 'organization_id' => $this->organization,
+ 'regime' => $regime, 'modality' => 'Modalidade documentada de ensaio', 'object' => 'Objecto sintético de concurso para ensaio QA; não corresponde a contratação real.',
+ 'instrument' => $regime === 'state' ? 'Decreto n.º 79/2022 — referência do âmbito estatal' : 'Regras documentadas do procedimento sintético',
+ 'edition' => $regime === 'external' ? 'Banco Mundial, Setembro/2025 — exemplar de referência' : 'QA-2026',
+ 'rule_source' => $regime === 'state' ? 'https://www.ufsa.gov.mz/divnovoreg.php' : 'https://example.invalid/qa-rules',
+ 'origin_published_at' => '2026-10-20T09:00', 'deadline' => '2026-11-20T15:00', 'timezone' => 'Africa/Maputo',
+ 'delivery_channel' => 'Canal externo documentado', 'delivery_address' => 'Endereço sintético para ensaio; não enviar propostas.',
+ 'public_contact' => 'contacto-interno-que-nao-deve-sair@example.test', 'contact_authorized' => false, 'internal_notes' => 'SEGREDO-INTERNO-QA',
+ 'public_reason' => 'Divulgação de anúncio sintético para ensaio QA, sem contratação real.',
+ 'lots' => [['code' => 'L1', 'description' => 'Lote de bens sintéticos', 'quantities' => '10 unidades de ensaio']], 'attachments' => []];
+ }
+
+ private function create(array $payload): string
+ {
+ $response = $this->post('/management/concursos', ['version' => 0, 'reason' => 'Preparação sintética documentada para ensaio QA.', 'draft' => $payload])->assertSessionHasNoErrors()->assertRedirect();
+
+ return basename(parse_url($response->headers->get('Location'), PHP_URL_PATH));
+ }
+
+ private function save(string $id, array $payload): void
+ {
+ $this->put('/management/concursos/'.$id, ['version' => DB::table('portal_tenders')->where('id', $id)->value('version'), 'reason' => 'Actualização sintética de ensaio com histórico conservado.', 'draft' => $payload])->assertRedirect()->assertSessionHasNoErrors();
+ }
+
+ private function publish(string $id, string $act = 'initial'): array
+ {
+ $plan = $this->postJson('/management/concursos/'.$id.'/previa', ['act' => $act])->assertOk()->json();
+ $this->post('/management/concursos/'.$id.'/publicar', ['act' => $act, 'hash' => $plan['hash'], 'reason' => $plan['payload']['public_reason']])->assertRedirect()->assertSessionHasNoErrors();
+
+ return $plan;
+ }
+
+ public function test_incomplete_draft_recovers_without_becoming_public_and_rejects_stale_save(): void
+ {
+ $id = $this->create(['title' => 'Preparação incompleta']);
+ $this->get('/management/concursos/'.$id)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $p) => $p->component('procurement/edit')->where('record.draft.title', 'Preparação incompleta'));
+ $this->get('/concursos/'.$id)->assertNotFound();
+ $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors(['organization_id', 'deadline', 'regime']);
+ $this->putJson('/management/concursos/'.$id, ['version' => 0, 'reason' => 'Pedido concorrente obsoleto, sem sobrescrever o rascunho.', 'draft' => ['title' => 'Não guardar']])->assertConflict();
+ $this->assertSame(1, DB::table('portal_tender_versions')->where('tender_id', $id)->count());
+ }
+
+ public function test_state_external_private_regimes_and_filters_are_public_without_ug_ea_or_guarantee_defaults(): void
+ {
+ $ids = [];
+ foreach (['state', 'external', 'private'] as $regime) {
+ $p = $this->payload($regime);
+ $ids[$regime] = $this->create($p);
+ $plan = $this->publish($ids[$regime]);
+ $this->assertArrayNotHasKey('responsible_unit', $plan['payload']);
+ $this->assertArrayNotHasKey('internal_notes', $plan['payload']);
+ $this->assertArrayNotHasKey('public_contact', $plan['payload']);
+ }
+ Auth::logout();
+ $this->get('/concursos?regime=external&entity='.$this->organization.'&q=SINT')->assertOk()->assertInertia(fn (Assert $p) => $p->component('procurement/index')->where('filters.regime', 'external')->has('items.data', 1)->where('items.data.0.id', $ids['external']));
+ $this->get('/concursos?q=&entity=§or=&state=open')->assertOk()->assertInertia(fn (Assert $p) => $p->where('filters', ['state' => 'open']));
+ $this->get('/concursos/'.$ids['private'].'?regime=private&q=SINT&page=2')->assertOk()->assertInertia(fn (Assert $p) => $p->component('procurement/show')->where('backUrl', '/concursos?q=SINT®ime=private&page=2')->where('publication.payload.regime', 'private')->missing('publication.payload.internal_notes')->missing('publication.payload.public_contact')->has('history', 1));
+ $this->get('/concursos/'.$ids['state'].'/imprimir')->assertOk()->assertInertia(fn (Assert $p) => $p->where('printMode', true));
+ }
+
+ public function test_extension_preserves_publication_origin_and_history_and_expiry_is_not_an_award(): void
+ {
+ $p = $this->payload();
+ $id = $this->create($p);
+ $this->publish($id);
+ $first = DB::table('portal_tender_versions')->where('tender_id', $id)->where('act', 'initial')->first();
+ $p['deadline'] = '2026-11-25T16:00';
+ $this->save($id, $p);
+ $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'correction'])->assertUnprocessable()->assertJsonValidationErrors('act');
+ $plan = $this->publish($id, 'extension');
+ $this->assertSame('2026-11-20T15:00', $plan['payload']['deadline_previous']);
+ $this->assertSame('2026-10-20T09:00', $plan['payload']['origin_published_at']);
+ $this->assertSame($first->payload, DB::table('portal_tender_versions')->where('id', $first->id)->value('payload'));
+ $this->travelTo(now()->setDate(2026, 11, 26));
+ $this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'deadline_ended')->where('publication.act', 'extension')->has('history', 2));
+ $this->travelBack();
+ $p['result'] = 'Resultado sintético de ensaio, não adjudicação real.';
+ $this->save($id, $p);
+ $this->publish($id, 'award');
+ $this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'results')->has('history', 3));
+ }
+
+ public function test_cancellation_and_later_clarification_do_not_reopen_the_procedure(): void
+ {
+ $p = $this->payload('private');
+ $id = $this->create($p);
+ $this->publish($id);
+ $p['result'] = 'Cancelamento sintético fundamentado para ensaio.';
+ $this->save($id, $p);
+ $this->publish($id, 'cancellation');
+ $p['object'] .= ' Esclarecimento posterior ao cancelamento.';
+ $this->save($id, $p);
+ $this->publish($id, 'clarification');
+ $this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'cancelled')->has('history', 3));
+ }
+
+ public function test_stale_preview_cannot_publish_new_draft_and_public_snapshot_stays_unchanged_until_explicit_publication(): void
+ {
+ $p = $this->payload();
+ $id = $this->create($p);
+ $plan = $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertOk()->json();
+ $p['title'] = 'Nova edição ainda privada';
+ $this->save($id, $p);
+ $this->postJson('/management/concursos/'.$id.'/publicar', ['act' => 'initial', 'hash' => $plan['hash'], 'reason' => $p['public_reason']])->assertConflict();
+ $this->publish($id);
+ $p['title'] = 'Rascunho futuro privado';
+ $this->save($id, $p);
+ $this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.title', 'Nova edição ainda privada'));
+ }
+
+ public function test_documented_minimum_dates_and_business_calendar_are_specific_to_the_procedure(): void
+ {
+ $p = $this->payload();
+ $p['deadline'] = '2026-10-19T15:00';
+ $id = $this->create($p);
+ $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('deadline');
+ $p['origin_published_at'] = '2026-10-23T09:00';
+ $p['deadline'] = '2026-10-27T09:00';
+ $p += ['minimum_days' => 2, 'minimum_source' => 'Regra sintética de contagem, cláusula QA-1', 'minimum_edition' => 'QA-2026', 'minimum_modality' => $p['modality'], 'minimum_day_basis' => 'business', 'minimum_working_days' => '1,2,3,4,5', 'minimum_holidays' => '2026-10-26', 'minimum_calendar_source' => 'Calendário sintético QA: segunda a sexta, exclui 26/10. Não é calendário oficial.'];
+ $this->save($id, $p);
+ $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('deadline');
+ $p['deadline'] = '2026-10-28T09:00';
+ $this->save($id, $p);
+ $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertOk();
+ $p['minimum_modality'] = 'Outra modalidade';
+ $this->save($id, $p);
+ $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('minimum_modality');
+ }
+
+ public function test_simple_account_can_follow_but_cannot_edit_or_publish_and_tracking_is_not_a_bid(): void
+ {
+ $id = $this->create($this->payload());
+ $this->publish($id);
+ $reader = User::factory()->unverified()->create();
+ $before = DB::table('portal_role_assignments')->count();
+ $this->actingAs($reader)->put('/concursos/'.$id.'/acompanhar')->assertRedirect();
+ $this->get('/concursos/acompanhar')->assertOk()->assertInertia(fn (Assert $p) => $p->where('watched', true)->has('items', 1)->where('items.0.changed', false));
+ $this->actingAs($this->editor);
+ $p = $this->payload();
+ $this->save($id, $p);
+ $this->publish($id, 'clarification');
+ $reader->forceFill(['email_verified_at' => now()])->save();
+ $this->actingAs($reader);
+ $this->get('/concursos/acompanhar')->assertInertia(fn (Assert $p) => $p->where('items.0.changed', true));
+ $this->get('/management/concursos')->assertForbidden();
+ $this->postJson('/management/concursos/'.$id.'/publicar', [])->assertForbidden();
+ $this->delete('/concursos/'.$id.'/acompanhar')->assertRedirect();
+ $this->assertSame($before, DB::table('portal_role_assignments')->count());
+ }
+
+ public function test_real_scanned_library_attachment_private_redaction_and_withdrawal_are_reused(): void
+ {
+ Storage::fake('local');
+ config(['queue.default' => 'sync', 'services.malware.scanner' => 'clamd', 'services.malware.socket' => null, 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
+ $this->assertTrue(app(MalwareScanner::class)->health()['available'], 'Current real scanner required');
+ $name = 'synthetic-tender-'.Str::uuid().'.txt';
+ $this->post('/management/portal-services/upload', ['file' => UploadedFile::fake()->createWithContent($name, 'Exemplo sintético de anúncio QA, sem valor jurídico.')])->assertSessionHasNoErrors();
+ $doc = DB::table('documents')->where('file_name', $name)->first();
+ $this->assertSame('clean', $doc->malware_scan_status);
+ $metadata = ['document_id' => $doc->id, 'title' => $name, 'category' => 'Synthetic QA', 'topic' => 'Tender QA', 'document_type' => 'unknown', 'issuer' => 'Synthetic QA only', 'source_url' => 'https://example.invalid/qa', 'language' => 'pt', 'version_label' => 'QA-1', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'unknown', 'rights_status' => 'authorized', 'data_classification' => 'public', 'source_reference' => 'Synthetic QA only, not an official notice.', 'legislation' => false, 'essential' => false, 'independent_review' => false, 'associations' => []];
+ $this->post('/management/portal-services/resources', $metadata)->assertSessionHasNoErrors();
+ $resource = DB::table('portal_resources')->where('document_id', $doc->id)->first();
+ $this->assertNotNull($resource);
+ $p = $this->payload('external');
+ $p['attachments'] = [['resource_id' => $resource->id, 'public' => true, 'purpose' => 'Anúncio de ensaio']];
+ $id = $this->create($p);
+ $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('attachments');
+ $this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
+ $this->publish($id);
+ Auth::logout();
+ $this->get('/documentos/'.$resource->id.'/download')->assertOk()->assertDownload($name);
+ $this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.attachments.0.available', true));
+ $this->actingAs($this->editor)->post('/management/portal-services/resources/'.$resource->id, ['published' => false])->assertSessionHasNoErrors();
+ Auth::logout();
+ $this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
+ $this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.attachments.0.available', false));
+ }
+}
diff --git a/tests/Feature/TerritoryGeometryApprovedAdministrationTest.php b/tests/Feature/TerritoryGeometryApprovedAdministrationTest.php
new file mode 100644
index 00000000..849c6e94
--- /dev/null
+++ b/tests/Feature/TerritoryGeometryApprovedAdministrationTest.php
@@ -0,0 +1,61 @@
+holder();
+ $this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
+ $this->assertFalse($actor->can('manage-portal'));
+ // A missing exemplar must produce the source error, not deny the approved actor.
+ try {
+ app(TerritoryGeometryImport::class)->preview($actor, 1);
+ $this->fail('The deliberately absent source must be reported.');
+ } catch (HttpException $exception) {
+ $this->assertSame(422, $exception->getStatusCode());
+ }
+ $this->getJson('/management/territory-geometry-import')->assertStatus(422);
+ $this->postJson('/management/territory-geometry-import', [])->assertStatus(422);
+ $this->assertFalse($actor->can('manage-portal'));
+ }
+
+ public function test_superuser_without_mfa_session_cannot_import(): void
+ {
+ $actor = $this->holder();
+ $this->actingAs($actor)->getJson('/management/territory-geometry-import')->assertForbidden();
+ $this->postJson('/management/territory-geometry-import', [])->assertForbidden();
+ }
+
+ public function test_ordinary_account_does_not_gain_cartographic_administration(): void
+ {
+ $this->holder();
+ $actor = User::factory()->create();
+ $this->actingAs($actor)->getJson('/management/territory-geometry-import')->assertForbidden();
+ $this->postJson('/management/territory-geometry-import', [])->assertForbidden();
+ }
+
+ private function holder(): User
+ {
+ $identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
+ $this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
+ $this->assertSame(55441, (int) $identity->port);
+ config(['portal.profile_access_enabled' => true, 'portal.managers' => [], 'geometry-import.enabled' => true,
+ 'geometry-import.directory' => storage_path('app/private/nonexistent-demarcation-source')]);
+ $actor = User::factory()->create(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()]);
+ $other = User::factory()->create();
+ DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]);
+ return $actor;
+ }
+}
diff --git a/tests/Unit/CorridorAgribusinessTest.php b/tests/Unit/CorridorAgribusinessTest.php
new file mode 100644
index 00000000..98622460
--- /dev/null
+++ b/tests/Unit/CorridorAgribusinessTest.php
@@ -0,0 +1,48 @@
+ $group) {
+ $original .= ''.chr(97 + $index).') '.$group['name'].'
Cadeias de valor prioritárias: Conteúdo original '.$index.'
Descrição preservada '.$index.'
';
+ }
+ $original .= '4. Âmbito de Investimento
Outros sectores serão acrescentados.
';
+ $service = new CorridorAgribusiness;
+ $html = $service->enrich($original);
+ $doc = new DOMDocument;
+ $doc->loadHTML(''.$html);
+ $xpath = new DOMXPath($doc);
+ $this->assertCount(6, $xpath->query('//ul[starts-with(@id,"agronegocio-oportunidades-")]'));
+ $this->assertCount(34, $xpath->query('//ul[starts-with(@id,"agronegocio-oportunidades-")]/li'));
+ foreach ($data['groups'] as $index => $group) {
+ $list = $doc->getElementById('agronegocio-oportunidades-'.($index + 1));
+ $this->assertSame(count($group['opportunities']), $list->getElementsByTagName('li')->length);
+ foreach ($group['opportunities'] as $opportunity) {
+ $this->assertStringContainsString($opportunity['description'], $list->textContent);
+ }
+ $this->assertStringContainsString(chr(97 + $index).') '.$group['name'], $html);
+ $this->assertStringContainsString('Conteúdo original '.$index, $html);
+ $this->assertStringContainsString('Descrição preservada '.$index, $html);
+ }
+ $this->assertStringContainsString('4. Âmbito de Investimento', $html);
+ $this->assertSame($html, $service->enrich($html));
+ }
+
+ public function test_broader_corridor_names_and_incomplete_sections_are_left_unchanged(): void
+ {
+ $html = 'Corredor de Nacala e Norte
Cadeias de valor prioritárias: preservadas
'
+ .'Corredor Maputo–Limpopo
Cadeias de valor prioritárias: preservadas
'
+ .'Corredor de Nacala
Sem parágrafo de cadeias prioritárias.
';
+ $this->assertSame($html, (new CorridorAgribusiness)->enrich($html));
+ }
+}
diff --git a/tests/Unit/OpportunityCorridorExplorerTest.php b/tests/Unit/OpportunityCorridorExplorerTest.php
new file mode 100644
index 00000000..6a408105
--- /dev/null
+++ b/tests/Unit/OpportunityCorridorExplorerTest.php
@@ -0,0 +1,36 @@
+ 'Primeira', 'province' => 'Sofala', 'district' => 'Dondo']);
+ $first->id = 101;
+ $other = new Opportunity(['title' => 'Outra']); $other->id = 102;
+ $query = Mockery::mock(Builder::class);
+ $query->shouldReceive('get')->once()->andReturn(new Collection([$first, $other]));
+ $catalogue = Mockery::mock(InvestmentCatalogue::class);
+ $filters = ['sector' => 'agronegocio', 'view' => 'corridors'];
+ $catalogue->shouldReceive('query')->with($filters)->once()->andReturn($query);
+ $catalogue->shouldReceive('context')->with(101)->once()->andReturn(['corridors' => collect([(object)['id'=>'a','name'=>'Corredor A'],(object)['id'=>'b','name'=>'Corredor B']])]);
+ $catalogue->shouldReceive('context')->with(102)->once()->andReturn(['corridors'=>collect()]);
+ $this->app->instance(InvestmentCatalogue::class, $catalogue);
+ $groups = app(OpportunityCorridorExplorer::class)->groups($filters);
+ $this->assertSame(['Corredor A','Corredor B','Outras localizações'], array_column($groups,'name'));
+ $this->assertSame([101,101,102], array_map(fn($g)=>$g['opportunities'][0]['id'], $groups));
+ $this->assertSame([1,1,1], array_column($groups,'count'));
+ $this->assertSame(['Sofala / Dondo'], $groups[0]['locations']);
+ $this->assertSame([], $groups[2]['locations']);
+ $this->assertSame([], $groups[0]['opportunities'][0]['stages']);
+ }
+}
diff --git a/tests/Unit/OpportunityPublicTextTest.php b/tests/Unit/OpportunityPublicTextTest.php
new file mode 100644
index 00000000..4cf73911
--- /dev/null
+++ b/tests/Unit/OpportunityPublicTextTest.php
@@ -0,0 +1,28 @@
+ 'investimentos nos corredores.docx', 'source_sha256' => CorridorAgribusinessOpportunities::SOURCE_HASH, 'corridor' => 'Corredor de Maputo'];
+ $body = 'Produção de arroz.'."\n\nFonte: ".$source['source'].'; SHA-256 '.$source['source_sha256']."\n\n".CorridorAgribusinessOpportunities::LIMITATIONS;
+ $record = new Opportunity(['description' => $body]);
+ $record->workflow_context = ['corridor_agribusiness' => $source];
+ $public = $record->toArray();
+ $this->assertStringContainsString('Produção de arroz.', $public['description']);
+ $this->assertStringNotContainsString('SHA-256', $public['description']);
+ $this->assertStringNotContainsString('.docx', $public['description']);
+ $this->assertSame($body, $record->description);
+ $this->assertArrayNotHasKey('workflow_context', $public);
+ $other = new Opportunity(['description' => $body]);
+ $this->assertSame($body, OpportunityPublicText::description($other, $body));
+ $this->assertSame('Texto revisto pela equipa.', OpportunityPublicText::description($record, 'Texto revisto pela equipa.'));
+ }
+}
diff --git a/tests/frontend/context-inheritance.test.mjs b/tests/frontend/context-inheritance.test.mjs
index 41259c8f..d04a3829 100644
--- a/tests/frontend/context-inheritance.test.mjs
+++ b/tests/frontend/context-inheritance.test.mjs
@@ -7,11 +7,57 @@ test('legacy draft without a compatible source keeps its shape and identity',()=
assert.equal(Object.hasOwn(inheritSimulatorFields(legacy),'compatible_fields_snapshot'),false);
});
import test from 'node:test';
-import { inheritCompatibleFields, inheritSimulatorFields } from '../../resources/js/lib/context-inheritance.ts';
+import { inheritCompatibleFields, inheritSimulatorFields, reusedInvestmentValue } from '../../resources/js/lib/context-inheritance.ts';
import { serializeBusinessPlanSnapshot } from '../../resources/js/lib/business-financial-plan.ts';
import { monthlySalesFromDraft } from '../../resources/js/lib/monthly-iva.ts';
import { restoreSimulationDraft, partitionSimulationDraft } from '../../resources/js/lib/simulation-form.ts';
+test('known investment questions become editable summaries after save/recovery, while missing and deliberate edits remain questions', () => {
+ const scope='dossier:A:simulator';
+ const source={project_title:'Florestas — Beira',project_summary:'Preparar investimento florestal',province_id:'p1',province_id_label:'Sofala',sector_id:'3',sector_id_label:'Agronegócio'};
+ const reused=inheritCompatibleFields(scope,{},source,null);
+ const draft={...reused.patch,context_reuse:reused.metadata,purpose:'simulate_only',journey_type:'investor'};
+ const saved=partitionSimulationDraft(draft);
+ const recovered=restoreSimulationDraft(saved.personal_snapshot,saved.economic_snapshot);
+ assert.equal(reusedInvestmentValue(scope,'project_title',recovered),source.project_title);
+ assert.equal(reusedInvestmentValue(scope,'province_id',recovered),'Sofala');
+ assert.equal(reusedInvestmentValue(scope,'sector_id',recovered),'Agronegócio');
+ assert.equal(reusedInvestmentValue(scope,'district_id',recovered),null);
+ assert.equal(reusedInvestmentValue('dossier:B:simulator','project_title',recovered),null);
+ assert.equal(reusedInvestmentValue(scope,'project_title',{...recovered,project_title:'Edição deliberada'}),null);
+ assert.equal(reusedInvestmentValue(scope,'project_title',{...recovered,project_title:''}),null);
+ assert.equal(reusedInvestmentValue(scope,'province_id',{...recovered,province_id_label:''}),null);
+ assert.equal(reusedInvestmentValue(scope,'currency',{...recovered,currency:'MZN'}),null);
+ assert.equal(reusedInvestmentValue(scope,'project_title',{...recovered,context_reuse:'invalid'}),null);
+});
+
+test('local geography and sector keep their labels when the dossier proposes different identifiers', () => {
+ const scope='dossier:A:simulator';
+ const source={province_id:'p1',province_id_label:'Sofala',district_id:'d1',district_id_label:'Dondo',sector_id:'3',sector_id_label:'Agronegócio'};
+ const local={province_id:'p2',province_id_label:'Gaza',district_id:'d2',district_id_label:'Outro distrito',sector_id:'4',sector_id_label:'Turismo'};
+ const reconciliation=inheritCompatibleFields(scope,local,source,null);
+ assert.deepEqual(reconciliation.patch,{});
+ assert.ok(reconciliation.conflicts.includes('province_id'));
+ const first=inheritCompatibleFields(scope,{},source,null);
+ const changed=inheritCompatibleFields(scope,{...first.patch,context_reuse:first.metadata},{...source,province_id:'p3',province_id_label:'Manica',district_id:'',district_id_label:''},first.metadata);
+ assert.equal(changed.patch.province_id,'p3');assert.equal(changed.patch.province_id_label,'Manica');
+ assert.equal(changed.patch.district_id,'');assert.equal(changed.patch.district_id_label,'');
+ const stale=inheritCompatibleFields(scope,{province_id_label:'Nome antigo'},source,null);
+ assert.equal(stale.patch.province_id,'p1');assert.equal(stale.patch.province_id_label,'Sofala');
+});
+
+test('investment entry selects the existing simulation route without replacing an earlier deliberate purpose', () => {
+ const scope='dossier:A:simulator';
+ const source={journey_type:'investor',journey_type_label:'Avaliar o meu investimento',purpose:'simulate_only',purpose_label:'Apenas simular'};
+ const first=inheritCompatibleFields(scope,{},source,null);
+ const values={...first.patch,context_reuse:first.metadata};
+ assert.equal(reusedInvestmentValue(scope,'journey_type',values),'Avaliar o meu investimento');
+ assert.equal(reusedInvestmentValue(scope,'purpose',values),'Apenas simular');
+ const old=inheritCompatibleFields(scope,{journey_type:'small_business',purpose:'seek_partner'},source,null);
+ assert.deepEqual(old.patch,{});
+ assert.equal(reusedInvestmentValue(scope,'purpose',{purpose:'seek_partner',context_reuse:old.metadata}),null);
+});
+
test('inherited source updates follow through recovery while local edits and deliberate clears survive',()=>{
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN',project_title:'A'}, null);
const saved={...first.patch,context_reuse:first.metadata};
diff --git a/tests/frontend/investment-field-origin.test.mjs b/tests/frontend/investment-field-origin.test.mjs
new file mode 100644
index 00000000..588dbe19
--- /dev/null
+++ b/tests/frontend/investment-field-origin.test.mjs
@@ -0,0 +1,22 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { inheritCompatibleFields } from '../../resources/js/lib/context-inheritance.ts';
+import { buildProjectDossierRows, exportProjectDossierCsv } from '../../resources/js/lib/project-dossier.ts';
+import { createProjectDossierReport, createProjectDossierPrintHtml } from '../../resources/js/lib/project-dossier-report.ts';
+
+test('reports distinguish opportunity inheritance, dossier declarations and later edits without rewriting the snapshot', async()=>{
+ const source={project_title:'Título do promotor',project_summary:'Resumo da oportunidade',sector_id:'1',sector_id_label:'Agronegócio',context_applicability_snapshot:JSON.stringify({version:1,field_provenance:{project_summary:{kind:'opportunity',title:'Florestas — Corredor da Beira',version:2},sector_id:{kind:'opportunity',title:'Florestas — Corredor da Beira',version:2},project_title:{kind:'process_declaration',context_revision:1}}})};
+ const inherited=inheritCompatibleFields('dossier:A:simulator',{},source,null);
+ const draft={...inherited.patch,context_reuse:inherited.metadata};
+ let rows=buildProjectDossierRows(draft);
+ assert.match(rows.find(r=>r[1]==='Resumo')[3],/Herdado de oportunidade «Florestas/);
+ assert.match(rows.find(r=>r[1]==='Título')[3],/dados introduzidos pelo utilizador no dossier/);
+ const edited={...draft,project_summary:'Resumo alterado no simulador'}; const before=JSON.stringify(edited);
+ rows=buildProjectDossierRows(edited);
+ assert.match(rows.find(r=>r[1]==='Resumo')[3],/Alterado posteriormente pelo utilizador; origem anterior: oportunidade/);
+ assert.match(exportProjectDossierCsv(edited),/origem anterior: oportunidade/);
+ const report=createProjectDossierReport(edited,{selectedSections:new Set(['summary','project'])});
+ assert.match(await createProjectDossierPrintHtml(report,[],false),/Resumo alterado no simulador/);
+ assert.equal(JSON.stringify(edited),before);
+ assert.equal(buildProjectDossierRows({project_summary:'Legado'}).find(r=>r[1]==='Resumo')[3],'Declarado pelo utilizador');
+});
diff --git a/tests/frontend/investment-land-preparation.test.mjs b/tests/frontend/investment-land-preparation.test.mjs
new file mode 100644
index 00000000..906583f8
--- /dev/null
+++ b/tests/frontend/investment-land-preparation.test.mjs
@@ -0,0 +1,54 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { investmentLandPreparation, landStatusVisible } from '../../resources/js/lib/investment-land-preparation.ts';
+import { simulationFieldGroups, validateSimulationDraft, partitionSimulationDraft, restoreSimulationDraft } from '../../resources/js/lib/simulation-form.ts';
+import { inheritCompatibleFields } from '../../resources/js/lib/context-inheritance.ts';
+import { buildProjectDossierRows, exportProjectDossierCsv } from '../../resources/js/lib/project-dossier.ts';
+import { createProjectDossierReport, createProjectDossierPrintHtml } from '../../resources/js/lib/project-dossier-report.ts';
+
+const snapshot = (need) => JSON.stringify({version:1,context_revision:2,land_preparation:{need,reason:'Decisão guardada no dossier'}});
+const draft = (need) => ({purpose:'simulate_only',journey_type:'investor',land_status:'duat',land_status_label:'DUAT',context_applicability_snapshot:snapshot(need)});
+
+test('no hides land status and yes restores the same saved value; unknown is a pending decision without a required answer', () => {
+ const field=simulationFieldGroups.location.find(field=>field.key==='land_status');
+ for(const need of ['no','yes','unknown']) {
+ const input=draft(need);const before=JSON.stringify(input);
+ assert.equal(field.showWhen(input),need==='yes');
+ assert.equal(Boolean(validateSimulationDraft({...input,land_status:''},'location').land_status),false);
+ const saved=partitionSimulationDraft(input);const recovered=restoreSimulationDraft(saved.personal_snapshot,saved.economic_snapshot);
+ assert.equal(recovered.land_status,'duat');assert.equal(recovered.land_status_label,'DUAT');
+ assert.equal(investmentLandPreparation(recovered.context_applicability_snapshot).need,need);
+ assert.equal(JSON.stringify(input),before);
+ }
+});
+
+test('old snapshots use S10 and absent or malformed decisions never imply yes or no', () => {
+ for(const [applicability,need] of [['applicable','yes'],['not_needed_for_preparation','no'],['undetermined','unknown']])
+ assert.equal(investmentLandPreparation(JSON.stringify({version:1,path:{steps:[{code:'S10',applicability}]}})).need,need);
+ for(const raw of ['', 'invalid', '{}', '{"version":1,"land_preparation":{"need":"invalid"}}'])
+ assert.equal(investmentLandPreparation(raw).need,'unknown');
+ assert.equal(landStatusVisible({}),true); // Standalone simulator preserves its previous behaviour.
+});
+
+test('current dossier assessment refreshes after server recovery without overwriting the saved land answer', () => {
+ const scope='dossier:A:simulator';
+ const inherited=inheritCompatibleFields(scope,{}, {context_applicability_snapshot:snapshot('yes'),land_status:'duat'},null);
+ const recovered={...inherited.patch,context_reuse:inherited.metadata,context_applicability_snapshot:snapshot('no')};
+ const restored=inheritCompatibleFields(scope,recovered,{context_applicability_snapshot:snapshot('yes')},recovered.context_reuse);
+ assert.equal(restored.patch.context_applicability_snapshot,snapshot('yes'));
+ assert.equal(Object.hasOwn(restored.patch,'land_status'),false);
+});
+
+test('report, CSV and print reflect yes, no and unknown without treating historical DUAT as current when hidden', async () => {
+ for(const need of ['yes','no','unknown']) {
+ const input=draft(need);const before=JSON.stringify(input);
+ const rows=buildProjectDossierRows(input);
+ assert.equal(rows.some(row=>row[1]==='Situação do terreno'),need==='yes');
+ const report=createProjectDossierReport(input,{selectedSections:new Set(['location'])});
+ const print=await createProjectDossierPrintHtml(report,[],false);
+ const csv=exportProjectDossierCsv(input);
+ if(need==='yes'){assert.match(print,/DUAT/);assert.match(csv,/DUAT/);}
+ else {assert.doesNotMatch(print,/DUAT/);assert.doesNotMatch(csv,/DUAT/);assert.match(print,need==='no'?/valores anteriores conservados/:/Definir se precisa de terra/);}
+ assert.equal(JSON.stringify(input),before);
+ }
+});
diff --git a/tests/frontend/investment-portfolio-continuity.test.mjs b/tests/frontend/investment-portfolio-continuity.test.mjs
new file mode 100644
index 00000000..2f71a7cc
--- /dev/null
+++ b/tests/frontend/investment-portfolio-continuity.test.mjs
@@ -0,0 +1,25 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { inheritCompatibleFields } from '../../resources/js/lib/context-inheritance.ts';
+import { buildProjectDossierRows, exportProjectDossierCsv } from '../../resources/js/lib/project-dossier.ts';
+import { createProjectDossierReport, createProjectDossierPrintHtml } from '../../resources/js/lib/project-dossier-report.ts';
+
+test('selected associations stay attached to each opportunity in reports and do not replace deliberate simulator answers', async()=>{
+ const choices=[
+ {opportunity:{key:'opportunity:1',title:'Oportunidade QA A'},territory:{title:'Sofala / Dondo'},portfolio_origins:[{title:'Âncora QA',id:'p',sha256:'internal-hash'}]},
+ {opportunity:{key:'opportunity:2',title:'Oportunidade QA B'},territory:{title:'Manica'},portfolio_origins:[{title:'Cluster QA',id:'c'}]},
+ ];
+ const snapshot=JSON.stringify({version:1,context_revision:3,opportunity_choices:choices,land_preparation:{need:'unknown'}});
+ const values={project_title:'Título deliberado',province_id:'local-edit',context_applicability_snapshot:'old'};
+ const result=inheritCompatibleFields('dossier:A:simulator',values,{project_title:'Origem',province_id:'source',context_applicability_snapshot:snapshot},null);
+ assert.equal(result.patch.project_title,undefined);assert.equal(result.patch.province_id,undefined);
+ assert.equal(result.patch.context_applicability_snapshot,snapshot);
+ const draft={...values,...result.patch};const before=JSON.stringify(draft);
+ const rows=buildProjectDossierRows(draft);
+ assert.ok(rows.some(row=>row[1]==='Associação escolhida na carteira'&&row[2]==='Âncora QA'&&row[3].startsWith('Oportunidade QA A;')));
+ assert.ok(rows.some(row=>row[1]==='Associação escolhida na carteira'&&row[2]==='Cluster QA'&&row[3].startsWith('Oportunidade QA B;')));
+ assert.match(exportProjectDossierCsv(draft),/Sofala \/ Dondo/);
+ const html=await createProjectDossierPrintHtml(createProjectDossierReport(draft,{selectedSections:new Set(['location'])}),[],false);
+ assert.match(html,/Âncora QA/);assert.match(html,/Cluster QA/);assert.match(html,/Manica/);
+ assert.doesNotMatch(html,/internal-hash/);assert.equal(JSON.stringify(draft),before);
+});
diff --git a/tests/frontend/project-dossier-report.test.mjs b/tests/frontend/project-dossier-report.test.mjs
index 7d91670e..d4a55dc3 100644
--- a/tests/frontend/project-dossier-report.test.mjs
+++ b/tests/frontend/project-dossier-report.test.mjs
@@ -12,6 +12,29 @@ import {
validateDossierAttachments,
} from '../../resources/js/lib/project-dossier-report.ts';
+test('report separates inapplicable documents from missing and unknown requirements without changing the saved snapshot', async () => {
+ const requirements = [
+ { label: 'Título DUAT', state: 'not_applicable', reason: 'Arrendamento declarado' },
+ { label: 'Identificação', state: 'missing', reason: 'Pedido em preparação' },
+ { label: 'Capital externo', state: 'indeterminate', reason: 'Origem por determinar' },
+ ];
+ const snapshot = JSON.stringify({ version: 1, context_revision: 2, preparation: {
+ enabled: true, mode: 'registration', version: 'QA', next: 'Completar identificação',
+ calculation_notice: 'Cálculo separado', notice: 'Preparação', requirements,
+ } });
+ const draft = { context_applicability_snapshot: snapshot };
+ const report = createProjectDossierReport(draft, { selectedSections: new Set(['sources']) });
+ const section = report.sections.find((section) => section.id === 'sources');
+ assert.ok(section);
+ assert.equal(section.rows.some((row) => row.field === 'Título DUAT'), false);
+ assert.match(section.paragraphs.join(' '), /Não exigido neste contexto: Título DUAT \(Arrendamento declarado\)/);
+ assert.equal(section.rows.find((row) => row.field === 'Identificação').value, 'Documento por associar');
+ assert.equal(section.rows.find((row) => row.field === 'Capital externo').value, 'Informação em falta para decidir');
+ const print = await createProjectDossierPrintHtml(report, [], false);
+ assert.match(print, /Arrendamento declarado/);
+ assert.equal(draft.context_applicability_snapshot, snapshot);
+});
+
test('executive summary exposes the same financial result and period without replacing missing data by zero', () => {
const draft = { currency:'MZN', financial_tools_snapshot:serializeFinancialToolsSnapshot({budgetPeriod:'Mensal',loan:{principal:'1200',rate:'0',months:'12',rateType:'nominal',periodMonths:'1',gracePeriods:'0',upfrontFee:'0',recurringFee:'0'},breakEven:{fixed:'100.01',price:'30',variable:'20'},funding:{total:'1000',secured:'800'},incomes:[{label:'Receita QA',amount:'1000'}],expenses:[{label:'Despesa QA',amount:'1100'}]}) };
const report = createProjectDossierReport(draft,{selectedSections:new Set(['summary','finance'])});
diff --git a/tests/frontend/search-territory.test.mjs b/tests/frontend/search-territory.test.mjs
index 1cac6baf..7a5f95e0 100644
--- a/tests/frontend/search-territory.test.mjs
+++ b/tests/frontend/search-territory.test.mjs
@@ -1,6 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { searchTerritoryHref } from '../../resources/js/lib/search-territory.ts';
+import { searchTerritoryHref, investmentPreparationHref } from '../../resources/js/lib/search-territory.ts';
+
+test('preparation carries only the validated portfolio origin and territorial choices, including nested catalogue return context',()=>{
+ const href=investmentPreparationHref(53,'/oportunidades/florestas?province_id=p&district_id=d&q=private-note&context=other','origin');
+ const query=new URL(href,'https://portal.invalid').searchParams;
+ assert.equal(query.get('source'),'opportunity:53');assert.equal(query.get('from_dossier'),'origin');
+ assert.equal(query.get('province_id'),'p');assert.equal(query.get('district_id'),'d');
+ assert.equal(query.has('q'),false);assert.equal(query.has('context'),false);
+ assert.equal(new URL(investmentPreparationHref(53,'/oportunidades/florestas?catalogue_query='+encodeURIComponent('province_id=p&district_id=d')),'https://portal.invalid').searchParams.get('district_id'),'d');
+});
test('portfolio to catalogue to related screen retains only territorial search filters', () => {
const start = '/carteira?province_id=inh&district_id=vil&q=airport&kind=project';
diff --git a/tests/frontend/simulation-draft-etag.test.mjs b/tests/frontend/simulation-draft-etag.test.mjs
new file mode 100644
index 00000000..1d54ac74
--- /dev/null
+++ b/tests/frontend/simulation-draft-etag.test.mjs
@@ -0,0 +1,12 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { simulationDraftEtag } from '../../resources/js/lib/simulation-draft-etag.ts';
+
+test('compressed draft reads use the exact version token while genuinely stale versions stay different', () => {
+ const tag='"sfd-6c07a968-7ff9-42fa-b252-072bb1acf221-v2"';
+ assert.equal(simulationDraftEtag(`W/${tag}`),tag);
+ assert.equal(simulationDraftEtag(tag),tag);
+ assert.notEqual(simulationDraftEtag(`W/${tag}`),tag.replace('-v2','-v3'));
+ assert.equal(simulationDraftEtag('W/"another-resource"'),'W/"another-resource"');
+ assert.equal(simulationDraftEtag(null),null);
+});
diff --git a/tools/corridor-agribusiness-qa-router.php b/tools/corridor-agribusiness-qa-router.php
new file mode 100644
index 00000000..699c9cf6
--- /dev/null
+++ b/tools/corridor-agribusiness-qa-router.php
@@ -0,0 +1,23 @@
+ 'testing', 'APP_URL' => 'http://127.0.0.1:18574', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1',
+ 'DB_PORT' => '55441', 'DB_DATABASE' => 'simulator_fiscal_delta_20261021_test', 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '',
+ 'PORTAL_PROFILE_ACCESS_ENABLED' => 'true', 'PORTAL_OPPORTUNITY_SPECIALIZED_DRAFTS_ENABLED' => 'true',
+ 'INVESTOR_PATH_ENABLED' => 'true', 'SESSION_DRIVER' => 'file', 'SESSION_COOKIE' => 'agribusiness_opportunities_qa',
+ 'CACHE_STORE' => 'array', 'MAIL_MAILER' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $k => $v) {
+ putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v;
+}
+$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
+$public = realpath(__DIR__.'/../public');
+$asset = realpath($public.rawurldecode($path));
+if ($asset && str_starts_with($asset, $public.DIRECTORY_SEPARATOR) && is_file($asset) && pathinfo($asset, PATHINFO_EXTENSION) !== 'php') return false;
+define('LARAVEL_START', microtime(true));
+require __DIR__.'/../vendor/autoload.php';
+$app = require __DIR__.'/../bootstrap/app.php';
+$app->make(Illuminate\Contracts\Http\Kernel::class)->bootstrap();
+$target = Illuminate\Support\Facades\DB::selectOne('SELECT current_database() AS name, inet_server_port() AS port');
+if (! $app->environment('testing') || $target->name !== 'simulator_fiscal_delta_20261021_test' || (int) $target->port !== 55441) {
+ http_response_code(503); exit('QA target rejected');
+}
+$app->handleRequest(Illuminate\Http\Request::capture());
diff --git a/tools/legacy-correspondence-audit.py b/tools/legacy-correspondence-audit.py
new file mode 100644
index 00000000..2d2fd387
--- /dev/null
+++ b/tools/legacy-correspondence-audit.py
@@ -0,0 +1,196 @@
+"""Reconcile the 50 captured identities; public GETs only, no DB/publication writes."""
+from pathlib import Path
+from html.parser import HTMLParser
+from concurrent.futures import ThreadPoolExecutor
+import collections, csv, datetime, hashlib, json, re, subprocess, sys, urllib.request
+
+ROOT = Path(__file__).resolve().parents[1]
+OUT = ROOT / 'outputs/legacy-correspondence-20261022'
+OUT.mkdir(parents=True, exist_ok=True)
+AUDIT = ROOT / 'docs/investmoz/v2/auditoria-portal'
+reconciliation = json.loads((ROOT / 'outputs/investmoz-legado-inventario-reconciliado-20261015/RECONCILIACAO-LEGADO.json').read_text(encoding='utf-8'))
+inventory = json.loads((ROOT / 'storage/app/private/investmoz-v2/portal-audit/legacy-discovery/inventory.json').read_text(encoding='utf-8'))
+config = json.loads(subprocess.check_output(['php', '-r', "$c=require 'config/legacy_content.php';echo json_encode($c);"], cwd=ROOT))
+mapped = {int(re.search(r'post-(\d+)-', item['external_id'])[1]): (slug, item) for slug, item in config['articles'].items() if item['publishable']}
+assert len(mapped) == 20 and len(reconciliation['articles']) == 50
+
+class Page(HTMLParser):
+ data = None
+ reading = False
+ chunks = None
+ def handle_starttag(self, tag, attrs):
+ attrs = dict(attrs)
+ if tag == 'script' and attrs.get('type') == 'application/json' and 'data-page' in attrs:
+ self.reading = True; self.chunks = []
+ elif 'data-page' in attrs and attrs['data-page'].startswith('{'):
+ self.data = json.loads(attrs['data-page'])
+ def handle_data(self, value):
+ if self.reading: self.chunks.append(value)
+ def handle_endtag(self, tag):
+ if tag == 'script' and self.reading:
+ self.data = json.loads(''.join(self.chunks)); self.reading = False
+
+def fetch(pair):
+ legacy_id, (slug, identity) = pair
+ url = 'https://teste-investidor.tempo.co.mz/' + slug
+ try:
+ request = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 InvesteMoz-Legacy-Correspondence', 'Cache-Control': 'no-cache'})
+ with urllib.request.urlopen(request, timeout=35) as response:
+ raw = response.read(); status = response.status
+ page = Page(); page.feed(raw.decode('utf-8'))
+ article = page.data['props']['article']
+ assert page.data['component'] == 'portal/legacy' and article['legacy_id'] == legacy_id
+ assert article['source_url'] == identity['source_url'] and article['html'].strip()
+ return legacy_id, {'url': url, 'status': status, 'identity_verified': True,
+ 'resource_id': article['id'], 'document_sha256': article['sha256'],
+ 'current_title': article['title'], 'image_url': article['image']['href'] if article.get('image') else None,
+ 'response_sha256': hashlib.sha256(raw).hexdigest()}
+ except Exception as error:
+ return legacy_id, {'url': url, 'identity_verified': False, 'error': type(error).__name__, 'status': getattr(error, 'code', None)}
+
+if '--reuse-http' in sys.argv:
+ cached = json.loads((OUT / 'public-pages.json').read_text(encoding='utf-8'))
+ checks = {int(k): v for k, v in cached['pages'].items()}; stamp = cached['checked_utc']
+ assert set(checks) == set(mapped)
+else:
+ with ThreadPoolExecutor(max_workers=4) as pool:
+ checks = dict(pool.map(fetch, mapped.items()))
+ stamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
+(OUT / 'public-pages.json').write_text(json.dumps({'checked_utc': stamp, 'method': 'Anonymous HTTP GET; Inertia article identity/source/content checked; no visual acceptance or new publication', 'pages': checks}, ensure_ascii=False, indent=2), encoding='utf-8')
+
+captures = {}
+for record in inventory['records']:
+ post = record.get('public_props', {}).get('post')
+ if post:
+ captures.setdefault(int(post['id']), []).append(record)
+staged = collections.defaultdict(list)
+prepared = {}
+for article in reconciliation['articles']:
+ legacy_id = article['legacy_id']
+ if legacy_id in mapped or article['group'] in ['mercado_financeiro', 'identidade_incompleta']:
+ continue
+ record = next(r for r in captures[legacy_id] if hashlib.sha256(r['public_props']['post']['content'].encode()).hexdigest() == article['body_sha256'])
+ original = ROOT / record['private_file']
+ assert original.is_file() and hashlib.sha256(original.read_bytes()).hexdigest() == record['sha256']
+ post = record['public_props']['post']; seo = record['public_props']['seo']
+ group = article['group']; directory = OUT / 'private-preparation' / group; directory.mkdir(parents=True, exist_ok=True)
+ payload = {'format': 'investmoz-legacy-article-v1', 'legacy_id': legacy_id, 'title': article['title'],
+ 'author': seo.get('author', 'tempo'), 'legacy_category': article['category'],
+ 'source_url': record['url'], 'published_at': seo.get('publishedTime', ''),
+ 'modified_at': article['modified_at'], 'content_html': post['content'],
+ 'image_source_url': post.get('thumbnail'), 'image_sha256': None,
+ 'references': article['references']}
+ data = (json.dumps(payload, ensure_ascii=False, indent=2) + '\n').encode()
+ filename = f"post-{legacy_id}-{article['modified_at'][:10].replace('-', '')}.txt"
+ path = directory / filename
+ if path.exists():
+ assert path.read_bytes() == data, 'Prepared version changed; preserve prior bytes'
+ else:
+ path.write_bytes(data)
+ item = {'legacy_id': legacy_id, 'title': article['title'], 'source_url': record['url'],
+ 'external_id': f"legacy-tempo-post-{legacy_id}-{article['modified_at'][:10].replace('-', '')}",
+ 'file_name': filename, 'relative_path': f'{group}/{filename}',
+ 'sha256': hashlib.sha256(data).hexdigest(), 'source_capture_sha256': record['sha256'],
+ 'original_body_sha256': article['body_sha256'], 'bytes': len(data), 'published': False,
+ 'scan': 'not_run_on_prepared_derivative', 'dependencies': article['dependencies'],
+ 'note': 'Private preparation only. Not imported, scanned, published or assigned to a current opportunity. Image unresolved, never substituted by generic image.'}
+ staged[group].append(item); prepared[legacy_id] = item
+assert len(prepared) == 28
+qa_receipt_path = OUT / 'qa-import-receipt.json'
+qa_rows = {}
+if qa_receipt_path.exists():
+ proof = json.loads(qa_receipt_path.read_text(encoding='utf-8'))
+ assert proof['qa_database'] == 'simulator_fiscal_delta_20261021_test' and proof['port'] == 55441
+ assert proof['imported_private'] == proof['clean_actual_scans'] == proof['idempotent_replay_reused'] == 28
+ qa_rows = {int(re.search(r'post-(\d+)-', r['external_id'])[1]): r for r in proof['resources']}
+ assert set(qa_rows) == set(prepared)
+ assert all(not r['published'] and r['malware_scan_status'] == 'clean' and r['sha256'] == prepared[i]['sha256'] for i, r in qa_rows.items())
+for group, items in staged.items():
+ (OUT / 'private-preparation' / group / 'preparation.json').write_text(json.dumps({'importable_manifest': False, 'group': group, 'items': items}, ensure_ascii=False, indent=2), encoding='utf-8')
+
+path = AUDIT / 'legado-conteudos-reconciliados.csv'
+with path.open(encoding='utf-8-sig', newline='') as f:
+ reader = csv.DictReader(f); columns = reader.fieldnames; rows = list(reader)
+editorial_receipt = AUDIT / 'legado-editorial-qa-20261022.json'
+editorial_rows = {}
+if editorial_receipt.exists():
+ editorial = json.loads(editorial_receipt.read_text(encoding='utf-8'))
+ assert editorial['qa_only'] and not editorial['destination_imported'] and not editorial['destination_published']
+ editorial_rows = {int(re.search(r'post-(\d+)-', r['external_id'])[1]): r for r in editorial['rows']}
+ assert len(editorial_rows) == 28 and all(r['private'] for r in editorial_rows.values())
+extra = ['Endereço no novo portal', 'Estado actual consolidado', 'Pendência actual', 'Conferência HTTP UTC', 'Grupo de preparação QA']
+destination_receipt = ROOT / 'docs/investmoz/unification/legacy-editorial-installation-receipt-20261022.json'
+destination_rows = {}
+if destination_receipt.exists():
+ destination = json.loads(destination_receipt.read_text(encoding='utf-8'))
+ assert destination['new_publications'] == 0
+ destination_rows = {r['legacy_id']: r for r in destination['rows']}
+ assert len(destination_rows) == 28 and all(r['private'] for r in destination_rows.values())
+for col in extra:
+ if col not in columns: columns.append(col)
+articles = {a['legacy_id']: a for a in reconciliation['articles']}
+for row in rows:
+ legacy_id = int(row['ID legado']); article = articles[legacy_id]; check = checks.get(legacy_id)
+ row['Endereço no novo portal'] = check['url'] if check else ''
+ row['Conferência HTTP UTC'] = stamp if check else ''
+ row['Grupo de preparação QA'] = article['group'] if legacy_id in prepared else ''
+ if check:
+ row['Estado actual consolidado'] = 'Publicado; identidade e conteúdo consultáveis na homologação' if check['identity_verified'] else 'Publicação documentada; consulta actual não confirmada'
+ row['Pendência actual'] = 'Não foi feita aceitação visual ou conferência pela Tempo nesta passagem.' if check['identity_verified'] else str(check)
+ if check['identity_verified']:
+ row['Situação'] = 'publicado'; row['Recurso ativo'] = str(check['resource_id'])
+ row['Grupo restante'] = 'publicados'
+ row['Dependência concreta'] = 'Sem pendência de integração desta identidade; mantém-se a revisão editorial corrente.'
+ row['Próxima ação'] = 'Preservar a publicação; gerir edições e versões pela biblioteca existente.'
+ row['Imagem atual'] = json.dumps({'scope': 'homologação', 'href': check['image_url'], 'status': 'referência na ficha; binário não reensaiado'}, ensure_ascii=False)
+ elif legacy_id in prepared:
+ row['Estado actual consolidado'] = 'Importado privado em QA; scan real limpo; por integrar na homologação' if legacy_id in qa_rows else 'Por integrar; exemplar derivado preparado privadamente, sem importação'
+ if legacy_id in qa_rows: row['Recurso QA'] = str(qa_rows[legacy_id]['id'])
+ row['Pendência actual'] = 'Fechar edição/destino de leitura e publicação por lote na homologação. Dependências históricas de 15/10, por confrontar com o estado actual (não são novos bloqueios): ' + ' | '.join(article['dependencies'])
+ if legacy_id in qa_rows:
+ row['Dependência concreta'] = 'Rascunho QA analisado; falta fechar leitor/edição e entrega/publicação na homologação. Referências externas e imagens não recolhidas continuam discriminadas no inventário.'
+ row['Próxima ação'] = 'Continuar por lote, preservando o carácter histórico/editorial; não converter em oportunidade vigente nem em regra fiscal.'
+ elif article['group'] == 'mercado_financeiro':
+ row['Estado actual consolidado'] = 'Original vazio'; row['Pendência actual'] = 'Fornecer corpo editorial pela Tempo; imagem e Fintech não substituem o original. Sem novo endereço público.'
+ else:
+ row['Estado actual consolidado'] = 'Original sem título'; row['Pendência actual'] = 'Identificação editorial pela Tempo para o post 74364; corpo com 21 caracteres úteis preservado. Sem título ou endereço novo inventado.'
+ row['Evidência atual'] = 'outputs/legacy-correspondence-20261022/public-pages.json; qa-import-receipt.json; editorial-gaps-current.json; inventário histórico de 15/10 preservado'
+ if legacy_id in editorial_rows:
+ assert editorial_rows[legacy_id]['original_sha256'] == prepared[legacy_id]['sha256']
+ row['Estado actual consolidado'] = 'Privado em QA; edição, leitura e prévia disponíveis; entrega privada preparada'
+ row['Pendência actual'] = 'Revisão editorial pela equipa; integração Git e transporte privado autenticado para homologação; publicação separada pelo portal. Imagens não verificadas mantêm referência/aviso.'
+ if legacy_id == 62413:
+ row['Pendência actual'] += ' Rever/corrigir a nota de demonstração QA antes de publicar.'
+ row['Dependência concreta'] = row['Pendência actual']
+ row['Próxima ação'] = 'Seguir o guia e a sequência de entrega privada do manual; não repetir importação QA ou provas dos 20 aceites.'
+ row['Evidência atual'] = 'legado-editorial-qa-20261022.json; manual: Legado — edição e leitura dos 28 rascunhos em QA; inventário e referências históricas preservados'
+ if legacy_id in destination_rows:
+ item = destination_rows[legacy_id]
+ assert item['sha256'] == prepared[legacy_id]['sha256']
+ row['Endereço no novo portal'] = f"https://teste-investidor.tempo.co.mz/management/portal-services/resources/{item['resource_id']}/article/preview"
+ row['Recurso ativo'] = str(item['resource_id'])
+ row['Estado actual consolidado'] = 'Rascunho privado na homologação; pesquisa, editor, prévia e recuperação conferidos'
+ row['Pendência actual'] = 'Revisão editorial e publicação separada pela equipa no portal. As referências sem exemplar de imagem verificado permanecem explícitas.'
+ if legacy_id == 62413:
+ row['Pendência actual'] += ' Rever/corrigir a nota de demonstração QA antes de publicar.'
+ row['Dependência concreta'] = row['Pendência actual']
+ row['Próxima ação'] = 'Rever pela gestão; corrigir e pré-visualizar; publicar expressamente quando autorizado, sem comandos.'
+ row['Evidência atual'] = 'docs/investmoz/unification/legacy-editorial-installation-receipt-20261022.json; preservação dos registos anteriores conferida; provas QA reutilizadas'
+assert len(rows) == 50 and len({row['ID legado'] for row in rows}) == 50
+with path.open('w', encoding='utf-8-sig', newline='') as f:
+ writer = csv.DictWriter(f, columns); writer.writeheader(); writer.writerows(rows)
+
+def esc(value): return str(value).replace('|', '\\|').replace('\n', ' ')
+table = ['# Correspondência integral dos 50 artigos do legado', '',
+ 'Conferência HTTP individual em ' + stamp + '. Lista por identidade editorial; imagens, recursos da biblioteca e variantes de URL não são artigos adicionais. Não altera publicações. Endereço em branco significa que não foi demonstrada uma página nova, não uma ligação a criar por suposição.', '',
+ '| ID | Título original | Endereço original | Endereço no novo portal | Estado | Pendência |', '| --- | --- | --- | --- | --- | --- |']
+for row in rows:
+ table.append('| ' + ' | '.join(esc(row[k]) for k in ['ID legado', 'Título', 'URLs observadas', 'Endereço no novo portal', 'Estado actual consolidado', 'Pendência actual']) + ' |')
+(AUDIT / 'legado-correspondencia-integral.md').write_text('\n'.join(table) + '\n', encoding='utf-8')
+summary = {'checked_utc': stamp, 'distinct_articles': 50, 'publications_documented': 20,
+ 'public_pages_identity_verified': sum(p['identity_verified'] for p in checks.values()),
+ 'pending_content': 28, 'private_preparation': {k: len(v) for k, v in staged.items()},
+ 'empty': 1, 'untitled': 1, 'database_writes_by_correspondence_script': 0, 'active_database_writes': 0, 'new_publications': 0,
+ 'qa_imports': len(qa_rows), 'qa_clean_scans': len(qa_rows), 'remaining_integration_complete': False}
+(OUT / 'receipt.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
+print(json.dumps(summary))
diff --git a/tools/prepare-legacy-editorial-delivery.php b/tools/prepare-legacy-editorial-delivery.php
new file mode 100644
index 00000000..dba43fb3
--- /dev/null
+++ b/tools/prepare-legacy-editorial-delivery.php
@@ -0,0 +1,117 @@
+make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+use App\Services\EditorialReading;
+use App\Services\VerifiedDocumentBinary;
+use Illuminate\Support\Facades\DB;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+
+$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
+if (! app()->environment('testing') || $target->name !== 'simulator_fiscal_delta_20261021_test' || (int) $target->port !== 55441) {
+ throw new RuntimeException('Named isolated QA only');
+}
+$directory = base_path('outputs/legacy-editorial-20261022');
+if (! is_dir($directory)) {
+ mkdir($directory, 0700, true);
+}
+$identities = json_decode(file_get_contents(config_path('legacy_editorial_resources.json')), true, 512, JSON_THROW_ON_ERROR);
+if (count($identities) !== 28) {
+ throw new RuntimeException('Exact 28-original scope required');
+}
+$label = $argv[1] ?? 'v1';
+if (! preg_match('/^[a-z0-9-]{1,32}$/D', $label)) {
+ throw new RuntimeException('Invalid package label');
+}
+$zip = new ZipArchive;
+$archive = $directory.'/legacy-editorial-private-'.$label.'.zip';
+if (is_file($archive)) {
+ throw new RuntimeException('Preserve existing package; choose a reviewed subsequent version rather than overwrite');
+}
+if ($zip->open($archive, ZipArchive::CREATE | ZipArchive::EXCL) !== true) {
+ throw new RuntimeException('Cannot create private package');
+}
+$manifest = ['format' => 'investmoz-legacy-editorial-private-delivery-v1', 'prepared_at' => gmdate(DATE_ATOM), 'destination' => 'teste-investidor.tempo.co.mz', 'publish' => false, 'documents' => [], 'images' => [],
+ 'rules' => ['Transport/import is private and distinct from publication.', 'Resolve target IDs by original SHA256/external identity; no QA user/document/resource IDs are authoritative in the destination.', 'Keep source QA revisions as provenance, never impersonate their actors as destination audit authors.', 'Recheck target scan and existing identity/version; reuse matching originals, never overwrite conflicts.', 'Apply current metadata/edition through authenticated management, creating destination history. No automatic publication.']];
+$reading = app(EditorialReading::class);
+$rows = [];
+$imageHashes = [];
+DB::beginTransaction();
+DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
+try {
+ foreach ($identities as $identity) {
+ $doc = DB::table('documents')->where('external_id', $identity['external_id'])->where('sha256', $identity['sha256'])->whereNull('deleted_at')->first();
+ if (! $doc) {
+ throw new RuntimeException('Missing original identity');
+ }
+ $resource = DB::table('portal_resources')->where('document_id', $doc->id)->first();
+ if (! $resource || $resource->published || $doc->data_classification !== 'internal') {
+ throw new RuntimeException('Expected private QA draft');
+ }
+ $binary = app(VerifiedDocumentBinary::class)->respond($doc);
+ $bytes = $binary instanceof BinaryFileResponse ? file_get_contents($binary->getFile()->getPathname()) : $binary->getContent();
+ if (hash('sha256', $bytes) !== $identity['sha256']) {
+ throw new RuntimeException('Original changed');
+ }
+ $file = 'originals/'.$identity['external_id'].'.txt';
+ $zip->addFromString($file, $bytes);
+ $display = $reading->reading($resource, null, true);
+ $edition = $resource->editorial_content ? json_decode($resource->editorial_content, true, 512, JSON_THROW_ON_ERROR) : null;
+ $imageBinding = null;
+ if ($edition && ! empty($edition['image_resource_id'])) {
+ $imageDoc = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $edition['image_resource_id'])->first(['d.*']);
+ if (! $imageDoc) {
+ throw new RuntimeException('Selected image missing');
+ }
+ $imageBinding = ['sha256' => $imageDoc->sha256, 'source_url' => $imageDoc->original_source];
+ unset($edition['image_resource_id']);
+ }
+ if ($display['image_url']) {
+ preg_match('~/documentos/(\d+)/view~', $display['image_url'], $match);
+ $imageDoc = $reading->publicResources()->where('r.id', (int) $match[1])->first(['d.*']);
+ $imageBinary = app(VerifiedDocumentBinary::class)->respond($imageDoc);
+ $imageBytes = $imageBinary instanceof BinaryFileResponse ? file_get_contents($imageBinary->getFile()->getPathname()) : $imageBinary->getContent();
+ if (! isset($imageHashes[$imageDoc->sha256])) {
+ $imageFile = 'images/'.$imageDoc->sha256;
+ $zip->addFromString($imageFile, $imageBytes);
+ $imageHashes[$imageDoc->sha256] = true;
+ $manifest['images'][] = ['file' => $imageFile, 'sha256' => $imageDoc->sha256, 'source_url' => $imageDoc->original_source, 'mode' => 'reuse target matching hash first; import only if absent and authorized'];
+ }
+ }
+ $revisions = DB::table('portal_resource_revisions')->where('resource_id', $resource->id)->orderBy('revision_no')->get();
+ $historyFile = 'provenance/'.$identity['external_id'].'.json';
+ $zip->addFromString($historyFile, json_encode(['scope' => 'QA provenance only; IDs and actors are not destination identities', 'resource_snapshot' => $resource, 'revisions' => $revisions], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+ $metadata = (array) $resource;
+ foreach (['id', 'document_id', 'created_by', 'approved_by', 'last_reviewed_by', 'last_reviewed_at', 'metadata_version', 'document_group_id', 'duplicate_of_resource_id', 'document_identity_key', 'editorial_content', 'created_at', 'updated_at'] as $key) {
+ unset($metadata[$key]);
+ }
+ $manifest['documents'][] = ['external_id' => $identity['external_id'], 'file' => $file, 'sha256' => $identity['sha256'], 'kind' => $identity['kind'], 'metadata' => $metadata, 'qa_metadata_version' => $resource->metadata_version, 'edition' => $edition, 'selected_image' => $imageBinding, 'original_image_reference' => $display['image_source_url'], 'original_image_sha256' => $display['image_expected_sha256'], 'body_image_references' => $display['image_references'], 'history_file' => $historyFile, 'publish' => false, 'version_identity' => 'Legado '.$identity['external_id']];
+ $rows[] = ['external_id' => $identity['external_id'], 'qa_resource_id' => $resource->id, 'version' => $resource->metadata_version, 'original_sha256' => $doc->sha256, 'private' => ! $resource->published, 'image_available' => (bool) $display['image_url'], 'image_reference' => $display['image_source_url'], 'body_image_references' => count($display['image_references']), 'history_revisions' => $revisions->count()];
+ }
+} finally {
+ DB::rollBack();
+}
+$manifest['notice'] = 'The QA proof edition of original post 62413 contains an explicitly marked QA note. Review/correct it in the portal before any publication; no editorial text is silently removed from this package.';
+$zip->addFromString('manifest.json', json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+$zip->close();
+$verify = new ZipArchive;
+if ($verify->open($archive, ZipArchive::CHECKCONS) !== true) {
+ throw new RuntimeException('Archive invalid');
+}
+foreach ($manifest['documents'] as $item) {
+ if (hash('sha256', $verify->getFromName($item['file'])) !== $item['sha256']) {
+ throw new RuntimeException('Archive original mismatch');
+ }
+}
+foreach ($manifest['images'] as $item) {
+ if (hash('sha256', $verify->getFromName($item['file'])) !== $item['sha256']) {
+ throw new RuntimeException('Archive image mismatch');
+ }
+}
+$entries = $verify->numFiles;
+$verify->close();
+$receipt = ['qa_only' => true, 'originals' => count($rows), 'rows' => $rows, 'archive_entries' => $entries, 'archive_sha256' => hash_file('sha256', $archive), 'archive_bytes' => filesize($archive), 'verified_original_hashes' => 28, 'included_image_originals' => count($manifest['images']), 'destination_imported' => false, 'destination_published' => false, 'database_writes' => 0];
+file_put_contents($directory.'/private-delivery-'.$label.'-receipt.json', json_encode($receipt, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+echo json_encode(['originals' => count($rows), 'archive_entries' => $entries, 'sha256' => $receipt['archive_sha256'], 'included_images' => count($manifest['images']), 'destination_imported' => false, 'destination_published' => false]);
diff --git a/tools/publish-corridor-agribusiness-qa.php b/tools/publish-corridor-agribusiness-qa.php
new file mode 100644
index 00000000..fc7dc844
--- /dev/null
+++ b/tools/publish-corridor-agribusiness-qa.php
@@ -0,0 +1,45 @@
+ 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
+ 'DB_DATABASE' => 'simulator_fiscal_delta_20261021_test', 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'SESSION_DRIVER' => 'array'] as $k => $v) {
+ putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v;
+}
+require __DIR__.'/../vendor/autoload.php';
+$app = require __DIR__.'/../bootstrap/app.php';
+$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+use Illuminate\Support\Facades\DB;
+use App\Models\Opportunity;
+use App\Services\CorridorAgribusinessOpportunities;
+use App\Services\OpportunityPromotion;
+$target = DB::selectOne('SELECT current_database() AS name, inet_server_port() AS port');
+if (! app()->environment('testing') || $target->name !== 'simulator_fiscal_delta_20261021_test' || (int) $target->port !== 55441) throw new RuntimeException('QA only');
+config(['portal.profile_access_enabled' => true]);
+$actor = App\Models\User::findOrFail(295);
+// QA-role simulation, like the bounded tests; this is not evidence of a real production MFA login.
+session(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'login.id' => null]);
+$request = Illuminate\Http\Request::create('/minhas-oportunidades', 'GET');
+$request->setUserResolver(fn () => $actor); $app->instance('request', $request);
+$entries = app(CorridorAgribusinessOpportunities::class)->entries();
+$dir = storage_path('app/private/agribusiness-opportunities-20261023');
+$records = Opportunity::whereIn('management_creation_key', array_column($entries, 'key'))->get();
+if (! file_exists($dir.'/before-disclosure.json')) file_put_contents($dir.'/before-disclosure.json', json_encode($records->map(fn ($r) => $r->getRawOriginal())->all(), JSON_THROW_ON_ERROR));
+$result = DB::transaction(function () use ($actor, $entries) {
+ DB::select('SELECT pg_advisory_xact_lock(231026, 34)');
+ $result = [];
+ foreach ($entries as $entry) {
+ $record = Opportunity::where('management_creation_key', $entry['key'])->lockForUpdate()->firstOrFail();
+ if ($record->status !== 'published') {
+ $values = app(App\Services\OpportunityManagement::class)->values($record);
+ if ($record->status !== 'draft' || ($values['canonical']['official_title'] ?? null) !== $entry['title'] || ($values['canonical']['executive_summary'] ?? null) !== $entry['summary']) throw new RuntimeException('Edited or withdrawn record requires individual review: '.$record->id);
+ $input = $record->workflow_context['promotional_summary']['input'];
+ $preview = app(OpportunityPromotion::class)->preview($actor, $record, [...$input, 'source_date' => null, 'action' => 'disclose', 'etag' => app(App\Services\OpportunityManagement::class)->etag($record)]);
+ app(OpportunityPromotion::class)->confirm($actor, $record->id, ['token' => $preview['token'], 'operation_key' => (string) Illuminate\Support\Str::uuid(), 'confirm' => true,
+ 'reason' => 'QA automatizada: divulgação das 34 fichas de Agronegócio expressamente instruída pela Tempo; em preparação, sem autorização de execução.']);
+ } elseif (data_get($record->workflow_context, 'publication.purpose') !== OpportunityPromotion::PURPOSE) throw new RuntimeException('Different publication preserved');
+ $result[] = ['id' => $record->id, 'title' => $entry['title'], 'corridor' => $entry['corridor'], 'url' => '/oportunidades/'.$record->slug];
+ }
+ return $result;
+});
+$receipt = ['target' => $target, 'published' => count($result), 'authentication' => 'QA role simulation only; no production authentication claimed', 'source_sha256' => CorridorAgribusinessOpportunities::SOURCE_HASH, 'records' => $result, 'active_writes' => 0];
+file_put_contents($dir.'/disclosure-receipt.json', json_encode($receipt, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
+echo json_encode(['published' => count($result), 'target' => $target, 'active_writes' => 0]).PHP_EOL;
diff --git a/tools/register-corridor-agribusiness-qa.php b/tools/register-corridor-agribusiness-qa.php
new file mode 100644
index 00000000..c5876e4c
--- /dev/null
+++ b/tools/register-corridor-agribusiness-qa.php
@@ -0,0 +1,34 @@
+ 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
+ 'DB_DATABASE' => 'simulator_fiscal_delta_20261021_test', 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => ''] as $k => $v) {
+ putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v;
+}
+require __DIR__.'/../vendor/autoload.php';
+$app = require __DIR__.'/../bootstrap/app.php';
+$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+use Illuminate\Support\Facades\DB;
+use App\Services\CorridorAgribusinessOpportunities;
+$target = DB::selectOne('SELECT current_database() AS name, inet_server_port() AS port');
+if (! app()->environment('testing') || $target->name !== 'simulator_fiscal_delta_20261021_test' || (int) $target->port !== 55441) throw new RuntimeException('Refusing non-QA target.');
+config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true]);
+$service = app(CorridorAgribusinessOpportunities::class);
+if (! isset($options['apply'])) {echo json_encode(['target' => $target, 'entries' => $service->entries(), 'writes' => 0], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); exit;}
+$actor = App\Models\User::findOrFail($options['actor'] ?? 0);
+$organization = $options['organization'] ?? '';
+$request = Illuminate\Http\Request::create('/minhas-oportunidades/drafts', 'POST');
+$request->setUserResolver(fn () => $actor); $app->instance('request', $request);
+$dir = storage_path('app/private/agribusiness-opportunities-20261023');
+if (! is_dir($dir)) mkdir($dir, 0700, true);
+if (! file_exists($dir.'/before.json')) {
+ $before = [];
+ foreach (['opportunities', 'audit_events', 'development_corridors', 'portal_role_assignments', 'portal_resources', 'documents'] as $table) $before[$table] = DB::table($table)->orderBy('id')->get()->all();
+ file_put_contents($dir.'/before.json', json_encode($before, JSON_THROW_ON_ERROR));
+}
+$records = $service->register($actor, $organization);
+$receipt = ['target' => $target, 'source_sha256' => $service::SOURCE_HASH, 'records' => $records, 'total' => count($records),
+ 'new' => count(array_filter($records, fn ($r) => $r['created'])), 'published' => App\Models\Opportunity::whereIn('id', array_column($records, 'id'))->published()->count(), 'active_writes' => 0];
+file_put_contents($dir.'/receipt.json', json_encode($receipt, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+echo json_encode($receipt, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE).PHP_EOL;
diff --git a/tools/restore-approved-portfolio-visibility.php b/tools/restore-approved-portfolio-visibility.php
new file mode 100644
index 00000000..5fda041d
--- /dev/null
+++ b/tools/restore-approved-portfolio-visibility.php
@@ -0,0 +1,96 @@
+bootstrap($app);
+(new Illuminate\Foundation\Bootstrap\LoadConfiguration)->bootstrap($app);
+$args = getopt('', ['manifest:', 'output:', 'apply', 'preview-sha256:', 'backup:', 'qa']);
+$manifestPath = $args['manifest'] ?? '';
+$manifestHash = '8d07007a2e1e8758d4741b4ac594f78d5943f71ad78b2daae7f85aefa9d2de4a';
+if (!is_file($manifestPath) || !hash_equals($manifestHash, hash_file('sha256', $manifestPath))) {
+ throw new RuntimeException('Exact reviewed 29-original recovery manifest required.');
+}
+$manifest = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
+if (count($manifest['items']) !== 29 || count(array_unique(array_column($manifest['items'], 'target_record_id'))) !== 29) {
+ throw new RuntimeException('Exact original scope required.');
+}
+$qa = isset($args['qa']);
+$c = config('database.connections.'.config('database.default'));
+$expected = $qa ? 'simulator_fiscal_delta_20261021_test' : 'teste_investidor';
+$port = $qa ? 55441 : 5432;
+if ($c['driver'] !== 'pgsql' || !empty($c['url']) || !in_array($c['host'], ['127.0.0.1', 'localhost'], true) || $c['database'] !== $expected || (int)$c['port'] !== $port) {
+ throw new RuntimeException('Named target mismatch.');
+}
+if (!$qa && (!getenv('SSH_CONNECTION') || !function_exists('posix_geteuid') || posix_geteuid() !== 0 || rtrim(config('app.url'), '/') !== 'https://teste-investidor.tempo.co.mz')) {
+ throw new RuntimeException('Authenticated destination operator required.');
+}
+$output = $args['output'] ?? '';
+if (!$output || str_contains(str_replace('\\', '/', $output), '/public/')) {
+ throw new RuntimeException('Private receipt path required.');
+}
+$pdo = new PDO('pgsql:host='.$c['host'].';port='.$c['port'].';dbname='.$expected, $c['username'], $c['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
+$apply = isset($args['apply']);
+$pdo->exec($apply ? 'BEGIN ISOLATION LEVEL SERIALIZABLE' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
+try {
+ $actual = $pdo->query('select current_database() as name,inet_server_port() as port')->fetch(PDO::FETCH_ASSOC);
+ if ($actual['name'] !== $expected || (int)$actual['port'] !== $port) throw new RuntimeException('Actual target mismatch.');
+ if ($apply) $pdo->query('select pg_advisory_xact_lock(231026,29)');
+ $plan = [];
+ $before = [];
+ foreach ($manifest['items'] as $item) {
+ $query = $pdo->prepare('select * from investment_dossiers where id = ?'.($apply ? ' for update' : ''));
+ $query->execute([$item['target_record_id']]);
+ $row = $query->fetch(PDO::FETCH_ASSOC);
+ if (!$row || $row['deleted_at'] !== null || $row['title'] !== $item['title'] || !hash_equals($item['content_sha256'], hash('sha256', $row['content'])) || !hash_equals($item['source_sha256'], hash('sha256', $row['source']))) {
+ throw new RuntimeException('Missing, archived or changed original: '.$item['code']);
+ }
+ if (!hash_equals($item['approval_sha256'], hash('sha256', $item['approval_evidence']))) throw new RuntimeException('Approval provenance changed.');
+ $replayed = $pdo->prepare("select count(*) from audit_events where source_system='portfolio-visibility-recovery-v1' and external_id=? and entity_id=? and action='approved_visibility_restored'");
+ $replayed->execute([$manifestHash.':'.$row['id'], $row['id']]);
+ $already = (int)$replayed->fetchColumn() === 1;
+ if ($already) {
+ if ($row['publication_status'] !== 'published' || $row['approval_basis'] !== 'document_owner' || $row['approval_evidence'] !== $item['approval_evidence']) throw new RuntimeException('Recovery subsequently changed: '.$item['code']);
+ } elseif ($row['publication_status'] !== 'draft' || $row['editor_id'] !== null || $row['reviewer_id'] !== null || (int)$row['version_no'] !== (int)$item['expected_target_version']) {
+ throw new RuntimeException('Preserve subsequent editorial decisions: '.$item['code']);
+ }
+ $before[] = $row;
+ $plan[] = ['id' => $row['id'], 'code' => $item['code'], 'version' => (int)$row['version_no'], 'status' => $row['publication_status'], 'content_sha256' => $item['content_sha256'], 'source_sha256' => $item['source_sha256'], 'already_restored' => $already];
+ }
+ $previewHash = hash('sha256', json_encode([$expected, $manifestHash, $plan], JSON_THROW_ON_ERROR));
+ if ($apply) {
+ if (!hash_equals($previewHash, $args['preview-sha256'] ?? '')) throw new RuntimeException('Preview changed; recover current state before writing.');
+ if (!$qa) {
+ $directory = $args['backup'] ?? '';
+ $backup = json_decode(file_get_contents($directory.'/backup-receipt.json'), true, 512, JSON_THROW_ON_ERROR);
+ $restore = json_decode(file_get_contents($directory.'/restore-receipt.json'), true, 512, JSON_THROW_ON_ERROR);
+ if (!$restore['restore_verified'] || $backup['target']['database'] !== $expected || (int)$backup['target']['port'] !== $port || !hash_equals($backup['sha256'], $restore['source_dump_sha256'])) throw new RuntimeException('Verified matching backup/restore required.');
+ }
+ $snapshot = $output.'.before.json';
+ if (is_file($snapshot)) throw new RuntimeException('Preserve earlier recovery snapshot; use a new receipt path.');
+ file_put_contents($snapshot, json_encode(['database' => $expected, 'rows' => $before], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
+ if (!$qa) chmod($snapshot, 0600);
+ foreach ($plan as $index => $entry) {
+ if ($entry['already_restored']) continue;
+ $item = $manifest['items'][$index];
+ $update = $pdo->prepare("update investment_dossiers set publication_status='published',approval_basis='document_owner',approval_evidence=?,published_at=now(),version_no=version_no+1,updated_at=now() where id=? and version_no=?");
+ $update->execute([$item['approval_evidence'], $entry['id'], $entry['version']]);
+ if ($update->rowCount() !== 1) throw new RuntimeException('Concurrent change.');
+ $event = $pdo->prepare("insert into audit_events(source_system,external_id,entity_type,entity_id,actor_user_id,action,before_data,after_data) values('portfolio-visibility-recovery-v1',?,'investment_dossiers',?,NULL,'approved_visibility_restored',?,?)");
+ $event->execute([$manifestHash.':'.$entry['id'], $entry['id'], json_encode(['version_no' => $entry['version'], 'publication_status' => 'draft', 'approval_basis' => $before[$index]['approval_basis']], JSON_THROW_ON_ERROR), json_encode(['code' => $item['code'], 'version_no' => $entry['version']+1, 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_sha256' => $item['approval_sha256'], 'content_sha256' => $item['content_sha256'], 'source_record_id' => $item['source_record_id'], 'source_published_at' => $item['original_published_at'], 'instruction' => 'Tempo 23/10/2026: preserve and restore completed approved functionality', 'authenticated_transport' => $qa ? 'isolated-QA' : 'SSH', 'not_a_new_editorial_approval' => true], JSON_THROW_ON_ERROR)]);
+ }
+ }
+ $visible = (int)$pdo->query("select count(*) from investment_dossiers d where publication_status='published' and (exists(select 1 from projects p where p.id=d.project_id and p.deleted_at is null) or exists(select 1 from economic_clusters c where c.id=d.cluster_id and c.deleted_at is null) or exists(select 1 from development_corridors r where r.id=d.corridor_id and r.deleted_at is null))")->fetchColumn();
+ $result = ['database' => $actual, 'qa' => $qa, 'mode' => $apply ? 'applied' : 'preview', 'manifest_sha256' => $manifestHash, 'preview_sha256' => $previewHash, 'scope' => count($plan), 'restored' => $apply ? count(array_filter($plan, fn($r) => !$r['already_restored'])) : 0, 'visible' => $visible, 'data_content_changed' => false, 'new_dossiers_imported' => 0, 'fake_editor_or_reviewer_assigned' => false, 'plan' => $plan];
+ if ($apply) $pdo->commit(); else $pdo->rollBack();
+ file_put_contents($output, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
+ if (!$qa) chmod($output, 0600);
+ unset($result['plan']);
+ echo json_encode($result, JSON_THROW_ON_ERROR);
+} catch (Throwable $error) {
+ if ($pdo->inTransaction()) $pdo->rollBack();
+ throw $error;
+}
diff --git a/tools/territory-demarcation-recovery-qa.php b/tools/territory-demarcation-recovery-qa.php
new file mode 100644
index 00000000..36b7f659
--- /dev/null
+++ b/tools/territory-demarcation-recovery-qa.php
@@ -0,0 +1,64 @@
+make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+$target = Illuminate\Support\Facades\DB::selectOne('select current_database() as database,inet_server_port() as port');
+throw_unless($target->database === 'simulator_fiscal_delta_20261021_test' && (int) $target->port === 55441
+ && config('database.connections.pgsql.host') === '127.0.0.1', RuntimeException::class, 'Isolated QA only.');
+$app->detectEnvironment(fn () => 'testing');
+config(['geometry-import.enabled' => true, 'portal.managers' => ['qa-content-promoter@example.test'],
+ 'services.malware.scanner' => 'clamd', 'services.malware.socket' => null,
+ 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
+$actor = App\Models\User::where('email', 'qa-content-promoter@example.test')->firstOrFail();
+$directory = config('geometry-import.directory');
+$out = storage_path('app/private/territory-demarcation-recovery');
+Illuminate\Support\Facades\File::ensureDirectoryExists($out, 0700);
+$manifest = [];
+foreach (glob($directory.'/moz-adm*-ine-*') as $file) {
+ $manifest[] = ['file' => basename($file), 'bytes' => filesize($file), 'sha256' => hash_file('sha256', $file)];
+}
+file_put_contents($out.'/source-inventory.json', json_encode($manifest, JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+$scanner = app(App\Services\Malware\MalwareScanner::class);
+$health = $scanner->health();
+throw_unless($health['available'], RuntimeException::class, 'Current scanner unavailable.');
+$scans = [];
+foreach ([1, 2, 3] as $level) {
+ $file = $directory.'/moz-adm'.$level.'-ine-20190607.geojson';
+ // Scope the stream budget to this retained GIS original in this process only.
+ config(['services.malware.max_file_bytes' => filesize($file), 'services.malware.max_stream_bytes' => filesize($file)]);
+ $scan = $scanner->scan($file, filesize($file), storage_path('app/private'));
+ $scan['signatures_at'] = $scan['signatures_at']?->format(DATE_ATOM);
+ if ($level === 3 && $scan['reason_code'] === 'scanner_decompression_limit') {
+ $binary = getenv('GIS_CLAMSCAN_BINARY');
+ $database = getenv('GIS_CLAMSCAN_DATABASE');
+ throw_unless(is_file($binary ?: '') && is_dir($database ?: ''), RuntimeException::class, 'GIS scanner paths required.');
+ $process = new Symfony\Component\Process\Process([$binary, '--database='.$database, '--max-filesize=40M', '--max-scansize=100M', '--no-summary', $file]);
+ $process->setTimeout(120)->mustRun();
+ throw_unless(str_ends_with(trim($process->getOutput()), ': OK'), RuntimeException::class, 'GIS original scan incomplete.');
+ $version = new Symfony\Component\Process\Process([$binary, '--database='.$database, '--version']);
+ $version->mustRun();
+ $parts = explode('/', trim($version->getOutput()));
+ $signatureTime = strtotime(end($parts));
+ throw_unless($signatureTime && time() - $signatureTime < 48 * 3600, RuntimeException::class, 'Current GIS signatures required.');
+ $scan = ['result' => 'clean', 'engine' => 'ClamAV', 'engine_version' => trim($version->getOutput()),
+ 'signatures_at' => gmdate(DATE_ATOM, $signatureTime), 'scope' => 'retained GIS original only',
+ 'original_sha256' => hash_file('sha256', $file), 'stream_attempt' => $scan, 'exit_code' => $process->getExitCode()];
+ }
+ $scans[] = ['file' => basename($file), 'sha256' => hash_file('sha256', $file), 'scan' => $scan];
+ echo json_encode(['level' => $level, 'scan' => $scan]).PHP_EOL;
+ throw_unless($scan['result'] === 'clean', RuntimeException::class, 'Original scan incomplete.');
+}
+file_put_contents($directory.'/geometry-source-scans.json', json_encode(['captured_at' => gmdate('c'), 'files' => $scans], JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+$service = app(App\Services\TerritoryGeometryImport::class);
+foreach ([1, 2, 3] as $level) {
+ $plan = $service->preview($actor, $level);
+ file_put_contents($out.'/preview-'.$level.'.json', json_encode($plan, JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ echo json_encode(['level' => $level, 'resolved' => $plan['resolved'], 'pending' => $plan['pending']]).PHP_EOL;
+ if (! in_array('--apply', $argv, true)) break; // Lower levels require imported parent correspondences.
+ $result = $service->apply($actor, ['level' => $level, 'hash' => $plan['hash'], 'idempotency_key' => (string) Illuminate\Support\Str::uuid(),
+ 'reason' => 'Recuperação QA da demarcação anteriormente demonstrada, com originais e correspondências aceites.', 'confirm' => true]);
+ file_put_contents($out.'/result-'.$level.'.json', json_encode($result, JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+ echo json_encode(['level' => $level, 'inserted' => $result['inserted'], 'reused' => $result['reused']]).PHP_EOL;
+}
diff --git a/tools/verify-corridor-agribusiness.php b/tools/verify-corridor-agribusiness.php
new file mode 100644
index 00000000..0fc28a18
--- /dev/null
+++ b/tools/verify-corridor-agribusiness.php
@@ -0,0 +1,33 @@
+sanitize($source['content_html']);
+$html = (new App\Services\CorridorAgribusiness)->enrich($original);
+$doc = new DOMDocument;
+$doc->loadHTML(''.$html);
+$xpath = new DOMXPath($doc);
+$groups = $xpath->query('//ul[starts-with(@id,"agronegocio-oportunidades-")]');
+$entries = $xpath->query('//ul[starts-with(@id,"agronegocio-oportunidades-")]/li');
+if ($groups->length !== 6 || $entries->length !== 34 || $sourceHash !== hash_file('sha256', $path)) {
+ throw new RuntimeException('Incomplete integration or source changed.');
+}
+// Removing only the additions must recover the exact sanitized original.
+foreach (iterator_to_array($groups) as $group) {
+ $group->parentNode->removeChild($group->previousSibling);
+ $group->parentNode->removeChild($group->nextSibling);
+ $group->parentNode->removeChild($group);
+}
+$body = $doc->getElementsByTagName('body')->item(0);
+$recovered = implode('', array_map(fn ($node) => $doc->saveHTML($node), iterator_to_array($body->childNodes)));
+if ($recovered !== $original) {
+ throw new RuntimeException('Original displayed content was changed.');
+}
+echo json_encode(['source_sha256' => $sourceHash, 'source_unchanged' => true, 'original_display_preserved' => true,
+ 'corridors' => 6, 'agribusiness_entries' => 34, 'database_writes' => 0], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE).PHP_EOL;