Conferir e publicar instituições pelo editor assistido com MFA nativo
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -28,20 +28,38 @@ final class FundingProgrammeController extends Controller
|
||||
{
|
||||
public function reference(Request $request, string $code, FundingProgrammeReferences $references): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
return tap(Inertia::render('funding/reference', ['reference' => $references->preview($request->user(), $code)])->toResponse($request),
|
||||
$plan = $references->preview($request->user(), $code);
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json($plan)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
return tap(Inertia::render('funding/reference', ['reference' => $plan])->toResponse($request),
|
||||
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
public function prepareReference(Request $request, string $code, FundingProgrammeReferences $references): RedirectResponse
|
||||
public function prepareReference(Request $request, string $code, FundingProgrammeReferences $references): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['hash' => 'required|string|size:64']);
|
||||
$result = $references->prepare($request->user(), $code, $data['hash']);
|
||||
if ($request->expectsJson()) {
|
||||
$reference = $references->preview($request->user(), $code);
|
||||
$service = app(FundingProgrammes::class);
|
||||
$matches = $service->matchesReference($request->user(), $result['id'], $reference['draft']);
|
||||
$publication = $matches ? $service->preview($request->user(), $result['id']) : null;
|
||||
|
||||
return response()->json([...$result, 'matches_reference' => $matches, 'publication' => $publication ? ['version' => $publication['version'], 'hash' => $publication['hash']] : null])->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
return redirect('/management/fundos/'.$result['id'])->with('success', $result['created']
|
||||
? 'Rascunho preparado com as fontes indicadas. Ainda não está publicado.'
|
||||
: 'Esta referência já tem ficha. Aberta a versão existente, sem substituir alterações ou acrescentar chamadas.');
|
||||
}
|
||||
|
||||
public function assisted(Request $request, FundingProgrammeReferences $references): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
return tap(Inertia::render('funding/assisted', ['batch' => $references->assisted($request->user())])->toResponse($request), static fn ($response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
public function manage(Request $request, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$service->guard($request->user());
|
||||
@@ -87,9 +105,12 @@ final class FundingProgrammeController extends Controller
|
||||
return response()->json($service->preview($request->user(), $id))->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function publish(Request $request, string $id, FundingProgrammes $service): RedirectResponse
|
||||
public function publish(Request $request, string $id, FundingProgrammes $service): RedirectResponse|JsonResponse
|
||||
{
|
||||
$service->publish($request->user(), $id, $request->all());
|
||||
$versionId = $service->publish($request->user(), $id, $request->all());
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['id' => $id, 'version_id' => $versionId, 'published' => true])->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Nova versão publicada. Os interesses anteriores conservam a versão guardada.');
|
||||
}
|
||||
|
||||
@@ -81,6 +81,25 @@ final class FundingProgrammeReferences
|
||||
return $choices;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function assisted(User $actor): array
|
||||
{
|
||||
app(FundingProgrammes::class)->guard($actor);
|
||||
$package = $this->package();
|
||||
$existing = DB::table('funding_programmes')->get(['id', 'code', 'version', 'published_version_id'])->keyBy('code');
|
||||
$items = [];
|
||||
foreach ($package['references'] as $code => $reference) {
|
||||
$draft = $reference['draft'];
|
||||
$row = $existing->get($code);
|
||||
$items[] = ['code' => $code, 'title' => $draft['title'], 'summary' => $draft['summary'], 'categories' => $draft['institution_categories'] ?? [],
|
||||
'types' => $draft['institution_types'] ?? [], 'status' => $draft['institution_status'] ?? 'reported', 'source_document' => $draft['source_document_name'] ?? null,
|
||||
'source_sha256' => $draft['source_document_sha256'] ?? null, 'locations' => $draft['locations'] ?? [], 'contacts' => $draft['contacts'] ?? [],
|
||||
'id' => $row?->id, 'published' => $row?->published_version_id !== null];
|
||||
}
|
||||
|
||||
return ['dataset_hash' => $package['sha256'], 'items' => $items];
|
||||
}
|
||||
|
||||
/** @return array{id:string,created:bool} */
|
||||
public function prepare(User $actor, string $code, string $hash): array
|
||||
{
|
||||
|
||||
@@ -38,6 +38,19 @@ final class FundingProgrammes
|
||||
abort_unless($this->access($actor), 403);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $reference */
|
||||
public function matchesReference(User $actor, string $id, array $reference): bool
|
||||
{
|
||||
$this->guard($actor);
|
||||
$row = DB::table('funding_programmes')->where('id', $id)->firstOrFail();
|
||||
$expected = $this->normalize($actor, $reference, false);
|
||||
$actual = json_decode($row->draft, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless(is_array($actual), 503);
|
||||
|
||||
return json_encode(Arr::sortRecursive($actual), JSON_THROW_ON_ERROR)
|
||||
=== json_encode(Arr::sortRecursive($expected), JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public function publicQuery(): Builder
|
||||
{
|
||||
abort_unless($this->installed(), 404);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
## 10/11/2026 — preparação editorial assistida das instituições
|
||||
|
||||
A autenticação SSH foi confirmada, mas não substitui a sessão e MFA recentes do editor no portal. Acrescentada `/management/fundos/referencias-assistidas`: selecção explícita das instituições do documento, conferência das fontes, preparação sequencial idempotente e publicação confirmada numa segunda operação. Preparar nunca publica. Fichas deliberadamente alteradas são preservadas e exigem revisão individual; mudança do conjunto de fontes interrompe a operação. Uma resposta de publicação perdida conserva versão/hash/fundamento para a mesma repetição. Expiração de MFA mantém as guardas e os resultados já confirmados.
|
||||
|
||||
Prova adicional delimitada:1caso API/27asserções,1caso frontend de separação preparação/publicação e recuperação da confirmação. Reutilizadas as5provas API/735asserções e28frontend anteriores; não se repetiu a carga das270instituições. Instalação e publicação no destino continuam distintas e serão registadas após confirmação efectiva. Sem migrações, novos direitos ou alteração das sessões existentes.
|
||||
|
||||
## 10/11/2026 — instituições por localização, categoria e tipo no processo do investidor
|
||||
|
||||
Pedido expresso da Tempo: inserir as instituições do documento agora indicado por localização/categoria/tipo e permitir que o investidor consulte bancos, seguradoras e outros intervenientes quando a necessidade surgir, sem limitar a decisão por falta de informação. **Implementação e conteúdo demonstrados em QA; nenhuma instalação ou publicação activa executada nesta passagem. Última revisão instalada confirmada:1c75a30a, APIEX/SWOT/Concursos preservados. ACT-12 não é encerrado pela preparação técnica.**
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
## 10/11/2026 — preparação editorial assistida das instituições
|
||||
|
||||
A autenticação SSH foi confirmada, mas não substitui a sessão e MFA recentes do editor no portal. Acrescentada `/management/fundos/referencias-assistidas`: selecção explícita das instituições do documento, conferência das fontes, preparação sequencial idempotente e publicação confirmada numa segunda operação. Preparar nunca publica. Fichas deliberadamente alteradas são preservadas e exigem revisão individual; mudança do conjunto de fontes interrompe a operação. Uma resposta de publicação perdida conserva versão/hash/fundamento para a mesma repetição. Expiração de MFA mantém as guardas e os resultados já confirmados.
|
||||
|
||||
Prova adicional delimitada:1caso API/27asserções,1caso frontend de separação preparação/publicação e recuperação da confirmação. Reutilizadas as5provas API/735asserções e28frontend anteriores; não se repetiu a carga das270instituições. Instalação e publicação no destino continuam distintas e serão registadas após confirmação efectiva. Sem migrações, novos direitos ou alteração das sessões existentes.
|
||||
|
||||
## 10/11/2026 — instituições por localização, categoria e tipo no processo do investidor
|
||||
|
||||
Pedido expresso da Tempo: inserir as instituições do documento agora indicado por localização/categoria/tipo e permitir que o investidor consulte bancos, seguradoras e outros intervenientes quando a necessidade surgir, sem limitar a decisão por falta de informação. **Implementação e conteúdo demonstrados em QA; nenhuma instalação ou publicação activa executada nesta passagem. Última revisão instalada confirmada:1c75a30a, APIEX/SWOT/Concursos preservados. ACT-12 não é encerrado pela preparação técnica.**
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,9 @@
|
||||
## 10/11/2026 — preparação editorial assistida das instituições
|
||||
|
||||
A autenticação SSH foi confirmada, mas não substitui a sessão e MFA recentes do editor no portal. Acrescentada `/management/fundos/referencias-assistidas`: selecção explícita das instituições do documento, conferência das fontes, preparação sequencial idempotente e publicação confirmada numa segunda operação. Preparar nunca publica. Fichas deliberadamente alteradas são preservadas e exigem revisão individual; mudança do conjunto de fontes interrompe a operação. Uma resposta de publicação perdida conserva versão/hash/fundamento para a mesma repetição. Expiração de MFA mantém as guardas e os resultados já confirmados.
|
||||
|
||||
Prova adicional delimitada:1caso API/27asserções,1caso frontend de separação preparação/publicação e recuperação da confirmação. Reutilizadas as5provas API/735asserções e28frontend anteriores; não se repetiu a carga das270instituições. Instalação e publicação no destino continuam distintas e serão registadas após confirmação efectiva. Sem migrações, novos direitos ou alteração das sessões existentes.
|
||||
|
||||
## 10/11/2026 — instituições por localização, categoria e tipo no processo do investidor
|
||||
|
||||
Pedido expresso da Tempo: inserir as instituições do documento agora indicado por localização/categoria/tipo e permitir que o investidor consulte bancos, seguradoras e outros intervenientes quando a necessidade surgir, sem limitar a decisão por falta de informação. **Implementação e conteúdo demonstrados em QA; nenhuma instalação ou publicação activa executada nesta passagem. Última revisão instalada confirmada:1c75a30a, APIEX/SWOT/Concursos preservados. ACT-12 não é encerrado pela preparação técnica.**
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Link } from "@inertiajs/react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
financialCategories,
|
||||
financialTypes,
|
||||
} from "@/components/funding-classification";
|
||||
import { profileJson } from "@/lib/profile-json";
|
||||
import { Frame, card, button } from "@/pages/investment/shared";
|
||||
|
||||
type Item = {
|
||||
code: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
source_document: string | null;
|
||||
source_sha256: string | null;
|
||||
categories: string[];
|
||||
types: string[];
|
||||
status: string;
|
||||
locations: unknown[];
|
||||
contacts: unknown[];
|
||||
published: boolean;
|
||||
};
|
||||
type Receipt = {
|
||||
id: string;
|
||||
matches_reference: boolean;
|
||||
publication: { version: number; hash: string } | null;
|
||||
published?: boolean;
|
||||
};
|
||||
|
||||
export default function Assisted({
|
||||
batch,
|
||||
}: {
|
||||
batch: { dataset_hash: string; items: Item[] };
|
||||
}) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [receipts, setReceipts] = useState<Record<string, Receipt>>({});
|
||||
const [reviewed, setReviewed] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const running = useRef(false);
|
||||
const stop = useRef(false);
|
||||
const pending = useRef<Record<string, object>>({});
|
||||
async function run(publish: boolean) {
|
||||
if (
|
||||
running.current ||
|
||||
!reviewed ||
|
||||
!selected.length ||
|
||||
(publish && (!confirmed || reason.trim().length < 20))
|
||||
)
|
||||
return;
|
||||
running.current = true;
|
||||
stop.current = false;
|
||||
setBusy(true);
|
||||
setMessage("");
|
||||
try {
|
||||
for (const code of selected) {
|
||||
if (stop.current) break;
|
||||
setMessage(
|
||||
`${publish ? "Publicação" : "Preparação"}: ${batch.items.find((i) => i.code === code)?.title}`,
|
||||
);
|
||||
if (publish) {
|
||||
const receipt = receipts[code];
|
||||
if (
|
||||
!receipt?.matches_reference ||
|
||||
!receipt.publication ||
|
||||
receipt.published
|
||||
)
|
||||
continue;
|
||||
const body = pending.current[code] ?? {
|
||||
...receipt.publication,
|
||||
reason: reason.trim(),
|
||||
confirm: true,
|
||||
};
|
||||
pending.current[code] = body;
|
||||
await profileJson(
|
||||
`/management/fundos/${receipt.id}/publicar`,
|
||||
body,
|
||||
);
|
||||
setReceipts((old) => ({
|
||||
...old,
|
||||
[code]: { ...receipt, published: true },
|
||||
}));
|
||||
delete pending.current[code];
|
||||
} else {
|
||||
const plan = await profileJson(
|
||||
`/management/fundos/referencias/${code}`,
|
||||
{},
|
||||
"GET",
|
||||
);
|
||||
if (plan.package_sha256 !== batch.dataset_hash)
|
||||
throw new Error(
|
||||
"As fontes mudaram. Actualize e confira novamente antes de continuar.",
|
||||
);
|
||||
const receipt = await profileJson(
|
||||
`/management/fundos/referencias/${code}/preparar`,
|
||||
{ hash: plan.hash },
|
||||
);
|
||||
setReceipts((old) => ({ ...old, [code]: receipt }));
|
||||
}
|
||||
}
|
||||
setMessage(
|
||||
stop.current
|
||||
? "Pausa concluída. As operações confirmadas foram conservadas."
|
||||
: publish
|
||||
? "Publicação das fichas conferidas concluída. Consulte o catálogo."
|
||||
: "Preparação concluída. Confira os resultados antes de confirmar a publicação.",
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
`${error instanceof Error ? error.message : "Operação interrompida."} Os resultados anteriores foram conservados. Se a sessão ou MFA expirou, autentique-se no portal e retome. A publicação incerta pode ser repetida com a mesma confirmação.`,
|
||||
);
|
||||
} finally {
|
||||
running.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Frame title="Conferir instituições e serviços financeiros">
|
||||
<p className={card}>
|
||||
A preparação cria rascunhos privados. A publicação exige uma
|
||||
confirmação separada e MFA recente no portal. As fichas
|
||||
conservam as incertezas das fontes; a sua presença não garante
|
||||
licença actual, crédito ou seguro.
|
||||
</p>
|
||||
<p>
|
||||
Fonte do conjunto:{" "}
|
||||
<code className="break-all">{batch.dataset_hash}</code>
|
||||
</p>
|
||||
<fieldset disabled={busy} className={card}>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
setSelected(
|
||||
batch.items
|
||||
.filter(
|
||||
(i) =>
|
||||
i.source_sha256 ===
|
||||
"119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108",
|
||||
)
|
||||
.map((i) => i.code),
|
||||
);
|
||||
setConfirmed(false);
|
||||
}}
|
||||
>
|
||||
Seleccionar instituições do documento
|
||||
</button>
|
||||
<p>{selected.length} seleccionadas</p>
|
||||
{batch.items.map((item) => (
|
||||
<details key={item.code} className="border-b py-2">
|
||||
<summary>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(item.code)}
|
||||
onChange={(e) => {
|
||||
setSelected((old) =>
|
||||
e.target.checked
|
||||
? [...old, item.code]
|
||||
: old.filter(
|
||||
(c) => c !== item.code,
|
||||
),
|
||||
);
|
||||
setConfirmed(false);
|
||||
}}
|
||||
/>{" "}
|
||||
{item.title}
|
||||
</label>{" "}
|
||||
—{" "}
|
||||
{receipts[item.code]?.published
|
||||
? "Publicação confirmada"
|
||||
: receipts[item.code]
|
||||
? receipts[item.code].matches_reference
|
||||
? "Conferida, aguarda publicação"
|
||||
: "Edições existentes: rever individualmente"
|
||||
: item.published
|
||||
? "Já tem publicação"
|
||||
: "Por preparar"}
|
||||
</summary>
|
||||
<p>{item.summary}</p>
|
||||
<p>
|
||||
{item.categories.map(c => financialCategories[c] ?? c).join(", ")} —{" "}
|
||||
{item.types.map(t => financialTypes[t] ?? t).join(", ")} — {item.status}
|
||||
</p>
|
||||
<p>
|
||||
{item.source_document}{" "}
|
||||
<code className="break-all">
|
||||
{item.source_sha256}
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
{item.locations.length} localizações;{" "}
|
||||
{item.contacts.length} contactos.
|
||||
</p>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/fundos/referencias/${item.code}`}
|
||||
>
|
||||
Conferir fonte e dados completos
|
||||
</Link>
|
||||
{receipts[item.code] && (
|
||||
<Link
|
||||
className="ml-4 underline"
|
||||
href={`/management/fundos/${receipts[item.code].id}`}
|
||||
>
|
||||
Alterar ficha existente
|
||||
</Link>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
<label className="block py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reviewed}
|
||||
onChange={(e) => setReviewed(e.target.checked)}
|
||||
/>{" "}
|
||||
Conferi as fontes e as fichas seleccionadas, incluindo dados
|
||||
por confirmar.
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={!reviewed || !selected.length}
|
||||
onClick={() => void run(false)}
|
||||
>
|
||||
1. Preparar e conferir seleccionadas
|
||||
</button>
|
||||
<label className="block py-3">
|
||||
Fundamento da publicação (mínimo 20 caracteres)
|
||||
<textarea
|
||||
className="block w-full border p-2"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</label>
|
||||
<label className="block py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>{" "}
|
||||
Confirmo a publicação pública das versões conferidas,
|
||||
preservando os dados e as incertezas apresentados.
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={
|
||||
!reviewed ||
|
||||
!confirmed ||
|
||||
reason.trim().length < 20 ||
|
||||
!selected.some(
|
||||
(c) =>
|
||||
receipts[c]?.matches_reference &&
|
||||
!receipts[c].published,
|
||||
)
|
||||
}
|
||||
onClick={() => void run(true)}
|
||||
>
|
||||
2. Publicar conferidas
|
||||
</button>
|
||||
</fieldset>
|
||||
{busy && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
stop.current = true;
|
||||
}}
|
||||
>
|
||||
Pausar após esta ficha
|
||||
</button>
|
||||
)}
|
||||
<p role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
<Link className="underline" href="/fundos">
|
||||
Consultar catálogo
|
||||
</Link>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { Link } from "@inertiajs/react";
|
||||
import { Frame, card, button } from "@/pages/investment/shared";
|
||||
export default function Manage({
|
||||
items,
|
||||
references = [],
|
||||
@@ -23,6 +23,12 @@ export default function Manage({
|
||||
<Link className="ml-4 underline" href="/fundos">
|
||||
Catálogo público
|
||||
</Link>
|
||||
<Link
|
||||
className={button}
|
||||
href="/management/fundos/referencias-assistidas"
|
||||
>
|
||||
Preparar e conferir as instituições fornecidas
|
||||
</Link>
|
||||
<details className={card}>
|
||||
<summary>
|
||||
Preparar referências fornecidas ({references.length})
|
||||
@@ -51,10 +57,10 @@ export default function Manage({
|
||||
{item.title}
|
||||
</Link>
|
||||
<p>
|
||||
Versão de trabalho {item.version} —{' '}
|
||||
Versão de trabalho {item.version} —{" "}
|
||||
{item.published
|
||||
? 'com publicação'
|
||||
: 'sem publicação pública'}
|
||||
? "com publicação"
|
||||
: "sem publicação pública"}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
@@ -65,11 +71,11 @@ export default function Manage({
|
||||
key={index}
|
||||
href={link.url}
|
||||
className="underline"
|
||||
aria-current={link.active ? 'page' : undefined}
|
||||
aria-current={link.active ? "page" : undefined}
|
||||
>
|
||||
{link.label
|
||||
.replace(/«/g, '«')
|
||||
.replace(/»/g, '»')}
|
||||
.replace(/«/g, "«")
|
||||
.replace(/»/g, "»")}
|
||||
</Link>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ Route::delete('/fundos/{id}/interesse', [Funding::class, 'removeInterest'])->whe
|
||||
|
||||
Route::middleware(['auth', 'verified'])->prefix('management/fundos')->group(function () {
|
||||
Route::get('/', [Funding::class, 'manage']);
|
||||
Route::get('/referencias-assistidas', [Funding::class, 'assisted']);
|
||||
Route::get('/novo', [Funding::class, 'edit']);
|
||||
Route::get('/referencias/{code}', [Funding::class, 'reference'])->where('code', '[a-z0-9-]+');
|
||||
Route::post('/referencias/{code}/preparar', [Funding::class, 'prepareReference'])->where('code', '[a-z0-9-]+');
|
||||
|
||||
@@ -16,6 +16,32 @@ final class FinancialInstitutionsSourceTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
public function test_assisted_editor_prepares_without_publishing_and_preserves_deliberate_edits(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
$actor = User::factory()->create();
|
||||
config(['portal.managers' => [$actor->email]]);
|
||||
$this->actingAs($actor);
|
||||
$this->get('/management/fundos/referencias-assistidas')->assertOk()->assertInertia(fn ($page) => $page->component('funding/assisted')->has('batch.items', 275));
|
||||
$plan = $this->getJson('/management/fundos/referencias/arko')->assertOk()->json();
|
||||
$receipt = $this->postJson('/management/fundos/referencias/arko/preparar', ['hash' => $plan['hash']])->assertOk()->assertJsonPath('created', true)->assertJsonPath('matches_reference', true)->json();
|
||||
$id = $receipt['id'];
|
||||
$this->assertNull(DB::table('funding_programmes')->where('id', $id)->value('published_version_id'));
|
||||
$this->postJson('/management/fundos/referencias/arko/preparar', ['hash' => $plan['hash']])->assertOk()->assertJsonPath('created', false)->assertJsonPath('id', $id);
|
||||
$body = [...$receipt['publication'], 'reason' => 'SYNTHETIC explicit editorial publication confirmation, rolled back.'];
|
||||
$this->postJson('/management/fundos/'.$id.'/publicar', $body)->assertUnprocessable();
|
||||
$published = $this->postJson('/management/fundos/'.$id.'/publicar', [...$body, 'confirm' => true])->assertOk()->json('version_id');
|
||||
$this->postJson('/management/fundos/'.$id.'/publicar', [...$body, 'confirm' => true])->assertOk()->assertJsonPath('version_id', $published);
|
||||
$row = DB::table('funding_programmes')->where('id', $id)->first();
|
||||
$draft = json_decode($row->draft, true);
|
||||
$draft['summary'] .= ' Deliberate QA editorial clarification.';
|
||||
DB::table('funding_programmes')->where('id', $id)->update(['draft' => json_encode($draft), 'version' => $row->version + 1]);
|
||||
$this->postJson('/management/fundos/referencias/arko/preparar', ['hash' => $plan['hash']])->assertOk()->assertJsonPath('matches_reference', false)->assertJsonPath('publication', null);
|
||||
$this->assertSame($draft, json_decode(DB::table('funding_programmes')->where('id', $id)->value('draft'), true));
|
||||
$other = User::factory()->create();
|
||||
$this->actingAs($other)->get('/management/fundos/referencias-assistidas')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_city_or_branch_text_is_searchable_without_treating_wildcards_as_search_commands(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { compileFunction } from 'node:vm';
|
||||
import { createRequire } from 'node:module';
|
||||
import ts from 'typescript';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { compileFunction } from "node:vm";
|
||||
import { createRequire } from "node:module";
|
||||
import ts from "typescript";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
const require = createRequire(import.meta.url);
|
||||
function load(file, mocks = {}) {
|
||||
const output = ts.transpileModule(
|
||||
fs.readFileSync(
|
||||
new URL('../../resources/js/' + file, import.meta.url),
|
||||
'utf8',
|
||||
new URL("../../resources/js/" + file, import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
{
|
||||
compilerOptions: {
|
||||
@@ -22,61 +22,63 @@ function load(file, mocks = {}) {
|
||||
},
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
compileFunction(output, ['require', 'module', 'exports'])(
|
||||
compileFunction(output, ["require", "module", "exports"])(
|
||||
(name) =>
|
||||
name in mocks
|
||||
? mocks[name]
|
||||
: name === '@/components/funding-classification' ? load('components/funding-classification.tsx',mocks) : name === '@/pages/investment/shared'
|
||||
? { card: 'card', input: 'input', button: 'button' }
|
||||
: require(name),
|
||||
: name === "@/components/funding-classification"
|
||||
? load("components/funding-classification.tsx", mocks)
|
||||
: name === "@/pages/investment/shared"
|
||||
? { card: "card", input: "input", button: "button" }
|
||||
: require(name),
|
||||
module,
|
||||
module.exports,
|
||||
);
|
||||
return module.exports;
|
||||
}
|
||||
const source = {
|
||||
source_url: 'https://example.invalid/source',
|
||||
source_reference: 'SYNTHETIC QA source',
|
||||
checked_on: '2026-11-10',
|
||||
source_url: "https://example.invalid/source",
|
||||
source_reference: "SYNTHETIC QA source",
|
||||
checked_on: "2026-11-10",
|
||||
};
|
||||
const product = {
|
||||
...source,
|
||||
code: 'qa-investment',
|
||||
title: 'SYNTHETIC QA investment',
|
||||
instrument: 'Investment credit',
|
||||
needs: ['investment'],
|
||||
coverage: 'national',
|
||||
coverage_text: 'SYNTHETIC national coverage',
|
||||
code: "qa-investment",
|
||||
title: "SYNTHETIC QA investment",
|
||||
instrument: "Investment credit",
|
||||
needs: ["investment"],
|
||||
coverage: "national",
|
||||
coverage_text: "SYNTHETIC national coverage",
|
||||
territory_ids: [],
|
||||
conditions: '',
|
||||
amounts: '',
|
||||
availability: 'unknown',
|
||||
contact_url: 'https://example.invalid/contact',
|
||||
conditions: "",
|
||||
amounts: "",
|
||||
availability: "unknown",
|
||||
contact_url: "https://example.invalid/contact",
|
||||
};
|
||||
const details = {
|
||||
institution_key: 'synthetic',
|
||||
institution_nature: 'bank',
|
||||
institution_key: "synthetic",
|
||||
institution_nature: "bank",
|
||||
contacts: [
|
||||
{
|
||||
...source,
|
||||
kind: 'website',
|
||||
value: 'https://example.invalid/contact',
|
||||
kind: "website",
|
||||
value: "https://example.invalid/contact",
|
||||
},
|
||||
],
|
||||
locations: [
|
||||
{
|
||||
...source,
|
||||
role: 'headquarters',
|
||||
label: 'SYNTHETIC Maputo office',
|
||||
address: 'SYNTHETIC address',
|
||||
territory_id: 'qa-maputo',
|
||||
territory_path: [{ name: 'Maputo' }],
|
||||
role: "headquarters",
|
||||
label: "SYNTHETIC Maputo office",
|
||||
address: "SYNTHETIC address",
|
||||
territory_id: "qa-maputo",
|
||||
territory_path: [{ name: "Maputo" }],
|
||||
},
|
||||
],
|
||||
products: [product],
|
||||
};
|
||||
test('actual institution view keeps physical address separate from product coverage and exposes unknown terms, source and contact', () => {
|
||||
const { InstitutionView } = load('components/funding-institution.tsx');
|
||||
test("actual institution view keeps physical address separate from product coverage and exposes unknown terms, source and contact", () => {
|
||||
const { InstitutionView } = load("components/funding-institution.tsx");
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(InstitutionView, { value: details }),
|
||||
);
|
||||
@@ -88,40 +90,164 @@ test('actual institution view keeps physical address separate from product cover
|
||||
assert.match(html, /SYNTHETIC QA source/);
|
||||
assert.match(html, /não é elegibilidade confirmada/);
|
||||
});
|
||||
test('adding a physical location does not fill product financing coverage', () => {
|
||||
const { InstitutionFields } = load('components/funding-institution.tsx');
|
||||
test("adding a physical location does not fill product financing coverage", () => {
|
||||
const { InstitutionFields } = load("components/funding-institution.tsx");
|
||||
let patch;
|
||||
const tree = InstitutionFields({
|
||||
value: details,
|
||||
change: (p) => (patch = p),
|
||||
territories: [],
|
||||
natures: { bank: 'Banco' },
|
||||
needs: { investment: 'Investimento' },
|
||||
natures: { bank: "Banco" },
|
||||
needs: { investment: "Investimento" },
|
||||
});
|
||||
const nodes = flatten(tree);
|
||||
nodes
|
||||
.find(
|
||||
(n) => n.type === 'button' && text(n) === 'Acrescentar sede/balcão',
|
||||
(n) => n.type === "button" && text(n) === "Acrescentar sede/balcão",
|
||||
)
|
||||
.props.onClick();
|
||||
assert.equal(patch.locations.length, 2);
|
||||
assert.equal(patch.locations[1].territory_id, '');
|
||||
assert.equal('products' in patch, false);
|
||||
assert.equal(details.products[0].coverage, 'national');
|
||||
assert.equal(patch.locations[1].territory_id, "");
|
||||
assert.equal("products" in patch, false);
|
||||
assert.equal(details.products[0].coverage, "national");
|
||||
});
|
||||
const flatten = (node) =>
|
||||
Array.isArray(node)
|
||||
? node.flatMap(flatten)
|
||||
: node && typeof node === 'object'
|
||||
: node && typeof node === "object"
|
||||
? [node, ...flatten(node.props?.children)]
|
||||
: [];
|
||||
const text = (node) =>
|
||||
Array.isArray(node)
|
||||
? node.map(text).join('')
|
||||
: node && typeof node === 'object'
|
||||
? node.map(text).join("")
|
||||
: node && typeof node === "object"
|
||||
? text(node.props?.children)
|
||||
: String(node ?? '');
|
||||
test('actual dossier continuation freezes a lost response retry and retains dossier/opportunity and selected product', async () => {
|
||||
: String(node ?? "");
|
||||
|
||||
test("assisted editorial preparation never publishes until a separate confirmation and retries the same publication", async () => {
|
||||
const states = [],
|
||||
refs = [],
|
||||
calls = [];
|
||||
let si = 0,
|
||||
ri = 0,
|
||||
lost = true;
|
||||
const hooks = {
|
||||
...React,
|
||||
useState(initial) {
|
||||
const i = si++;
|
||||
if (!(i in states)) states[i] = initial;
|
||||
return [
|
||||
states[i],
|
||||
(v) => {
|
||||
states[i] = typeof v === "function" ? v(states[i]) : v;
|
||||
},
|
||||
];
|
||||
},
|
||||
useRef(initial) {
|
||||
const i = ri++;
|
||||
return (refs[i] ??= { current: initial });
|
||||
},
|
||||
};
|
||||
const Assisted = load("pages/funding/assisted.tsx", {
|
||||
react: hooks,
|
||||
"@inertiajs/react": { Link: "a" },
|
||||
"@/pages/investment/shared": {
|
||||
Frame: "main",
|
||||
card: "card",
|
||||
button: "button",
|
||||
},
|
||||
"@/lib/profile-json": {
|
||||
profileJson: async (url, body) => {
|
||||
calls.push({ url, body: structuredClone(body) });
|
||||
if (url.endsWith("/publicar")) {
|
||||
if (lost) {
|
||||
lost = false;
|
||||
throw new Error("SYNTHETIC lost response");
|
||||
}
|
||||
return { published: true };
|
||||
}
|
||||
if (url.endsWith("/preparar"))
|
||||
return {
|
||||
id: "qa-id",
|
||||
matches_reference: true,
|
||||
publication: { version: 2, hash: "qa-plan" },
|
||||
};
|
||||
return { package_sha256: "qa-dataset", hash: "qa-source" };
|
||||
},
|
||||
},
|
||||
}).default;
|
||||
const props = {
|
||||
batch: {
|
||||
dataset_hash: "qa-dataset",
|
||||
items: [
|
||||
{
|
||||
code: "qa",
|
||||
title: "QA institution",
|
||||
summary: "QA",
|
||||
categories: [],
|
||||
types: [],
|
||||
locations: [],
|
||||
contacts: [],
|
||||
source_sha256:
|
||||
"119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const render = () => {
|
||||
si = 0;
|
||||
ri = 0;
|
||||
return Assisted(props);
|
||||
};
|
||||
const click = (tree, prefix) =>
|
||||
flatten(tree)
|
||||
.find((n) => n.type === "button" && text(n).startsWith(prefix))
|
||||
.props.onClick();
|
||||
let tree = render();
|
||||
click(tree, "Seleccionar");
|
||||
tree = render();
|
||||
let boxes = flatten(tree).filter((n) => n.type === "input");
|
||||
boxes[1].props.onChange({ target: { checked: true } });
|
||||
tree = render();
|
||||
click(tree, "1.");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.ok(calls.every((c) => !c.url.endsWith("/publicar")));
|
||||
tree = render();
|
||||
assert.equal(
|
||||
flatten(tree).find(
|
||||
(n) => n.type === "button" && text(n).startsWith("2."),
|
||||
).props.disabled,
|
||||
true,
|
||||
);
|
||||
flatten(tree)
|
||||
.find((n) => n.type === "textarea")
|
||||
.props.onChange({
|
||||
target: {
|
||||
value: "SYNTHETIC explicit source review and publication.",
|
||||
},
|
||||
});
|
||||
boxes = flatten(tree).filter((n) => n.type === "input");
|
||||
boxes[2].props.onChange({ target: { checked: true } });
|
||||
tree = render();
|
||||
click(tree, "2.");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
tree = render();
|
||||
flatten(tree)
|
||||
.find((n) => n.type === "textarea")
|
||||
.props.onChange({
|
||||
target: {
|
||||
value: "A later reason must not change an uncertain confirmation.",
|
||||
},
|
||||
});
|
||||
tree = render();
|
||||
click(tree, "2.");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls[3], calls[2]);
|
||||
assert.equal(calls[2].body.confirm, true);
|
||||
assert.match(text(render()), /Publicação confirmada/);
|
||||
});
|
||||
test("actual dossier continuation freezes a lost response retry and retains dossier/opportunity and selected product", async () => {
|
||||
const states = [],
|
||||
refs = [];
|
||||
let stateIndex = 0,
|
||||
@@ -142,19 +268,19 @@ test('actual dossier continuation freezes a lost response retry and retains doss
|
||||
};
|
||||
class Validation extends Error {}
|
||||
const { FundingDossierChoice } = load(
|
||||
'components/funding-dossier-choice.tsx',
|
||||
"components/funding-dossier-choice.tsx",
|
||||
{
|
||||
react: hooks,
|
||||
'@inertiajs/react': { Link: 'a', router: {} },
|
||||
'@/lib/operation-key': { createOperationKey: () => 'qa-operation' },
|
||||
'@/lib/profile-json': {
|
||||
"@inertiajs/react": { Link: "a", router: {} },
|
||||
"@/lib/operation-key": { createOperationKey: () => "qa-operation" },
|
||||
"@/lib/profile-json": {
|
||||
ProfileValidationError: Validation,
|
||||
profileJson: async (url, body) => {
|
||||
calls.push({ url, body: structuredClone(body) });
|
||||
if (calls.length === 1)
|
||||
throw new Error('SYNTHETIC lost response');
|
||||
throw new Error("SYNTHETIC lost response");
|
||||
return {
|
||||
href: '/investimentos/qa-dossier?opportunity=qa#financiamento',
|
||||
href: "/investimentos/qa-dossier?opportunity=qa#financiamento",
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -162,23 +288,23 @@ test('actual dossier continuation freezes a lost response retry and retains doss
|
||||
);
|
||||
const props = {
|
||||
record: {
|
||||
id: 'qa-programme',
|
||||
version_id: 'qa-version',
|
||||
payload: { kind: 'institution', products: [product] },
|
||||
id: "qa-programme",
|
||||
version_id: "qa-version",
|
||||
payload: { kind: "institution", products: [product] },
|
||||
},
|
||||
dossier: {
|
||||
id: 'qa-dossier',
|
||||
id: "qa-dossier",
|
||||
version: 4,
|
||||
opportunity: 'qa-opportunity',
|
||||
opportunity: "qa-opportunity",
|
||||
closed: false,
|
||||
href: '/investimentos/qa-dossier',
|
||||
href: "/investimentos/qa-dossier",
|
||||
},
|
||||
selection: {
|
||||
need: 'investment',
|
||||
territory: 'qa-location',
|
||||
territory_label: 'SYNTHETIC Nampula',
|
||||
need: "investment",
|
||||
territory: "qa-location",
|
||||
territory_label: "SYNTHETIC Nampula",
|
||||
},
|
||||
needs: { investment: 'Investment' },
|
||||
needs: { investment: "Investment" },
|
||||
destinations: [],
|
||||
};
|
||||
const render = () => {
|
||||
@@ -187,38 +313,38 @@ test('actual dossier continuation freezes a lost response retry and retains doss
|
||||
return FundingDossierChoice(props);
|
||||
};
|
||||
let tree = render();
|
||||
const selects = flatten(tree).filter((n) => n.type === 'select');
|
||||
const selects = flatten(tree).filter((n) => n.type === "select");
|
||||
selects[1].props.onChange({ target: { value: product.code } });
|
||||
selects[2].props.onChange({ target: { value: 'qa-branch' } });
|
||||
selects[2].props.onChange({ target: { value: "qa-branch" } });
|
||||
flatten(tree)
|
||||
.find((n) => n.type === 'input' && n.props.type === 'checkbox')
|
||||
.find((n) => n.type === "input" && n.props.type === "checkbox")
|
||||
.props.onChange({ target: { checked: true } });
|
||||
tree = render();
|
||||
flatten(tree)
|
||||
.find((n) => n.type === 'button')
|
||||
.find((n) => n.type === "button")
|
||||
.props.onClick();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
tree = render();
|
||||
assert.equal(
|
||||
flatten(tree).find((n) => n.type === 'fieldset').props.disabled,
|
||||
flatten(tree).find((n) => n.type === "fieldset").props.disabled,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
text(flatten(tree).find((n) => n.type === 'button')),
|
||||
'Repetir a mesma intenção',
|
||||
text(flatten(tree).find((n) => n.type === "button")),
|
||||
"Repetir a mesma intenção",
|
||||
);
|
||||
flatten(tree)
|
||||
.find((n) => n.type === 'button')
|
||||
.find((n) => n.type === "button")
|
||||
.props.onClick();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls[1], calls[0]);
|
||||
assert.equal(calls[0].body.context, 'qa-dossier');
|
||||
assert.equal(calls[0].body.opportunity, 'qa-opportunity');
|
||||
assert.equal(calls[0].body.context, "qa-dossier");
|
||||
assert.equal(calls[0].body.opportunity, "qa-opportunity");
|
||||
assert.equal(calls[0].body.product, product.code);
|
||||
assert.equal(calls[0].body.location_id, 'qa-branch');
|
||||
assert.equal(calls[0].body.location_id, "qa-branch");
|
||||
tree = render();
|
||||
assert.equal(
|
||||
flatten(tree).find((n) => n.type === 'a').props.href,
|
||||
'/investimentos/qa-dossier?opportunity=qa#financiamento',
|
||||
flatten(tree).find((n) => n.type === "a").props.href,
|
||||
"/investimentos/qa-dossier?opportunity=qa#financiamento",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user