Session: 70bc80b1-7487-464f-8ad5-699a49710fe1

CWD: /var/lib/metahuman-ocr-worker/work/job-232/worktree Branch: HEAD Mode: range From: origin/new_staging2 To: origin/hotfix/ssma-occurrence-readequacao-author Model: deepseek-v4-flash Duration: 6m6s Files: 5 Status: complete

Coverage

5
Selected
5
Completed
0
Reused
0
Failed
0
Waived

Token Usage

3.96M
Prompt Tokens
87.73K
Completion Tokens
4.05M
Total Tokens
98
LLM Requests
3.79M
Cache Read
0
Cache Write
File breakdown 3 files
FilePromptCompletionCache ReadCache WriteTotal
src/Controller/SsmaController.php,src/Service/Ssma/SsmaNotif… 3.26M 56.74K 3.15M0 3.32M
tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.… 701.66K 30.53K 639.1K0 732.19K
File Grouping 313 455 00 768

Review Comments (9 findings)

Severity:
Category:
tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php 4 comments
test medium L42
Esta asserção que deveria garantir que o autor do aprofundamento é preservado na reprovação verifica, na prática, apenas um comentário de código: a string `aprofundamento_finalized_by_*` só existe na linha de comentário adicionada em `SsmaOccurrenceApprovalService` ("// Mantém aprofundamento_finalized_by_* — ..."), não em código executável. Ou seja, o teste fica verde por causa de um comentário e quebra se alguém reescrever esse comentário, sem que haja qualquer regressão real — e, pior, não detecta o risco de verdade (algum `unset`/sobrescrita das chaves `aprofundamento_finalized_by_*` no ramo de reprovação). Troque por uma verificação sobre o código real (por exemplo, ausência de remoção dessas chaves) ou, preferencialmente, por um teste funcional que reprove uma ocorrência e confirme que as chaves permanecem em `details`.
Existing Code
        self::assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc);
Suggested Change
        self::assertStringNotContainsString("unset(\$details['aprofundamento_finalized_by", $this->approvalSrc);
test high L58
O teste não cobre a regra de permissão que a PR introduz. Ele só confirma que o controller contém os nomes `isSsmaOccurrenceInReadequacao` e `stampSsmaAprofundamentoAuthor`; a mera presença do nome não prova que, em readequação, outro especialista do mesmo tipo é barrado nem que o autor continua autorizado. Como a mudança toca autorização (quem pode editar o aprofundamento) e estado, se a verificação de autor fosse removida ou invertida em `canAccessSsmaEventAprofundamento`, estes testes continuariam verdes. Vale adicionar/atualizar um teste funcional que passe pelo caminho real (endpoint/edição da ocorrência) exercitando: autor reenvia e volta para validação, outro especialista do tipo é negado, e gestor/tenant admin edita em paralelo sem reabrir a fila.
Existing Code
        self::assertStringContainsString('isSsmaOccurrenceInReadequacao', $this->controllerSrc);
test medium L65
O helper de extração pega sempre 1800 caracteres a partir do `function`, então ele invade o método seguinte — hoje ele já engloba trecho de `notifyMetaAbonoRequester`. Com isso, a checagem de que a notificação não vai para quem cadastrou (`assertStringNotContainsString('getCreatedById()', ...)`) passa de forma trivial: o arquivo inteiro do serviço não contém mais `getCreatedById()`, e a janela ainda pode variar conforme métodos vizinhos. Na prática, o teste não prova que `notifyAprofundamentoAuthorOnReject` resolve o destinatário pelo autor do aprofundamento; se a notificação voltar a atingir o cadastrante por outro caminho (ou passar a usar outro campo), ele continua verde. Sugestão: delimitar o corpo até o próximo `function ` (ou usar `token_get_all`) e verificar de fato o vínculo destinatário↔autor (memberId autor presente, sem fallback para o criador).
Existing Code
        $chunk = substr($src, $start, 1800);
test medium L54
Esta asserção valida a ausência de um comentário humano no controller, não o comportamento. Ela passa apenas porque o comentário foi apagado e passaria/ falharia independentemente de a edição paralela voltar a reabrir a fila de validação — ou seja, não garante a regra que o nome do teste promete. Vale reforçar a cobertura verificando o guard real (`if ($wantsFinalize)` envolvendo a chamada de reenvio) e a inexistência de reenvio fora do caminho de reenvio do aprofundamento.
Existing Code
            'Readequação: reenvia tanto na edição completa',
tests/Unit/Product/Ssma/assert_branch_ui_fixes.php 1 comments
test low L373
Este check valida a ausência de um comentário humano no controller (`'Readequação: reenvia tanto na edição completa'`), não o comportamento. Ele passa simplesmente porque o comentário foi apagado, e volta a falhar se alguém reintroduzir um texto parecido, sem que a fila de validação seja de fato reaberta. A garantia que importa — edição de gestor/tenant não reenvia para validação e só o reenvio do aprofundamento reabre a fila — deveria ser verificada por comportamento (teste do fluxo), e aqui a asserção poderia se apoiar em algo executável, não em texto de comentário.
Existing Code
    && fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')
src/Controller/SsmaController.php 3 comments
bug medium L27946-L27948
A fila de validação agora só reabre quando o payload traz `aprofundamento_complete` (ou `aprofundamento_status = finalized`) no nível raiz — isto é, apenas no fluxo "+Aprofundamento". Existem ocorrências que chegam ao aprovador sem nunca ter tido aprofundamento finalizado: é o caso do ROS "visto e resolvido", liberado pelo gate em `isEventReadyForOccurrenceValidation()` (`ssmaEventSkipsAprofundamentoValidationGate`). Se o aprovador reprovar uma dessas, o "Editar" da tela cheia continua salvando normalmente, mas nunca reenvia, porque o formulário só envia essas chaves no modo `aprofundamento_only` (`templates/ssma/occurrence/partials/_modal_event.html.twig`, ~linha 7420). Resultado prático: a ocorrência fica presa em Readequação, sem caminho de volta pela edição — antes o `if ($wasRejectedApproval)` cobria esse cenário. Vale cobrir também o caso "sem aprofundamento finalizado" (ex.: `ros_resolved`, ou ausência de `aprofundamento_finalized_by_*`) ou confirmar com o produto que nesses casos o especialista deve obrigatoriamente passar pelo "+Aprofundamento" para reenviar.
Existing Code
        if ($wantsFinalize) {
            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);
        }
maintainability low L12803
Esta checagem compara a string literal `'rejected'` em vez de usar o valor canônico do `SsmaOccurrenceApprovalService` (constante ou `getState()`), que é o padrão já usado no mesmo arquivo (`maybeSubmitOccurrenceForValidation`). Como este teste é justamente o que restringe quem edita o aprofundamento na readequação, qualquer mudança futura no valor do status desliga a trava em silêncio e volta a liberar a edição para todos os especialistas do tipo. Sugestão: comparar com `SsmaOccurrenceApprovalService::STATUS_REJECTED`.
Existing Code
        return strtolower(trim((string) ($approval['status'] ?? ''))) === 'rejected';
security medium L12766-L12767
A decisão de "está em readequação" usa o array `$details` recebido por parâmetro, mas no `updateEvent` esse valor pode vir do corpo da requisição (`is_array($data['details'] ?? null) ? $data['details'] : $existingDetails`, na linha ~27829), não do estado salvo do evento. Basta o cliente enviar `details` sem a chave `occurrence_approval` para a condição cair no `false` e o fluxo voltar a liberar a edição do aprofundamento para qualquer especialista do mesmo tipo — exatamente o que a PR pretende restringir. Como é um gate de autorização, o mais seguro é avaliar a readequação a partir dos detalhes persistidos (`$existingDetails`), e não do payload.
Existing Code
        // Readequação: só quem enviou o aprofundamento (admin já passou acima).
        if ($this->isSsmaOccurrenceInReadequacao($details)) {
src/Service/Ssma/SsmaNotificationService.php 1 comments
bug medium L292-L294
Ao reprovar uma ocorrência que não tem autor de aprofundamento gravado, o aviso in-app deixa de ser enviado para qualquer pessoa — antes o cadastrante era notificado. Isso acontece em registros legados (aprofundamento finalizado antes desta mudança) e também no ROS "visto e resolvido", que passa no gate de validação sem nunca ter aprofundamento finalizado (`ssmaEventSkipsAprofundamentoValidationGate`), portanto sem `aprofundamento_finalized_by_*`. Na prática a ocorrência volta para readequação e ninguém é avisado, o que é regressão em relação ao comportamento anterior. Sugiro manter o fallback para o cadastrante quando não houver autor gravado.
Existing Code
        $recipients = [];
        if ($memberId > 0) {
            $recipients[] = $memberId;
Suggested Change
        $recipients = [];
        if ($memberId > 0) {
            $recipients[] = $memberId;
        } elseif ($userId > 0) {
            $author = $this->entityManager->find(User::class, $userId);
            if ($author instanceof User) {
                $recipients[] = $author;
            }
        } elseif ((int) $event->getCreatedById() > 0) {
            $registrant = $this->entityManager->find(User::class, (int) $event->getCreatedById());
            if ($registrant instanceof User) {
                $recipients[] = $registrant;
            }
        }
Files Reviewed 5 files
  • tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
  • src/Service/Ssma/SsmaNotificationService.php
  • src/Service/Ssma/SsmaOccurrenceApprovalService.php
  • tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
  • src/Controller/SsmaController.php
Session Tasks 1 tasks
File Grouping 1 requests

grouping_task

Request #1 deepseek-flash P:313 C:455 2778ms
Reasoning
Let me analyze the changed files. - src/Controller/SsmaController.php - src/Service/Ssma/SsmaNotificationService.php - src/Service/Ssma/SsmaOccurrenceApprovalService.php - tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php - tests/Unit/Product/Ssma/assert_branch_ui_fixes.php All are Ssma-related. Controller and services are the main production code. Tests relate to Ssma occurrence. I'd group the three production files together (Controller + two Ssma services) since they belong to same feature. The two tests could be grouped separately or with production. Let me group production code and tests separately, or all together since they're under 10 files. Probably best: one group for production Ssma code, one group for tests. But tests test the same feature. Hmm. Given the constraint that groups should be reviewed together, I'll make a group of Controller + services (they're the core change), and a group of tests. Or maybe just put all in one group since max 10 files. I'll do two groups: production and tests. Actually the test for author (readequação author) likely relates to approval service and controller changes. Grouping tests with their production code is common. But grouping all 5 together is fine too. Let me do: 1. "SSMA readequação/approval production changes" - Controller + 2 services 2. "SSMA unit tests" - 2 tests That seems reasonable.
[{"label": "SSMA occurrence approval/notification production logic", "files": ["src/Controller/SsmaController.php", "src/Service/Ssma/SsmaNotificationService.php", "src/Service/Ssma/SsmaOccurrenceApprovalService.php"]}, {"label": "SSMA occurrence readequação tests and UI assertions", "files": ["tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php", "tests/Unit/Product/Ssma/assert_branch_ui_fixes.php"]}]
Conversations 2 files
src/Controller/SsmaController.php,src/Service/Ssma/SsmaNotificationService.php,src/Service/Ssma/SsmaOccurrenceApprovalService.php 68 requests

plan_task

Request #1 deepseek-flash P:13.85K C:6.88K CR:1.28K CW:0 29359ms
Reasoning
Let me analyze the diff carefully. The change is in SSMA occurrence readequação flow. Let me understand the changes. Files: 1. `src/Controller/SsmaController.php` 2. `src/Service/Ssma/SsmaNotificationService.php` 3. `src/Service/Ssma/SsmaOccurrenceApprovalService.php` Let me examine each change. ### SsmaController.php Change 1: Renamed `notifyOccurrenceRegistrantOnReject` → `notifyAprofundamentoAuthorOnReject`. Change 2: In the method that checks can edit occurrence (around line 12761), added logic: ```php $loggedMember = $this->getCurrentCompanyMember($company, $user); // Readequação: só quem enviou o aprofundamento (admin já passou acima). if ($this->isSsmaOccurrenceInReadequacao($details)) { $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0 || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0; if ($hasAuthor) { return $this->isSsmaAprofundamentoAuthor($details, $user, $loggedMember); } } ``` And removed the later `$loggedMember = $this->getCurrentCompanyMember($company, $user);`. Wait, the `$loggedMember` assignment was moved up. But there's a concern: originally `$loggedMember` was assigned at line 12769 (after the technical types check). Now it's assigned earlier at line 12761. Is there any issue? It's just moved up. Need to check whether `getCurrentCompanyMember` has side effects or whether it was intended to be lazy. Probably fine. Change 3: New helper methods `isSsmaOccurrenceInReadequacao`, `isSsmaAprofundamentoAuthor`, `stampSsmaAprofundamentoAuthor`. `isSsmaOccurrenceInReadequacao`: ```php $approval = is_array($details['occurrence_approval'] ?? null) ? $details['occurrence_approval'] : []; return strtolower(trim((string) ($approval['status'] ?? ''))) === 'rejected'; ``` `isSsmaAprofundamentoAuthor`: ```php $authorUserId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0); if ($authorUserId > 0 && (int) $user->getId() === $authorUserId) { return true; } $authorMemberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0); return $authorMemberId > 0 && $member instanceof CompanyMembers && (int) $member->getId() === $authorMemberId; ``` `stampSsmaAprofundamentoAuthor`: ```php $details['aprofundamento_finalized_by_user_id'] = (int) $user->getId(); $memberId = $this->resolveCompanyMemberIdByUserId($company, (int) $user->getId()); if ($memberId) { $details['aprofundamento_finalized_by_member_id'] = $memberId; } $name = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? '')); $details['aprofundamento_finalized_by_name'] = $name !== '' ? $name : (string) ($user->getEmail() ?? ''); ``` Change 4: In the update flow, `stampSsmaAprofundamentoAuthor($detailsOut, $company, $user);` when `aprofundamento_complete` set to true and status finalized. Change 5: The submit condition changed: ```php - if ($wasRejectedApproval) { - // Readequação: reenvia tanto na edição completa quanto ao finalizar só o aprofundamento. - $this->maybeSubmitOccurrenceForValidation($event, $company, $user, true); - } elseif ($wantsFinalize) { - $this->maybeSubmitOccurrenceForValidation($event, $company, $user, false); + // Readequação só volta à fila ao reenviar o aprofundamento. Editar (gestor/tenant) + // em paralelo não starta esse fluxo. + if ($wantsFinalize) { + $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval); } ``` ### SsmaNotificationService.php `notifyOccurrenceRegistrantOnReject` → `notifyAprofundamentoAuthorOnReject`. New logic determines recipients from details, falling back to memberId, else userId. If no recipients, return. Important: The recipients array can contain a mix: `$memberId` (int) or a `User` object. Passed to `$this->notify($recipients, ...)`. Need to check the `notify` method signature — what does it accept? Previously it was `[$recipient]` where `$recipient` is a User. Now it may be `[$memberId]` (an int). Does `notify()` accept member IDs? That's a critical question. Let me check. Actually, this is a big potential issue — if `notify()` expects User objects but receives an int member ID, it may fail or misbehave. Need to verify via code search for `function notify(`. ### SsmaOccurrenceApprovalService.php Text changes and comment. Preserves author. Also `aprofundamento_status = 'draft'` and `aprofundamento_complete = false`. Seems fine. Now let me think about risk points. **Issue 1 (high?):** `notify()` recipient type mismatch — `$recipients[]` may be an int memberId while the original code passed a User object. Need to verify `notify()` handles member IDs. This is the crux. Let me check. Actually, the background says the notification service — `notify` accepts `array $recipients`. Let me search. **Issue 2:** In `isSsmaOccurrenceInReadequacao`, the check is based on `occurrence_approval.status === 'rejected'`. But the approval service sets `aprofundamento_status = 'draft'`. Is `occurrence_approval.status` kept as 'rejected' after readequação? The comment says "Mantém aprofundamento_finalized_by_*". Need to verify that after re-submission the approval status is reset, otherwise editing may remain restricted. But the diff in approval service shows the state stored. Hmm. Actually, wait — a subtle bug: the readequação check requires `occurrence_approval.status === 'rejected'`. But the notification/readequate logic relies on this status. When the user re-submits (reenvia), does the status change from 'rejected' to 'pending'? If not, the author restriction persists. Probably it does change when submitting for validation. Need to verify with code_search for `occurrence_approval` status transitions. **Issue 3:** `$loggedMember` moved earlier. `getCurrentCompanyMember` may be called regardless. Minor. **Issue 4:** In `isSsmaAprofundamentoAuthor`, if the user matches authorUserId, returns true. Otherwise checks member. What if user's member id matches? Fine. But what if the author has only userId recorded and the logged user is the same person but with a different User record? Unlikely. **Issue 5:** `stampSsmaAprofundamentoAuthor` uses `resolveCompanyMemberIdByUserId`. Need to confirm that method exists. It's newly called. Let me check if it exists in the file. The diff doesn't show it being added, so presumably it exists. **Issue 6:** The order of issues — the authorization logic changed for editing. The new branch: if in readequação and has author, return isSsmaAprofundamentoAuthor. This means other technical specialists of the type can no longer edit. That's intended. But what about the case where `hasAuthor` is false (legacy occurrences without author)? Then falls through to the old logic (technical types check), which allows technical types to edit. The background says legacy follows old rule. OK. But there's a subtlety: In readequação, admins already passed above. But what about "supervisor" or manager? The comment says admin already passed. What about other roles? They'd fall to technical types. Fine. **Issue 7:** `notifyAprofundamentoAuthorOnReject` — the `$recipients` array mixing types. And if `$memberId > 0`, it uses memberId and ignores userId. If the memberId recipient doesn't resolve (e.g., member deleted), notification may be lost. But the `elseif` means when memberId present, userId fallback isn't used. Potential issue but minor. **Issue 8:** The old behavior notified `$event->getCreatedById()` — the registrant. New behavior notifies the aprofundamento author. This is the intended change. But is there a scenario where the aprofundamento author is not set (legacy) and thus NO notification is sent? The old code notified the registrant. Now if no author, `$recipients === []` returns early — no notification. That's a behavior change/regression for legacy occurrences: nobody gets notified. The background explicitly says legacy follows old rule of access, but for notifications? Hmm. The rule says "ocorrências antigas sem autor gravado seguem a regra antiga de acesso (legado)." But the notification code returns early if no author. Actually wait, in readequação context, if the approval is rejected but no author stored (old occurrence), then no one gets notified. Is that acceptable? Could be a regression. But maybe acceptable given the nature. Medium at most. Hmm, but actually this is a real regression risk: previously `notifyOccurrenceRegistrantOnReject` always notified the event creator. Now if the author fields are absent (e.g., occurrence where aprofundamento was finalized before this change was deployed), no notification is sent. Worth flagging as medium. **Issue 9:** The `stampSsmaAprofundamentoAuthor` is called with `&$details` by reference and modifies `$detailsOut`. But is `$detailsOut` the same array persisted? Need to check whether `$detailsOut` gets saved. The diff shows `$data['aprofundamento_complete'] = true; $detailsOut['aprofundamento_complete'] = true; ... $this->stampSsmaAprofundamentoAuthor($detailsOut, $company, $user);`. Then later `$service->updateFromPayload($event, $data, $user, $editorMeta)`. Hmm, the `$data` vs `$detailsOut`. It stamps `$detailsOut` but does `$data` contain details? Need to verify that `$detailsOut` is what's persisted. This is important: if the author is stamped on a different array that isn't persisted, the notification would never find the author. Let me verify with code search / file read. That's a key risk. Let me plan a tool call to read the surrounding code of the update flow to confirm `$detailsOut` is persisted. **Issue 10:** In `isSsmaOccurrenceInReadequacao`, using `occurrence_approval.status === 'rejected'`. Need to confirm the actual stored status string. In approval service, `$state` and `self::STATUS_APPROVED`. What is STATUS_REJECTED value? Let me check. The check uses literal 'rejected'. If the constant is something else like 'reprovado', mismatch. Need to verify. The approval service stores `$state`. Let me search. Actually the diff shows `'occurrence_approval' => $state` returned but also presumably stored in details. Need to check what value status holds — probably 'rejected' literal. Let me verify. **Issue 11:** `resolveCompanyMemberIdByUserId` — need to verify existence and behavior (company scoping). Not in diff. Search. **Issue 12:** `@param array<string, mixed> $details` for by-reference param — fine. Let me also consider the `notify` recipients — this is likely the biggest one. Let me check. Let me plan tool calls: 1. Search for `function notify(` in SsmaNotificationService to check signature and whether it accepts member IDs. 2. Read the diff of other files? Actually, we can use file_read_diff for other changed files but those are test files. Not necessary. 3. Search for `notifyOccurrenceRegistrantOnReject` to find remaining callers (maybe other callers still reference the old method name → fatal error). Actually the method was renamed in both controller and service. But are there other callers? Search `notifyOccurrenceRegistrantOnReject` across codebase. 4. Search for `occurrence_approval` status values / `STATUS_REJECTED`. 5. Search for `resolveCompanyMemberIdByUserId`. 6. Search for `getCurrentCompanyMember`. 7. Read the update flow context to verify `$detailsOut` persisted. 8. Search for `aprofundamento_finalized_by` to see other readers. Let me structure the issues. Given the review checklist emphasizes: "Efeito colateral perdido em refatoração" and "God object / lógica duplicada" and "escopo". Let me think about duplication: `isSsmaAprofundamentoAuthor` and `stampSsmaAprofundamentoAuthor` are both in the controller; the notification service also reads the same `aprofundamento_finalized_by_*` keys. Is there duplicated logic? The notification service duplicates the "read author from details" logic that the controller has. Slight duplication but acceptable. Also there's a possibility: the notification service uses `memberId` first then `userId`. The controller authorization uses `userId` first then `memberId`. Inconsistent precedence — but not a bug per se. Potential real bug: In notification service, `$recipients[] = $memberId;` — an int. The `notify()` method probably expects `User[]`. Let me verify. If it expects User objects, passing an int would break. The comment says "avisa só quem enviou" — maybe notify resolves memberId. Let me search. I'll now write the plan. Since I can't call tools, I just describe them. Let me now craft the issues in pt-BR, sorted by severity. High severity candidates: - `notify()` recipient may be an int memberId instead of User → type mismatch / notification failure or error. Need verify. - Possible that `$detailsOut` author not persisted (stamp on wrong array) → author never recorded → readequação access broken and notification lost. Need verify. - Removed `$wasRejectedApproval` branch: previously readequação re-submitted even on full edit; now only on `$wantsFinalize`. Is that intended? Yes per background. But could there be a regression where the full-edit re-submit is needed? Background says intended. Not flag as high. Actually, there's a subtle issue with the condition change: `$this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);` — the 4th arg semantic. Previously the two branches passed `true` (when rejected) and `false` (when wantsFinalize). Now passes `$wasRejectedApproval`. Need to check the meaning of the 4th parameter. Let me search `maybeSubmitOccurrenceForValidation`. Hmm, need to verify the boolean param meaning. Let me search. Also potential: `$wantsFinalize` may be false in the readequação full edit case, so no submit — intended. Let me also consider: The readequação access check uses `occurrence_approval.status === 'rejected'`. But once the author resubmits, status becomes pending, so restriction lifted. Fine. But a bug: after readequação and re-submit, `isSsmaOccurrenceInReadequacao` false, so the technical types can edit again. Intended. Now let me think about the notification recipient fallback: if `memberId > 0` uses memberId; `notify` receives int member id. Hmm. Let me formulate ~6-8 issues covering main risks. Potential issue: legacy occurrences without author → notification silently skipped (behavior regression). Medium. Potential issue: when memberId present but user is not a member of the company, notify by memberId might target wrong person or cross-company. Low/medium. Potential issue: `stampSsmaAprofundamentoAuthor` only stamps when memberId resolves; if not, `aprofundamento_finalized_by_member_id` not set, only userId. Then notification falls to userId branch. Fine. Potential issue: The `$hasAuthor` check requires author present; but isSsmaOccurrenceInReadequacao requires status rejected. Combined, if in readequação with author, only author edits. If a manager edits... admins already returned true. But what about "tenant admin"? The comment says admin already passed. Need to verify the earlier code returns true for admins. Let me search the surrounding code around line 12761 to see prior guards. Actually can't read partial easily but I can file_read the file? No file_read tool, only file_read_diff. Hmm. Tools available: code_search, file_read_diff, file_find. There's no general file_read except via diff. Wait, the system lists `file_read` in examples but actually available tools are code_search, file_read_diff, file_find. Let me re-read. Available tools: code_search, file_read_diff, file_find. The `file_read` mentioned in rules examples may not be available. I'll use code_search and file_read_diff primarily. file_read_diff can show diffs of other files — but for the same reviewed files, we already have diff. Hmm, file_read_diff "view the changes made to other files in the list of modifications". So it's for other changed files. For non-local claims, I'd use code_search. I'll frame the → lines accordingly using code_search. Let me now write the plan. Order issues by severity: 1. [high] `notify()` recipients type — int memberId passed. Need verify notify signature. → code_search `function notify(` in SsmaNotificationService; and search how `notify` handles recipients (member vs User). Actually, let me reconsider. The service is used elsewhere; the `notify` method might have `array $recipients` and internally resolve. Let me just flag and plan search. 2. [high] Author stamping on `$detailsOut` may not be persisted / used by updateFromPayload. Verify. → code_search `$detailsOut` and `updateFromPayload`. 3. [medium] Legacy occurrences without author: reject notification silently dropped (regression vs earlier behavior that notified the registrant). → code_search for `notifyOccurrenceRegistrantOnReject` remaining callers and check fallback. Actually the earlier code notified registrant. Now no author → no notification. This is a functional regression for legacy. Flag medium-high. 4. [medium] `isSsmaOccurrenceInReadequacao` matches literal 'rejected'; confirm stored status constant value. → code_search `STATUS_REJECTED` / `occurrence_approval` / `'rejected'`. 5. [medium] Renamed methods — ensure no remaining references to old names causing fatal errors. → code_search `notifyOccurrenceRegistrantOnReject`. 6. [medium] The `$loggedMember` moved before the technical-types check — verify `getCurrentCompanyMember` has no side effects/is not expensive; also there could be a case where `$loggedMember` was intentionally fetched later. Low. 7. [medium] Precedence inconsistency between authorization (userId first) and notification (memberId first). Low. 8. [low] `maybeSubmitOccurrenceForValidation` 4th arg semantics — confirm `$wasRejectedApproval` is the correct flag now that branches merged. → code_search. Let me also consider: `resolveCompanyMemberIdByUserId` existence. → code_search. Let me also verify `stampSsmaAprofundamentoAuthor` uses `CompanyMembers` type and `$member->getId()`. Fine. Let me combine some to avoid too many. Target maybe 7-8 issues. Let me write descriptions per format: problem location, nature, potential impact. Now, the format: Each issue description must cover three dimensions. And → lines with tool name, args, purpose. Let me write. Also Summary line. Let me be careful about severity ordering: high first. I'll produce: Summary: Ajusta o fluxo de readequação do SSMA para registrar o autor do aprofundamento (user/member/name em details), restringir a edição/reenvio a esse autor em ocorrências reprovadas e direcionar a notificação in-app de reprovação a ele, além de impedir que a edição administrativa reabra a fila de validação. Issues: 1. [high] Notificação de reprovação pode passar um ID de membro (int) para `notify()`, que historicamente recebia objetos `User`. Impact: falha/erro ao enviar o aviso ou destinatário errado. → code_search `function notify(` file_patterns ["src/Service/Ssma/SsmaNotificationService.php"] — confirmar assinatura e tipos aceitos. → code_search `->notify(` ["src/Service/Ssma/SsmaNotificationService.php"] — ver como o método trata cada item do array de destinatários. 2. [high] Autor do aprofundamento é gravado em `$detailsOut`, que pode não ser o array persistido por `updateFromPayload`. Se não for o mesmo, o autor nunca é salvo e a trava de readequação e a notificação quebram. → code_search `detailsOut` ["src/Controller/SsmaController.php"] — confirmar se `$detailsOut` é o que vai para `updateFromPayload`/persistência. → code_search `function updateFromPayload` — verificar de onde o serviço lê os details. 3. [medium] Ocorrências antigas sem `aprofundamento_finalized_by_*` deixam de notificar ninguém na reprovação. Antes o aviso ia para o cadastrante; agora `$recipients === []` retorna cedo. → code_search `notifyOccurrenceRegistrantOnReject` — verificar chamadores antigos/fallback. → code_search `aprofundamento_finalized_by` — mapear quem lê/grava esses campos. 4. [medium] `isSsmaOccurrenceInReadequacao` compara string literal `'rejected'` com `occurrence_approval.status`. Se o status persistido usar outro valor/constante, a trava de readequação nunca ativa (ou ativa errado). → code_search `occurrence_approval` — confirmar valores de status gravados. → code_search `STATUS_REJECTED|STATUS_APPROVED` — comparar com a constante do serviço. 5. [medium] Renome de métodos (controller e service) — se restar chamador do nome antigo, erro fatal. Verificar referências remanescentes. → code_search `notifyOccurrenceRegistrantOnReject` — checar referências restantes. 6. [medium] A 3ª via de submit mudou: só `$wantsFinalize` reenvia, e o 4º argumento agora é `$wasRejectedApproval`. Confirmar o significado do parâmetro para não inverter comportamento (ex.: reenvio tratado como novo cadastro). → code_search `function maybeSubmitOccurrenceForValidation` — entender o 4º parâmetro. → code_search `maybeSubmitOccurrenceForValidation(` — demais chamadas e valores passados. 7. [low] Inconsistência de precedência: autorização checa userId antes de memberId; notificação checa memberId antes de userId. Pode divergir destinatário vs quem pode editar em cenários de dados parciais. → code_search `resolveCompanyMemberIdByUserId` — confirmar contrato. Hmm, maybe merge 7 into something. It's fine as low. Also maybe mention `stampSsmaAprofundamentoAuthor` only sets member_id if resolved; if member lookup fails the notification falls to userId. That's covered. Let me also add about the moved `$loggedMember` line — it was removed from later and added earlier. Potential: previously `getCurrentCompanyMember` might have been called after a cheap early return; now it's called always. Minor perf. Low. Could mention. I think 7 issues is good. Let me finalize with pt-BR. Let me double check tool names allowed: code_search, file_read_diff, file_find. I'll use code_search and file_find. For issue about `resolveCompanyMemberIdByUserId` existence, use code_search. Let me write invocation arguments properly (search_text plus file_patterns). Write final.
Summary: A mudança ajusta o fluxo de readequação do SSMA: passa a gravar o autor do aprofundamento em `ssma_events.details` (`aprofundamento_finalized_by_user_id/_member_id/_name`), restringe a edição/reenvio do aprofundamento a esse autor quando a ocorrência está reprovada, direciona a notificação in-app de reprovação a ele (em vez do cadastrante), e impede que a edição administrativa reabra sozinha a fila de validação. Issues 1. [high] A notificação de reprovação agora pode enviar um ID de membro (inteiro) dentro do array de destinatários para `notify()`, que historicamente recebia objetos `User` (`[$recipient]`). Se `notify()` não souber resolver um inteiro de membro, o aviso falha, é enviado para a pessoa errada ou estoura erro — quebrando exatamente o comportamento que a PR quer garantir (avisar quem fez o aprofundamento). → code_search `function notify(` file_patterns ["src/Service/Ssma/SsmaNotificationService.php"] — confirmar a assinatura e o tipo aceito para cada elemento de `$recipients`. → code_search `->notify(` file_patterns ["src/Service/Ssma/SsmaNotificationService.php"] — verificar se o método trata item do array como `User`, como memberId ou como ambos. 2. [high] O autor do aprofundamento é gravado em `$detailsOut` por referência, mas não é evidente que esse array seja o mesmo persistido por `updateFromPayload`. Se o serviço persistir outro array (ex.: `$data`), os campos `aprofundamento_finalized_by_*` nunca são salvos no banco — a trava de readequação nunca reconhece o autor e a notificação cai no caminho `$recipients === []`. → code_search `detailsOut` file_patterns ["src/Controller/SsmaController.php"] — confirmar se `$detailsOut` é a fonte enviada a `updateFromPayload`/persistência. → code_search `function updateFromPayload` — verificar de qual estrutura o serviço lê `details` ao atualizar o evento. 3. [medium] Ocorrências antigas, criadas antes desta mudança, não têm `aprofundamento_finalized_by_*` gravado. Ao reprovar, `$recipients === []` faz o método retornar cedo e ninguém é notificado — antes o aviso sempre ia para o cadastrante (`$event->getCreatedById()`). Isso é uma regressão funcional de notificação para o legado, ainda que a PR trate o legado apenas para acesso. → code_search `aprofundamento_finalized_by` file_patterns ["src/"] — mapear todos os pontos que leem/gravam esses campos e confirmar a ausência de fallback. → code_search `notifyOccurrenceRegistrantOnReject` — checar se havia outro caminho de notificação do cadastrante que precisava ser preservado. 4. [medium] `isSsmaOccurrenceInReadequacao` compara a string literal `'rejected'` com `occurrence_approval.status`. Se o status persistido usar outro valor/convenção (ex.: `reprovado`, ou a constante `self::STATUS_REJECTED` com conteúdo diferente), a trava de readequação nunca liga — permitindo que qualquer especialista do tipo edite — ou liga em momento indevido. → code_search `occurrence_approval` file_patterns ["src/"] — confirmar os valores efetivamente gravados em `status`. → code_search `STATUS_REJECTED|STATUS_APPROVED` file_patterns ["src/Service/Ssma/SsmaOccurrenceApprovalService.php"] — comparar a constante com o literal usado no controller. 5. [medium] Renomear `notifyOccurrenceRegistrantOnReject` para `notifyAprofundamentoAuthorOnReject` no controller e no service exige que não sobre nenhum chamador do nome antigo; qualquer referência remanescente gera erro fatal em tempo de execução. → code_search `notifyOccurrenceRegistrantOnReject` — listar chamadas residuais do nome antigo em todo o repositório. 6. [medium] A lógica de reenvio foi fundida: agora só `$wantsFinalize` dispara `maybeSubmitOccurrenceForValidation`, e o 4º argumento passou a ser `$wasRejectedApproval`. É preciso confirmar o significado desse booleano para não inverter o tratamento (reenvio de readequação sendo tratado como cadastro novo, ou vice-versa). → code_search `function maybeSubmitOccurrenceForValidation` file_patterns ["src/Controller/SsmaController.php"] — entender semanticamente o 4º parâmetro. → code_search `maybeSubmitOccurrenceForValidation(` — conferir os valores passados nas demais chamadas. 7. [low] A precedência para identificar o autor é inconsistente entre os dois lados: a autorização (`isSsmaAprofundamentoAuthor`) prioriza `_user_id` e só depois `_member_id`, enquanto a notificação prioriza `_member_id` e só usa `_user_id` como fallback. Em dados parciais (um campo preenchido e o outro não, ou apontando para pessoas diferentes), quem pode editar pode divergir de quem é avisado. → code_search `resolveCompanyMemberIdByUserId` file_patterns ["src/Controller/SsmaController.php"] — confirmar o contrato do resolvedor e quando ele retorna vazio.

main_task

Request #1 deepseek-flash P:16.41K C:126 CR:2.56K CW:0 1591ms
I'll start by verifying the key claims in the review plan using context tools.
Tool Calls (2)
code_search
Show Details
{"search_text": "function notify(", "file_patterns": ["src/Service/Ssma/SsmaNotificationService.php"]}
File: src/Service/Ssma/SsmaNotificationService.php
Match lines: 1
357|    private function notify(

code_search
Show Details
{"search_text": "notifyOccurrenceRegistrantOnReject"}
File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 1
33|        self::assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc);

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 1
378|    && fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')

Request #2 deepseek-flash P:16.69K C:154 CR:16.51K CW:0 1132ms
Reasoning
Let me read the notify function.
Tool Calls (2)
file_read
Show Details
{"file_path": "src/Service/Ssma/SsmaNotificationService.php", "start_line": 240, "end_line": 440}
File: src/Service/Ssma/SsmaNotificationService.php (Total lines: 485)
IS_TRUNCATED: false
LINE_RANGE: 240-440
240|
241|    /**
242|     * @param list<int> $memberIds
243|     */
244|    public function notifyActionValidators(array $memberIds, int $actionId, User $sender): void
245|    {
246|        $this->notifyActionPlan($memberIds, $actionId, $sender, 'Uma nova ação foi resolvida - faça a validação da mesma');
247|    }
248|
249|    /**
250|     * @param list<int> $memberIds
251|     */
252|    public function notifyMetaAbonoApprovers(array $memberIds, string $kind, ?User $sender): void
253|    {
254|        $this->notify(
255|            $memberIds,
256|            sprintf('Uma solicitação de abono de meta de %s foi registrada.', $this->metaAbonoKindLabel($kind)),
257|            self::PENDING,
258|            $sender,
259|            $this->urlGenerator->generate('ssma_prevencao_index') . '#tab_prev_metas'
260|        );
261|    }
262|
263|    public function notifyOccurrenceValidationApprovers(
264|        Company $company,
265|        SsmaEvent $event,
266|        User $sender,
267|        bool $resubmitted = false
268|    ): void {
269|        $title = $this->eventTitle($event);
270|        $this->notify(
271|            $this->ssmaOccurrenceTypeConfig->getFlashReportApproverIds($company),
272|            $resubmitted
273|                ? sprintf('A ocorrência "%s" foi corrigida e aguarda nova validação.', $title)
274|                : sprintf('A ocorrência "%s" foi finalizada e aguarda sua validação.', $title),
275|            self::PENDING,
276|            $sender,
277|            $this->occurrenceViewUrl((int) $event->getId(), 'event')
278|        );
279|    }
280|
281|    /**
282|     * Fluxo natural da readequação: avisa só quem enviou o aprofundamento reprovado.
283|     * Não dispara para todos os especialistas do tipo (isso é o cadastro novo).
284|     * Destinatários extras ficam a cargo da automação ssma_on_occurrence_rejected.
285|     */
286|    public function notifyAprofundamentoAuthorOnReject(SsmaEvent $event, User $sender, string $note): void
287|    {
288|        $details = is_array($event->getDetails()) ? $event->getDetails() : [];
289|        $memberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
290|        $userId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
291|
292|        $recipients = [];
293|        if ($memberId > 0) {
294|            $recipients[] = $memberId;
295|        } elseif ($userId > 0) {
296|            $author = $this->entityManager->find(User::class, $userId);
297|            if ($author instanceof User) {
298|                $recipients[] = $author;
299|            }
300|        }
301|
302|        if ($recipients === []) {
303|            return;
304|        }
305|
306|        $content = sprintf(
307|            'A ocorrência "%s" foi reprovada na validação e voltou para readequação do aprofundamento.',
308|            $this->eventTitle($event)
309|        );
310|        if (trim($note) !== '') {
311|            $content .= ' Observação: ' . trim($note);
312|        }
313|
314|        $this->notify(
315|            $recipients,
316|            $content,
317|            self::PENDING,
318|            $sender,
319|            $this->occurrenceViewUrl((int) $event->getId(), 'event')
320|        );
321|    }
322|
323|    public function notifyMetaAbonoRequester(int $memberId, string $kind, ?User $sender): void
324|    {
325|        $this->notify(
326|            [$memberId],
327|            sprintf('Seu pedido de revisão de metas de %s foi respondido', $this->metaAbonoKindLabel($kind)),
328|            NotificationsCenter::TYPE_GENERAL,
329|            $sender,
330|            $this->urlGenerator->generate('ssma_prevencao_index') . '#tab_prev_meta_reviews'
331|        );
332|    }
333|
334|    /**
335|     * @param list<int> $memberIds
336|     */
337|    private function notifyActionPlan(array $memberIds, int $actionId, User $sender, string $content): void
338|    {
339|        if ($actionId <= 0) {
340|            return;
341|        }
342|
343|        $this->notify(
344|            $memberIds,
345|            $content,
346|            self::PENDING,
347|            $sender,
348|            $this->urlGenerator->generate('ssma_plano_acao_index', ['action' => $actionId])
349|        );
350|    }
351|
352|    /**
353|     * Aceita IDs de CompanyMembers ou instâncias de User.
354|     *
355|     * @param list<int|User> $recipients
356|     */
357|    private function notify(
358|        array $recipients,
359|        string $content,
360|        string $type,
361|        ?User $sender,
362|        string $buttonUrl,
363|        string $hub = self::HUB,
364|        string $product = self::PRODUCT,
365|        ?Company $restrictToCompany = null
366|    ): void {
367|        $senderId = $sender instanceof User ? (int) $sender->getId() : 0;
368|        $created = 0;
369|
370|        foreach ($recipients as $recipient) {
371|            if (!$recipient instanceof User) {
372|                $recipient = $this->resolveMemberUser((int) $recipient, $restrictToCompany);
373|            }
374|            if (!$recipient instanceof User || ($senderId > 0 && (int) $recipient->getId() === $senderId)) {
375|                continue;
376|            }
377|
378|            try {
379|                $this->notificationsCenterService->createNotification(
380|                    recipient: $recipient,
381|                    hub: $hub,
382|                    product: $product,
383|                    content: $content,
384|                    type: $type,
385|                    sender: $sender,
386|                    buttonUrl: $buttonUrl,
387|                    flush: false
388|                );
389|                $created++;
390|            } catch (\Throwable) {
391|            }
392|        }
393|
394|        if ($created === 0) {
395|            return;
396|        }
397|
398|        try {
399|            $this->entityManager->flush();
400|        } catch (\Throwable) {
401|        }
402|    }
403|
404|    private function resolveMemberUser(int $memberId, ?Company $restrictToCompany = null): ?User
405|    {
406|        if ($memberId <= 0) {
407|            return null;
408|        }
409|
410|        $member = $this->entityManager->find(CompanyMembers::class, $memberId);
411|        if (!$member instanceof CompanyMembers || $member->getIsRemoved()) {
412|            return null;
413|        }
414|
415|        if ($restrictToCompany instanceof Company) {
416|            $memberCompany = $member->getCompany();
417|            if (
418|                !$memberCompany instanceof Company
419|                || (int) $memberCompany->getId() !== (int) $restrictToCompany->getId()
420|            ) {
421|                return null;
422|            }
423|        }
424|
425|        $user = $member->getUser();
426|
427|        return $user instanceof User ? $user : null;
428|    }
429|
430|    /**
431|     * @param iterable<mixed> $ids
432|     * @return list<int>
433|     */
434|    private function uniquePositiveIds(iterable $ids): array
435|    {
436|        $unique = [];
437|        foreach ($ids as $id) {
438|            $id = (int) $id;
439|            if ($id > 0) {
440|                $unique[$id] = $id;
code_search
Show Details
{"search_text": "function maybeSubmitOccurrenceForValidation", "file_patterns": ["src/Controller/SsmaController.php"]}
File: src/Controller/SsmaController.php
Match lines: 1
11977|    private function maybeSubmitOccurrenceForValidation(

Request #3 deepseek-flash P:18.91K C:302 CR:16.77K CW:0 2248ms
Reasoning
Issue 1 is resolved: `notify()` accepts both ints and User. So no problem there. However, note that `notifyAprofundamentoAuthorOnReject` does NOT pass `$restrictToCompany` to notify, but `notify` doesn't accept the company from callers... Actually `notify` has a `$restrictToCompany` param but the call passes only 5 args, so it's null. That means `resolveMemberUser` doesn't restrict to company. Since memberId is stored in the event's details, it's trusted-ish. Probably fine — cross-company risk minimal since the member id comes from the same occurrence. Not a strong finding. Let me look at maybeSubmitOccurrenceForValidation.
Tool Calls (2)
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 11960, "end_line": 12060}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 11960-12060
11960|        }
11961|
11962|        return true;
11963|    }
11964|
11965|    private function isSsmaEventStatusFinalized(\App\Entity\SsmaEvent $event): bool
11966|    {
11967|        $raw = trim((string) $event->getStatus());
11968|        $upper = mb_strtoupper($raw);
11969|
11970|        return $upper === SsmaEvent::STATUS_CONCLUIDO
11971|            || in_array(mb_strtolower($raw), ['finalizada', 'resolvida', 'concluida'], true);
11972|    }
11973|
11974|    /**
11975|     * Marca pendente e notifica aprovadores quando a ocorrência foi finalizada (não no rascunho).
11976|     */
11977|    private function maybeSubmitOccurrenceForValidation(
11978|        \App\Entity\SsmaEvent $event,
11979|        Company $company,
11980|        User $sender,
11981|        bool $resubmitted = false
11982|    ): void {
11983|        if ($this->ssmaOccurrenceApprovalService->isApproved($event)) {
11984|            return;
11985|        }
11986|
11987|        if (!$resubmitted && !$this->isSsmaEventStatusFinalized($event)) {
11988|            return;
11989|        }
11990|
11991|        if (!$this->isEventReadyForOccurrenceValidation($event)) {
11992|            return;
11993|        }
11994|
11995|        $status = $this->ssmaOccurrenceApprovalService->getState($event)['status'];
11996|
11997|        if ($resubmitted) {
11998|            if ($status !== \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_REJECTED) {
11999|                return;
12000|            }
12001|            $this->ssmaOccurrenceApprovalService->markPending($event, true);
12002|            $this->ssmaNotificationService->notifyOccurrenceValidationApprovers($company, $event, $sender, true);
12003|
12004|            return;
12005|        }
12006|
12007|        if ($status === \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_PENDING) {
12008|            return;
12009|        }
12010|
12011|        if ($status === \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_REJECTED) {
12012|            return;
12013|        }
12014|
12015|        $this->ssmaOccurrenceApprovalService->markPending($event, false);
12016|        $this->ssmaNotificationService->notifyOccurrenceValidationApprovers($company, $event, $sender, false);
12017|    }
12018|
12019|    private function notifyAprofundamentoAuthorOnReject(
12020|        SsmaEvent $event,
12021|        Company $company,
12022|        User $sender,
12023|        string $note
12024|    ): void {
12025|        $this->ssmaNotificationService->notifyAprofundamentoAuthorOnReject($event, $sender, $note);
12026|    }
12027|
12028|    private function canManageSsmaOccurrences(): bool
12029|    {
12030|        $user = $this->getUser();
12031|        $company = $this->getSsmaCompany() ?? ($user instanceof User ? $user->getCompany() : null);
12032|        if ($user instanceof User && $company instanceof Company
12033|            && $this->shouldStripSsmaManagementUiForUser($company, $user)) {
12034|            return false;
12035|        }
12036|
12037|        if (
12038|            $this->isGranted('ROLE_SUPER_ADMIN')
12039|            || $this->isGranted('ROLE_MANAGER')
12040|            || $this->isGranted('ROLE_MANAGER_GESTOR')
12041|            || $this->isGranted('ROLE_TENANT')
12042|        ) {
12043|            return true;
12044|        }
12045|
12046|        $member = null;
12047|        if ($user instanceof User) {
12048|            $company = $this->getSsmaCompany() ?? $user->getCompany();
12049|            if ($company instanceof Company) {
12050|                $member = $this->getCurrentCompanyMember($company, $user);
12051|            }
12052|        }
12053|        if ($this->memberIsSsmaGestorAdministrador($member)) {
12054|            return true;
12055|        }
12056|
12057|        $tag = $this->ssmaCurrentMemberPermissionTag();
12058|
12059|        $request = $this->requestStack->getCurrentRequest();
12060|        if ($request && (
code_search
Show Details
{"search_text": "maybeSubmitOccurrenceForValidation", "file_patterns": ["src/Controller/SsmaController.php"]}
File: src/Controller/SsmaController.php
Match lines: 9
3484|                    $this->maybeSubmitOccurrenceForValidation($eventEntity, $company, $user);
8382|                $this->maybeSubmitOccurrenceForValidation($parent, $company, $user);
8528|            $this->maybeSubmitOccurrenceForValidation($event, $event->getCompany(), $user);
9966|                        $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
10056|                    $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
11977|    private function maybeSubmitOccurrenceForValidation(
27715|                $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
27947|            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);
30277|                    $this->maybeSubmitOccurrenceForValidation($event, $company, $user);

Request #4 deepseek-flash P:20.65K C:615 CR:19.2K CW:0 3313ms
Reasoning
Now let's check the semantics: `$resubmitted = true` means the occurrence needs re-validation after rejection. In `maybeSubmitOccurrenceForValidation`, when `$resubmitted` is true it requires status === STATUS_REJECTED and marks pending. So passing `$wasRejectedApproval` as the 4th arg means: if the occurrence was rejected before, treat the finalize as a resubmission. Correct. Previously: if `$wasRejectedApproval` → call with true (regardless of wantsFinalize); elseif `$wantsFinalize` → call with false. Now only `$wantsFinalize` triggers, with `$wasRejectedApproval`. Hmm — this changes behavior: previously, an edit (complete edit) when rejected would resubmit. Now, editing when rejected doesn't resubmit — but that's the intent of the PR ("Editar (gestor/tenant) não reenvia para validação"). Wait, but the requirement says "Editar (gestor/tenant) não reenvia para validação. A fila só reabre ao reenviar o aprofundamento." So the intent is satisfied. But wait: consider the case where `$wasRejectedApproval` is true and `$wantsFinalize` is false — previously resubmit; now nothing happens. But there's a subtlety: previously in that branch, if `$wasRejectedApproval` was true but the edit wasn't finalizing, it would call with resubmitted=true; then inside, status === STATUS_REJECTED so it marks pending and notifies. Now that only occurs when wantsFinalize. Hmm, but is `$wantsFinalize` true when "reenviar o aprofundamento"? Need to check the context of line 27947. Also, potential issue: when `$wasRejectedApproval` is true but `$wantsFinalize` is false — but wait, what if the user re-edits the whole occurrence (not just aprofundamento) after rejection? The `approval status` remains 'rejected' until resubmitted. So submitting edits... nothing. That matches intent. But an important edge: if `$wasRejectedApproval` is false and `$wantsFinalize` is true — the previous behavior called with `false`, now calls with `false` too. Fine. Now let me look at the code around 27850-28000 to check the detailsOut flow.
Tool Calls (1)
file_read
Show Details
{"end_line": 28010, "file_path": "src/Controller/SsmaController.php", "start_line": 27840}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 27840-28010
27840|            if ($aprofundamentoFinalized && !$this->isSsmaAprofundamentoAdmin($company, $user)) {
27841|                return new JsonResponse(['success' => false, 'message' => 'Aprofundamento finalizado. Somente um administrador ou gestor administrador pode alterar.'], 403);
27842|            }
27843|            $data = $this->mergeSsmaEventAprofundamentoOnlyPayload($event, $data);
27844|        } else {
27845|            $data = array_merge($this->eventToValidationArray($event), $data);
27846|            if ($aprofundamentoFinalized && !$this->isSsmaAprofundamentoAdmin($company, $user)) {
27847|                if ($aprofundamentoOnly || $isAprofundamentoUpdate) {
27848|                    return new JsonResponse(['success' => false, 'message' => 'Aprofundamento finalizado. Somente um administrador ou gestor administrador pode alterar.'], 403);
27849|                }
27850|                $data = $this->stripSsmaEventAprofundamentoFieldsFromPayload(
27851|                    $data,
27852|                    (string) ($data['type'] ?? $event->getType()),
27853|                    $existingDetails
27854|                );
27855|            }
27856|        }
27857|
27858|        $data = $this->normalizeSsmaEventPayload($data, $company);
27859|        $data = $this->normalizeSsmaEventManagerInDetails($data, $company);
27860|        if (!$aprofundamentoOnly) {
27861|            $data = $this->applySsmaEventHierarchyManagerForPlainMember($data, $company, $user, $existingDetails);
27862|            if (!empty($data['__ssma_event_hierarchy_blocked'])) {
27863|                return new JsonResponse([
27864|                    'success' => false,
27865|                    'message' => (string) ($data['__ssma_event_hierarchy_message'] ?? $this->ssmaEventHierarchyBlockMessage()),
27866|                ], 422);
27867|            }
27868|            if (!$this->isSsmaHierarchyLockedManagerProfile($company, $user)) {
27869|                $data = $this->applySsmaEventManagerAssignment($data, $company, $user, $existingDetails);
27870|            }
27871|        }
27872|        $data = $this->applySsmaEventAreaResponsibleFromLocation($data, $company);
27873|        $data = $this->applySsmaDescaracterPermissionGate(
27874|            $data,
27875|            $company,
27876|            $user,
27877|            $existingDetails
27878|        );
27879|        if (($data['__ssma_forbidden_reason'] ?? null) === 'descaracterizacao_sem_permissao') {
27880|            return new JsonResponse(['success' => false, 'message' => 'Você não tem permissão para descaracterizar/decidir sobre este acidente.'], 403);
27881|        }
27882|
27883|        $eventType = (string) ($data['type'] ?? $event->getType());
27884|        $eventDetails = is_array($data['details'] ?? null) ? $data['details'] : [];
27885|        // Em modo só-aprofundamento o especialista já está autorizado; não stripa o que acabou de enviar.
27886|        if (
27887|            !$aprofundamentoOnly
27888|            && !$this->canAccessSsmaEventAprofundamento($company, $user, $eventType, $eventDetails, $event->getCreatedById())
27889|            && in_array($eventType, ['ROS', 'QUASE_ACIDENTE', 'ACIDENTE_PESSOAL', 'ACIDENTE_MATERIAL', 'ACIDENTE_AMBIENTAL'], true)
27890|        ) {
27891|            $data = $this->stripSsmaEventAprofundamentoFieldsFromPayload(
27892|                $data,
27893|                $eventType,
27894|                $existingDetails
27895|            );
27896|        }
27897|
27898|        // Draft vs finalize do aprofundamento (especialista / admin).
27899|        $wantsFinalize = false;
27900|        if ($aprofundamentoOnly || !empty($data['aprofundamento_complete']) || array_key_exists('aprofundamento_status', $data)) {
27901|            $detailsOut = is_array($data['details'] ?? null) ? $data['details'] : [];
27902|            $wantsFinalize = !empty($data['aprofundamento_complete'])
27903|                || strtolower(trim((string) ($data['aprofundamento_status'] ?? ''))) === 'finalized';
27904|            if ($wantsFinalize) {
27905|                $data['aprofundamento_complete'] = true;
27906|                $detailsOut['aprofundamento_complete'] = true;
27907|                $detailsOut['aprofundamento_status'] = 'finalized';
27908|                $this->stampSsmaAprofundamentoAuthor($detailsOut, $company, $user);
27909|            } else {
27910|                $data['aprofundamento_complete'] = false;
27911|                $detailsOut['aprofundamento_complete'] = false;
27912|                $detailsOut['aprofundamento_status'] = 'draft';
27913|            }
27914|            $data['details'] = $detailsOut;
27915|        }
27916|
27917|        // Valida apenas se type ou details estiverem presentes no payload (atualização parcial ok)
27918|        if (!empty($data['type']) || !empty($data['details'])) {
27919|            $validator = new \App\Service\Ssma\SsmaEventValidator();
27920|            $errors    = $validator->validate($data, [
27921|                'allowed_type_keys' => $this->ssmaOccurrenceTypeConfig->getAllowedTypeKeys($company),
27922|                'soft_aprofundamento' => $aprofundamentoOnly && !$wantsFinalize,
27923|            ]);
27924|            if ($errors !== []) {
27925|                return new JsonResponse(['success' => false, 'errors' => $errors], 422);
27926|            }
27927|        }
27928|
27929|        $teamScopeErr = $this->validateSsmaEventPayloadAgainstTeamScope($data, $company, $user);
27930|        if ($teamScopeErr !== null) {
27931|            return new JsonResponse(['success' => false, 'message' => $teamScopeErr], 403);
27932|        }
27933|
27934|        $service = new \App\Service\Ssma\SsmaEventService($this->entityManager, $this->ssmaOccurrenceTypeConfig);
27935|        $prevType         = $event->getType();
27936|        $prevStatus       = $event->getStatus();
27937|        $prevImmediateRisk = !empty($event->getDetails()['immediate_risk']);
27938|        $prevManagerId = (int) ($event->getDetails()['manager_id'] ?? 0);
27939|        $wasRejectedApproval = $this->ssmaOccurrenceApprovalService->getState($event)['status']
27940|            === \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_REJECTED;
27941|        $editorMeta = $this->resolveSsmaEditorMeta($company, $user);
27942|        $event      = $service->updateFromPayload($event, $data, $user, $editorMeta);
27943|
27944|        // Readequação só volta à fila ao reenviar o aprofundamento. Editar (gestor/tenant)
27945|        // em paralelo não starta esse fluxo.
27946|        if ($wantsFinalize) {
27947|            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);
27948|        }
27949|
27950|        $registeredByName = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
27951|        if ($registeredByName === '') {
27952|            $registeredByName = $user->getEmail() ?? 'Sistema';
27953|        }
27954|        $automationContext = [
27955|            'registered_by_name' => $registeredByName,
27956|            'type_raw'           => $event->getType(),
27957|            'new_type'           => $event->getType(),
27958|        ];
27959|        $flashApproval = null;
27960|
27961|        try {
27962|            $this->ssmaAutomationService->triggerForEvent(
27963|                'ssma_on_occurrence_updated',
27964|                $event,
27965|                $company,
27966|                $automationContext
27967|            );
27968|            if ($prevStatus !== $event->getStatus()) {
27969|                $this->ssmaAutomationService->triggerForEvent(
27970|                    'ssma_on_status_change',
27971|                    $event,
27972|                    $company,
27973|                    array_merge($automationContext, ['old_status' => $prevStatus])
27974|                );
27975|            }
27976|            if ($prevType !== $event->getType()) {
27977|                $this->ssmaAutomationService->triggerForEvent(
27978|                    'ssma_on_occurrence_type_changed',
27979|                    $event,
27980|                    $company,
27981|                    array_merge($automationContext, [
27982|                        'old_type' => $prevType,
27983|                        'new_type' => $event->getType(),
27984|                    ])
27985|                );
27986|            }
27987|            $newImmediateRisk = !empty($event->getDetails()['immediate_risk']);
27988|            $newManagerId = (int) ($event->getDetails()['manager_id'] ?? 0);
27989|            // Dispara quando risco vira SIM, ou quando Relatado por é preenchido com risco já ativo.
27990|            if ($newImmediateRisk && (!$prevImmediateRisk || ($prevManagerId <= 0 && $newManagerId > 0))) {
27991|                $this->ssmaNotificationService->notifyImmediateRisk($event);
27992|            }
27993|            if ($newManagerId > 0 && $newManagerId !== $prevManagerId) {
27994|                $this->ssmaNotificationService->notifyOccurrenceResponsible($newManagerId, (int) $event->getId(), 'event', $user);
27995|            }
27996|
27997|            // P2: quando a ocorrência está pronta, tenta abrir aprovação na CC (não envia PDF).
27998|            // Idempotente: se já pending/sent, ou sem aprovador, maybeAutoRequestApproval só faz skip.
27999|            // Importante para ROS: já pode estar "ready" no create (sem aprovador) e precisar
28000|            // re-tentar no update depois que a automação ganha aprovador.
28001|            $eventPayload = $this->ssmaAutomationService->buildEventPayload($event, $company, $automationContext);
28002|            $nowReady = $this->ssmaAutomationService->passesFlashReportRequirements($eventPayload);
28003|            if ($nowReady) {
28004|                $flashApproval = $this->ssmaFlashReportService->maybeAutoRequestApproval($event, $user);
28005|                if (!empty($flashApproval['skipped'])) {
28006|                    $flashApproval = null;
28007|                }
28008|            } else {
28009|                $flashApproval = null;
28010|            }
Request #5 deepseek-flash P:24.08K C:216 CR:21.25K CW:0 1726ms
Reasoning
So `$detailsOut` is `$data['details']` and `$data` is passed to `updateFromPayload`. Let me verify updateFromPayload uses `$data['details']`. Also note the authorization gate at 27886-27896: `canAccessSsmaEventAprofundamento(...$event->getCreatedById())`. Let me examine the modified `canAccessSsmaEventAprofundamento` (line ~12761). Let me read the surrounding function.
Tool Calls (2)
file_read
Show Details
{"end_line": 12845, "file_path": "src/Controller/SsmaController.php", "start_line": 12700}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 12700-12845
12700|        }
12701|
12702|        if ($this->memberIsSsmaGestorAdministrador($member)) {
12703|            return true;
12704|        }
12705|
12706|        try {
12707|            $tag = $this->resolveSsmaProductPermissionTagForMember($member);
12708|            $tagName = $tag instanceof \App\Entity\PermissionTag ? (string) $tag->getName() : '';
12709|
12710|            return in_array($tagName, ['Gestor de Equipe', 'Supervisor de Equipe', 'Supervisor'], true);
12711|        } catch (\Throwable) {
12712|            return false;
12713|        }
12714|    }
12715|
12716|    /**
12717|     * Membro/Inspetor comum do produto SSMA (não gestor admin / gestor de equipe).
12718|     * Resolve a tag mesmo quando o usuário tem ROLE_MANAGER de plataforma.
12719|     */
12720|    private function isSsmaPlainProductMember(?Company $company, ?User $user): bool
12721|    {
12722|        if (!$company || !$user) {
12723|            return false;
12724|        }
12725|
12726|        $member = $this->getCurrentCompanyMember($company, $user);
12727|        if (!$member) {
12728|            return false;
12729|        }
12730|
12731|        if ($this->memberIsSsmaGestorAdministrador($member)) {
12732|            return false;
12733|        }
12734|
12735|        try {
12736|            $tag = $this->resolveSsmaProductPermissionTagForMember($member);
12737|            $name = $tag instanceof \App\Entity\PermissionTag ? (string) $tag->getName() : '';
12738|
12739|            return in_array($name, ['Membro', 'Inspetor', 'Membro (default)'], true);
12740|        } catch (\Throwable) {
12741|            return false;
12742|        }
12743|    }
12744|
12745|    /**
12746|     * Quem pode preencher o Aprofundamento (2ª etapa) de ROS/Quase Acidente:
12747|     * Gestor Administrador SSMA / SUPER_ADMIN, técnico SSMA do tipo ou gestor direto do relator.
12748|     *
12749|     * Não usa canManageSsmaOccurrences()/ROLE_MANAGER — can_create de Membro não libera 2ª etapa.
12750|     *
12751|     * @param array<string, mixed> $details
12752|     */
12753|    private function canAccessSsmaEventAprofundamento(
12754|        Company $company,
12755|        User $user,
12756|        string $type,
12757|        array $details,
12758|        int $createdByUserId = 0
12759|    ): bool {
12760|        if ($this->isSsmaAprofundamentoAdmin($company, $user)) {
12761|            return true;
12762|        }
12763|
12764|        $loggedMember = $this->getCurrentCompanyMember($company, $user);
12765|
12766|        // Readequação: só quem enviou o aprofundamento (admin já passou acima).
12767|        if ($this->isSsmaOccurrenceInReadequacao($details)) {
12768|            $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0
12769|                || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0;
12770|            if ($hasAuthor) {
12771|                return $this->isSsmaAprofundamentoAuthor($details, $user, $loggedMember);
12772|            }
12773|        }
12774|
12775|        if (in_array($type, $this->resolveCurrentUserTechnicalTypes($company, $user), true)) {
12776|            return true;
12777|        }
12778|
12779|        if (!in_array($type, ['ROS', 'QUASE_ACIDENTE'], true)) {
12780|            return false;
12781|        }
12782|
12783|        if (!$loggedMember) {
12784|            return false;
12785|        }
12786|
12787|        return $this->isSsmaEventDirectManagerOfReporter(
12788|            $company,
12789|            $loggedMember,
12790|            $type,
12791|            $details,
12792|            $createdByUserId
12793|        );
12794|    }
12795|
12796|    /**
12797|     * @param array<string, mixed> $details
12798|     */
12799|    private function isSsmaOccurrenceInReadequacao(array $details): bool
12800|    {
12801|        $approval = is_array($details['occurrence_approval'] ?? null) ? $details['occurrence_approval'] : [];
12802|
12803|        return strtolower(trim((string) ($approval['status'] ?? ''))) === 'rejected';
12804|    }
12805|
12806|    /**
12807|     * @param array<string, mixed> $details
12808|     */
12809|    private function isSsmaAprofundamentoAuthor(array $details, User $user, ?CompanyMembers $member): bool
12810|    {
12811|        $authorUserId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
12812|        if ($authorUserId > 0 && (int) $user->getId() === $authorUserId) {
12813|            return true;
12814|        }
12815|
12816|        $authorMemberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
12817|
12818|        return $authorMemberId > 0 && $member instanceof CompanyMembers
12819|            && (int) $member->getId() === $authorMemberId;
12820|    }
12821|
12822|    /**
12823|     * @param array<string, mixed> $details
12824|     */
12825|    private function stampSsmaAprofundamentoAuthor(array &$details, Company $company, User $user): void
12826|    {
12827|        $details['aprofundamento_finalized_by_user_id'] = (int) $user->getId();
12828|        $memberId = $this->resolveCompanyMemberIdByUserId($company, (int) $user->getId());
12829|        if ($memberId) {
12830|            $details['aprofundamento_finalized_by_member_id'] = $memberId;
12831|        }
12832|        $name = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
12833|        $details['aprofundamento_finalized_by_name'] = $name !== '' ? $name : (string) ($user->getEmail() ?? '');
12834|    }
12835|
12836|    /**
12837|     * @param array<string, mixed> $details
12838|     */
12839|    private function resolveSsmaEventReporterMemberId(
12840|        Company $company,
12841|        string $type,
12842|        array $details,
12843|        int $createdByUserId = 0
12844|    ): ?int {
12845|        if ($type === 'ROS') {
code_search
Show Details
{"search_text": "function updateFromPayload"}
File: src/Service/Ssma/SsmaEventService.php
Match lines: 1
201|    public function updateFromPayload(SsmaEvent $event, array $data, User $user, array $editorMeta = []): SsmaEvent

Request #6 deepseek-flash P:26.14K C:159 CR:24.19K CW:0 1716ms
Reasoning
Now check `isSsmaAprofundamentoAdmin` exists (used before). Yes presumably. Note: `isSsmaOccurrenceInReadequacao` checks `occurrence_approval.status === 'rejected'`. Let me check what values are stored. Check SsmaOccurrenceApprovalService constants and getState.
Tool Calls (1)
file_read
Show Details
{"end_line": 140, "file_path": "src/Service/Ssma/SsmaOccurrenceApprovalService.php", "start_line": 1}
File: src/Service/Ssma/SsmaOccurrenceApprovalService.php (Total lines: 158)
IS_TRUNCATED: false
LINE_RANGE: 1-140
1|<?php
2|
3|declare(strict_types=1);
4|
5|namespace App\Service\Ssma;
6|
7|use App\Entity\CompanyMembers;
8|use App\Entity\SsmaEvent;
9|use App\Entity\User;
10|use App\Util\Utf8MojibakeNormalizer;
11|use Doctrine\ORM\EntityManagerInterface;
12|
13|/**
14| * Aprovação da ocorrência (não do flash report).
15| * Depois de aprovada, o flash pode ser enviado.
16| */
17|final class SsmaOccurrenceApprovalService
18|{
19|    public const STATUS_PENDING = 'pending';
20|    public const STATUS_APPROVED = 'approved';
21|    public const STATUS_REJECTED = 'rejected';
22|
23|    public function __construct(private EntityManagerInterface $entityManager)
24|    {
25|    }
26|
27|    /**
28|     * @return array{status: string, approved_by_member_id: int|null, approved_by_name: string, approved_at: string, note: string}
29|     */
30|    public function getState(SsmaEvent $event): array
31|    {
32|        $details = $event->getDetails();
33|        $raw = is_array($details['occurrence_approval'] ?? null) ? $details['occurrence_approval'] : [];
34|
35|        return [
36|            'status' => (string) ($raw['status'] ?? ''),
37|            'approved_by_member_id' => isset($raw['approved_by_member_id']) ? (int) $raw['approved_by_member_id'] : null,
38|            'approved_by_name' => (string) ($raw['approved_by_name'] ?? ''),
39|            'approved_at' => (string) ($raw['approved_at'] ?? ''),
40|            'note' => (string) ($raw['note'] ?? ''),
41|        ];
42|    }
43|
44|    public function isApproved(SsmaEvent $event): bool
45|    {
46|        return $this->getState($event)['status'] === self::STATUS_APPROVED;
47|    }
48|
49|    public function decide(
50|        SsmaEvent $event,
51|        User $actor,
52|        ?CompanyMembers $member,
53|        string $decision,
54|        string $note = '',
55|    ): array {
56|        $decision = strtolower(trim($decision));
57|        if (!in_array($decision, [self::STATUS_APPROVED, self::STATUS_REJECTED], true)) {
58|            return ['success' => false, 'message' => 'Decisão inválida. Use approved ou rejected.'];
59|        }
60|
61|        if ($decision === self::STATUS_REJECTED && trim($note) === '') {
62|            return ['success' => false, 'message' => 'Informe a observação para reprovar a ocorrência.'];
63|        }
64|
65|        $currentStatus = $this->getState($event)['status'];
66|        if ($currentStatus === self::STATUS_REJECTED) {
67|            return [
68|                'success' => false,
69|                'message' => 'A ocorrência está em readequação. Corrija e reenvie antes de validar de novo.',
70|            ];
71|        }
72|        if ($currentStatus === self::STATUS_APPROVED) {
73|            return ['success' => false, 'message' => 'Esta ocorrência já foi validada.'];
74|        }
75|
76|        $details = $event->getDetails();
77|        if (!is_array($details)) {
78|            $details = [];
79|        }
80|        $normalizedDetails = Utf8MojibakeNormalizer::normalizeRecursive($details);
81|        $details = is_array($normalizedDetails) ? $normalizedDetails : [];
82|
83|        $actorName = trim((string) ($actor->getName() ?: $actor->getEmail() ?: 'Usuário'));
84|        $state = [
85|            'status' => $decision,
86|            'approved_by_member_id' => $member instanceof CompanyMembers ? (int) $member->getId() : null,
87|            'approved_by_name' => Utf8MojibakeNormalizer::normalize($actorName),
88|            'approved_at' => (new \DateTimeImmutable())->format('c'),
89|            'note' => Utf8MojibakeNormalizer::normalize(trim($note)),
90|        ];
91|        $details['occurrence_approval'] = $state;
92|
93|        $history = is_array($details['changelog'] ?? null) ? $details['changelog'] : [];
94|        $history[] = [
95|            'at' => $state['approved_at'],
96|            'message' => $decision === self::STATUS_APPROVED
97|                ? 'Ocorrência validada'
98|                : 'Ocorrência reprovada na validação',
99|            'source' => 'occurrence_approval',
100|        ];
101|        $details['changelog'] = $history;
102|
103|        $event->setDetails($details);
104|
105|        if ($decision === self::STATUS_REJECTED) {
106|            $prevStatus = $event->getStatus();
107|            $event->setStatus(SsmaEvent::STATUS_ABERTO);
108|            $details['aprofundamento_status'] = 'draft';
109|            $details['aprofundamento_complete'] = false;
110|            // Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento.
111|            $event->setDetails($details);
112|            if ($prevStatus !== SsmaEvent::STATUS_ABERTO) {
113|                $event->appendHistory('Ocorrência devolvida para readequação após reprovação.', [
114|                    'from' => $prevStatus,
115|                    'to' => SsmaEvent::STATUS_ABERTO,
116|                ]);
117|            }
118|        }
119|
120|        $this->entityManager->flush();
121|
122|        return [
123|            'success' => true,
124|            'message' => $decision === self::STATUS_APPROVED
125|                ? 'Ocorrência aprovada. As automações (flash e/ou comitê) podem seguir.'
126|                : 'Ocorrência reprovada. O registro voltou para readequação.',
127|            'occurrence_approval' => $state,
128|        ];
129|    }
130|
131|    public function markPending(SsmaEvent $event, bool $resubmitted = false): void
132|    {
133|        $details = $event->getDetails();
134|        if (!is_array($details)) {
135|            $details = [];
136|        }
137|
138|        $details['occurrence_approval'] = [
139|            'status' => self::STATUS_PENDING,
140|            'approved_by_member_id' => null,
Request #7 deepseek-flash P:28.01K C:529 CR:26.24K CW:0 3456ms
Reasoning
Status 'rejected' literal is correct. Fine. Now let's verify `resolveCompanyMemberIdByUserId` exists. And note the flow: `stampSsmaAprofundamentoAuthor($detailsOut, ...)` is called at 27908 in the `wantsFinalize` branch, which is inside the block guarded by `$aprofundamentoOnly || !empty($data['aprofundamento_complete']) || array_key_exists('aprofundamento_status', $data)`. Hmm, but there's an ordering concern: At line 27886-27896, `canAccessSsmaEventAprofundamento` is called with `$eventDetails` = `$data['details']` — before the stamp. That's the authorization for the update. In readequação state, `isSsmaOccurrenceInReadequacao($details)` is checked with the details from the payload, not from the persisted event. Hmm — wait, when in readequação and a non-author specialist sends an update with `aprofundamento_only`, at line ~27830-27848: `$aprofundamentoFinalized` is computed from `$existingDetails`? Let me look at the code above 27840. Actually the key question: does the readequação gate at 27886 work? `$eventDetails` is `$data['details']` which is merged from eventToValidationArray plus payload — so it includes the persisted `occurrence_approval` from the event. So the gate works for non-aprofundamentoOnly updates. For `$aprofundamentoOnly` path, the check is skipped (`!$aprofundamentoOnly` condition) — "Em modo só-aprofundamento o especialista já está autorizado". Hmm, so in aprofundamentoOnly mode, is there any authorization? Let me look above 27840 for the aprofundamentoOnly determination and permission checks. Let's read 27750-27845.
Tool Calls (2)
file_read
Show Details
{"end_line": 27845, "file_path": "src/Controller/SsmaController.php", "start_line": 27700}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 27700-27845
27700|                    $company,
27701|                    ['registered_by_name' => $registeredByName]
27702|                );
27703|                if (!empty($event->getDetails()['immediate_risk'])) {
27704|                    $this->ssmaNotificationService->notifyImmediateRisk($event);
27705|                }
27706|                if ($this->ssmaEventAprofundamentoPending($event)) {
27707|                    $this->ssmaNotificationService->notifyAprofundamentoSpecialists(
27708|                        $company,
27709|                        $user,
27710|                        (string) $event->getType(),
27711|                        (int) $event->getId(),
27712|                        'event'
27713|                    );
27714|                }
27715|                $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
27716|                try {
27717|                    $flashApprovals[] = $this->ssmaFlashReportService->maybeAutoRequestApproval($event, $user);
27718|                } catch (\Throwable $flashErr) {
27719|                    $this->ssmaLogger->warning('Ssma createEvent flash approval: ' . $flashErr->getMessage());
27720|                    $flashApprovals[] = ['success' => false, 'message' => $flashErr->getMessage()];
27721|                }
27722|            }
27723|
27724|            $count = count($events);
27725|            $message = $count > 1
27726|                ? sprintf('%d ocorrências registradas — uma por acidentado.', $count)
27727|                : 'Ocorrência registrada com sucesso.';
27728|
27729|            return new JsonResponse([
27730|                'success' => true,
27731|                'message' => $message,
27732|                'event'   => $service->serialize($events[0]),
27733|                'events'  => array_map(static fn ($e) => $service->serialize($e), $events),
27734|                'count'   => $count,
27735|                'flash_approval' => $this->sanitizeFlashApprovalApiResponse($flashApprovals[0] ?? null),
27736|            ], 201);
27737|        } catch (\Throwable $e) {
27738|            $this->ssmaLogger->error('Ssma createEvent failed: '.$e->getMessage(), ['exception' => $e]);
27739|
27740|            $msg = $this->getParameter('kernel.debug') ? $e->getMessage() : 'Erro ao salvar o evento. Tente novamente.';
27741|
27742|            return new JsonResponse(['success' => false, 'message' => $msg], 500);
27743|        }
27744|    }
27745|
27746|    /**
27747|     * GET /manager/ssma/events/{id}
27748|     * Retorna um evento pelo ID.
27749|     */
27750|    public function getEvent(int $id): JsonResponse
27751|    {
27752|        /** @var \App\Entity\User|null $user */
27753|        $user    = $this->getUser();
27754|        $company = $this->getSsmaCompany() ?? $user?->getCompany();
27755|        if (!$user || !$company) {
27756|            return new JsonResponse(['success' => false, 'message' => 'Não autenticado.'], 401);
27757|        }
27758|
27759|        $event = $this->entityManager->getRepository(\App\Entity\SsmaEvent::class)->find($id);
27760|
27761|        if (!$event || $event->getCompany()->getId() !== $company->getId()) {
27762|            return new JsonResponse(['success' => false, 'message' => 'Evento não encontrado.'], 404);
27763|        }
27764|
27765|        if (!$this->isSsmaEventVisibleUnderOccurrenceTeamDashboardScope($event, $company, $user)) {
27766|            return new JsonResponse(['success' => false, 'message' => 'Evento não encontrado.'], 404);
27767|        }
27768|
27769|        $service = new \App\Service\Ssma\SsmaEventService($this->entityManager, $this->ssmaOccurrenceTypeConfig);
27770|        $serialized = $service->serialize($event);
27771|        $registeredByMemberId = $this->resolveSsmaEventRegisteredByMemberId($company, $event);
27772|        $serialized['created_by_member_id'] = $registeredByMemberId;
27773|        if (is_array($serialized['details'] ?? null) && $registeredByMemberId && empty($serialized['details']['registered_by_member_id'])) {
27774|            $serialized['details']['registered_by_member_id'] = $registeredByMemberId;
27775|        }
27776|        $detailsForAccess = is_array($serialized['details'] ?? null) ? $serialized['details'] : [];
27777|        $eventType = strtoupper(trim((string) ($serialized['type'] ?? $event->getType() ?? '')));
27778|        $aprofStatus = strtolower(trim((string) ($detailsForAccess['aprofundamento_status'] ?? '')));
27779|        $canAccessAprof = $this->canAccessSsmaEventAprofundamento(
27780|            $company,
27781|            $user,
27782|            $eventType,
27783|            $detailsForAccess,
27784|            (int) ($event->getCreatedById() ?? 0)
27785|        );
27786|        $serialized['_can_edit_aprofundamento'] = $canAccessAprof
27787|            && ($aprofStatus !== 'finalized' || $this->isSsmaAprofundamentoAdmin($company, $user));
27788|        $serialized['_user_technical_types'] = $this->resolveCurrentUserTechnicalTypes($company, $user);
27789|
27790|        return new JsonResponse(['success' => true, 'event' => $serialized]);
27791|    }
27792|
27793|    /**
27794|     * POST /manager/ssma/events/{id}
27795|     * Atualiza um evento existente.
27796|     */
27797|    public function updateEvent(Request $request, int $id): JsonResponse
27798|    {
27799|        /** @var \App\Entity\User|null $user */
27800|        $user    = $this->getUser();
27801|        $company = $user?->getCompany();
27802|        if (!$user || !$company) {
27803|            return new JsonResponse(['success' => false, 'message' => 'Não autenticado.'], 401);
27804|        }
27805|
27806|        $event = $this->entityManager->getRepository(\App\Entity\SsmaEvent::class)->find($id);
27807|
27808|        if (!$event || $event->getCompany()->getId() !== $company->getId()) {
27809|            return new JsonResponse(['success' => false, 'message' => 'Evento não encontrado.'], 404);
27810|        }
27811|
27812|        if (!$this->isSsmaEventVisibleUnderOccurrenceTeamDashboardScope($event, $company, $user)) {
27813|            return new JsonResponse(['success' => false, 'message' => 'Evento não encontrado.'], 404);
27814|        }
27815|
27816|        $data = json_decode($request->getContent(), true) ?? [];
27817|        $aprofundamentoOnly = !empty($data['aprofundamento_only']);
27818|        $existingDetails = is_array($event->getDetails()) ? $event->getDetails() : [];
27819|        $aprofundamentoStatus = strtolower(trim((string) ($existingDetails['aprofundamento_status'] ?? '')));
27820|        $aprofundamentoFinalized = $aprofundamentoStatus === 'finalized'
27821|            || !empty($existingDetails['aprofundamento_complete']);
27822|
27823|        $canFullEdit = $this->canEditSsmaEvent($company, $user, $event);
27824|        $eventTypeForAccess = (string) ($data['type'] ?? $event->getType());
27825|        $canAprofundamento = $this->canAccessSsmaEventAprofundamento(
27826|            $company,
27827|            $user,
27828|            $eventTypeForAccess,
27829|            is_array($data['details'] ?? null) ? $data['details'] : $existingDetails,
27830|            (int) ($event->getCreatedById() ?? 0)
27831|        );
27832|        $isAprofundamentoUpdate = $aprofundamentoOnly
27833|            || !empty($data['aprofundamento_complete'])
27834|            || strtolower(trim((string) ($data['aprofundamento_status'] ?? ''))) === 'finalized';
27835|
27836|        if (!$canFullEdit) {
27837|            if (!$isAprofundamentoUpdate || !$canAprofundamento) {
27838|                return new JsonResponse(['success' => false, 'message' => 'Você não tem autorização para editar esse tipo de ocorrência.'], 403);
27839|            }
27840|            if ($aprofundamentoFinalized && !$this->isSsmaAprofundamentoAdmin($company, $user)) {
27841|                return new JsonResponse(['success' => false, 'message' => 'Aprofundamento finalizado. Somente um administrador ou gestor administrador pode alterar.'], 403);
27842|            }
27843|            $data = $this->mergeSsmaEventAprofundamentoOnlyPayload($event, $data);
27844|        } else {
27845|            $data = array_merge($this->eventToValidationArray($event), $data);
code_search
Show Details
{"search_text": "function resolveCompanyMemberIdByUserId"}
File: src/Controller/SsmaController.php
Match lines: 1
12895|    private function resolveCompanyMemberIdByUserId(Company $company, int $userId): ?int

Request #8 deepseek-flash P:30.92K C:371 CR:28.42K CW:0 2277ms
Reasoning
At line 27825-27831, `canAccessSsmaEventAprofundamento` is called with `$data['details']` — for aprofundamentoOnly payload, the front may not send `details` at all, so it falls back to `$existingDetails`. If the front sends a partial `details` (without `occurrence_approval`), then `isSsmaOccurrenceInReadequacao` won't detect readequação and falls through to the technical types check → a non-author specialist could pass the gate. Hmm. But do they send details? Let's check what a front-end aprofundamentoOnly payload contains. In the aprofundamentoOnly branch, `mergeSsmaEventAprofundamentoOnlyPayload($event, $data)` builds from the provided data. The front likely sends the whole aprofundamento payload with `details`. Hard to confirm; but the review plan suggests the concern of the literal string only. Hmm, this is a possible real issue: authorization relies on `occurrence_approval` presence in payload details rather than persisted details. Let me check whether `$data['details']` typically includes `occurrence_approval`. Front-end probably builds details from the loaded event (`serialized.details`), which would include occurrence_approval. Let's check the cypress/js to see. Let's search js for aprofundamento_only. Actually the changed file list includes cypress spec. Let's check how the front sends the aprofundamento payload. Search in public/js for 'aprofundamento_only'.
Tool Calls (1)
code_search
Show Details
{"search_text": "aprofundamento_only"}
File: cypress/e2e/ssma/README.md
Match lines: 1
166|`POST /manager/ssma/events/{id}` com `aprofundamento_only + aprofundamento_complete=true` só seta

File: cypress/e2e/ssma/ssmaOccurrenceFinalizeValidateUi.spec.js
Match lines: 1
41|        aprofundamento_only: true,

File: cypress/e2e/ssma/ssmaOccurrenceRegisterEditUi.spec.js
Match lines: 1
239|        aprofundamento_only: true,

File: cypress/e2e/ssma/ssmaPermissionScenarios.spec.js
Match lines: 6
237|      aprofundamento_only: true,
273|      aprofundamento_only: true,
320|        aprofundamento_only: true,
339|      aprofundamento_only: true,
388|        aprofundamento_only: true,
687|        aprofundamento_only: true,

File: cypress/support/ssmaHub.js
Match lines: 1
214|    aprofundamento_only: true,

File: docs/engineering/pr/hotfix-ssma-occ-type-perms-datatables-new-production/PR_descricao_hotfix-ssma-occ-type-perms-datatables-new-production.md
Match lines: 2
93|| `templates/ssma/occurrence/partials/_modal_event.html.twig` | `EvModal.openAprofundamento`, botões rascunho/finalizar, payload `aprofundamento_only` |
136|5. **+Aprofundamento — rascunho e finalizar:** especialista autorizado pode salvar rascunho (`soft_aprofundamento` / status draft) e depois finalizar. Payload parcial via `aprofundamento_only: true` no update.

File: docs/ssma/CHAT-BASE-hotfix-permission-ocorrencia.md
Match lines: 2
129|No **update só-aprofundamento** (`aprofundamento_only`) a hierarquia **não** roda de novo (especialista sem superior não toma 422).
187|- Aprofundamento Quase: sem 500 no responsável da área; `nature` na mescla; hierarquia ignorada em `aprofundamento_only`.

File: docs/ssma/e2e-permission-scenarios.md
Match lines: 2
135|- Após finalizado (#147), edição via `POST /manager/ssma/events/{id}` com `aprofundamento_only`
172|  com `aprofundamento_only` + `aprofundamento_complete=true`) → concluir (`POST

File: docs/ssma/engineering/ssma-permissoes-membro-analise-cypress.md
Match lines: 1
455|**Aprofundamento:** especialista vê **todas** as ocorrências do tipo da tag, não só pendentes. Sem filtro “só pendentes” no hub. Save = `POST /manager/ssma/events/{id}` com `aprofundamento_only` / `aprofundamento_status`.  

File: src/Controller/SsmaController.php
Match lines: 2
27817|        $aprofundamentoOnly = !empty($data['aprofundamento_only']);
29986|        $merged['aprofundamento_only'] = true;

File: src/Service/Ssma/SsmaEventValidator.php
Match lines: 1
717|        if (!empty($data['aprofundamento_only']) && \in_array($descVal, ['0', '1'], true)) {

File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 1
7421|            payload.aprofundamento_only = true;

File: tests/Unit/Product/Ssma/SsmaEventValidatorTest.php
Match lines: 1
860|            'aprofundamento_only'     => true,

Request #9 deepseek-flash P:32.17K C:185 CR:31.23K CW:0 2363ms
Reasoning
Let's inspect the payload building in _modal_event.html.twig around line 7380-7460.
Tool Calls (2)
file_read
Show Details
{"file_path": "templates/ssma/occurrence/partials/_modal_event.html.twig", "start_line": 7360, "end_line": 7440}
File: templates/ssma/occurrence/partials/_modal_event.html.twig (Total lines: 8167)
IS_TRUNCATED: false
LINE_RANGE: 7360-7440
7360|            people_ids:       peopleIds,
7361|            witness_ids:      (document.getElementById('ev_witness_ids') || { value: '' }).value,
7362|            team_id:          teamVal,
7363|            unit_id:          teamVal || null,
7364|            responsible_ids:  (document.getElementById('ev_responsible_ids') || { value: '' }).value,
7365|            strategic_nature: (document.getElementById('ev_strategic_nature') || { value: '' }).value,
7366|            area_label:       (document.getElementById('ev_area_label') || { value: '' }).value.trim(),
7367|            evidences:        evEvidences.slice()
7368|        };
7369|
7370|        var catWrap = document.getElementById('ev-category-wrap');
7371|        var catSel  = document.getElementById('ev_category');
7372|        if (catWrap && catSel && !catWrap.classList.contains('d-none')) {
7373|            var catVal = (catSel.value || '').trim();
7374|            if (catVal) {
7375|                payload.category = catVal;
7376|            }
7377|        }
7378|
7379|        var cd = document.getElementById('ev_classifier_dano');
7380|        var cr = document.getElementById('ev_classifier_risco');
7381|        var ca = document.getElementById('ev_classifier_afetado');
7382|        if (cd && cd.value) payload.classifier_dano    = cd.value;
7383|        if (cr && cr.value) payload.classifier_risco   = cr.value;
7384|        if (ca && ca.value) payload.classifier_afetado = ca.value;
7385|
7386|        if (evRequiresAccidentTechnical(type)) {
7387|            var derivedCrit = evResolvePotentialSeverity(
7388|                (document.getElementById('ev_derived_criticality_select') || {}).value,
7389|                (document.getElementById('ev_derived_criticality') || {}).value,
7390|                payload.consequence,
7391|                payload.potential_consequence
7392|            );
7393|            if (derivedCrit) {
7394|                payload.potential_severity = derivedCrit;
7395|            }
7396|        } else if (type === 'ROS') {
7397|            // Campo já é a escala de gravidade (LEVE…CRITICO).
7398|            var rosPc = (document.getElementById('ev_ros_potential_consequence') || { value: '' }).value;
7399|            var rosSev = evResolvePotentialSeverity(rosPc);
7400|            if (rosSev) payload.potential_severity = rosSev;
7401|        } else if (type === 'QUASE_ACIDENTE') {
7402|            var qaPc = (document.getElementById('ev_qa_potential_consequence') || { value: '' }).value;
7403|            var qaCrit = evResolvePotentialSeverity(
7404|                qaPc,
7405|                (document.getElementById('ev_criticality') || {}).value
7406|            );
7407|            if (qaCrit) payload.potential_severity = qaCrit;
7408|        } else {
7409|            var critEl = document.getElementById('ev_criticality');
7410|            if (critEl && critEl.value) {
7411|                payload.potential_severity = evResolvePotentialSeverity(critEl.value) || critEl.value;
7412|            }
7413|        }
7414|
7415|        if (evRequiresAprofundamento(type)) {
7416|            payload.corrective_actions = evCollectCorrectiveActions();
7417|        }
7418|
7419|        // Marca 2ª etapa: backend exige campos técnicos só quando o médico finaliza o Aprofundamento.
7420|        if (evCurrentStep === 'aprofundamento' && evAprofundamentoOnlyMode) {
7421|            payload.aprofundamento_only = true;
7422|            payload.aprofundamento_complete = !!finalizeAprofundamento;
7423|            payload.aprofundamento_status = finalizeAprofundamento ? 'finalized' : 'draft';
7424|        }
7425|
7426|        // Etapa 1 de acidentes: não envia campos técnicos vazios (evita disparar validação da 2ª etapa).
7427|        if (
7428|            (type === 'ACIDENTE_PESSOAL' || type === 'ACIDENTE_MATERIAL' || type === 'ACIDENTE_AMBIENTAL')
7429|            && evCurrentStep !== 'aprofundamento'
7430|            && !evCanEditAprofundamento(type)
7431|        ) {
7432|            payload.consequence = '';
7433|            payload.potential_consequence = '';
7434|            delete payload.potential_severity;
7435|            delete payload.injury_type;
7436|            delete payload.injury_classification;
7437|            delete payload.injury_severity;
7438|            delete payload.descaracterizado;
7439|            delete payload.descaracter_comment;
7440|            delete payload.body_parts;
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 12890, "end_line": 12930}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 12890-12930
12890|        }
12891|
12892|        return $this->resolveCompanyMemberIdByUserId($company, (int) $event->getCreatedById());
12893|    }
12894|
12895|    private function resolveCompanyMemberIdByUserId(Company $company, int $userId): ?int
12896|    {
12897|        if ($userId <= 0) {
12898|            return null;
12899|        }
12900|
12901|        $cacheKey = ((int) $company->getId()) . ':' . $userId;
12902|        if (array_key_exists($cacheKey, $this->ssmaMemberIdByUserIdCache)) {
12903|            return $this->ssmaMemberIdByUserIdCache[$cacheKey];
12904|        }
12905|
12906|        $members = $this->entityManager->getRepository(CompanyMembers::class)
12907|            ->findBy(['company' => $company, 'user' => $userId, 'isRemoved' => 0]);
12908|
12909|        $resolved = null;
12910|        foreach ($members as $member) {
12911|            if ($member instanceof CompanyMembers) {
12912|                $resolved = (int) $member->getId();
12913|                break;
12914|            }
12915|        }
12916|
12917|        $this->ssmaMemberIdByUserIdCache[$cacheKey] = $resolved;
12918|
12919|        return $resolved;
12920|    }
12921|
12922|    /**
12923|     * Quem troca Gestor responsável / Relatado por: tag do produto ssma-occurrences.
12924|     * ROLE_MANAGER de plataforma + tag Membro continua Membro (caso Palloma).
12925|     * Tag técnica de aprofundamento não transforma em Gestor.
12926|     */
12927|    private function canSelectSsmaEventResponsibleManager(?Company $company, ?User $user): bool
12928|    {
12929|        if ($this->isGranted('ROLE_SUPER_ADMIN') || $this->isGranted('ROLE_TENANT')) {
12930|            return true;
Request #10 deepseek-flash P:34.15K C:216 CR:32.26K CW:0 1932ms
Reasoning
Now, is `details` sent in the aprofundamento payload? Let's look at the payload builder start (around 7300-7360) to see if `details` key is included. Actually line 7355-7368 shows payload keys; I didn't see `details`. Let's check for `payload.details` in this file.
Tool Calls (2)
code_search
Show Details
{"file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"], "search_text": "payload.details"}
No matches found
code_search
Show Details
{"file_patterns": ["src/Controller/SsmaController.php"], "search_text": "function mergeSsmaEventAprofundamentoOnlyPayload"}
File: src/Controller/SsmaController.php
Match lines: 1
29957|    private function mergeSsmaEventAprofundamentoOnlyPayload(\App\Entity\SsmaEvent $event, array $data): array

Request #11 deepseek-flash P:34.44K C:1.17K CR:34.18K CW:0 5795ms
Reasoning
So the aprofundamento payload does NOT include `details`. Therefore, in `updateEvent`, at line 27825, `is_array($data['details'] ?? null)` is false → falls back to `$existingDetails`, which contains `occurrence_approval` = rejected. Good, so the readequação gate works for aprofundamentoOnly. And at line 27884, `$eventDetails = is_array($data['details']) ? ... : []` — but note that in the aprofundamentoOnly path, `mergeSsmaEventAprofundamentoOnlyPayload` is called at 29843 in the `!$canFullEdit` branch. But if `$canFullEdit` is true (admin/gestor), `$data = array_merge($this->eventToValidationArray($event), $data)` includes details. OK. But wait a moment: the gate at 27886 requires `!$aprofundamentoOnly`. For admin editing (canFullEdit true), the gate at 27886 is skipped anyway. Now important: line 27825 calls `canAccessSsmaEventAprofundamento` with `$data['details']` fallback to existingDetails. But this is the first authorization check. If the payload DOES include `details` (partial without occurrence_approval), the readequação branch could be bypassed. Does any caller send `details`? The `saveEvent`/`updateEvent` from the modal doesn't include details. But other clients? Let's check tests/cypress. Probably no details. Low risk; also this is pre-existing behavior concern... Actually the readequação gate is new; a bypass would be a security issue but requires the client to send `details` without occurrence_approval. The payload from modal doesn't. Hmm, but let me check `eventToValidationArray` → array_merge with `$data`: `array_merge($this->eventToValidationArray($event), $data)` — this means details from event are preserved unless `$data['details']` overrides entirely (array_merge at top level, so `details` key is replaced wholesale by payload's details). So if a caller sends partial `details`, occurrence_approval is lost from `$data['details']` → gate bypass in the later check at 27886 too (which passes `$eventDetails`). Hmm, at 27886 it's for non-aprofundamentoOnly. But there `!$canFullEdit` is... actually line 27886 is reached for both canFullEdit true and false. But the earlier check at 27837 only applies when `!$canFullEdit`. For canFullEdit=true users it doesn't matter (they're admin/gestor anyway... well, canEditSsmaEvent may be true for the registrant too). Hmm wait — is that a problem for the readequação rule "só o autor edita"? If the registrant (cadastrante) has canFullEdit = true (canEditSsmaEvent), then in readequação, they can do a full edit which includes the aprofundamento fields? At line 27846: `if ($aprofundamentoFinalized && !isSsmaAprofundamentoAdmin)` → strips aprofundamento fields. In readequação, aprofundamento_status was set back to 'draft' by the approval service, so `$aprofundamentoFinalized` = false → aprofundamento fields not stripped! Then at 27886, `canAccessSsmaEventAprofundamento` with `$eventDetails`. If the readequação gate is active and the user isn't the author → strip. Good, that's the new protection. Since `$eventDetails` comes from `$data['details']` (from eventToValidationArray, which includes occurrence_approval since the modal sends no details key... but for full edits, does the payload include `details`? `eventToValidationArray($event)` returns details of event; then `array_merge` with `$data`; if $data has no 'details' key, event's details persist). Fine. So the strip at 27886 protects the readequação for canFullEdit users. But it's guarded with `in_array($eventType, ['ROS','QUASE_ACIDENTE','ACIDENTE_PESSOAL','ACIDENTE_MATERIAL','ACIDENTE_AMBIENTAL'])`. OK. Now, the ordering issue: `stampSsmaAprofundamentoAuthor` is called inside the finalize branch, AFTER the strip check. But note that the strip happens BEFORE the finalize block (line 27886-27896 is before 27898). So when a non-author strips the aprofundamento fields including `aprofundamento_complete`, wantsFinalize would be false. OK. Now, a critical question for review plan issue #2: does `$detailsOut` get persisted? `$data['details'] = $detailsOut;` and `updateFromPayload($event, $data, ...)`. Let's confirm updateFromPayload reads `$data['details']`.
Tool Calls (1)
file_read
Show Details
{"end_line": 280, "file_path": "src/Service/Ssma/SsmaEventService.php", "start_line": 195}
File: src/Service/Ssma/SsmaEventService.php (Total lines: 872)
IS_TRUNCATED: false
LINE_RANGE: 195-280
195|    /**
196|     * Atualiza um SsmaEvent existente com um payload parcial ou completo.
197|     *
198|     * @param array<string, mixed> $data
199|     * @param array<string, mixed> $editorMeta name, role (Colaborador|Profissional|Administrador)
200|     */
201|    public function updateFromPayload(SsmaEvent $event, array $data, User $user, array $editorMeta = []): SsmaEvent
202|    {
203|        $prevStatus      = $event->getStatus();
204|        $prevConsequence = $event->getConsequence();
205|        $prevType        = $event->getType();
206|        $prevDetails     = $event->getDetails();
207|        $prevLocation    = $event->getLocation();
208|        $prevDescription = $event->getDescription();
209|
210|        if (isset($data['details']) && is_array($data['details'])) {
211|            $existingDetails = is_array($event->getDetails()) ? $event->getDetails() : [];
212|            $incomingDetails = $data['details'];
213|            // Front sempre envia evidences: [] quando a lista local está vazia — não apagar anexos já salvos.
214|            foreach (['evidences', 'ros_resolution_evidences'] as $evKey) {
215|                if (
216|                    array_key_exists($evKey, $incomingDetails)
217|                    && is_array($incomingDetails[$evKey])
218|                    && $incomingDetails[$evKey] === []
219|                    && is_array($existingDetails[$evKey] ?? null)
220|                    && $existingDetails[$evKey] !== []
221|                ) {
222|                    unset($incomingDetails[$evKey]);
223|                }
224|            }
225|            $data['details'] = array_merge($existingDetails, $incomingDetails);
226|            foreach (['registered_by_member_id', 'registered_by_name'] as $lockKey) {
227|                if (array_key_exists($lockKey, $existingDetails)) {
228|                    $data['details'][$lockKey] = $existingDetails[$lockKey];
229|                } else {
230|                    unset($data['details'][$lockKey]);
231|                }
232|            }
233|        }
234|
235|        $this->applyPayload($event, $data);
236|
237|        // Permitir override explícito de status; se não enviado, preserva o status atual.
238|        if (!empty($data['status'])) {
239|            $event->setStatus($data['status']);
240|        }
241|
242|        $editorName = trim((string) ($editorMeta['name'] ?? ''));
243|        if ($editorName === '') {
244|            $editorName = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
245|        }
246|        if ($editorName === '') {
247|            $editorName = (string) ($user->getEmail() ?? 'Sistema');
248|        }
249|        $editorRole  = trim((string) ($editorMeta['role'] ?? 'Colaborador'));
250|        $editorLabel = sprintf('%s (%s)', $editorName, $editorRole);
251|
252|        if ($event->getStatus() !== $prevStatus) {
253|            $event->appendHistory(sprintf(
254|                'Status alterado de "%s" para "%s" por %s',
255|                EventStatusEnum::label($prevStatus),
256|                EventStatusEnum::label($event->getStatus()),
257|                $editorLabel
258|            ), ['from' => $prevStatus, 'to' => $event->getStatus(), 'editor' => $editorName, 'editor_role' => $editorRole]);
259|        }
260|        if ($prevConsequence !== $event->getConsequence()) {
261|            $fromLabel = $prevConsequence ? EventConsequenceEnum::label($prevConsequence) : '—';
262|            $toLabel   = $event->getConsequence() ? EventConsequenceEnum::label((string) $event->getConsequence()) : '—';
263|            $event->appendHistory(sprintf(
264|                'Consequência alterada de "%s" para "%s" por %s',
265|                $fromLabel,
266|                $toLabel,
267|                $editorLabel
268|            ), ['from' => $prevConsequence, 'to' => $event->getConsequence(), 'editor' => $editorName, 'editor_role' => $editorRole]);
269|        }
270|        if ($prevType !== $event->getType()) {
271|            $event->appendHistory(sprintf(
272|                'Tipo alterado de "%s" para "%s" por %s',
273|                $this->resolveEventTypeLabelForKey($event->getCompany(), $prevType),
274|                $this->resolveEventTypeLabelForKey($event->getCompany(), $event->getType()),
275|                $editorLabel
276|            ), ['from' => $prevType, 'to' => $event->getType(), 'editor' => $editorName, 'editor_role' => $editorRole]);
277|        }
278|
279|        $newDetails = $event->getDetails();
280|        $prevSeverity = $prevDetails['potential_severity'] ?? null;
Request #12 deepseek-flash P:36.88K C:358 CR:35.58K CW:0 2611ms
Reasoning
Good: `$data['details']` is merged & persisted via `applyPayload`. So the stamp is persisted. Issue #2 resolved. Wait — but there's a subtlety: `applyPayload` may handle `details`. At line 210-233: if `isset($data['details'])`, merges. Then applyPayload. Let's confirm applyPayload sets details. Probably `if (isset($data['details'])) $event->setDetails(...)`. Fine. Now, an important detail: In the `$wantsFinalize` branch, `stampSsmaAprofundamentoAuthor` is called only when wantsFinalize. Good, as designed. Now, potential issue: The stamp is written even when the author is an admin/gestor editing... fine. Now let's think about the notification path more carefully. In the reject flow at line ~4131 in the controller, `notifyOccurrenceRegistrantOnReject` → `notifyAprofundamentoAuthorOnReject`. Let's look at the reject method to check `$event` details — is the event's details loaded with occurrence_approval and aprofundamento_finalized_by_*? The `decide()` in the approval service is called which sets details and flushes. Let's see the controller's reject method.
Tool Calls (1)
file_read
Show Details
{"end_line": 4160, "file_path": "src/Controller/SsmaController.php", "start_line": 4040}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 4040-4160
4040|            $result['committee_use_case'] = $this->resolveOccurrenceCommitteeUseCase($event);
4041|            $result['open_committee'] = true;
4042|            $eventId = (int) $event->getId();
4043|            $companyId = (int) $company->getId();
4044|            $userId = (int) $user->getId();
4045|            $this->deferAfterResponse(function () use ($eventId, $companyId, $userId): void {
4046|                $this->runDeferredOccurrenceApprovedSideEffects($eventId, $companyId, $userId);
4047|            });
4048|        }
4049|
4050|        return new JsonResponse($result);
4051|    }
4052|
4053|    /**
4054|     * SMTP/PDF das automações não podem bloquear o JSON de validar/reprovar.
4055|     * event_dispatcher precisa estar no locator do AbstractController (getSubscribedServices).
4056|     */
4057|    private function deferAfterResponse(callable $callback): void
4058|    {
4059|        $safe = function () use ($callback): void {
4060|            try {
4061|                $callback();
4062|            } catch (\Throwable $e) {
4063|                $this->ssmaLogger->warning('Ssma deferred occurrence side-effect: ' . $e->getMessage());
4064|            }
4065|        };
4066|
4067|        try {
4068|            $dispatcher = $this->container->get('event_dispatcher');
4069|            if (!$dispatcher instanceof EventDispatcherInterface) {
4070|                throw new \RuntimeException('event_dispatcher indisponível');
4071|            }
4072|            $dispatcher->addListener(KernelEvents::TERMINATE, static function () use ($safe): void {
4073|                $safe();
4074|            });
4075|        } catch (\Throwable $e) {
4076|            $this->ssmaLogger->warning('Ssma deferAfterResponse fallback sync: ' . $e->getMessage());
4077|            $safe();
4078|        }
4079|    }
4080|
4081|    private function runDeferredOccurrenceApprovedSideEffects(int $eventId, int $companyId, int $userId): void
4082|    {
4083|        $event = $this->entityManager->getRepository(SsmaEvent::class)->find($eventId);
4084|        $company = $this->entityManager->getRepository(Company::class)->find($companyId);
4085|        $user = $this->entityManager->getRepository(User::class)->find($userId);
4086|        if (!$event instanceof SsmaEvent || !$company instanceof Company || !$user instanceof User) {
4087|            return;
4088|        }
4089|
4090|        $automationContext = [
4091|            'registered_by_name' => trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''))
4092|                ?: ($user->getEmail() ?? 'Sistema'),
4093|            'type_raw' => $event->getType(),
4094|            'new_type' => $event->getType(),
4095|        ];
4096|        try {
4097|            $this->ssmaAutomationService->triggerForEvent(
4098|                'ssma_on_occurrence_approved',
4099|                $event,
4100|                $company,
4101|                $automationContext
4102|            );
4103|            $this->ssmaAutomationService->triggerForEvent(
4104|                'ssma_on_occurrence_updated',
4105|                $event,
4106|                $company,
4107|                $automationContext
4108|            );
4109|        } catch (\Throwable $automationError) {
4110|            $this->ssmaLogger->warning('Ssma approveOccurrence automations: ' . $automationError->getMessage());
4111|        }
4112|
4113|        try {
4114|            $this->ssmaFlashReportService->maybeAutoRequestApproval(
4115|                $event,
4116|                $user,
4117|                'Envio automático após validação da ocorrência.'
4118|            );
4119|        } catch (\Throwable $flashError) {
4120|            $this->ssmaLogger->warning('Ssma approveOccurrence flash auto: ' . $flashError->getMessage());
4121|        }
4122|    }
4123|
4124|    private function runDeferredOccurrenceRejectedSideEffects(int $eventId, int $companyId, int $userId, string $note): void
4125|    {
4126|        $event = $this->entityManager->getRepository(SsmaEvent::class)->find($eventId);
4127|        $company = $this->entityManager->getRepository(Company::class)->find($companyId);
4128|        $user = $this->entityManager->getRepository(User::class)->find($userId);
4129|        if (!$event instanceof SsmaEvent || !$company instanceof Company || !$user instanceof User) {
4130|            return;
4131|        }
4132|
4133|        try {
4134|            $this->notifyAprofundamentoAuthorOnReject($event, $company, $user, $note);
4135|        } catch (\Throwable $notifyError) {
4136|            $this->ssmaLogger->warning('Ssma rejectOccurrence notify: ' . $notifyError->getMessage());
4137|        }
4138|        $automationContext = [
4139|            'registered_by_name' => trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''))
4140|                ?: ($user->getEmail() ?? 'Sistema'),
4141|            'type_raw' => $event->getType(),
4142|            'new_type' => $event->getType(),
4143|        ];
4144|        try {
4145|            $this->ssmaAutomationService->triggerForEvent(
4146|                'ssma_on_occurrence_rejected',
4147|                $event,
4148|                $company,
4149|                $automationContext
4150|            );
4151|        } catch (\Throwable $automationError) {
4152|            $this->ssmaLogger->warning('Ssma rejectOccurrence automations: ' . $automationError->getMessage());
4153|        }
4154|    }
4155|
4156|    /**
4157|     * GET /manager/ssma/occurrence/{id}/flash-report/context?kind=event
4158|     * Carrega destinatários/aprovadores do flash report sob demanda (Fase D).
4159|     */
4160|    public function occurrenceFlashReportContext(Request $request, int $id): JsonResponse
Request #13 deepseek-flash P:38.88K C:2.12K CR:37.12K CW:0 9875ms
Reasoning
Now let me check the notification: it uses memberId first. `resolveMemberUser($memberId, null)` — no company restriction. Cross-company: memberId comes from the event's details, so it's trustworthy data. Fine. However, there's a subtle bug: the recipient precedence. The controller authorization uses `_user_id` first then `_member_id`; the notification uses `_member_id` first. If a user has multiple CompanyMembers in the same company... Actually `stampSsmaAprofundamentoAuthor` writes both from the same user, and `resolveCompanyMemberIdByUserId` returns the first member found. So they should point to the same person. Minor. But there's a real discrepancy risk: if `aprofundamento_finalized_by_member_id` exists but resolveMemberUser returns null (e.g., member removed), then `$recipients` is non-empty (contains the memberId int) so it won't fall back to `$userId`. Then notify() will find nothing → no notification. Hmm — that's a real edge: `$recipients[] = $memberId;` even if the member no longer exists/is removed. notify() skips. The fallback to user id is skipped because `$recipients !== []`. Is this a real concern? If member is removed, probably the user shouldn't be notified anyway. Low priority. Now, more important: the review plan's issue #1 is invalid (notify supports ints). #2 invalid. #3 is real-ish (legacy occurrences without author get no notification). Let me consider: is that a regression worth flagging? The PR description addresses legacy only for access ("ocorrências antigas sem autor gravado seguem a regra antiga de acesso (legado)"). But for notifications, before this change, the registrant was always notified. Now, legacy occurrences in readequação → nobody notified. That's a behavioral regression for legacy data. Hmm, but for legacy occurrences in readequação... readequação status is new? Actually the approval flow pre-existed (occurrence_approval with rejected). Let's check git history? Not available. The PR description says "Regressão: reprovar → readequar → reenviar". Whether legacy rejection notifications matter is debatable. I'd flag it as medium at most, since the PR explicitly claims legacy keeps old access rules, which implies the author fallback should exist. Actually careful: they intentionally changed to notify the author. For legacy occurrences without author, no notification. Might deserve a comment as medium/low. The rules say favor precision. I think this is a legitimate finding: a silent behavior change for existing records — notification lost. I'll include it, at medium/low severity. Now issue #4: status literal 'rejected' matches STATUS_REJECTED. Not an issue. Issue #5: no residual callers of old name. Confirmed via search — only tests reference it (asserting absence). Fine. Issue #6: semantics — `$resubmitted = true` means "reenvio de readequação". Let me double check `isSsmaOccurrenceInReadequacao` / markPending logic. In maybeSubmit: if `$resubmitted` and status !== REJECTED → return (no-op). Since `$wasRejectedApproval` is computed before the update and the approval status isn't changed by updateFromPayload, if `$wasRejectedApproval` is true, the status is still REJECTED unless the payload's details override occurrence_approval (unlikely). So markPending($event, true) and notify approvers with resubmitted=true. Correct. If `$wasRejectedApproval` false and wantsFinalize: status is probably '' (never validated) or pending. If pending → return (line 12007). If '' → markPending(false) + notify. Fine. Previously the same. Good. Now, one behavioral change: previously, when `$wasRejectedApproval && !$wantsFinalize`, the code called maybeSubmit(..., true) which would markPending and notify approvers. Now it does nothing. That's the intent ("Editar não reenvia"). But wait — what about a full edit by the author themselves after rejection, e.g., the author edits general data plus the aprofundamento? wantsFinalize only when aprofundamento_complete is sent. Hmm, the modal for full edit: for accidents the payload sets `payload.aprofundamento_complete` only in the aprofundamento step. Hmm, `evRequiresAprofundamento(type)`... Let me check whether a full edit by the author that re-sends the aprofundamento marks wantsFinalize. In `updateEvent`, `$data = array_merge($this->eventToValidationArray($event), $data)` — eventToValidationArray includes `aprofundamento_status`? If it includes the event's current aprofundamento_status ('draft' after rejection) then `array_key_exists('aprofundamento_status', $data)` is true → enters the details block. `$wantsFinalize = !empty($data['aprofundamento_complete']) || strtolower($data['aprofundamento_status']) === 'finalized'`. Let me check eventToValidationArray to see if it includes aprofundamento_status/comlete and what values. If the event's aprofundamento is 'draft', wantsFinalize=false → the else branch sets data['aprofundamento_complete']=false and details aprofundamento_status='draft'. That's fine (keeps draft). So after rejection, a full edit retains draft status → no resubmission. Only the aprofundamento step (which sends aprofundamento_complete=true/status='finalized') triggers resubmission. Good. Hmm, but there's a subtle issue: the full-edit payload from the modal for an occurrence with aprofundamento might send `aprofundamento_complete` truthy? At line 7422, only when `evCurrentStep === 'aprofundamento' && evAprofundamentoOnlyMode`. OK. But hold on — what about the case where the author re-finalizes via the aprofundamento step but the status was NOT rejected (e.g., they edit an already-finalized aprofundamento as admin)? Then `$wasRejectedApproval` false → maybeSubmit(..., false). If status approved → isApproved returns early. If pending → returns. Fine. Now let's consider a potentially significant regression: After rejection, the occurrence status is set to STATUS_ABERTO ('Aberto'?). Then re-finalizing: `maybeSubmitOccurrenceForValidation($event, $company, $user, true)` → `$resubmitted=true` skips the finalized-status check. Good. Now let's think about whether the strip at line 27886 could break the author's own resubmission. For the author (canFullEdit false, aprofundamentoOnly true) → skip strip since `$aprofundamentoOnly`. Fine. What if the author resubmits through full edit (not aprofundamentoOnly)? At 27846, `$aprofundamentoFinalized` false (draft) → no strip there. Then at 27886: `canAccessSsmaEventAprofundamento($company, $user, $eventType, $eventDetails, ...)` with `$eventDetails = $data['details']` which now contains occurrence_approval (from event). In readequação with author → returns true (isSsmaAprofundamentoAuthor). Good. But careful: `$eventDetails` includes the original `aprofundamento_finalized_by_user_id`? Yes since merged from event details. Good. Hmm — but one issue: in the readequação branch, the author is checked only if `$hasAuthor`. If the author fields are missing (legacy) → falls through to the technical-types check → any specialist of the type (or direct manager of reporter) can edit. That's the documented legacy behavior ("ocorrências antigas sem autor gravado seguem a regra antiga de acesso"). OK, intentional. Now, notice `$details['aprofundamento_finalized_by_user_id']` is written into `$detailsOut` which then goes through `updateFromPayload` → merged into existing details. On rejection, the `decide()` method preserves the fields. Good. But wait — potential data integrity issue: `stampSsmaAprofundamentoAuthor` writes the author every time the aprofundamento is finalized, including when an admin/gestor edits and re-finalizes. Fine. Another consideration: In the `$wantsFinalize` block, is `$detailsOut` possibly missing the previous author fields? `$detailsOut = is_array($data['details'] ?? null) ? $data['details'] : []`. In the aprofundamentoOnly path, `$data` was built by `mergeSsmaEventAprofundamentoOnlyPayload($event, $data)` — let's check whether that includes details from the event (needed to preserve occurrence_approval, author). If it doesn't include existing details, then `$detailsOut` might be `[]` and setting `$data['details'] = $detailsOut` would wipe other details — but updateFromPayload merges with existingDetails, so nothing is lost. Good. Let me check mergeSsmaEventAprofundamentoOnlyPayload anyway to verify the `occurrence_approval` isn't lost and that `$data['details']` handling at 27884/27901 is right.
Tool Calls (1)
file_read
Show Details
{"end_line": 30080, "file_path": "src/Controller/SsmaController.php", "start_line": 29957}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 29957-30080
29957|    private function mergeSsmaEventAprofundamentoOnlyPayload(\App\Entity\SsmaEvent $event, array $data): array
29958|    {
29959|        $merged = $this->eventToValidationArray($event);
29960|        $existingDetails = is_array($merged['details'] ?? null) ? $merged['details'] : [];
29961|        $incomingDetails = is_array($data['details'] ?? null) ? $data['details'] : [];
29962|        $keys = $this->ssmaEventAprofundamentoFieldKeys();
29963|
29964|        foreach ($keys as $key) {
29965|            if (array_key_exists($key, $data)) {
29966|                $merged[$key] = $data[$key];
29967|            }
29968|            if (array_key_exists($key, $incomingDetails)) {
29969|                $existingDetails[$key] = $incomingDetails[$key];
29970|            }
29971|        }
29972|
29973|        // Consequência pode vir no nível raiz do formulário (não só em details).
29974|        if (array_key_exists('consequence', $data) && $data['consequence'] !== null && $data['consequence'] !== '') {
29975|            $merged['consequence'] = $data['consequence'];
29976|        }
29977|
29978|        if (array_key_exists('aprofundamento_complete', $data)) {
29979|            $merged['aprofundamento_complete'] = $data['aprofundamento_complete'];
29980|        }
29981|        if (array_key_exists('aprofundamento_status', $data)) {
29982|            $merged['aprofundamento_status'] = $data['aprofundamento_status'];
29983|        }
29984|
29985|        $merged['details'] = $existingDetails;
29986|        $merged['aprofundamento_only'] = true;
29987|
29988|        return $merged;
29989|    }
29990|
29991|    /**
29992|     * S/N de descaracterização só pode ser gravado por membro com flag na coluna Descaracterizar.
29993|     *
29994|     * @param array<string, mixed>      $data
29995|     * @param array<string, mixed>|null $existingDetails details já persistidos (update)
29996|     *
29997|     * @return array<string, mixed>
29998|     */
29999|    private function applySsmaDescaracterPermissionGate(
30000|        array $data,
30001|        Company $company,
30002|        $user,
30003|        ?array $existingDetails = null
30004|    ): array {
30005|        $member = $this->getCurrentCompanyMember($company, $user);
30006|        $type = strtoupper(trim((string) ($data['type'] ?? '')));
30007|        $can = $member instanceof CompanyMembers
30008|            && $type !== ''
30009|            && $this->ssmaOccurrenceTypeConfig->memberCanDescharacterizeForType(
30010|                $company,
30011|                (int) $member->getId(),
30012|                $type
30013|            );
30014|        if ($can) {
30015|            return $data;
30016|        }
30017|
30018|        // Sem a permissão "Descaracterizar": o front (ev_descaracter_*) já desabilita o botão
30019|        // "Não" e pré-seleciona "Sim" ('0') quando o campo está vazio — resubmeter '0', a
30020|        // suspeita ou o comentário não é uma tentativa de decisão, é o próprio fluxo normal do
30021|        // especialista sem a flag. O que precisa ser bloqueado (403, nunca reescrito
30022|        // silenciosamente para o front) é a tentativa de gravar 'descaracterizado' = '1' (Não é
30023|        // acidente) quando essa decisão ainda não existia — ver
30024|        // docs/ssma/engineering/ssma-permissoes-membro-analise-cypress.md — P0.
30025|        $details = isset($data['details']) && is_array($data['details']) ? $data['details'] : [];
30026|        $existingDesc = ($existingDetails !== null && array_key_exists('descaracterizado', $existingDetails)
30027|            && \in_array((string) $existingDetails['descaracterizado'], ['0', '1'], true))
30028|            ? (string) $existingDetails['descaracterizado']
30029|            : null;
30030|
30031|        if (array_key_exists('descaracterizado', $details)) {
30032|            $incomingDesc = \in_array((string) $details['descaracterizado'], ['0', '1'], true)
30033|                ? (string) $details['descaracterizado']
30034|                : null;
30035|
30036|            if ($incomingDesc === '1' && $existingDesc !== '1') {
30037|                $data['__ssma_forbidden_reason'] = 'descaracterizacao_sem_permissao';
30038|
30039|                return $data;
30040|            }
30041|
30042|            // Mantém o valor já persistido (se houver); sem isso, aceita o default seguro '0'
30043|            // que o próprio front já pré-seleciona — sem a flag não há como decidir "Não".
30044|            $details['descaracterizado'] = $existingDesc ?? ($incomingDesc ?? '0');
30045|        } elseif ($existingDetails !== null && array_key_exists('descaracterizado', $existingDetails)) {
30046|            $details['descaracterizado'] = $existingDetails['descaracterizado'];
30047|        }
30048|
30049|        if ($existingDetails !== null && array_key_exists('descaracter_comment', $existingDetails)) {
30050|            $details['descaracter_comment'] = $existingDetails['descaracter_comment'];
30051|        } else {
30052|            unset($details['descaracter_comment']);
30053|        }
30054|        unset($data['descaracterizado'], $data['descaracter_comment']);
30055|        $data['details'] = $details;
30056|
30057|        return $data;
30058|    }
30059|
30060|    /**
30061|     * Resolve manager_id a partir de manager_name quando o front/LLM só enviou o nome.
30062|     *
30063|     * @param array<string, mixed> $data
30064|     *
30065|     * @return array<string, mixed>
30066|     */
30067|    private function normalizeSsmaEventManagerInDetails(array $data, Company $company): array
30068|    {
30069|        $details = $data['details'] ?? [];
30070|        if (!is_array($details)) {
30071|            return $data;
30072|        }
30073|
30074|        $rawId = $details['manager_id'] ?? null;
30075|        if ($rawId !== null && $rawId !== '') {
30076|            $details['manager_id'] = (int) $rawId;
30077|            $data['details'] = $details;
30078|
30079|            return $data;
30080|        }
Request #14 deepseek-flash P:42.75K C:489 CR:40.96K CW:0 2915ms
Reasoning
Let me check `ssmaEventAprofundamentoFieldKeys()` to see if `aprofundamento_finalized_by_*` keys are included — these are not "field keys" in the form, so they'll be preserved because `$existingDetails` from eventToValidationArray includes them. Fine. Now an important observation: the controller's `notifyAprofundamentoAuthorOnReject` wrapper takes `$company` but doesn't use it. The service `notifyAprofundamentoAuthorOnReject` doesn't take company either, so `notify()`'s `$restrictToCompany` isn't used → no company isolation check when resolving the member user. Consider: could this notify a user from another company? The memberId stored in details is from this company. Unless data is corrupted. Low. Now think about "Efeito colateral perdido em refatoração" — priority #2 in the user rules. Before: rejection notified `$event->getCreatedById()` (the registrant). Now: notifies the aprofundamento author. If the author fields are missing (legacy occurrences), nobody is notified. That's the lost side effect. This aligns with issue #3. I'll report it as a finding: medium. Now let's also check if there's a test file and whether the new test covers. The test files are in the "other changed files" list, not in review scope. But I can read them for context. Not needed. Let me now consider a deeper issue with `isSsmaOccurrenceInReadequacao` being checked with `$details` passed by callers. There are multiple callers of `canAccessSsmaEventAprofundamento`. Let's list them to see whether the readequação gate could break something, e.g., listing/serialization.
Tool Calls (2)
code_search
Show Details
{"search_text": "canAccessSsmaEventAprofundamento\\(", "use_perl_regexp": true}
File: docs/ssma/e2e-permission-scenarios.md
Match lines: 1
134|  = `canAccessSsmaEventAprofundamento()`).

File: docs/ssma/engineering/ssma-permissoes-ocorrencia-auditoria-2026-09.md
Match lines: 1
200|poder ver isso aqui." Hoje `canAccessSsmaEventAprofundamento()` só dá bypass para

File: src/Controller/SsmaController.php
Match lines: 6
3631|        $canAccess = $this->canAccessSsmaEventAprofundamento(
12753|    private function canAccessSsmaEventAprofundamento(
27646|            && !$this->canAccessSsmaEventAprofundamento(
27779|        $canAccessAprof = $this->canAccessSsmaEventAprofundamento(
27825|        $canAprofundamento = $this->canAccessSsmaEventAprofundamento(
27888|            && !$this->canAccessSsmaEventAprofundamento($company, $user, $eventType, $eventDetails, $event->getCreatedById())

code_search
Show Details
{"search_text": "isSsmaAprofundamentoAdmin"}
File: .opencodereview/rules/ssma/occurrence-ros-aprofundamento-readonly.md
Match lines: 1
30|- Quem libera: `isSsmaAprofundamentoAdmin`, tag técnica do tipo (`resolveUserTechnicalTypes`), gestor direto do relator (ROS/QA)

File: docs/ssma/engineering/ssma-permissoes-membro-analise-cypress.md
Match lines: 1
128|- Admin/gestor (`isSsmaAprofundamentoAdmin`) bypassa tipo.

File: docs/ssma/engineering/ssma-permissoes-ocorrencia-auditoria-2026-09.md
Match lines: 1
201|`isSsmaAprofundamentoAdmin` (que resolve para `Gestor Administrador`); `Gestor de Equipe` e

File: src/Controller/SsmaController.php
Match lines: 12
3630|        $isAdmin = $this->isSsmaAprofundamentoAdmin($company, $user);
12085|        if ($user instanceof User && $this->isSsmaAprofundamentoAdmin($company, $user)) {
12596|        if ($this->isSsmaAprofundamentoAdmin($company, $user)) {
12632|    private function isSsmaAprofundamentoAdmin(?Company $company, ?User $user): bool
12675|        $isSsmaAdmin = $this->isSsmaAprofundamentoAdmin($company, $user);
12760|        if ($this->isSsmaAprofundamentoAdmin($company, $user)) {
13366|            || $this->isSsmaAprofundamentoAdmin($company, $user);
14709|                'ssma_is_admin_aprofundamento' => $this->isSsmaAprofundamentoAdmin($company, $user instanceof User ? $user : null),
14716|                    || $this->isSsmaAprofundamentoAdmin($company, $user instanceof User ? $user : null)
27787|            && ($aprofStatus !== 'finalized' || $this->isSsmaAprofundamentoAdmin($company, $user));
27840|            if ($aprofundamentoFinalized && !$this->isSsmaAprofundamentoAdmin($company, $user)) {
27846|            if ($aprofundamentoFinalized && !$this->isSsmaAprofundamentoAdmin($company, $user)) {

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 2
167|    && fileContains($controller, 'isSsmaAprofundamentoAdmin($company, $user)')
501|fileContains($ssmaCtrl, 'function isSsmaAprofundamentoAdmin')

Request #15 deepseek-flash P:44.14K C:210 CR:43.14K CW:0 1756ms
Reasoning
Caller at 27646 and 3631 — let's check those to see the context and whether the readequação gate could break some flow (e.g., treating readequação as blocking the author). Let me check line 3631 context and 27646.
Tool Calls (2)
file_read
Show Details
{"end_line": 3670, "file_path": "src/Controller/SsmaController.php", "start_line": 3600}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 3600-3670
3600|        if (!$requires) {
3601|            return $empty;
3602|        }
3603|
3604|        $details = [];
3605|        $eventEntity = null;
3606|        $eventId = (int) ($occurrence['id'] ?? 0);
3607|        if ($eventId > 0) {
3608|            $eventEntity = $this->entityManager->getRepository(SsmaEvent::class)->find($eventId);
3609|            if ($eventEntity instanceof SsmaEvent) {
3610|                $details = is_array($eventEntity->getDetails()) ? $eventEntity->getDetails() : [];
3611|            }
3612|        }
3613|        if ($details === [] && is_array($occurrence['details'] ?? null)) {
3614|            $details = $occurrence['details'];
3615|        }
3616|
3617|        $status = strtolower(trim((string) ($details['aprofundamento_status'] ?? '')));
3618|        // Só 'finalized' trava edição. `aprofundamento_complete` sozinho (save da 1ª etapa / legado)
3619|        // deixava o técnico com o botão visível e o offcanvas em readonly.
3620|        $finalized = $status === 'finalized';
3621|        $fieldsIncomplete = $eventEntity instanceof SsmaEvent
3622|            ? $this->ssmaEventAprofundamentoPending($eventEntity)
3623|            : !empty($occurrence['aprofundamento_pending']);
3624|        $pending = !$finalized && (
3625|            $fieldsIncomplete
3626|            || $status === 'draft'
3627|            || $status === ''
3628|        );
3629|
3630|        $isAdmin = $this->isSsmaAprofundamentoAdmin($company, $user);
3631|        $canAccess = $this->canAccessSsmaEventAprofundamento(
3632|            $company,
3633|            $user,
3634|            $type,
3635|            $details,
3636|            (int) ($occurrence['created_by_id'] ?? 0)
3637|        );
3638|        $canEdit = $canAccess && (!$finalized || $isAdmin);
3639|        $rosResolved = $type === 'ROS' && !empty($details['ros_resolved']);
3640|        // Campos completos bastam. Sem flag (legado / save da 1ª etapa) não bloqueia;
3641|        // só draft explícito continua pendente de "Finalizar aprofundamento".
3642|        $validationReady = $rosResolved || (!$fieldsIncomplete && $status !== 'draft');
3643|
3644|        return [
3645|            'show' => $canAccess,
3646|            'pending' => $pending,
3647|            'finalized' => $finalized,
3648|            'can_edit' => $canEdit,
3649|            'is_admin' => $isAdmin,
3650|            'validation_ready' => $validationReady,
3651|        ];
3652|    }
3653|
3654|    /**
3655|     * Resolve "responsável da área" a partir do local da ocorrência.
3656|     * 1) location_responsibles (Controle de Espaço / config SSMA)
3657|     * 2) CompanyArea com nome igual/contido no local → primeiro responsável da área
3658|     *
3659|     * @param array<string, mixed> $occurrence
3660|     * @param list<array<string, mixed>> $allMembers
3661|     *
3662|     * @return array<string, mixed>
3663|     */
3664|    private function enrichOccurrenceAreaResponsible(array $occurrence, Company $company, array $allMembers): array
3665|    {
3666|        $storedId = (int) ($occurrence['area_responsible_id'] ?? 0);
3667|        if ($storedId > 0) {
3668|            $occurrence['area_responsible_id'] = $storedId;
3669|            if (trim((string) ($occurrence['area_responsible_name'] ?? '')) === '') {
3670|                $name = '';
file_read
Show Details
{"end_line": 27680, "file_path": "src/Controller/SsmaController.php", "start_line": 27620}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 27620-27680
27620|        }
27621|        if (!$this->isSsmaHierarchyLockedManagerProfile($company, $user)) {
27622|            $data = $this->applySsmaEventManagerAssignment($data, $company, $user);
27623|        }
27624|        $data = $this->applySsmaEventAreaResponsibleFromLocation($data, $company);
27625|        $data = $this->applySsmaDescaracterPermissionGate($data, $company, $user);
27626|        if (($data['__ssma_forbidden_reason'] ?? null) === 'descaracterizacao_sem_permissao') {
27627|            return new JsonResponse(['success' => false, 'message' => 'Você não tem permissão para descaracterizar/decidir sobre este acidente.'], 403);
27628|        }
27629|        $data = $this->ensureSsmaEventTitle($data);
27630|
27631|        $validator = new \App\Service\Ssma\SsmaEventValidator();
27632|        $errors    = $validator->validate($data, [
27633|            'allowed_type_keys' => $this->ssmaOccurrenceTypeConfig->getAllowedTypeKeys($company),
27634|        ]);
27635|        if ($errors !== []) {
27636|            return new JsonResponse([
27637|                'success' => false,
27638|                'message' => implode(' ', $errors),
27639|                'errors'  => $errors,
27640|            ], 422);
27641|        }
27642|
27643|        $eventDetails = is_array($data['details'] ?? null) ? $data['details'] : [];
27644|        if (
27645|            in_array($typeKey, ['ROS', 'QUASE_ACIDENTE', 'ACIDENTE_PESSOAL', 'ACIDENTE_MATERIAL', 'ACIDENTE_AMBIENTAL'], true)
27646|            && !$this->canAccessSsmaEventAprofundamento(
27647|                $company,
27648|                $user,
27649|                $typeKey,
27650|                $eventDetails,
27651|                (int) ($user->getId() ?? 0)
27652|            )
27653|        ) {
27654|            $data = $this->stripSsmaEventAprofundamentoFieldsFromPayload($data, $typeKey, []);
27655|        }
27656|
27657|        $teamScopeErr = $this->validateSsmaEventPayloadAgainstTeamScope($data, $company, $user);
27658|        if ($teamScopeErr !== null) {
27659|            return new JsonResponse(['success' => false, 'message' => $teamScopeErr], 403);
27660|        }
27661|
27662|        try {
27663|            $service  = new \App\Service\Ssma\SsmaEventService($this->entityManager, $this->ssmaOccurrenceTypeConfig);
27664|            $editorMeta = $this->resolveSsmaEditorMeta($company, $user);
27665|            $registeredByName = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
27666|            if ($registeredByName === '') {
27667|                $registeredByName = $user->getEmail() ?? 'Sistema';
27668|            }
27669|            $registeredByMemberId = $this->resolveCompanyMemberIdByUserId($company, (int) $user->getId());
27670|            if (!isset($data['details']) || !is_array($data['details'])) {
27671|                $data['details'] = [];
27672|            }
27673|            if ($registeredByMemberId) {
27674|                $data['details']['registered_by_member_id'] = $registeredByMemberId;
27675|            }
27676|            $data['details']['registered_by_name'] = $registeredByName;
27677|            $payloads = $service->splitPersonalAccidentPayloads($data);
27678|
27679|            foreach ($payloads as $payload) {
27680|                $payloadErrors = $validator->validate($payload, [
Request #16 deepseek-flash P:46.37K C:1.26K CR:44.29K CW:0 6956ms
Reasoning
Now the create path (line 27646) — passes `$details` from the new payload. In readequação, a create isn't relevant. Important: caller at 3631 — used for enriching occurrence lists to decide `can_edit` of aprofundamento. It fetches event details from DB, so readequação detection works. If the user is not the author → can_edit false, show=$canAccess=false → the aprofundamento button hidden. That's the intended behavior. Hmm, but note: `show => $canAccess`. For a non-author specialist in readequação, `show` false. That is intended per the requirement ("Em readequação: só o autor (e admin/tenant) edita o aprofundamento"). Although "show" false may hide the view of the aprofundamento entirely — but that's the PR's intent. Now let me focus on the more subtle potential bug: the `$hasAuthor` check plus fall-through. When in readequação and author exists, return `isSsmaAprofundamentoAuthor(...)` — which returns false for non-author. Fine. Now let's consider `isSsmaOccurrenceInReadequacao($details)`: it reads `$details['occurrence_approval']['status']`. In some code paths, `$details` passed may not include occurrence_approval — e.g., `$data['details']` from payload in the update path when the front sends details. Already discussed. Now, another point: the `notifyAprofundamentoAuthorOnReject` private method in the controller still receives `Company $company` unused — minor (already was unused before? Before it passed $company to the service: `notifyOccurrenceRegistrantOnReject($event, $sender, $note)` — no, previous service signature didn't take company either; so $company was already unused). Skip. Now let's evaluate the service-level rule: "Não deixe a mesma regra de autorização ou de domínio recalculada com critério diferente em outro service". Here, the authorization decision `isSsmaOccurrenceInReadequacao` (status 'rejected' literal) vs the service's `SsmaOccurrenceApprovalService::STATUS_REJECTED`. Duplicated string literal in the controller instead of reusing the constant / service state. That's a legitimate maintainability finding per the user rules ("Valor de domínio com conjunto fechado (status, tipo, origem) é normalizado e validado na entrada, nunca comparado como string bruta vinda do front" — well, this is from details JSON, not front). But there's an existing single source of truth: `SsmaOccurrenceApprovalService::getState()/STATUS_REJECTED`. The new helper re-implements the same check with a raw literal in the controller. This duplicates the domain check. It's not necessarily a bug (string matches constant), but per user rule #1 (duplicated logic, single source of truth) it's worth flagging as low/medium maintainability. Also, `maybeSubmitOccurrenceForValidation` uses the constant. I think a low-severity maintainability comment is justified but the rules say don't overload with style. Hmm. The literal matches, so no bug. I'd rather flag it as a low maintainability nit at most. Given "Favor precision over recall", maybe skip. But the user rules explicitly prioritize duplicated domain rules with a different criterion. Here the criterion is the same but the source is duplicated. I'll include it as low severity, brief. Now let me double-check the notification priority issue (#7) — is there a real scenario? `stampSsmaAprofundamentoAuthor` always sets both when the member exists. If the member doesn't exist for the user (e.g., edge case where the user's CompanyMembers is removed), only `_user_id` is set → notification falls to userId → resolves User. Authorization uses `_user_id` first → match. So consistent. Where they diverge: multiple members for same user (resolveCompanyMemberIdByUserId returns first found by findBy — non-deterministic order) — the stamp stores that member id; authorization first compares by user id → matches. Notification uses member id → resolves to user via CompanyMembers::getUser() → same user. So actually they converge on the same User. Hmm, unless the stored member id belongs to a different user (data corruption). So issue #7 is weak; I'd skip it. Wait, careful: `resolveCompanyMemberIdByUserId` uses `findBy(['company'=>..., 'user'=>$userId, 'isRemoved'=>0])`. Note `'user' => $userId` — passing an int for an association. Doctrine findBy with an int for an association field: it may work (Doctrine accepts identifier values for single-valued associations? Actually Doctrine 2.x `findBy(['user' => 5])` works — it converts to the identifier). OK not our concern. Now, `resolveMemberUser` in the notification service does not restrict to the company (null passed). Not a security issue in practice. Let's now check the test file to see what's asserted — maybe reveals intent. Not in review scope but useful. Let me read the new test quickly to see whether `approfundamento_finalized_by_*` naming matches (e.g., misspelling). The names match across files: `aprofundamento_finalized_by_user_id`, `_member_id`, `_name`. Let me verify in the test and other consumers (twig/js) to ensure the front uses the same key names for display. Search for `aprofundamento_finalized_by` across repo.
Tool Calls (1)
code_search
Show Details
{"search_text": "aprofundamento_finalized_by"}
File: src/Controller/SsmaController.php
Match lines: 7
12768|            $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0
12769|                || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0;
12811|        $authorUserId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
12816|        $authorMemberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
12827|        $details['aprofundamento_finalized_by_user_id'] = (int) $user->getId();
12830|            $details['aprofundamento_finalized_by_member_id'] = $memberId;
12833|        $details['aprofundamento_finalized_by_name'] = $name !== '' ? $name : (string) ($user->getEmail() ?? '');

File: src/Service/Ssma/SsmaNotificationService.php
Match lines: 2
289|        $memberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
290|        $userId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);

File: src/Service/Ssma/SsmaOccurrenceApprovalService.php
Match lines: 1
110|            // Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento.

File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 2
32|        self::assertStringContainsString('aprofundamento_finalized_by_member_id', $this->notificationSrc);
42|        self::assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc);

Request #17 deepseek-flash P:48.07K C:1.66K CR:47.62K CW:0 7874ms
Reasoning
No front displays the name. The `_name` field is written but never read anywhere. Minor dead data. Not worth flagging? Could mention as low. Probably skip. Now let me check the reject flow: is `notifyAprofundamentoAuthorOnReject` also used in the "bulk reject" or other places? Search for runDeferredOccurrenceRejectedSideEffects. Also important: the requirement says the notification should go to the author. But note the notification's `$sender` is the approver. `notify()` skips if recipient === sender. If the approver is the author themselves (e.g., the author is also the approver?) — hmm, they'd not be notified, but that's the general rule. Now let's consider the important thing: after rejection, the author re-finalizes → `stampSsmaAprofundamentoAuthor` re-stamps. Fine. Now, another consideration: When the author resubmits, `maybeSubmitOccurrenceForValidation(..., true)` → markPending(true) and notifies approvers. And the `occurrence_approval.status` becomes 'pending'. But `aprofundamento_finalized_by_*` remains. Good. Now the key risk of a real functional bug: In `updateEvent`, the readequação gate is evaluated at line 27825 with `$data['details'] ?? $existingDetails`. When the front sends `details` (full edit path from another UI?), `$data['details']` at that point is the raw payload details (before merge with event). Hmm, actually `$data` is raw JSON payload; for full-edit modal it may not include details. OK. Now let's think about `canEditSsmaEvent` — for a plain member registrant, does canFullEdit return true? If the registrant isn't the author of the aprofundamento, in readequação they could do a full edit; the aprofundamento fields would be stripped at 27886 due to the readequação gate (since not author). Good — that's the new protection working as intended. But wait: what if the registrant is a plain member with canFullEdit = true and, in readequação, they re-send the full payload including `aprofundamento_complete=true`? At 27846: `$aprofundamentoFinalized` is false (draft) → no strip. Then 27886: not author → strip aprofundamento fields → `$wantsFinalize` false. Good. But careful: if the registrant is the author... fine. Hmm, but here's a thought: the strip at 27886 uses `$eventDetails = $data['details']` at that point, which for full edits is the merged event details (since eventToValidationArray merged). The readequação gate reads occurrence_approval from persisted event → 'rejected' → correct. BUT — there's a subtle issue: at 27884, `$eventDetails` is extracted from `$data['details']`, and if the payload includes `details` (from the modal? no), it might lack occurrence_approval. For rota `saveEvent`? Let's not go deeper. The gate at 27886 is only reached when the earlier 27837 check passed or canFullEdit true. Actually wait. Consider `$aprofundamentoOnly === true` (author resubmitting) — at 27886, the condition `!$aprofundamentoOnly` is false → skip strip entirely. That's "especialista já está autorizado" — but that comment assumes the earlier 27825 check authorized them. Yes, 27825 runs `canAccessSsmaEventAprofundamento` with existingDetails (since payload has no details) → readequação gate → only author. Good. Now: `isSsmaAprofundamentoAdmin` at 27840: in `!$canFullEdit` branch, if `$aprofundamentoFinalized` — in readequação aprofundamento is draft, so not finalized → passes. OK. Now let's examine whether the changed behavior of `maybeSubmitOccurrenceForValidation` breaks the "Validar sem Finalizar (#797)" flow — the PR notes regression risk. In the previous code, `$wasRejectedApproval` alone triggered resubmission. Now `$wantsFinalize` must be true. Under #797 (validate without finishing), for a rejected occurrence, the user might edit general data and submit... but the requirement explicitly wants that not to reopen the queue. Fine. Hmm, but there's one important scenario: The requirement says "A fila só reabre ao reenviar o aprofundamento." What if the occurrence type does NOT require aprofundamento (e.g., ACIDENTE_* without aprofundamento, or a type without the aprofundamento step)? Then after rejection, how does the user resubmit? `$wantsFinalize` requires `aprofundamento_complete` truthy or status 'finalized'. For types that don't have aprofundamento, the modal might send `aprofundamento_complete = true` for ACIDENTE types? Let's check `evRequiresAprofundamento`. For an occurrence without the aprofundamento step, the full edit would not set aprofundamento_complete... but the event's details may already contain `aprofundamento_status = 'finalized'` (because to get validated, the aprofundamento must have been finalized). Hmm! If the aprofundamento was finalized previously, the approval rejected sets it back to 'draft' and complete=false. So the details have draft. Then the user resubmits via... which UI? For occurrences where the aprofundamento is required, the modal has the aprofundamento step. For those without aprofundamento (e.g., ACIDENTE_PESSOAL? let me check `evRequiresAprofundamento`), the modal in step 1 does NOT send aprofundamento_complete. Then in readequação, the user can never resubmit → the occurrence is stuck! That's a potentially serious regression. Wait — previously, on `$wasRejectedApproval`, any edit (full edit) would resubmit. So for occurrences without the aprofundamento step, the resubmission was via the full edit. Now that path is removed and requires `$wantsFinalize`. If the modal never sends aprofundamento_complete for those types, the occurrence can never return to the queue. Hmm, but does `maybeSubmitOccurrenceForValidation` with resubmitted=true require the aprofundamento? No, it just requires status rejected. So the question: which occurrence types require the aprofundamento step in the modal, and does the reject flow only apply to those? The occurrence validation (`occurrence_approval`) applies to flash reports; `maybeSubmitOccurrenceForValidation` is called from create and update. Rejection can happen for any occurrence that reached validation. Let me check `evRequiresAprofundamento` in the twig.
Tool Calls (2)
code_search
Show Details
{"file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"], "search_text": "function evRequiresAprofundamento"}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 1
1668|    function evRequiresAprofundamento(type) {

code_search
Show Details
{"file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"], "search_text": "evAprofundamentoOnlyMode"}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 22
1638|    var evAprofundamentoOnlyMode = false;
1671|        if (!evAprofundamentoOnlyMode && t === 'ROS' && evIsRosResolvedChecked()) {
3091|        var isDoctorAprof = !!evAprofundamentoOnlyMode || evCurrentStep === 'aprofundamento';
3269|        if (evAprofundamentoOnlyMode && evAprofundamentoCanEditFromServer === false && !evHasTechnicalType(type)) {
3285|        if (evAprofundamentoOnlyMode) {
3510|            draft.classList.toggle('d-none', !evAprofundamentoOnlyMode || evCurrentStep !== 'aprofundamento');
3512|        if (evAprofundamentoOnlyMode) {
3560|            general.classList.toggle('is-readonly', !!evAprofundamentoOnlyMode);
6423|            if (boxesWrap && evAprofundamentoOnlyMode && evCanDescharacterizeAccident()) {
6543|        evAprofundamentoOnlyMode = false;
6963|            evAprofundamentoOnlyMode = true;
6976|            // Sincroniza a UI de descaracterização agora que evAprofundamentoOnlyMode=true.
7008|        evAprofundamentoOnlyMode = false;
7126|        if (evAprofundamentoOnlyMode && evIsAprofundamentoFinalized() && !EV_IS_ADMIN_APROFUNDAMENTO) {
7142|        var finalizeAprofundamento = !evAprofundamentoOnlyMode || !!evAprofundamentoFinalizeIntent;
7143|        if (evAprofundamentoOnlyMode) {
7153|        if (evCurrentStep === 'general' && !evAprofundamentoOnlyMode) {
7170|            if (!evAprofundamentoOnlyMode) {
7176|        var softAprofundamentoDraft = evAprofundamentoOnlyMode && !finalizeAprofundamento;
7420|        if (evCurrentStep === 'aprofundamento' && evAprofundamentoOnlyMode) {
7568|            if (descAnswered && evAprofundamentoOnlyMode && evCanDescharacterizeAccident()) {
7579|            if (!evAprofundamentoOnlyMode || !evCanDescharacterizeAccident()) {

Request #18 deepseek-flash P:50.32K C:172 CR:49.66K CW:0 2068ms
Tool Calls (2)
file_read
Show Details
{"file_path": "templates/ssma/occurrence/partials/_modal_event.html.twig", "start_line": 1660, "end_line": 1690}
File: templates/ssma/occurrence/partials/_modal_event.html.twig (Total lines: 8167)
IS_TRUNCATED: false
LINE_RANGE: 1660-1690
1660|            window.SsmaShared.toggleYesNo('.js-ev-immediate-risk-opt', isYes ? '1' : '0');
1661|        }
1662|    }
1663|
1664|    function evTypeHasAprofundamento(type) {
1665|        return EV_APROFUNDAMENTO_TYPE_KEYS.indexOf(type || evSelectedType()) !== -1;
1666|    }
1667|
1668|    function evRequiresAprofundamento(type) {
1669|        var t = type || evSelectedType();
1670|        // No cadastro, "Visto e resolvido" dispensa a 2ª etapa. Na view do técnico não.
1671|        if (!evAprofundamentoOnlyMode && t === 'ROS' && evIsRosResolvedChecked()) {
1672|            return false;
1673|        }
1674|        return evTypeHasAprofundamento(t);
1675|    }
1676|
1677|    /** Campos técnicos de acidente (não ROS/QA). */
1678|    function evRequiresAccidentTechnical(type) {
1679|        return EV_APROFUNDAMENTO_ACCIDENT_KEYS.indexOf(type || evSelectedType()) !== -1;
1680|    }
1681|
1682|    function evUpdateAprofundamentoTitle(type) {
1683|        var title = document.getElementById('ev-aprofundamento-title');
1684|        if (!title) return;
1685|        var titles = {
1686|            ROS: 'Campos do ROS',
1687|            QUASE_ACIDENTE: 'Campos do Quase Acidente',
1688|            ACIDENTE_PESSOAL: 'Aprofundamento pelo Profissional da Saúde',
1689|            ACIDENTE_MATERIAL: 'Aprofundamento pela Segurança do Trabalho',
1690|            ACIDENTE_AMBIENTAL: 'Aprofundamento por Especialistas Ambientais'
file_read
Show Details
{"file_path": "templates/ssma/occurrence/partials/_modal_event.html.twig", "start_line": 7130, "end_line": 7195}
File: templates/ssma/occurrence/partials/_modal_event.html.twig (Total lines: 8167)
IS_TRUNCATED: false
LINE_RANGE: 7130-7195
7130|            return;
7131|        }
7132|        var form = document.getElementById('form-event-new');
7133|        var MV   = evModalValidation();
7134|        window.__ssmaEvSkipGenericValidationToast = false;
7135|        if (MV) MV.clearState(EV_MODAL_SCOPE);
7136|
7137|        if (window.EvModal && typeof window.EvModal.syncTagHiddens === 'function') {
7138|            window.EvModal.syncTagHiddens();
7139|        }
7140|
7141|        // Clique no primary = finalizar (quando só-aprofundamento); draft zera a intent antes.
7142|        var finalizeAprofundamento = !evAprofundamentoOnlyMode || !!evAprofundamentoFinalizeIntent;
7143|        if (evAprofundamentoOnlyMode) {
7144|            // Reativa intent padrão após o ciclo (draft seta false antes do click).
7145|            evAprofundamentoFinalizeIntent = true;
7146|            if (evCurrentStep === 'general') {
7147|                evSetStep('aprofundamento');
7148|                evEnsureCorrectiveActionsSeed();
7149|                return;
7150|            }
7151|        }
7152|
7153|        if (evCurrentStep === 'general' && !evAprofundamentoOnlyMode) {
7154|            if (!evValidateGeneralStep(MV)) {
7155|                if (MV) evShowFieldErrors();
7156|                else { form.reportValidity(); }
7157|                return;
7158|            }
7159|            if (evRequiresAprofundamento(evSelectedType()) && evCanEditAprofundamento(evSelectedType())) {
7160|                evSetStep('aprofundamento');
7161|                evEnsureCorrectiveActionsSeed();
7162|                return;
7163|            }
7164|        }
7165|
7166|        if (evCurrentStep === 'aprofundamento'
7167|            && evRequiresAprofundamento(evSelectedType())
7168|            && !evCanEditAprofundamento(evSelectedType())) {
7169|            // Sem permissão de aprofundamento: registra só o geral (não bloqueia o botão).
7170|            if (!evAprofundamentoOnlyMode) {
7171|                evSetStep('general');
7172|            }
7173|        }
7174|
7175|        var hasErrors = false;
7176|        var softAprofundamentoDraft = evAprofundamentoOnlyMode && !finalizeAprofundamento;
7177|
7178|        var dtEl  = document.getElementById('ev_datetime');
7179|        var dtIso = dtEl && dtEl.value ? dtEl.value : '';
7180|        if (dtIso && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(dtIso)) {
7181|            dtIso = dtIso + ':00';
7182|        }
7183|        var dtFutureOrInvalid = false;
7184|        if (!softAprofundamentoDraft) {
7185|            if (!dtIso) {
7186|                if (MV) MV.markInvalid(window.jQuery('#ev_datetime'));
7187|                hasErrors = true;
7188|            } else {
7189|                // Data do evento não pode ser inválida nem cair em dia futuro (hoje inteiro é permitido).
7190|                var evWhen = new Date((dtEl && dtEl.value) ? dtEl.value : dtIso);
7191|                if (isNaN(evWhen.getTime()) || evIsEventDatetimeFutureDay(dtEl ? dtEl.value : dtIso)) {
7192|                    if (MV) MV.markInvalid(window.jQuery('#ev_datetime'));
7193|                    hasErrors = true;
7194|                    dtFutureOrInvalid = true;
7195|                }
Request #19 deepseek-flash P:51.95K C:395 CR:50.43K CW:0 2785ms
Reasoning
So all occurrence types requiring aprofundamento are in EV_APROFUNDAMENTO_TYPE_KEYS, which includes ROS, QUASE_ACIDENTE, ACIDENTE_*. And the modal step flow: for the full edit (not aprofundamentoOnlyMode), if the type requires aprofundamento and the user can edit it, the primary button goes to the aprofundamento step, and then... does the payload get sent with `aprofundamento_complete=true`? Line 7420: only if `evCurrentStep === 'aprofundamento' && evAprofundamentoOnlyMode`. So in a full edit (not only-mode) at the aprofundamento step, `aprofundamento_complete` is NOT set at the root; instead, the aprofundamento fields in `details` would be sent (payload.details includes aprofundamento fields? Let's check how details is built for the full edit — the payload includes `details` maybe via a different path). Hmm. Let me check where `payload.details` is set... Search for `details:` in the payload building area or `evCollectAprofundamento`. Let's search `aprofundamento_status` in twig.
Tool Calls (2)
code_search
Show Details
{"file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"], "search_text": "aprofundamento_status"}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 2
6547|        var aprofStatus = String(detEarly.aprofundamento_status || (data && data.aprofundamento_status) || '').toLowerCase();
7423|            payload.aprofundamento_status = finalizeAprofundamento ? 'finalized' : 'draft';

code_search
Show Details
{"file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"], "search_text": "details"}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 89
685|            <input type="hidden" id="ev_injured_person_details" name="ev_injured_person_details" value="">
2027|    function evGetInjuredDetailsObj() {
2028|        var hidden = document.getElementById('ev_injured_person_details');
2032|    function evSetInjuredDetailsObj(obj) {
2033|        var hidden = document.getElementById('ev_injured_person_details');
2225|        var saved = (evGetInjuredDetailsObj()[String(pid)] || {});
2315|            var prev = evGetInjuredDetailsObj()[card.getAttribute('data-person-id')] || {};
2361|    function evSyncInjuredPersonDetailsHidden() {
2365|        var out = evGetInjuredDetailsObj();
2373|        evSetInjuredDetailsObj(out);
2383|            Object.keys(evGetInjuredDetailsObj()).forEach(function (pid) {
2452|            // Nunca apagar caixinhas médicas já montadas / details salvos só porque as tags
2454|            var existingDetails = evGetInjuredDetailsObj();
2456|            if (hasCards || Object.keys(existingDetails || {}).length) {
2461|            evSetInjuredDetailsObj({});
2474|                var saved = evGetInjuredDetailsObj()[personId] || {};
2483|        evSyncInjuredPersonDetailsHidden();
2521|            // Persiste só este card (não reescreve o mapa inteiro de details).
2524|                var all = evGetInjuredDetailsObj();
2526|                evSetInjuredDetailsObj(all);
2570|        var saved = evGetInjuredDetailsObj()[card.getAttribute('data-person-id')] || {};
2712|                evSyncInjuredPersonDetailsHidden();
2716|                evSyncInjuredPersonDetailsHidden();
2733|                evSyncInjuredPersonDetailsHidden();
2822|        var details = evGetInjuredDetailsObj();
2823|        delete details[String(personId)];
2824|        evSetInjuredDetailsObj(details);
2863|                var saved = evGetInjuredDetailsObj()[personId] || {};
2868|                    evSyncInjuredPersonDetailsHidden();
2885|    /** Remonta caixinhas a partir de injured_person_details (+ person_id legado). */
2889|        var details = evGetInjuredDetailsObj();
2890|        var ids = Object.keys(details);
2898|        if (primary && !details[primary]) {
2899|            details[primary] = { attendance_date: evTodayDateInputValue(), had_injury: true, body_parts: [] };
2900|            evSetInjuredDetailsObj(details);
2901|            ids = Object.keys(details);
2910|            evCreateInjuredPersonCard(pid, details[pid] || {}, false);
2919|        evSyncInjuredPersonDetailsHidden();
2956|            data.__injured_details = (document.getElementById('ev_injured_person_details') || {}).value || '';
2987|            if (data.__injured_details) {
2988|                var ie = document.getElementById('ev_injured_person_details');
2989|                if (ie) ie.value = data.__injured_details;
5079|        ['ev_people_ids', 'ev_witness_ids', 'ev_responsible_ids', 'ev_injured_person_details', 'ev_approach_custom'].forEach(function (id) {
5538|            var det = (data.details && typeof data.details === 'object') ? data.details : data;
6022|        evSyncInjuredPersonDetailsHidden();
6107|                evSyncInjuredPersonDetailsHidden();
6148|            evSyncInjuredPersonDetailsHidden();
6546|        var detEarly = (data && data.details && typeof data.details === 'object') ? data.details : (data || {});
6555|        // det: objeto details (formato serialize) ou fallback para o próprio data (formato listagem)
6556|        var det  = (data.details && typeof data.details === 'object') ? data.details : data;
6822|        var injRaw = det.injured_person_details || data.injured_person_details || {};
6855|        var injEl = document.getElementById('ev_injured_person_details');
6870|            // Re-sync depois das tags + details: garante caixinhas médicas mesmo se
6876|            // Se sync ainda não viu pessoas nas tags, remonta a partir dos details salvos.
6879|            if (!hasMedCards && Object.keys(evGetInjuredDetailsObj() || {}).length) {
6888|        // Restaura suspeita por card a partir do det ou do injured_person_details do card primário.
7494|            evSyncInjuredPersonDetailsHidden();
7503|            var detailsObj = (typeof evGetInjuredDetailsObj === 'function') ? evGetInjuredDetailsObj() : {};
7504|            var primaryData = (primaryId && detailsObj[primaryId]) ? detailsObj[primaryId] : null;
7554|            payload.injured_person_details = (document.getElementById('ev_injured_person_details') || { value: '' }).value;
7697|                'details.deviation_type': 'Tipo de desvio',
7698|                'details.failed_barrier': 'Barreira que falhou',
7699|                'details.failed_barrier_other': 'Descrição da barreira (Outro)',
7700|                'details.involvement_type': 'Envolvimento',
7701|                'details.immediate_risk': 'Risco imediato',
7702|                'details.potential_severity': 'Consequência potencial / Gravidade',
7703|                'details.potential_consequence': 'Consequência potencial',
7704|                'details.improvement_suggestions': 'Sugestões de melhoria',
7705|                'details.nature': 'Natureza',
7706|                'details.barrier_type': 'Tipo de barreira',
7707|                'details.person_id': 'Pessoa envolvida',
7708|                'details.person_type': 'Tipo de vínculo',
7709|                'details.injury_type': 'Tipo de lesão',
7710|                'details.injury_classification': 'Classificação da lesão',
7711|                'details.injury_severity': 'Severidade da lesão',
7712|                'details.had_injury': 'Houve lesão',
7713|                'details.descaracterizado': 'Caracterizado como acidente',
7714|                'details.descaracter_comment': 'Comentário de caracterização',
7715|                'details.work_leave': 'Licença / afastamento',
7716|                'details.asset_type': 'Tipo de ativo',
7717|                'details.operational_impact': 'Impacto operacional',
7718|                'details.environmental_medium': 'Meio ambiental',
7719|                'details.pollutant_type': 'Tipo de poluente',
7720|                'details.containment_done': 'Contenção realizada',
7721|                'details.activity': 'Registro do ocorrido',
7722|                'details.approach': 'Abordagem',
7723|                'details.strategic_nature': 'Natureza estratégica',
7734|                if (text.indexOf('details.') === -1 && !/^(consequence|type|location|manager_id|description|nature|agent)\s*:/i.test(text)) {
7751|                return text.replace(/^details\.[a-z0-9_.]+\s*:\s*/i, '');

Request #20 deepseek-flash P:54.23K C:2.19K CR:52.22K CW:0 9904ms
Reasoning
So the front never sends a `details` object; it sends flat fields (deviation_type etc.) that the backend maps into details via `applyPayload`/normalize. So `$data['details']` in updateEvent is generally absent → `$detailsOut` in the finalize block is `[]` and then `$data['details'] = ['aprofundamento_complete'=>true, ..., 'aprofundamento_finalized_by_*'=>...]`. updateFromPayload merges with existingDetails → preserved. Good. Hmm wait — important: if `$data['details']` is absent and `$detailsOut = []` then `$data['details'] = $detailsOut` sets ONLY the aprofundamento keys, and updateFromPayload merges `array_merge($existingDetails, $incomingDetails)`. The event's existing details include `changelog`, `occurrence_approval`, etc. Fine. But careful: `array_merge($this->eventToValidationArray($event), $data)` — eventToValidationArray includes 'details' key from event; but `$data` is the raw payload without 'details', so array_merge keeps event's details. Then at 27901 `$detailsOut = $data['details']` = event details (not empty!). Hmm: at 27845 `$data = array_merge($this->eventToValidationArray($event), $data)` — eventToValidationArray returns a key 'details'? Let's verify. If yes, `$data['details']` at 27884 and 27901 is the event's details, and setting `$data['details'] = $detailsOut` would include the whole details. That's fine both ways. For the `aprofundamentoOnly` path where `!$canFullEdit`: `$data = mergeSsmaEventAprofundamentoOnlyPayload($event, $data)` → `$merged['details'] = $existingDetails` (all event details). So `$detailsOut` = full details. Then `$data['details'] = $detailsOut` — includes occurrence_approval and previously stamped author. Good. So no data loss. Now the critical regression question I raised: for the full-edit flow (non-aprofundamentoOnly) after rejection, the front's primary button navigates to the aprofundamento step (line 7159) if the type requires aprofundamento and the user can edit it. Then submitting at the aprofundamento step: `payload.aprofundamento_only` is NOT set (line 7420 requires evAprofundamentoOnlyMode), and `aprofundamento_complete` is NOT set at the root. But wait — does the payload include `aprofundamento_status`? No (only line 7423). So in that full-edit path, the backend's `$wantsFinalize` at 27902 requires `!empty($data['aprofundamento_complete'])` or `aprofundamento_status === 'finalized'`. In the full-edit path, neither. Unless eventToValidationArray adds aprofundamento_complete from the event → after rejection, complete=false. So `$wantsFinalize` false → no resubmission. Hmm! So after rejection, the author using the full "Editar" flow would not resubmit — they must use the aprofundamento-only flow. Is that intended? The requirement: "Em readequação: só o autor (e admin/tenant) edita o aprofundamento" and "A fila só reabre ao reenviar o aprofundamento". The UI for the author post-rejection: how do they reopen the aprofundamento modal? Probably the aprofundamento button (Editar aprofundamento) which sets evAprofundamentoOnlyMode = true. In that mode, finalize sends aprofundamento_only + complete + status finalized. Then `$wantsFinalize` true → resubmit. OK so intended path works. But what about the scenario in the review plan's regression note: "reprovar → readequar → reenviar". Under the new code, if the author uses the full edit modal and the type requires the aprofundamento, the primary button moves to the aprofundamento step (line 7159) and then `finalizeAprofundamento = !evAprofundamentoOnlyMode || intent` = true, but that only affects the DRAFT/soft logic and not the payload `aprofundamento_complete` (which is only set in the only-mode branch). Hmm, so `finalizeAprofundamento` is used at line 7176 for `softAprofundamentoDraft`. So in the full-edit path, the aprofundamento fields are sent as flat fields, and the backend neither stamps the author nor resubmits. Hmm, but then how did the old code behave? Old code: `$wasRejectedApproval` → maybeSubmit(..., true) → resubmit regardless of wantsFinalize. So the full-edit path worked for resubmission. Now it doesn't. That's a potential regression: the author editing via the full modal (Edit button, not the aprofundamento-only button) in readequação will not return the occurrence to the validation queue. Is the "Editar" full modal available to the author? For a plain member author of the aprofundamento... Let's check `evCanEditAprofundamento` and whether the "Editar" button exists for them. There is an "Editar" action in the occurrence list. If canFullEdit is false (not registrant), the modal is aprofundamento-only mode. The author of the aprofundamento might not be the registrant; in that case canFullEdit false → the modal is only-mode → the resubmission path works. If the author IS also the registrant (common: the same specialist registers and finishes the aprofundamento), then canFullEdit could be true → the full modal → the update would not resubmit. Hmm! That's a plausible regression. Let me verify: does the modal in full-edit mode for a finalized-in-the-past aprofundamento go to the aprofundamento step and send fields? At line 7153-7164: `evCurrentStep === 'general' && !evAprofundamentoOnlyMode` → validates general, then if requiresAprofundamento && canEditAprofundamento → setStep('aprofundamento') and return; the second submit sends the payload. At that point, `payload.aprofundamento_complete` is not set. So the backend sees no aprofundamento_complete/status. But wait — does `eventToValidationArray($event)` include `aprofundamento_status` at root? Let's check eventToValidationArray. If `eventToValidationArray` includes 'aprofundamento_status' => from event details ('draft' after rejection), then at 27900 `array_key_exists('aprofundamento_status', $data)` is true → enters the block; `$wantsFinalize` = !empty(aprofundamento_complete=false) || 'draft' === 'finalized' → false. Then the else branch sets data['aprofundamento_complete']=false, details 'draft'. So the finalized aprofundamento flat fields sent in the payload... would they be applied? The details are set to draft, so the resubmitted aprofundamento stays draft → the occurrence is not ready for validation → `isEventReadyForOccurrenceValidation` would probably fail anyway. So even if we called maybeSubmit with resubmitted=true, `isEventReadyForOccurrenceValidation` might return false. Hmm, but maybe the aprofundamento fields sent as flat payload get merged... but aprofundamento_status forced to 'draft' would make it not-ready. Hmm, this is getting deep and speculative. The review guidance says avoid non-local claims without verification; but the PR description explicitly states this behavior is intended ("Editar (gestor/tenant) não reenvia para validação"). But it says gestor/tenant — for them the queue shouldn't reopen. For the author... The author resubmits through the aprofundamento flow, which sends finalized. I think the intended design is that the readequação resubmission always goes through the aprofundamento-finalize path, and the full edit doesn't reopen. Since the PR author and the tests (assert_branch_ui_fixes, cypress) assert this, I'd not flag it as a bug without stronger evidence. Hmm, but wait — is there a risk the author can't resubmit at all in some flow? Let's check the list UI: for a rejected occurrence, the author of the aprofundamento (non-registrant) gets `can_edit` true from enrichOccurrenceAprofundamentoAccess (line 3631) since `canAccess` true and status draft → not finalized → canEdit true. So the aprofundamento button is available. Good. But now think about a non-author specialist in readequação: `canAccess` false → `can_edit` false AND `show` false. Would they still see the occurrence in their list? `show` is used for what? Let's check where this enrich output is consumed — could `show => false` hide the occurrence card for others, affecting the list? Let's check usages of 'show' key / aprofundamento_access. Actually the third element of the array returned: keys show, pending, finalized, can_edit, is_admin, validation_ready. Let's find how it's used in twig/JS: search for 'aprofundamento_access' or the caller at 3631's method name.
Tool Calls (2)
code_search
Show Details
{"search_text": "enrichOccurrenceAprofundamento"}
No matches found
file_read
Show Details
{"end_line": 3600, "file_path": "src/Controller/SsmaController.php", "start_line": 3560}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 3560-3600
3560|            'occurrence_approver_ids' => $company instanceof Company
3561|                ? $this->ssmaOccurrenceTypeConfig->getFlashReportApproverIds($company)
3562|                : [],
3563|            'ssma_flash_report_lazy' => ($occurrence['is_ssma_event'] ?? false) === true,
3564|        ]));
3565|    }
3566|
3567|    /**
3568|     * Flags do botão +Aprofundamento na view da ocorrência.
3569|     *
3570|     * @param array<string, mixed> $occurrence
3571|     *
3572|     * @return array{
3573|     *   show: bool,
3574|     *   pending: bool,
3575|     *   finalized: bool,
3576|     *   can_edit: bool,
3577|     *   is_admin: bool,
3578|     *   validation_ready: bool
3579|     * }
3580|     */
3581|    private function resolveOccurrenceViewAprofundamentoFlags(
3582|        array $occurrence,
3583|        ?Company $company,
3584|        ?User $user
3585|    ): array {
3586|        $empty = [
3587|            'show' => false,
3588|            'pending' => false,
3589|            'finalized' => false,
3590|            'can_edit' => false,
3591|            'is_admin' => false,
3592|            'validation_ready' => true,
3593|        ];
3594|        if (!$company || !$user || empty($occurrence['is_ssma_event'])) {
3595|            return $empty;
3596|        }
3597|
3598|        $type = strtoupper(trim((string) ($occurrence['type_value'] ?? $occurrence['type'] ?? '')));
3599|        $requires = in_array($type, ['ROS', 'QUASE_ACIDENTE', 'ACIDENTE_PESSOAL', 'ACIDENTE_MATERIAL', 'ACIDENTE_AMBIENTAL'], true);
3600|        if (!$requires) {
Request #21 deepseek-flash P:56.97K C:526 CR:56.32K CW:0 3286ms
Reasoning
`show` gates the aprofundamento button visibility. Fine and intended. Now, one more important consideration for the requirement: "Gestor administrador e tenant admin: Editar paralelo, sem abrir a fila de validação." — the admin edit path: `canFullEdit` true for them → strip not applied at 27846 (they're admins) → they can edit the aprofundamento too. Their edit with `aprofundamento_complete`? The full modal for admins also goes through the aprofundamento step. If they submit at the aprofundamento step, no root-level aprofundamento_complete → wantsFinalize false → no queue reopen. Good. Now, let's consider the scenario the review plan flagged as #4 — no problem. Now potential real bug in the notification: it uses `$details['aprofundamento_finalized_by_member_id']` first. `notify()` receives `$recipients` with an int; `resolveMemberUser($memberId, null)` — no `$restrictToCompany`. Wait, `notify()` signature has `$restrictToCompany` param but the call omits it. That's the same as other callers (e.g., notifyMetaAbonoRequester). OK. Now let's carefully consider whether the notification is even reached with the author's data. The reject flow: `decide()` sets details with occurrence_approval and preserves `aprofundamento_finalized_by_*`. Then `runDeferredOccurrenceRejectedSideEffects` re-fetches the event from the DB, so getDetails includes the author fields. Good. Now let's verify the reject controller path calls decide first. Let's read around line 3980-4040 (rejectOccurrence). Actually, is `notifyAprofundamentoAuthorOnReject` called anywhere else? Line 4134 in the deferred. Let's search.
Tool Calls (2)
code_search
Show Details
{"search_text": "runDeferredOccurrenceRejectedSideEffects"}
File: .opencodereview/rules/ssma/occurrence-approve.md
Match lines: 2
6|- `src/Controller/SsmaController.php` — métodos `approveOccurrence`, `deferAfterResponse`, `runDeferredOccurrenceApprovedSideEffects`, `runDeferredOccurrenceRejectedSideEffects`
64|1. **Efeito colateral perdido.** Automações, e-mail de rejeição e flash automático **não foram removidos**; mudaram de momento (depois do HTTP). Equivalente no diff: `runDeferredOccurrenceApprovedSideEffects` / `runDeferredOccurrenceRejectedSideEffects`.

File: src/Controller/SsmaController.php
Match lines: 2
4035|                $this->runDeferredOccurrenceRejectedSideEffects($eventId, $companyId, $userId, $rejectNote);
4124|    private function runDeferredOccurrenceRejectedSideEffects(int $eventId, int $companyId, int $userId, string $note): void

file_read
Show Details
{"end_line": 4010, "file_path": "src/Controller/SsmaController.php", "start_line": 3930}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 3930-4010
3930|            ], 422);
3931|        }
3932|
3933|        $directSend = !empty($payload['direct_send']) && $flashPerms['can_direct_send'];
3934|
3935|        if ($directSend) {
3936|            $details = $event->getDetails();
3937|            $flashStatus = (string) ((is_array($details['flash_report'] ?? null) ? $details['flash_report'] : [])['status'] ?? '');
3938|            if ($flashStatus === 'pending') {
3939|                return new JsonResponse([
3940|                    'success' => false,
3941|                    'message' => 'Já existe um envio aguardando aprovação na Central de Comunicações.',
3942|                ], 422);
3943|            }
3944|            if ($flashStatus === 'sent') {
3945|                return new JsonResponse([
3946|                    'success' => false,
3947|                    'message' => 'Flash report já foi enviado.',
3948|                ], 422);
3949|            }
3950|            $result = $this->ssmaFlashReportService->sendApprovedReport(
3951|                $event,
3952|                $user,
3953|                $note !== '' ? $note : null
3954|            );
3955|        } else {
3956|            $result = $this->ssmaFlashReportService->sendApprovedReport(
3957|                $event,
3958|                $user,
3959|                $note !== '' ? $note : null
3960|            );
3961|        }
3962|
3963|        return new JsonResponse($result, ($result['success'] ?? false) ? 200 : 422);
3964|    }
3965|
3966|    /**
3967|     * Valida ou reprova a ocorrência (gate antes do flash report).
3968|     */
3969|    public function approveOccurrence(Request $request, int $id): JsonResponse
3970|    {
3971|        /** @var User|null $user */
3972|        $user = $this->getUser();
3973|        if (!$user instanceof User) {
3974|            return new JsonResponse(['success' => false, 'message' => 'Não autenticado.'], 401);
3975|        }
3976|
3977|        $company = $user->getCompany();
3978|        if (!$company) {
3979|            return new JsonResponse(['success' => false, 'message' => 'Empresa não encontrada.'], 403);
3980|        }
3981|
3982|        if (!$this->canApproveSsmaOccurrence($company, $user)) {
3983|            return new JsonResponse(['success' => false, 'message' => 'Sem permissão para validar esta ocorrência.'], 403);
3984|        }
3985|
3986|        $event = $this->entityManager->getRepository(SsmaEvent::class)->find($id);
3987|        if (!$event instanceof SsmaEvent || $event->getCompany()->getId() !== $company->getId()) {
3988|            return new JsonResponse(['success' => false, 'message' => 'Ocorrência não encontrada.'], 404);
3989|        }
3990|
3991|        $payload = json_decode($request->getContent(), true) ?? [];
3992|        $decision = (string) ($payload['decision'] ?? $payload['status'] ?? 'approved');
3993|        $note = trim((string) ($payload['note'] ?? ''));
3994|
3995|        $approvalStatus = $this->ssmaOccurrenceApprovalService->getState($event)['status'];
3996|        if ($approvalStatus === \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_REJECTED) {
3997|            return new JsonResponse([
3998|                'success' => false,
3999|                'message' => 'A ocorrência está em readequação. Corrija e reenvie antes de validar de novo.',
4000|            ], 422);
4001|        }
4002|
4003|        $member = $this->entityManager->getRepository(CompanyMembers::class)
4004|            ->findOneBy(['user' => $user, 'company' => $company, 'isRemoved' => 0]);
4005|
4006|        $wasApproved = $this->ssmaOccurrenceApprovalService->isApproved($event);
4007|        try {
4008|            $result = $this->ssmaOccurrenceApprovalService->decide($event, $user, $member, $decision, $note);
4009|        } catch (\Throwable $e) {
4010|            $this->ssmaLogger->error('Ssma approveOccurrence decide: ' . $e->getMessage(), [
Request #22 deepseek-flash P:58.91K C:570 CR:57.47K CW:0 3882ms
Reasoning
Fine. Now let's look at the `SsmaNotificationService` side more carefully for the case where `$memberId > 0` but the approver is the member... no. One more: The service method now doesn't take `Company` param, so no company isolation. But is this service used for other companies? The occurrence's author member belongs to the occurrence's company by construction. Let's now consider the review plan's issue #3 (legacy no-notification). Is that a genuine concern? The notification previously went to `$event->getCreatedById()`. With the new code, if no author is stamped, no notification. The PR description explicitly mentions legacy: "ocorrências antigas sem autor gravado seguem a regra antiga de acesso (legado)" — for access. For notification, they say nothing. So legacy records in readequação lose notification. Worth a medium comment? The user rules priority #2 is about lost side effects in refactoring — this matches exactly: the notification to the registrant was replaced, and for records without the new fields, the notification disappears entirely. I'll report it as medium, suggesting a fallback to `$event->getCreatedById()` when no author is stamped. Hmm, but is that desirable? The PR wants to stop notifying the registrant. Adding a fallback would notify the registrant for legacy records only. That seems a reasonable suggestion and consistent with "legacy keeps old behavior". I'll phrase it carefully as a question/suggestion. Now, are there other more concrete issues I should check? Let me re-check the `isSsmaOccurrenceInReadequacao` + `$hasAuthor` logic interplay with `canEditSsmaEvent` and the UI `_can_edit_aprofundamento` in getEvent (27786): `$canAccessAprof && ($aprofStatus !== 'finalized' || isAdmin)`. In readequação, aprofStatus = 'draft' → for a non-author specialist, canAccess false → no edit. Good. Now potential issue: `resolveCurrentUserTechnicalTypes` check now comes AFTER the readequação gate, so technical specialists of the type lose access in readequação. That's the intent. Let me now check the tests' expectations to see if the asserted behavior matches the implementation (the test file is not in review scope, but a mismatch might indicate a bug). Let me read the test.
Tool Calls (1)
file_read
Show Details
{"file_path": "tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php"}
File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php (Total lines: 70)
IS_TRUNCATED: false
LINE_RANGE: 1-70
1|<?php
2|
3|declare(strict_types=1);
4|
5|namespace App\Tests\Unit\Product\Ssma;
6|
7|use PHPUnit\Framework\TestCase;
8|
9|/**
10| * Readequação (Brenda): reprovação reabre aprofundamento para quem enviou,
11| * não para todos os especialistas do tipo. Gestor/tenant editam em paralelo
12| * sem reenviar a fila de validação.
13| */
14|final class SsmaOccurrenceReadequacaoAuthorTest extends TestCase
15|{
16|    private string $controllerSrc;
17|    private string $notificationSrc;
18|    private string $approvalSrc;
19|
20|    protected function setUp(): void
21|    {
22|        $root = dirname(__DIR__, 4);
23|        $this->controllerSrc = (string) file_get_contents($root . '/src/Controller/SsmaController.php');
24|        $this->notificationSrc = (string) file_get_contents($root . '/src/Service/Ssma/SsmaNotificationService.php');
25|        $this->approvalSrc = (string) file_get_contents($root . '/src/Service/Ssma/SsmaOccurrenceApprovalService.php');
26|    }
27|
28|    public function testRejectNotifiesAprofundamentoAuthorNotRegistrant(): void
29|    {
30|        self::assertStringContainsString('notifyAprofundamentoAuthorOnReject', $this->controllerSrc);
31|        self::assertStringContainsString('notifyAprofundamentoAuthorOnReject', $this->notificationSrc);
32|        self::assertStringContainsString('aprofundamento_finalized_by_member_id', $this->notificationSrc);
33|        self::assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc);
34|        self::assertStringNotContainsString(
35|            'getCreatedById()',
36|            $this->extractMethod($this->notificationSrc, 'notifyAprofundamentoAuthorOnReject')
37|        );
38|    }
39|
40|    public function testRejectKeepsAuthorAndUsesReadequacaoCopy(): void
41|    {
42|        self::assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc);
43|        self::assertStringContainsString('voltou para readequação', $this->approvalSrc);
44|        self::assertStringContainsString('devolvida para readequação após reprovação', $this->approvalSrc);
45|    }
46|
47|    public function testResubmitOnlyWhenAprofundamentoIsResent(): void
48|    {
49|        self::assertStringContainsString(
50|            'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)',
51|            $this->controllerSrc
52|        );
53|        self::assertStringNotContainsString(
54|            'Readequação: reenvia tanto na edição completa',
55|            $this->controllerSrc
56|        );
57|        self::assertStringContainsString('stampSsmaAprofundamentoAuthor', $this->controllerSrc);
58|        self::assertStringContainsString('isSsmaOccurrenceInReadequacao', $this->controllerSrc);
59|    }
60|
61|    private function extractMethod(string $src, string $method): string
62|    {
63|        $start = strpos($src, 'function ' . $method);
64|        self::assertNotFalse($start, $method . ' ausente');
65|        $chunk = substr($src, $start, 1800);
66|
67|        return $chunk;
68|    }
69|}
70|
Request #23 deepseek-flash P:60.41K C:1.95K CR:59.39K CW:0 9774ms
Reasoning
The test asserts `getCreatedById()` is NOT in the notification method — so the fallback is intentionally excluded. So flagging "legacy no notification" conflicts with the test's explicit intent... The test only asserts the method doesn't use getCreatedById. So intentionally excluding the registrant. Hmm. That weakens my issue #3 finding: the PR intentionally does not fall back to the registrant. But is silently notifying nobody for legacy records intentional? The PR description says legacy uses old access rules for permissions, but not for notification. Since the test explicitly forbids getCreatedById in that method, suggesting a fallback to getCreatedById would contradict the author's intent. I could still mention it, but it's borderline. Actually, I could suggest a fallback to... what? There's nothing else. Given the test explicitly asserts the absence, I'd rather not report it — it's an intentional product decision. Hmm, but the reviewer's job includes flagging regressions for legacy. The risk: existing rejected occurrences (pre-deploy) in readequação — do they exist? After deployment, no occurrence in 'rejected' state can exist without the author field... unless they were rejected before deploy and not yet resubmitted. In that transition window, no notification. That's transient and low-impact. I'll mention it at low severity? Given the test explicitly encodes the intent, I'll skip it to avoid noise. Hmm. Actually wait. There's a more interesting angle: the PR removes notification to the registrant entirely. What if the author is the registrant? Then it's the same. Fine. Let me reconsider: the checklist says "Efeito colateral perdido em refatoração" is priority #2 and is a local criterion to the diff. The removed line notified the registrant; the added code notifies the author. Is the removal justified explicitly in the PR? Yes: "Aviso in-app só para quem enviou o aprofundamento (não o registrante...)". Justified. So per their own rule, the removal is explicitly justified → don't flag. Good, skip. Now let's dig into whether there is a real bug I haven't found. Let's check `getCurrentCompanyMember` and whether `$loggedMember` could be null for the author (e.g., author is a platform admin without CompanyMembers). Suppose a SUPER_ADMIN finalized the aprofundamento — he'd pass `isSsmaAprofundamentoAdmin` anyway. Fine. Now, `stampSsmaAprofundamentoAuthor` sets `_user_id` and conditionally `_member_id`. If the author had a member id but later the member row is removed, authorization via `isSsmaAprofundamentoAuthor` compares user id first → still author. Notification uses memberId (removed → resolveMemberUser returns null → skip) and doesn't fall back since recipients non-empty. So notification silently drops for a removed member... but notify() would skip them anyway if resolved. Actually if member is removed, `resolveMemberUser` returns null → skip → no notification. If the code had fallen back to userId, `entityManager->find(User)` would notify the user even though their member was removed. Which is better? Ambiguous. Not a solid finding. Let me look for a potential bug: `$details['aprofundamento_finalized_by_member_id'] = $memberId;` — type int. `notifyAprofundamentoAuthorOnReject` casts to int. Fine. Now let's check the `updateEvent` decision at 27946 again in terms of the requirement "Editar (gestor/tenant) não reenvia". Consider the ADMIN/gestor who edits and finalizes the aprofundamento (they're allowed to edit aprofundamento). If they submit with `aprofundamento_complete=true` (aprofundamentoOnly mode possibly) while status is rejected, `$wasRejectedApproval` true → maybeSubmit(..., true) → resubmits. Is that desired? The requirement says admin edits in parallel shouldn't reopen the queue. But if the admin explicitly finalizes the aprofundamento, it arguably should reopen. Edge case; the requirement says "Editar dados gerais sem reenviar aprofundamento: não volta para a fila". So finalizing the aprofundamento (even by an admin) should reopen. Consistent. Now, hmm, wait. An admin finalizing the aprofundamento in readequação would also OVERWRITE the author stamp (since stampSsmaAprofundamentoAuthor writes the admin as author). Then the notification for a subsequent rejection goes to the admin, and only the admin can readequar. Is that intended? "quem readequa é quem fez o aprofundamento" — if the admin re-finalized, then the admin becomes the author. That's consistent with the rule as stated, though it may surprise. Not a bug. Now let me double check something important about `isSsmaOccurrenceInReadequacao` being used to gate the aprofundamento for ALL cases including in the *create* flow at 27646 — a new occurrence can't be rejected, so fine. Next: does anything else read `_can_edit_aprofundamento` etc.? Fine. Let me now look for other places that check whether a user can edit the aprofundamento, to verify there's a single source of truth (per user rule: "Não deixe a mesma regra de autorização... recalculada com critério diferente em outro service, listener ou controller"). E.g., `ssmaEventAprofundamentoPending` or a separate check in `SsmaEventService` or the twig. Search for `aprofundamento_finalized_by` — only the three files. So no duplicate. But wait: is there any *other* place that decides who can edit the aprofundamento, e.g., a `_can_edit_aprofundamento` computed in a different method or the "Gestor direto do relator" logic? `canAccessSsmaEventAprofundamento` is the single source. Good. Another thing to verify per the review plan: issue #6 about maybeSubmit's 4th argument. Verified semantically correct. Now, is there an issue with `maybeSubmitOccurrenceForValidation` no longer being called on a non-finalize edit when the occurrence was rejected AND the type has no aprofundamento requirement? For types without the aprofundamento step (e.g., some occurrence types that skip the 2nd stage — like ROS with "visto e resolvido"), the validation gate: `maybeSubmitOccurrenceForValidation` is called on create/update when ready. For a ROS with "visto e resolvido", the aprofundamento is not required and the occurrence can reach validation. If it's rejected → readequação. Now the user must resubmit: their resubmission path... the full modal edit (Editar) would send the general payload; `$wantsFinalize` requires `aprofundamento_complete` truthy or status 'finalized'. For "visto e resolvido" ROS, is `evRequiresAprofundamento` false → the modal doesn't go to the aprofundamento step, so `aprofundamento_status`/`aprofundamento_complete` are never sent. Also `$aprofundamentoOnly` false. So `$wantsFinalize` false → the occurrence can NEVER return to the validation queue! Wait, but does `eventToValidationArray($event)` include `aprofundamento_status`? If the event's details have `aprofundamento_status` (set to 'draft' by the reject), then at 27900 `array_key_exists('aprofundamento_status', $data)` may be true → enters the block → wantsFinalize false (draft) → sets complete=false again. And `maybeSubmit` is not called. So indeed, for a "visto e resolvido" ROS that got rejected, the user edits and submits → no resubmission → stuck in readequação forever. Previously (`$wasRejectedApproval` → resubmit), it worked. Hmm! But wait — is `eventToValidationArray` including these keys? Let me check. Also check whether ROS "visto e resolvido" can reach validation at all. In `maybeSubmitOccurrenceForValidation`, `isEventReadyForOccurrenceValidation` decides. For ROS resolved, it might be ready. Let me check `isEventReadyForOccurrenceValidation` and `ssmaEventAprofundamentoPending`. This could be a genuine high-severity regression: occurrences that don't have a finalize-aprofundamento step can no longer be resubmitted after rejection. Let me verify `eventToValidationArray` content first.
Tool Calls (2)
code_search
Show Details
{"search_text": "function eventToValidationArray"}
File: src/Controller/SsmaController.php
Match lines: 1
29264|    private function eventToValidationArray(\App\Entity\SsmaEvent $event): array

code_search
Show Details
{"search_text": "function isEventReadyForOccurrenceValidation"}
File: src/Controller/SsmaController.php
Match lines: 1
11944|    private function isEventReadyForOccurrenceValidation(\App\Entity\SsmaEvent $event): bool

Request #24 deepseek-flash P:62.46K C:154 CR:62.21K CW:0 1893ms
Tool Calls (2)
file_read
Show Details
{"end_line": 11962, "file_path": "src/Controller/SsmaController.php", "start_line": 11910}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 11910-11962
11910|        return in_array(strtoupper(trim($type)), [
11911|            'ROS',
11912|            'QUASE_ACIDENTE',
11913|            'ACIDENTE_PESSOAL',
11914|            'ACIDENTE_MATERIAL',
11915|            'ACIDENTE_AMBIENTAL',
11916|        ], true);
11917|    }
11918|
11919|    private function isSsmaEventAprofundamentoExplicitlyFinalized(\App\Entity\SsmaEvent $event): bool
11920|    {
11921|        $details = is_array($event->getDetails()) ? $event->getDetails() : [];
11922|        $status = strtolower(trim((string) ($details['aprofundamento_status'] ?? '')));
11923|
11924|        return $status === 'finalized' || !empty($details['aprofundamento_complete']);
11925|    }
11926|
11927|    /**
11928|     * ROS "visto e resolvido" dispensa o aprofundamento técnico no gate de validação.
11929|     */
11930|    private function ssmaEventSkipsAprofundamentoValidationGate(\App\Entity\SsmaEvent $event): bool
11931|    {
11932|        if (strtoupper(trim((string) $event->getType())) !== 'ROS') {
11933|            return false;
11934|        }
11935|
11936|        $details = is_array($event->getDetails()) ? $event->getDetails() : [];
11937|
11938|        return !empty($details['ros_resolved']);
11939|    }
11940|
11941|    /**
11942|     * Ocorrência pronta para o gate de validação (aprofundamento completo ou tipo sem essa etapa).
11943|     */
11944|    private function isEventReadyForOccurrenceValidation(\App\Entity\SsmaEvent $event): bool
11945|    {
11946|        if ($this->ssmaEventSkipsAprofundamentoValidationGate($event)) {
11947|            return true;
11948|        }
11949|
11950|        if ($this->ssmaEventAprofundamentoPending($event)) {
11951|            return false;
11952|        }
11953|
11954|        if ($this->ssmaEventTypeRequiresAprofundamentoFinalizeGate($event->getType())) {
11955|            $details = is_array($event->getDetails()) ? $event->getDetails() : [];
11956|            $status = strtolower(trim((string) ($details['aprofundamento_status'] ?? '')));
11957|            if ($status === 'draft') {
11958|                return false;
11959|            }
11960|        }
11961|
11962|        return true;
file_read
Show Details
{"end_line": 29340, "file_path": "src/Controller/SsmaController.php", "start_line": 29264}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 29264-29340
29264|    private function eventToValidationArray(\App\Entity\SsmaEvent $event): array
29265|    {
29266|        return [
29267|            'type'        => $event->getType(),
29268|            'origin'      => $event->getOrigin(),
29269|            'datetime'    => $event->getEventDatetime()->format('Y-m-d\\TH:i:s'),
29270|            'location'    => $event->getLocation(),
29271|            'description' => $event->getDescription(),
29272|            'nature'      => $event->getNature(),
29273|            'agent'       => $event->getAgent(),
29274|            'consequence' => $event->getConsequence(),
29275|            'impacts'     => $event->getImpacts(),
29276|            'details'     => $event->getDetails(),
29277|        ];
29278|    }
29279|
29280|    /**
29281|     * Garante título quando o campo vier vazio: gera via IA a partir da atividade/descrição.
29282|     *
29283|     * @param array<string, mixed> $data
29284|     *
29285|     * @return array<string, mixed>
29286|     */
29287|    private function ensureSsmaEventTitle(array $data): array
29288|    {
29289|        $details = isset($data['details']) && is_array($data['details']) ? $data['details'] : [];
29290|        $title   = trim((string) ($details['title'] ?? $data['title'] ?? ''));
29291|        if ($title !== '') {
29292|            return $data;
29293|        }
29294|
29295|        // Default determinístico (sem PII): "Acidente Pessoal · 15/07/2026"
29296|        $generated = $this->ssmaOccurrenceLlmService->buildDeterministicTitle(
29297|            isset($data['type']) ? (string) $data['type'] : null,
29298|            isset($data['datetime']) ? (string) $data['datetime'] : null
29299|        );
29300|        if ($generated === '') {
29301|            return $data;
29302|        }
29303|
29304|        $details['title'] = $generated;
29305|        $data['details']  = $details;
29306|        $data['title']    = $generated;
29307|
29308|        return $data;
29309|    }
29310|
29311|    /**
29312|     * @param mixed $rawIds
29313|     *
29314|     * @return int[]
29315|     */
29316|    private function normalizeSsmaActionResponsibleIds($rawIds): array
29317|    {
29318|        $ids = array_values(array_unique(array_filter(array_map('intval', (array) $rawIds))));
29319|        if (count($ids) > 1) {
29320|            $ids = [reset($ids)];
29321|        }
29322|
29323|        return $ids;
29324|    }
29325|
29326|    /**
29327|     * O formulário envia campos de detalhe no nível raiz; validador e persistência usam `details`.
29328|     *
29329|     * @param array<string, mixed> $data
29330|     *
29331|     * @return array<string, mixed>
29332|     */
29333|    private function normalizeSsmaEventPayload(array $data, ?Company $company = null): array
29334|    {
29335|        $detailKeys = [
29336|            'activity', 'approach', 'responsible_ids',
29337|            'title', 'manager_id', 'people_ids', 'team_id', 'evidences',
29338|            'strategic_nature', 'area_label', 'potential_consequence',
29339|            'classifier_dano', 'classifier_risco', 'classifier_afetado',
29340|            'category',
Request #25 deepseek-flash P:64.27K C:590 CR:62.59K CW:0 3832ms
Reasoning
`eventToValidationArray` returns `details` = event details (which for a rejected occurrence has `aprofundamento_status = 'draft'` and `aprofundamento_complete = false`), but the ROOT keys `aprofundamento_status`/`aprofundamento_complete` are NOT included. So at 27900, the condition `$aprofundamentoOnly || !empty($data['aprofundamento_complete']) || array_key_exists('aprofundamento_status', $data)`: - For a full edit (not only-mode) where the modal does NOT send aprofundamento_complete/status: false → the block is skipped → `$wantsFinalize` false → no resubmit. So for ROS "visto e resolvido" (skip aprofundamento), after rejection, the user edits via the full modal (no aprofundamento step; no aprofundamento keys in payload) → no resubmission ever. The occurrence is stuck in "Readequação" forever. That's a serious regression — IF such a flow can reject. Can a ROS "visto e resolvido" be sent to validation and rejected? Yes: `maybeSubmitOccurrenceForValidation` on create/update → `isEventReadyForOccurrenceValidation` returns true for ros_resolved → markPending → approver validates → rejects. Then readequação. Before the change, the author/registrant edits and re-submits (previously maybeSubmit(..., true) with resubmitted=true → markPending(true)). Now: no path. Hmm, but wait: For the ROS-resolved case, does the modal send `aprofundamento_complete`? At line 7415: `if (evRequiresAprofundamento(type)) payload.corrective_actions = ...`. And 7420 only in only-mode. In the general step for ROS-resolved the modal skips the aprofundamento step (evRequiresAprofundamento returns false when not only-mode & ros_resolved). So no. But hold on — is there any other path that resubmits for such occurrences? E.g., the edit may trigger `maybeAutoRequestApproval` (flash) but for the occurrence approval queue, only maybeSubmit does. Let me check if there's another place that calls `markPending` — e.g., an endpoint "reenviar para validação". Search `markPending`.
Tool Calls (2)
code_search
Show Details
{"search_text": "markPending"}
File: src/Controller/SsmaController.php
Match lines: 2
12001|            $this->ssmaOccurrenceApprovalService->markPending($event, true);
12015|        $this->ssmaOccurrenceApprovalService->markPending($event, false);

File: src/Service/Ssma/SsmaOccurrenceApprovalService.php
Match lines: 1
131|    public function markPending(SsmaEvent $event, bool $resubmitted = false): void

code_search
Show Details
{"search_text": "ssmaEventTypeRequiresAprofundamentoFinalizeGate"}
File: src/Controller/SsmaController.php
Match lines: 2
11908|    private function ssmaEventTypeRequiresAprofundamentoFinalizeGate(string $type): bool
11954|        if ($this->ssmaEventTypeRequiresAprofundamentoFinalizeGate($event->getType())) {

Request #26 deepseek-flash P:65.06K C:697 CR:64.77K CW:0 4457ms
Reasoning
`markPending` is only called from `maybeSubmitOccurrenceForValidation` (both call sites are within it). So after the change, resubmission after rejection occurs only when `$wantsFinalize` is true — i.e., only when the aprofundamento finalize was explicitly sent. Now, is `maybeSubmitOccurrenceForValidation` also called from the create path (line 27715) and admin paths? For an occurrence in readequação, the only way to exit rejected is `status !== rejected` → i.e., markPending. Wait — `maybeSubmitOccurrenceForValidation` at line 11997: with `$resubmitted=true` and status rejected → markPending(true). With `$resubmitted=false`, line 12011: `if ($status === STATUS_REJECTED) return;` — so even a non-resubmitted call does nothing when rejected. So yes: after rejection, the ONLY transition out of 'rejected' is a call with `$resubmitted=true` from line 27947, which requires `$wantsFinalize`. So the question is whether every rejected occurrence's readequação flow (from the UI) sends `aprofundamento_complete=true`/`status=finalized` (root level). Cases: 1. Occurrence with aprofundamento step, author uses "+Aprofundamento" flow → aprofundamentoOnly mode → sends it. OK. 2. ROS "visto e resolvido" (no aprofundamento step) → full edit → does NOT send it → stuck. 3. Author also registrant with full edit modal → goes to the aprofundamento step (line 7159) if `evCanEditAprofundamento(selectedType)` and requires aprofundamento; submits from the aprofundamento step without only-mode → no root aprofundamento keys → stuck (though the flat aprofundamento fields in details are sent... wait, actually how are the aprofundamento fields sent in the full modal? As flat root fields (e.g., `deviation_type`, `potential_consequence`), which normalizeSsmaEventPayload moves into details. So the data changes but the status stays draft (line 27912 forces draft since `array_key_exists('aprofundamento_status'...)` is false → the block is skipped entirely; so details' aprofundamento_status stays whatever the merged event details had → 'draft' from rejection). So the occurrence remains "draft" and can never be resubmitted → stuck! Hmm, wait. For case 3, does the modal, when opened via "Editar" on a rejected occurrence, use the only-mode? Let's check how the modal decides `evAprofundamentoOnlyMode` — line 6963. Let's read around 6940-7010 for the openEditar / openAprofundamento functions.
Tool Calls (1)
file_read
Show Details
{"end_line": 7015, "file_path": "templates/ssma/occurrence/partials/_modal_event.html.twig", "start_line": 6900}
File: templates/ssma/occurrence/partials/_modal_event.html.twig (Total lines: 8167)
IS_TRUNCATED: false
LINE_RANGE: 6900-7015
6900|        // Propaga suspeita para cada card (checkbox editável no aprofundamento).
6901|        document.querySelectorAll('.ev-injured-person-box').forEach(function (card) {
6902|            var chk = card.querySelector('.ev-inj-suspect-chk');
6903|            if (chk) chk.checked = suspectOn;
6904|            card.setAttribute('data-descaracter-suspect', suspectOn ? '1' : '0');
6905|            var yesNoWrap = card.querySelector('.ev-inj-descaracter-yesno-wrap');
6906|            if (yesNoWrap) yesNoWrap.classList.toggle('d-none', !suspectOn);
6907|        });
6908|        var descVal = det.descaracterizado != null ? det.descaracterizado : data.descaracterizado;
6909|        if (descVal === true || descVal === 1) descVal = '1';
6910|        if (descVal === false || descVal === 0) descVal = '0';
6911|        evSetVal('ev_descaracterizado', descVal == null ? '' : String(descVal));
6912|        evSyncDescaracterUi();
6913|
6914|        // ── Evidências já anexadas ──────────────────────────
6915|        var evidences = Array.isArray(det.evidences) ? det.evidences : (Array.isArray(data.evidences) ? data.evidences : []);
6916|        evEvidences = evidences.map(function (e) {
6917|            return {
6918|                name: e.name || e.filename || '',
6919|                path: e.path || '',
6920|                persisted: true
6921|            };
6922|        });
6923|        evEvidenceRenderList();
6924|
6925|        // ── Labels do modal ─────────────────────────────────
6926|        var btnLbl = document.getElementById('ev-btn-label');
6927|        var modalTitle = document.getElementById('ev-modal-title');
6928|        if (modalTitle) modalTitle.textContent = 'Editar ocorrência';
6929|        evApplyAuraTitleStatusVisibility('edit');
6930|        evSetStep('general');
6931|        $('#ev_manager').trigger('change');
6932|    };
6933|
6934|    /**
6935|     * Abre o offcanvas no aprofundamento (especialista).
6936|     * Admin/gestor administrador edita tudo desde informações gerais — não trava o 1º passo.
6937|     */
6938|    window.EvModal.openAprofundamento = function (data) {
6939|        data = data || {};
6940|        var serverCanEditAprofundamento = (data._can_edit_aprofundamento === true || data._can_edit_aprofundamento === false)
6941|            ? data._can_edit_aprofundamento
6942|            : null;
6943|        if (EV_IS_ADMIN_APROFUNDAMENTO && window.OccurrenceModal && typeof window.OccurrenceModal.openEdit === 'function') {
6944|            window.OccurrenceModal.openEdit(data);
6945|            return;
6946|        }
6947|        var EV_GET_URL = '{{ path('ssma_event_get', {id: '__EV_ID__'})|e('js') }}';
6948|
6949|        function openWith(full) {
6950|            full = full || data;
6951|            if (serverCanEditAprofundamento !== null) {
6952|                full._can_edit_aprofundamento = serverCanEditAprofundamento;
6953|            }
6954|            if (Array.isArray(full._user_technical_types) && full._user_technical_types.length) {
6955|                EV_USER_TECHNICAL_TYPES = full._user_technical_types;
6956|            }
6957|            var occType = full.type || full.type_value || '';
6958|            window.__ssmaEvEditingType = occType || window.__ssmaEvEditingType || '';
6959|            window.EvModal.populateForEdit(full);
6960|            evAprofundamentoCanEditFromServer = (full._can_edit_aprofundamento === true || full._can_edit_aprofundamento === false)
6961|                ? full._can_edit_aprofundamento
6962|                : (serverCanEditAprofundamento !== null ? serverCanEditAprofundamento : null);
6963|            evAprofundamentoOnlyMode = true;
6964|            evAprofundamentoFinalizeIntent = true;
6965|            var modalTitle = document.getElementById('ev-modal-title');
6966|            if (modalTitle) modalTitle.textContent = 'Aprofundamento técnico';
6967|            if (typeof evEnsureCorrectiveActionsSeed === 'function') {
6968|                evEnsureCorrectiveActionsSeed();
6969|            }
6970|            if (occType) {
6971|                evEnsureTypeOptionEnabled(occType);
6972|                applyTypeBlock(occType);
6973|            }
6974|            evSetStep('aprofundamento');
6975|            evSetAprofundamentoReadonly(evShouldLockAprofundamentoFields(occType));
6976|            // Sincroniza a UI de descaracterização agora que evAprofundamentoOnlyMode=true.
6977|            // Isso também pré-seleciona "Sim" quando o profissional não pode descaracterizar.
6978|            evSyncDescaracterUi();
6979|            if (typeof window.openOffcanvasmodalEventNew === 'function') {
6980|                window.openOffcanvasmodalEventNew();
6981|            }
6982|        }
6983|
6984|        var eventId = data.id;
6985|        if (!eventId || String(eventId) === 'undefined') {
6986|            openWith(data);
6987|            return;
6988|        }
6989|        fetch(EV_GET_URL.replace('__EV_ID__', encodeURIComponent(String(eventId))), {
6990|            method: 'GET',
6991|            credentials: 'same-origin',
6992|            headers: { 'X-Requested-With': 'XMLHttpRequest' }
6993|        })
6994|            .then(function (res) { return res.json(); })
6995|            .then(function (result) {
6996|                var fullData = (result && result.success && result.event) ? result.event : data;
6997|                openWith(fullData);
6998|            })
6999|            .catch(function () {
7000|                openWith(data);
7001|            });
7002|    };
7003|
7004|    window.EvModal.openCreate = function (opts) {
7005|        opts = opts || {};
7006|        window.__ssmaEvCreateMode = opts.createMode || null;
7007|        window.__ssmaEvEditingType = '';
7008|        evAprofundamentoOnlyMode = false;
7009|        evAprofundamentoCanEditFromServer = null;
7010|        evAprofundamentoFinalizeIntent = true;
7011|        evAprofundamentoFinalized = false;
7012|        // Nova ocorrência sempre começa limpa (não restaura draft de preenchimento anterior).
7013|        var modeEl = document.getElementById('ev_form_mode');
7014|        var idEl = document.getElementById('ev_id');
7015|        if (modeEl) {
Request #27 deepseek-flash P:67.51K C:309 CR:65.66K CW:0 2842ms
Reasoning
So `EvModal.openAprofundamento` sets `evAprofundamentoOnlyMode = true`, and the "+Aprofundamento" button uses this. For admins, it falls back to the full edit. Now for the ROS "visto e resolvido" case (skip aprofundamento): the user with the registrant/manager profile uses `OccurrenceModal.openEdit` (full edit). The modal's primary button: `evCurrentStep === 'general' && !evAprofundamentoOnlyMode` → validates general, then `evRequiresAprofundamento(type)` for ROS with ros_resolved checked → false → doesn't go to the aprofundamento step → submits with no aprofundamento keys. So `$wantsFinalize` false → no resubmission. Stuck in readequação. Wait, but there's a subtlety. Where does the readequação show up in the UI, and how does the user resubmit? Let's check the cypress spec for readequação resubmission to see the intended flow. The modified cypress file `ssmaOccurrenceRegisterEditUi.spec.js` (not in review scope but context). Let's search for 'readequa' in cypress.
Tool Calls (1)
code_search
Show Details
{"search_text": "readequa"}
File: .opencodereview/rules/ssma/occurrence-approve.md
Match lines: 1
25|- Reprovar com ocorrência já em readequação (`STATUS_REJECTED`): 422, sem nova decisão.

File: config/ssma/occurrence_ui_metadata.php
Match lines: 1
66|        'readequacao'              => ['dot' => '#6c757d', 'label' => 'Readequação'],

File: cypress/e2e/ssma/ssmaOccurrenceRegisterEditUi.spec.js
Match lines: 4
3| * e reprovar (readequação) no detalhe.
208|describe('SSMA — Formulário real: reprovar (readequação) no detalhe', () => {
290|    // Readequação: o profissional que fez o aprofundamento altera; Validar some.
296|      expect(hasRenderedElementWithClass(html, 'js-occ-approve-btn'), 'sem Validar em readequação').to.eq(false);

File: cypress/e2e/ssma/ssmaPermissionScenarios.spec.js
Match lines: 2
246|  it('aprovador NÃO vê "Validar" em MATERIAL #148 (já reprovado — em readequação)', () => {
255|      expect(hasRenderedElementWithClass(html, 'js-occ-approve-btn'), 'sem botão Validar enquanto está em readequação').to.eq(false);

File: docs/engineering/pr/feature-ssma-ocorrencia-correcoes-new-production/PR_descricao_feature-ssma-ocorrencia-correcoes-new-production.md
Match lines: 15
5|**Problema de negócio:** após o merge da PR #273 (`feature/ssma-validacao-ocorrencia-new-production`) em `new_production`, persistiam bugs e gaps de UX levantados por Felipe/Brenda: ocorrência em Readequação ainda podia ser validada; reenvio após correção não funcionava ao salvar só o aprofundamento; campos de suspeita/caracterizar apareciam fora do perfil médico; plano de ações na árvore sem exclusão de entrada, toolbar e validador; cards/modais do plano sem status e navegação à origem.
22|- `SsmaController.php` — `maybeSubmitOccurrenceForValidation()`, bloqueio de validação em Readequação/finalizada, endpoint `deleteCauseTreeActionPlanEntry`, helpers de status de ação
23|- `SsmaOccurrenceApprovalService.php` — `decide()` bloqueado quando ocorrência está em readequação
29|- `occurrence_view.html.twig`, `_tab_occurrences.html.twig`, `_modal_event.html.twig` — Readequação, validação, suspeita/caracterizar só para médico (`.is-doctor-aprof`), Reprovar com cor da plataforma
32|- `report.html.twig` — status Readequação
33|- `occurrence_ui_metadata.php` — metadado do status Readequação
46|- `tests/Unit/Product/Ssma/assert_branch_ui_fixes.php` — +6 asserts de validação/Readequação/médico/select duplicado
59|1. Ocorrência em **Readequação** (`approval_status = rejected`) não pode ser aprovada/reprovada até correção e reenvio — bloqueio no service, controller e UI (botão oculto).
60|2. Após Readequação, o reenvio para validação ocorre na **edição completa** e ao **finalizar apenas o aprofundamento**.
76|## 2. Ocorrência — validação e Readequação
77|1. Abrir ocorrência em status **Readequação** → botão de validar não aparece.
78|2. Tentar aprovar via API → mensagem: *"A ocorrência está em readequação. Corrija e reenvie antes de validar de novo."*
83|**Resultado esperado:** fluxo de Readequação trava validação até reenvio; reenvio funciona nos dois caminhos de edição.
113|- `assert_branch_ui_fixes.php` — **6/6 asserts novos desta PR OK** (validação, Readequação, médico, select duplicado). O script reporta 5 falhas **pré-existentes da base** (flash report modal incompleto, gate de edição tenant, hook CI Regra 81) — não introduzidas por esta PR.
174|**Regressão sugerida:** validação pós-#273, listagem com status Readequação, plano de ações na árvore e no hub, cronograma de projetos.

File: docs/ssma/CHAT-BASE-hotfix-permission-ocorrencia.md
Match lines: 1
32|rejected / readequação                → sem Validar (especialista reedita)

File: src/Controller/SsmaController.php
Match lines: 5
3999|                'message' => 'A ocorrência está em readequação. Corrija e reenvie antes de validar de novo.',
12766|        // Readequação: só quem enviou o aprofundamento (admin já passou acima).
12767|        if ($this->isSsmaOccurrenceInReadequacao($details)) {
12799|    private function isSsmaOccurrenceInReadequacao(array $details): bool
27944|        // Readequação só volta à fila ao reenviar o aprofundamento. Editar (gestor/tenant)

File: src/Service/Ssma/SsmaNotificationService.php
Match lines: 2
282|     * Fluxo natural da readequação: avisa só quem enviou o aprofundamento reprovado.
307|            'A ocorrência "%s" foi reprovada na validação e voltou para readequação do aprofundamento.',

File: src/Service/Ssma/SsmaOccurrenceApprovalService.php
Match lines: 4
69|                'message' => 'A ocorrência está em readequação. Corrija e reenvie antes de validar de novo.',
110|            // Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento.
113|                $event->appendHistory('Ocorrência devolvida para readequação após reprovação.', [
126|                : 'Ocorrência reprovada. O registro voltou para readequação.',

File: templates/manager/ssma/report.html.twig
Match lines: 2
939|{% set normalized_status = _is_rejected_occ ? 'readequacao' : status_value|replace({'-': '_'}) %}
942|    {% set stat = status_map['readequacao']|default({ 'label': 'Readequação', 'dot': '#6c757d' }) %}

File: templates/ssma/occurrence/occurrence_view.html.twig
Match lines: 4
502|    ? 'readequacao'
734|               em '' (nunca enviado), 'approved' ou 'rejected'/readequação. Ver
2978|        ? 'readequacao'
3257|                    showToast('A ocorrência está em readequação. Corrija e reenvie antes de validar de novo.', 'Atenção', 'fas fa-exclamation-triangle', 'bg-warning');

File: templates/ssma/occurrence/tabs/_tab_occurrences.html.twig
Match lines: 3
479|                    ? (status_map['readequacao'] ?? { 'label': 'Readequação', 'dot': '#6c757d' })
714|                    ? (status_map['readequacao'] ?? { 'label': 'Readequação', 'dot': '#6c757d' })
1265|            return OCCURRENCE_STATUS_META.readequacao || { label: 'Readequação', dot: '#6c757d' };

File: tests/Service/Goals/GoalCheckInServiceTest.php
Match lines: 1
138|    public function testHealthIsOnTrackWhenProgressAndRecencyAreAdequate(): void

File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 7
10| * Readequação (Brenda): reprovação reabre aprofundamento para quem enviou,
14|final class SsmaOccurrenceReadequacaoAuthorTest extends TestCase
40|    public function testRejectKeepsAuthorAndUsesReadequacaoCopy(): void
43|        self::assertStringContainsString('voltou para readequação', $this->approvalSrc);
44|        self::assertStringContainsString('devolvida para readequação após reprovação', $this->approvalSrc);
54|            'Readequação: reenvia tanto na edição completa',
58|        self::assertStringContainsString('isSsmaOccurrenceInReadequacao', $this->controllerSrc);

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 9
354|    ? ok('Validação: botão oculto em Readequação (rejected)')
355|    : fail('Validação: botão ainda aparece em Readequação');
356|fileContains($approvalService, 'A ocorrência está em readequação')
358|    : fail('Validação: service ainda permite validar Readequação');
359|fileContains($controller, 'A ocorrência está em readequação. Corrija e reenvie antes de validar de novo.')
360|    ? ok('Validação: controller bloqueia approve em Readequação')
361|    : fail('Validação: controller ainda permite approve em Readequação');
373|    && fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')
374|    ? ok('Validação: reenvio após Readequação só ao reenviar aprofundamento')

Request #28 deepseek-flash P:70.12K C:1.31K CR:67.71K CW:0 11576ms
Reasoning
Interesting: the previous PR #273 doc explicitly says "Após Readequação, o reenvio para validação ocorre na edição completa e ao finalizar apenas o aprofundamento". Now this PR intentionally removes the "edição completa" path. So it's a deliberate change, aligned with the requirement. But this raises the question about occurrences where the full edit is the only path (ROS visto e resolvido, or occurrences where the aprofundamento step doesn't exist). Is there any occurrence that can be rejected where the author can't use the aprofundamento-only flow? Cases: - Types that require aprofundamento (ROS, QUASE_ACIDENTE, ACIDENTE_*) — but for these, ROS with "visto e resolvido" skips the aprofundamento step. In such a case, the occurrence got validated without the aprofundamento → rejected → the user must resubmit. `evRequiresAprofundamento` in only-mode: `if (!evAprofundamentoOnlyMode && t === 'ROS' && evIsRosResolvedChecked()) return false;` → in only-mode it returns TRUE (since condition requires !onlyMode). Hmm, so in the aprofundamento-only mode for a ROS-resolved, `evRequiresAprofundamento` returns true. But would the "+Aprofundamento" button even be shown for a ROS-resolved occurrence? `resolveOccurrenceViewAprofundamentoFlags`: `$requires` = type in the list → true; `$pending` etc. Not based on ros_resolved. So the button shows and the user could open the aprofundamento-only modal, which in only-mode (openAprofundamento) sets evAprofundamentoOnlyMode=true, step=aprofundamento, and on submit sends aprofundamento_complete=true, status='finalized'. So the ROS-resolved author could resubmit via the +Aprofundamento button. Hmm, so they have a path — but is it the natural/expected path? For a ROS "visto e resolvido", the user would likely use "Editar". With the change, editing gives no feedback (the edit saves, but the occurrence remains in readequação with no visible error). That's a UX/behavioral regression worth flagging as a potential functional gap: occurrences whose readequação can only be completed through the full edit (no aprofundamento finalize) will remain stuck. Hmm, but hold on. Is there really a case where an occurrence can be rejected without ever having had an aprofundamento finalized? To reach validation (`maybeSubmitOccurrenceForValidation` → markPending), `isEventReadyForOccurrenceValidation` must return true: either ros_resolved (skip gate), or not aprofundamento-pending and status !== 'draft' (for types requiring the finalize gate). So: - ROS with ros_resolved=true → can be validated without aprofundamento finalized → can be rejected → readequação. The author then must find the aprofundamento button to resubmit... but wait, after rejection, `resolveOccurrenceViewAprofundamentoFlags` computes can_edit using the readequação gate: for ros_resolved, the author of the aprofundamento — but there IS no aprofundamento author (never finalized!) → `$hasAuthor` false → falls through to technical types → if the user is a technical specialist of ROS, canAccess true → can_edit = true (draft status). So the +Aprofundamento button is available. In only-mode, `evRequiresAprofundamento` true → the modal shows the aprofundamento step and finalizing sends the keys → wantsFinalize true → resubmit. But: is `isEventReadyForOccurrenceValidation` satisfied? For ROS-resolved it returns true immediately. OK. So resubmission possible, though with an extra aprofundamento step. But there's another important case: what if the user who did NOT finalize the aprofundamento... any occurrence that reached validation without an aprofundamento author. Example: QUASE_ACIDENTE where the aprofundamento was never required? Let's check `ssmaEventAprofundamentoPending` — if all aprofundamento fields are complete (fieldsIncomplete false) and status !== 'draft', the gate passes → can be validated. Hmm, `aprofundamento_status` may be '' (never set, legado) and fields complete → validated. Then rejection → no author stamp → legacy rule. So for those cases where no author was stamped, `$hasAuthor` false → old behavior (technical types can edit) and the resubmit path requires wantsFinalize... For a legacy occurrence validated without the aprofundamento-step flow, the user would need to open the aprofundamento modal and finalize it to resubmit. That is a change of flow but not an impossibility. Hmm, so the "stuck" scenario: it's not impossible to resubmit as long as the +Aprofundamento button is available and the modal sends the finalize keys. Is the +Aprofundamento button shown for a non-author in readequação? Only if the user passes the gate (legacy → technical type → yes). For the author → yes via author check. So the resubmission path exists but requires the user to use the aprofundamento modal instead of "Editar". Whether the UI guides them is a product question; the cypress test at line 290-296 asserts the author can change and Validar disappears. Let me read that cypress section to see the intended resubmit flow (it's in "other changed files" but useful context). Let me read the diff of the cypress spec and the assert_branch_ui_fixes.
Tool Calls (1)
file_read_diff
Show Details
{"path_array": ["cypress/e2e/ssma/ssmaOccurrenceRegisterEditUi.spec.js", "tests/Unit/Product/Ssma/assert_branch_ui_fixes.php"]}
==== FILE: cypress/e2e/ssma/ssmaOccurrenceRegisterEditUi.spec.js ====
diff --git a/cypress/e2e/ssma/ssmaOccurrenceRegisterEditUi.spec.js b/cypress/e2e/ssma/ssmaOccurrenceRegisterEditUi.spec.js
--- a/cypress/e2e/ssma/ssmaOccurrenceRegisterEditUi.spec.js
+++ b/cypress/e2e/ssma/ssmaOccurrenceRegisterEditUi.spec.js
@@ -287,7 +287,7 @@ describe('SSMA — Formulário real: reprovar (readequação) no detalhe', () =>
       expect(body.event.details.aprofundamento_status, 'reprovação reabre o aprofundamento').to.eq('draft');
     });
 
-    // Especialista volta a ver "Finalizar ocorrência" (readequação), não mais "Validar".
+    // Readequação: o profissional que fez o aprofundamento altera; Validar some.
     cy.then(() => {
       loginAs('aprof_material');
       return getSsmaOccurrenceViewHtml(eventId);
==== FILE: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php ====
diff --git a/tests/Unit/Product/Ssma/assert_branch_ui_fixes.php b/tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
--- a/tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
+++ b/tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
@@ -344,6 +344,7 @@ fileContains($controller, "'approver_ids' => \$configIds")
     : fail('Flash report GET approvers: approver_ids mistura automação com config');
 
 $approvalService = $root . '/src/Service/Ssma/SsmaOccurrenceApprovalService.php';
+$notificationService = $root . '/src/Service/Ssma/SsmaNotificationService.php';
 fileContains($occView, '#ssmaOccurrenceApproveModal .js-occ-approve-reject')
     && fileContains($occView, '--company-theme1-800')
     ? ok('Validação: Reprovar usa cor da plataforma')
@@ -367,10 +368,16 @@ fileNotContains($occView, 'Conclua o aprofundamento técnico (finalizado e com t
     && fileNotContains($controller, 'Conclua o aprofundamento técnico (finalizado e com todos os campos obrigatórios) antes de validar a ocorrência.')
     ? ok('Validação: approve não exige aprofundamento no front nem no endpoint')
     : fail('Validação: approve ainda bloqueia por aprofundamento pendente');
-fileContains($controller, 'if ($wasRejectedApproval) {')
-    && fileNotContains($controller, 'if (!$aprofundamentoOnly && $wasRejectedApproval)')
-    ? ok('Validação: reenvio após Readequação também no aprofundamento')
-    : fail('Validação: reenvio ainda só na edição completa');
+fileContains($controller, 'if ($wantsFinalize) {')
+    && fileContains($controller, 'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)')
+    && fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')
+    ? ok('Validação: reenvio após Readequação só ao reenviar aprofundamento')
+    : fail('Validação: edição paralela ainda reenvia a fila');
+fileContains($controller, 'notifyAprofundamentoAuthorOnReject')
+    && fileContains($notificationService, 'notifyAprofundamentoAuthorOnReject')
+    && fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')
+    ? ok('Validação: reprovação avisa quem fez o aprofundamento')
+    : fail('Validação: reprovação ainda avisa o registrante');
 fileContains($modal, 'select.ssma-member-tag-native-select')
     && fileContains($modal, 'form-group:has(> .ssma-member-tag-search-wrap)')
     ? ok('Evento etapa 1: select nativo oculto quando há busca de membro')

Request #29 deepseek-flash P:72.45K C:689 CR:71.42K CW:0 4485ms
Reasoning
The cypress test only checks the view's "Validar" button visibility, and the earlier assertion that the aprofundamento goes back to draft. Note the previous assert: 'Validação: reenvio após Readequação também no aprofundamento' — checking `if ($wasRejectedApproval) {` — wait, `fileNotContains($controller, 'if (!$aprofundamentoOnly && $wasRejectedApproval)')`. So the previous PR had a variant. Now changed. So the PR intentionally narrows resubmission to the aprofundamento path. Given the requirement explicitly states "Editar (gestor/tenant) não reenvia para validação. A fila só reabre ao reenviar o aprofundamento", the removal is intentional. The risk case (occurrence rejected without any aprofundamento finalize — e.g., ROS visto e resolvido) is a real functional gap though: those users have no way to resubmit via "Editar", and they may not have an autor stamp either → they might not even see the +Aprofundamento button... wait, they would if they're technical of the type (legacy fallthrough) — for ROS-resolved ROS: the ROS technical specialist sees the button. Hmm, and would the author (if stamped) see it? Yes. But hold on — actually, is ROS-resolved able to be rejected? Let's check whether "Validar" appears for ROS-resolved occurrences. The approve button visibility in occurrence_view is based on approval status. If the occurrence was submitted for validation (pending), the approver can reject. So yes. I need to decide whether to report this. It's a cross-file, non-local claim, but I've verified: (1) resubmission after rejection now requires `$wantsFinalize`; (2) `$wantsFinalize` requires root `aprofundamento_complete` truthy or `aprofundamento_status === 'finalized'`; (3) the full-edit modal only sends these in aprofundamentoOnly mode; (4) `maybeSubmitOccurrenceForValidation` returns early for rejected status unless `$resubmitted` is true. Therefore, any occurrence rejected that is re-submitted through the full edit path will stay in readequação indefinitely. This is a genuine concern worth flagging, but I should be careful about the claim "the full edit modal never sends aprofundamento_complete". Let me double check whether the modal's full-edit path sends `aprofundamento_complete` elsewhere, e.g., a hidden input `ev_aprofundamento_complete` or in the form serialization. Search for `aprofundamento_complete` in the twig.
Tool Calls (2)
code_search
Show Details
{"file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"], "search_text": "aprofundamento_complete"}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 1
7422|            payload.aprofundamento_complete = !!finalizeAprofundamento;

code_search
Show Details
{"search_text": "aprofundamento_complete"}
File: cypress/e2e/ssma/README.md
Match lines: 1
166|`POST /manager/ssma/events/{id}` com `aprofundamento_only + aprofundamento_complete=true` só seta

File: cypress/e2e/ssma/ssmaOccurrenceFinalizeValidateUi.spec.js
Match lines: 1
42|        aprofundamento_complete: true,

File: cypress/e2e/ssma/ssmaOccurrenceRegisterEditUi.spec.js
Match lines: 1
240|        aprofundamento_complete: true,

File: cypress/e2e/ssma/ssmaPermissionScenarios.spec.js
Match lines: 1
389|        aprofundamento_complete: true,

File: cypress/support/ssmaHub.js
Match lines: 1
215|    aprofundamento_complete: true,

File: docs/engineering/pr/hotfix-ssma-ap-validacao-etapa1-new-production/PR_descricao_hotfix-ssma-ap-validacao-etapa1-new-production.md
Match lines: 2
36|| `SsmaEventValidator.php` | Validação soft na 1ª etapa de acidentes; campos técnicos só com `aprofundamento_complete` ou payload de aprofundamento; mensagens humanizadas |
55|- `SsmaEventValidatorTest.php` — etapa 1 soft, etapa 2 com `aprofundamento_complete`, mensagens humanas

File: docs/ssma/CHAT-BASE-hotfix-permission-ocorrencia.md
Match lines: 1
66|| `details.aprofundamento_complete` | `true` |

File: docs/ssma/e2e-permission-scenarios.md
Match lines: 1
172|  com `aprofundamento_only` + `aprofundamento_complete=true`) → concluir (`POST

File: docs/ssma/engineering/ssma-permissoes-membro-analise-cypress.md
Match lines: 1
445|| Estado aprofundamento | `ssma_events.details` | `aprofundamento_status` (`draft`/`finalized`/`''`), `aprofundamento_complete` |

File: src/Controller/SsmaController.php
Match lines: 15
3618|        // Só 'finalized' trava edição. `aprofundamento_complete` sozinho (save da 1ª etapa / legado)
11924|        return $status === 'finalized' || !empty($details['aprofundamento_complete']);
16728|            'aprofundamento_complete' => (
16730|                || !empty($details['aprofundamento_complete'])
27821|            || !empty($existingDetails['aprofundamento_complete']);
27833|            || !empty($data['aprofundamento_complete'])
27900|        if ($aprofundamentoOnly || !empty($data['aprofundamento_complete']) || array_key_exists('aprofundamento_status', $data)) {
27902|            $wantsFinalize = !empty($data['aprofundamento_complete'])
27905|                $data['aprofundamento_complete'] = true;
27906|                $detailsOut['aprofundamento_complete'] = true;
27910|                $data['aprofundamento_complete'] = false;
27911|                $detailsOut['aprofundamento_complete'] = false;
29945|            'aprofundamento_complete',
29978|        if (array_key_exists('aprofundamento_complete', $data)) {
29979|            $merged['aprofundamento_complete'] = $data['aprofundamento_complete'];

File: src/Service/Ssma/SsmaAutomationService.php
Match lines: 2
2246|        if (!empty($details['aprofundamento_complete'])
2271|        if (!empty($details['aprofundamento_complete'])

File: src/Service/Ssma/SsmaE2ePermissionSeeder.php
Match lines: 2
248|                'aprofundamento_complete' => true,
262|                'aprofundamento_complete' => false,

File: src/Service/Ssma/SsmaEventValidator.php
Match lines: 2
67|        // o front marca aprofundamento_complete (salvou a 2ª etapa).
69|        $forceAccidentDeep = !empty($data['aprofundamento_complete'])

File: src/Service/Ssma/SsmaOccurrenceApprovalService.php
Match lines: 1
109|            $details['aprofundamento_complete'] = false;

File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 1
7422|            payload.aprofundamento_complete = !!finalizeAprofundamento;

File: tests/Unit/Product/Ssma/SsmaEventValidatorTest.php
Match lines: 10
435|            'aprofundamento_complete' => true,
557|            'aprofundamento_complete' => true,
584|            'aprofundamento_complete' => true,
620|            'aprofundamento_complete' => true,
651|            'aprofundamento_complete' => true,
677|            'aprofundamento_complete' => true,
743|            'aprofundamento_complete' => true,
774|            'aprofundamento_complete' => true,
804|            'aprofundamento_complete' => true,
859|            'aprofundamento_complete' => true,

Request #30 deepseek-flash P:74.43K C:708 CR:73.09K CW:0 4356ms
Reasoning
Confirmed: only line 7422 in the modal sets it, inside the `evCurrentStep === 'aprofundamento' && evAprofundamentoOnlyMode` block. Hmm, wait. But consider the case where the modal is opened via `EvModal.openAprofundamento` (only-mode) — that's the "+Aprofundamento" flow. In full-edit mode, the modal also can reach the aprofundamento step (line 7159-7163). So for an occurrence where the user opens the full "Editar" modal and clicks save at the aprofundamento step, the payload has no root aprofundamento keys. Hmm, but then: `payload.corrective_actions = evCollectCorrectiveActions()` at 7415 when evRequiresAprofundamento. So the flat technical fields are sent. Those go into details via normalization. So the aprofundamento data IS saved but its status remains 'draft' (the block at 27900 is skipped, so nothing changes status). Wait — if the block is skipped, nothing sets `aprofundamento_status`; but the fields merged keep whatever was persisted: after rejection, 'draft'. So the occurrence stays draft → not ready for validation → even the old `maybeSubmit(...,true)` would have returned early at `isEventReadyForOccurrenceValidation` (because status draft for types requiring the gate). Hmm! So under the OLD code, a full-edit resubmission for a type requiring the finalize gate would also fail (status draft → not ready). Which means the full-edit path only really worked for cases where the gate passes: ros_resolved, or types not requiring the gate, or when the aprofundamento fields were considered complete (`ssmaEventAprofundamentoPending` false) and status !== 'draft' — but the reject sets status to 'draft'... only for the gate types. And `ssmaEventAprofundamentoPending` — let's check: it might return false when required fields are filled, in which case status 'draft' still blocks via the finalize gate. So under the old code, full-edit resubmission worked only for: (a) ros_resolved (skip gate) and (b) types not in `ssmaEventTypeRequiresAprofundamentoFinalizeGate` list. Since that list is the same 5 types... For all 5 types the gate applies. So under the old code, the full-edit resubmit worked effectively only for ROS-resolved. And now that path is closed → ROS "visto e resolvido" rejected occurrences can no longer be resubmitted through "Editar". Hmm, and note: for ROS-resolved, the author would have to use the +Aprofundamento modal (which requires the aprofundamento; the modal in only-mode forces `evRequiresAprofundamento` true, and finalizing requires the ROS technical fields). For ROS-resolved occurrences the aprofundamento step is unusual but possible. Let's check `ssmaEventAprofundamentoPending` to see whether ROS-resolved returns false.
Tool Calls (1)
code_search
Show Details
{"search_text": "function ssmaEventAprofundamentoPending"}
File: src/Controller/SsmaController.php
Match lines: 1
16751|    private function ssmaEventAprofundamentoPending(\App\Entity\SsmaEvent $e): bool

Request #31 deepseek-flash P:75.19K C:82 CR:75.01K CW:0 1613ms
Tool Calls (1)
file_read
Show Details
{"end_line": 16810, "file_path": "src/Controller/SsmaController.php", "start_line": 16700}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 16700-16810
16700|            'potential_consequence_label' => ($pcq = (string) ($details['potential_consequence'] ?? '')) !== '' && EventConsequenceEnum::isValid($pcq)
16701|                ? EventConsequenceEnum::label($pcq) : '',
16702|            'impacts_display'      => implode(', ', array_filter(array_map(
16703|                static fn (string $imp) => \App\Enum\Ssma\EventImpactEnum::label($imp),
16704|                array_filter(is_array($e->getImpacts()) ? $e->getImpacts() : [], static fn ($v) => is_string($v) && $v !== '')
16705|            ))),
16706|            'event_datetime'       => $e->getEventDatetime()->format('d/m/Y H:i'),
16707|            'had_injury'           => !empty($details['had_injury']),
16708|            'body_parts'           => $this->ssmaEnrichBodyPartsForDisplay($details),
16709|            'body_location_detail' => trim((string) ($details['body_location_detail'] ?? '')),
16710|            'injury_type_label'      => ($it = (string) ($details['injury_type'] ?? '')) !== '' ? InjuryTypeEnum::label($it) : '',
16711|            'injury_severity_label'  => ($is = (string) ($details['injury_severity'] ?? '')) !== '' ? InjurySeverityEnum::label($is) : '',
16712|            'injury_classification'  => (string) ($details['injury_classification'] ?? ''),
16713|            'injury_classification_label' => $this->ssmaReportInjuryClassificationLabel(
16714|                (string) ($details['injury_classification'] ?? '')
16715|            ),
16716|            'work_leave'             => (string) ($details['work_leave'] ?? ''),
16717|            'work_leave_label'       => $this->ssmaReportWorkLeaveLabel($details['work_leave'] ?? ''),
16718|            'occurrence_approval'    => is_array($details['occurrence_approval'] ?? null) ? $details['occurrence_approval'] : [],
16719|            'injured_people_maps'    => $this->ssmaBuildInjuredPeopleMapsForView($details),
16720|            'potential_severity'     => (string) ($details['potential_severity'] ?? ''),
16721|            'deviation_type'         => (string) ($details['deviation_type'] ?? ''),
16722|            'failed_barrier'         => (string) ($details['failed_barrier'] ?? ''),
16723|            'barrier_type'           => (string) ($details['barrier_type'] ?? ''),
16724|            'descaracter_suspect'    => !empty($details['descaracter_suspect']) ? 1 : 0,
16725|            'descaracterizado'       => array_key_exists('descaracterizado', $details) ? (string) $details['descaracterizado'] : '',
16726|            'aprofundamento_pending' => $this->ssmaEventAprofundamentoPending($e) ? 1 : 0,
16727|            'aprofundamento_status' => strtolower(trim((string) ($details['aprofundamento_status'] ?? ''))),
16728|            'aprofundamento_complete' => (
16729|                strtolower(trim((string) ($details['aprofundamento_status'] ?? ''))) === 'finalized'
16730|                || !empty($details['aprofundamento_complete'])
16731|            ) ? 1 : 0,
16732|            'witness_ids'            => $this->ssmaExtractIdListFromDetails($details['witness_ids'] ?? null),
16733|            'created_by_id'          => $e->getCreatedById(),
16734|            'created_by_member_id'   => ($companyForCreator = $e->getCompany()) instanceof Company
16735|                ? $this->resolveSsmaEventRegisteredByMemberId($companyForCreator, $e)
16736|                : ((int) ($details['registered_by_member_id'] ?? 0) ?: null),
16737|            'created_by_name'        => trim((string) ($details['registered_by_name'] ?? '')),
16738|            'area_responsible_id'    => ((int) ($details['area_responsible_id'] ?? 0)) ?: null,
16739|            'area_responsible_name'  => trim((string) ($details['area_responsible_name'] ?? '')),
16740|            'e2e_seed'               => !empty($details['e2e_seed']),
16741|            'e2e_seed_key'           => (string) ($details['e2e_seed_key'] ?? ''),
16742|            'immediate_risk'         => !empty($details['immediate_risk']) ? 1 : 0,
16743|            'ros_resolved'           => !empty($details['ros_resolved']) ? 1 : 0,
16744|        ], $this->ssmaNormalizeHandFootDetailsForDisplay($details));
16745|    }
16746|
16747|    /**
16748|     * Flag do filtro “campos obrigatórios pendentes” no aprofundamento técnico.
16749|     * Usa o validador completo + checklist ROS/QA (soft no create, rígido no filtro).
16750|     */
16751|    private function ssmaEventAprofundamentoPending(\App\Entity\SsmaEvent $e): bool
16752|    {
16753|        $data = $this->eventToValidationArray($e);
16754|        $validator = new \App\Service\Ssma\SsmaEventValidator();
16755|
16756|        return $validator->aprofundamentoPendingErrors($data) !== [];
16757|    }
16758|
16759|    /**
16760|     * Lista de acidentados com mapa corporal para a view (troca por avatar).
16761|     *
16762|     * @param array<string, mixed> $details
16763|     *
16764|     * @return list<array<string, mixed>>
16765|     */
16766|    private function ssmaBuildInjuredPeopleMapsForView(array $details): array
16767|    {
16768|        $raw = $details['injured_person_details'] ?? null;
16769|        $primaryId = (string) ($details['person_id'] ?? '');
16770|        $items = [];
16771|
16772|        if (is_array($raw) && $raw !== []) {
16773|            foreach ($raw as $pid => $card) {
16774|                if (!is_array($card) || empty($card['had_injury'])) {
16775|                    continue;
16776|                }
16777|                $parts = $this->ssmaEnrichBodyPartsForDisplay($card);
16778|                if ($parts === []) {
16779|                    continue;
16780|                }
16781|                $hf = $this->ssmaNormalizeHandFootDetailsForDisplay($card);
16782|                $it = (string) ($card['injury_type'] ?? '');
16783|                $is = (string) ($card['injury_severity'] ?? '');
16784|                $items[] = [
16785|                    'person_id'            => (string) $pid,
16786|                    'body_parts'           => $parts,
16787|                    'body_location_detail' => trim((string) ($card['body_location_detail'] ?? '')),
16788|                    'injury_type_label'    => $it !== '' ? InjuryTypeEnum::label($it) : '',
16789|                    'injury_severity_label' => $is !== '' ? InjurySeverityEnum::label($is) : '',
16790|                    'hand_finger_label'    => (string) ($hf['hand_finger_label'] ?? ''),
16791|                    'foot_toe_label'       => (string) ($hf['foot_toe_label'] ?? ''),
16792|                    'work_leave_label'     => $this->ssmaReportWorkLeaveLabel($card['work_leave'] ?? ''),
16793|                    'cat_type_label'       => $this->ssmaReportCatTypeLabel($card['work_leave'] ?? ''),
16794|                    'attendance_date_label' => $this->ssmaReportAttendanceDateLabel($card['attendance_date'] ?? ''),
16795|                    'breve_relato'         => trim((string) ($card['breve_relato'] ?? '')),
16796|                    'injury_classification_label' => $this->ssmaReportInjuryClassificationLabel(
16797|                        (string) ($card['injury_classification'] ?? '')
16798|                    ),
16799|                ];
16800|            }
16801|        }
16802|
16803|        if ($items === [] && !empty($details['had_injury'])) {
16804|            $parts = $this->ssmaEnrichBodyPartsForDisplay($details);
16805|            if ($parts !== []) {
16806|                $hf = $this->ssmaNormalizeHandFootDetailsForDisplay($details);
16807|                $it = (string) ($details['injury_type'] ?? '');
16808|                $is = (string) ($details['injury_severity'] ?? '');
16809|                $items[] = [
16810|                    'person_id'            => $primaryId,
Request #32 deepseek-flash P:77.49K C:2.13K CR:75.26K CW:0 10823ms
Reasoning
`aprofundamentoPendingErrors` — for ROS-resolved, does it consider required fields? ROS-resolved is "visto e resolvido" so probably the checklist is empty → no errors → pending false. But then `ssmaEventTypeRequiresAprofundamentoFinalizeGate('ROS')` true and status 'draft' → `isEventReadyForOccurrenceValidation` false. Hmm! After rejection, status is forced to 'draft'. So for ROS-resolved, re-validation would ALSO be blocked by the finalize gate → `maybeSubmitOccurrenceForValidation` would return early at 11991 even with resubmitted=true. So under the old code, ROS-resolved readequação resubmission also failed! Unless... hold on. `ssmaEventSkipsAprofundamentoValidationGate` returns true for ROS with `ros_resolved` → `isEventReadyForOccurrenceValidation` returns true at the first check. Yes! Line 11946: if ROS and ros_resolved → return true. So ROS-resolved passes the gate regardless of draft status. So resubmission via full edit worked for ROS-resolved under the old code and is now broken. Hmm, so is the fail real? For ROS-resolved: - After rejection: status 'draft' details, occurrence_approval rejected. - Author (ROS-resolved author... was there an author stamped? The aprofundamento was never finalized for a ROS-resolved occurrence → no stamp. So `$hasAuthor` false → legacy → gate falls through to `resolveCurrentUserTechnicalTypes` → ROS technical specialist can access. So the author (likely the ROS technical specialist) sees the +Aprofundamento button; or canFullEdit (registrant) can edit. - To resubmit they'd need to submit with root aprofundamento_complete=true → only via the aprofundamento-only modal. So the resubmit path exists but only through the +Aprofundamento modal (which for a ROS-resolved occurrence is odd but functional). Hmm, but if the user only has full-edit rights (registrant, not a ROS technical type, not the aprofundamento author)... For ROS-resolved, the registrant may be a plain member; the ROS technical specialist usually fills it in. Hmm — with ros_resolved, the registration is "visto e resolvido" and the occurrence goes straight to validation. The registrant could be anyone (e.g., a Membro). Then the approver rejects. Who can now readequar? - `$hasAuthor` false → legacy fallthrough: `resolveCurrentUserTechnicalTypes` (ROS technical specialist) → if the registrant is not a ROS technical type, they can't access the aprofundamento. - canFullEdit for the registrant: `canEditSsmaEvent` → let's check: for the registrant member with can_create permission, is canFullEdit true? If yes, they can edit and, at the strip step 27886, they're not an aprofundamento author/tech → the aprofundamento fields (which for ROS-resolved are... the payload's flat fields like potential_consequence) get stripped. Then `$wantsFinalize` false → no resubmit. So they edit, get success, but nothing changes about the queue → the occurrence is stuck in Readequação with no way forward for them. Is that a real scenario? ROS resolved → registered by a member (Membro tag, can_create). Approver rejects. Now only ROS technical specialists can readequar. Under the old code, the registrant could resubmit by editing (maybeSubmit with resubmitted=true → ros_resolved → gate passes → markPending). So it's a behavior change: the registrant loses the ability. Was that the intent? The requirement says "quem readequa é quem fez o aprofundamento" — for a ROS-resolved there IS no aprofundamento. So there's a gap for the "no aprofundamento" case. Hmm, this is getting quite deep and speculative regarding the product intent. The reviewer instruction says favor precision over recall and avoid non-local claims without verification. I've verified the code paths reasonably. But is this within the diff's scope? Yes — it's about the changed lines at 27946 (the resubmit condition) which is in the reviewed file. How confident am I? The key claim: after a rejection of a ROS-resolved occurrence, the registrant (full-edit) can no longer resubmit. Before the change, they could. I verified: - Old: `if ($wasRejectedApproval) maybeSubmit(..., true)` — unconditional on the edit type. - New: only when `$wantsFinalize`. - `$wantsFinalize` needs root-level `aprofundamento_complete`/`aprofundamento_status` — only sent by the aprofundamento-only modal. That's solid. So the resubmit capability for occurrences that reached validation without an aprofundamento finalize (ROS visto e resolvido is the concrete case, verified by `ssmaEventSkipsAprofundamentoValidationGate`) is lost for the full-edit path. I think this is worth flagging as a medium/high finding with a suggestion: either keep resubmission for the case where the occurrence has no aprofundamento finalize (`ros_resolved` / non-aprofundamento types) or ensure the UI directs the user to the aprofundamento flow. Actually careful: the "no aprofundamento finalize" case is exactly the legacy case, which they said follows old rules. Hmm, they said old ACCESS rules for legacy. But the resubmit condition is orthogonal. Actually — wait. Let me reconsider. Should I flag it? Risk of false positive: maybe the intent is that only the aprofundamento re-submission reopens the queue, full stop, and ROS-resolved occurrences also go through the aprofundamento UI (the specialist fills the ROS fields). The cypress/docs mention "reenvio funciona nos dois caminhos de edição" before; now only one. The PR requirement states "A fila só reabre ao reenviar o aprofundamento". So the author explicitly wants only one path. For ROS-resolved, the specialist must use the aprofundamento flow — arguably fine since a ROS always has the +/- fields... but a ROS-resolved has no required technical fields; the specialist would still be able to open the aprofundamento modal and finalize. Given the ambiguity, I'd report it as a medium-priority "attention" item asking to confirm the flow for occurrences that reach validation without the aprofundamento finalize (ROS "visto e resolvido"), since the full-edit resubmission was removed. This is a legit review observation, framed carefully. Hmm. Let me weigh: The instructions say "Favor precision over recall: report only defects that are likely real". Is "the user must use the aprofundamento flow instead of Edit" a defect? For ROS-resolved, the occurrence is one where the aprofundamento was never needed. The user would be forced into a flow the product says isn't required. And for the registrant (non-technical) with full-edit rights, it's a dead end if they're not a ROS technical type and not the aprofundamento author. Hmm, but for ROS-resolved, is the registrant likely to be a ROS technical specialist? Possibly. Uncertain. I'll report it as medium with a clear "confirm"/"evaluate" framing rather than asserting a definite bug. Actually, the user's rule for comment format: (1) what changed and practical impact; (2) why it matters; (3) what to do. Fine. Hmm, one more check: does the readequação status appear for the full-edit modal in a way that blocks editing the general data? No. Let me also double check the case of occurrences that are NOT of the 5 aprofundamento types (e.g., a type without an aprofundamento step, like "ABORDAGEM"?). Do other types go through occurrence validation? `maybeSubmitOccurrenceForValidation` is called for events created in the modal; the modal supports types like ABORDAGEM, DIREITO_RECUSA, INSPECAO? The validator allowed_type_keys come from config. For types not in the 5, `ssmaEventTypeRequiresAprofundamentoFinalizeGate` is false and `ssmaEventAprofundamentoPending`... `aprofundamentoPendingErrors` might return errors for non-aprofundamento types? Unclear. If a non-aprofundamento type reaches validation and is rejected, the readequação resubmission would also be impossible now (no aprofundamento step in the modal at all → the user can never send `aprofundamento_complete`). That's a stronger case: for types without the aprofundamento step, `evAprofundamentoOnlyMode` modal isn't offered (+Aprofundamento button only for the 5 types — `$requires` check at 3599) → the user has NO way to resubmit. Let me verify whether non-aprofundamento types can be submitted for validation. Which types exist? Let's check config allowed type keys.
Tool Calls (2)
code_search
Show Details
{"file_patterns": ["config/ssma/*"], "search_text": "type_keys|allowed_types|'QUASE_ACIDENTE'", "use_perl_regexp": true}
File: config/ssma/occurrence_ui_metadata.php
Match lines: 1
17|        'QUASE_ACIDENTE'      => 'Quase Acidente',

code_search
Show Details
{"file_patterns": ["templates/ssma/occurrence/tabs/_tab_occurrences.html.twig"], "search_text": "openAprofundamento"}
No matches found
Request #33 deepseek-flash P:79.68K C:43 CR:79.49K CW:0 1617ms
Tool Calls (1)
code_search
Show Details
{"search_text": "openAprofundamento"}
File: .opencodereview/rules/ssma/occurrence-ros-aprofundamento-readonly.md
Match lines: 3
7|- `templates/ssma/occurrence/partials/_modal_event.html.twig` — `EvModal.openAprofundamento`, `evAprofundamentoCanEditFromServer`, `evCanEditAprofundamento`, `evSetAprofundamentoReadonly`
33|- Ao abrir o modal, `data._can_edit_aprofundamento` deve refletir `can_aprofundamento.can_edit` do Twig **antes** de `EvModal.openAprofundamento(data)`
36|- `openAprofundamento` preserva `_can_edit_aprofundamento` após fetch `ssma_event_get` (não perder no `openWith`)

File: docs/engineering/pr/hotfix-ssma-occ-type-perms-datatables-new-production/PR_descricao_hotfix-ssma-occ-type-perms-datatables-new-production.md
Match lines: 1
93|| `templates/ssma/occurrence/partials/_modal_event.html.twig` | `EvModal.openAprofundamento`, botões rascunho/finalizar, payload `aprofundamento_only` |

File: templates/ssma/occurrence/occurrence_view.html.twig
Match lines: 2
1522|        if (window.EvModal && typeof window.EvModal.openAprofundamento === 'function') {
1523|            window.EvModal.openAprofundamento(data);

File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 1
6938|    window.EvModal.openAprofundamento = function (data) {

Request #34 deepseek-flash P:80.13K C:89 CR:79.62K CW:0 1394ms
Tool Calls (1)
file_read
Show Details
{"end_line": 1540, "file_path": "templates/ssma/occurrence/occurrence_view.html.twig", "start_line": 1490}
File: templates/ssma/occurrence/occurrence_view.html.twig (Total lines: 3394)
IS_TRUNCATED: false
LINE_RANGE: 1490-1540
1490|        } catch (ignore) {}
1491|        // Abre o offcanvas de edição na própria página da ocorrência.
1492|        if (data && typeof window.ssmaOpenOccurrenceEditWithSpinner === 'function') {
1493|            window.ssmaOpenOccurrenceEditWithSpinner($btn, data);
1494|            return;
1495|        }
1496|        if (data && window.OccurrenceModal && typeof window.OccurrenceModal.openEdit === 'function') {
1497|            window.OccurrenceModal.openEdit(data);
1498|            return;
1499|        }
1500|        // Fallback: se o offcanvas não estiver disponível, mantém o fluxo antigo (lista).
1501|        try {
1502|            sessionStorage.setItem('ssma_pending_occurrence_edit', raw);
1503|        } catch (ignore2) {}
1504|        window.location.href = ssmaOccurrenceIndexUrl;
1505|    });
1506|
1507|    $(document).on('click', '.js-occ-view-aprofundamento-btn', function (e) {
1508|        e.preventDefault();
1509|        var raw = $(this).attr('data-occurrence') || '';
1510|        if (!raw) {
1511|            return;
1512|        }
1513|        var data = null;
1514|        try {
1515|            data = JSON.parse(raw);
1516|        } catch (ignore) {}
1517|        if (!data || !data.id) {
1518|            return;
1519|        }
1520|        data._can_edit_aprofundamento = {{ can_aprof.can_edit|default(false) ? 'true' : 'false' }};
1521|        data.type = data.type || data.type_value || '';
1522|        if (window.EvModal && typeof window.EvModal.openAprofundamento === 'function') {
1523|            window.EvModal.openAprofundamento(data);
1524|            return;
1525|        }
1526|        if (typeof showToast === 'function') {
1527|            showToast('Não foi possível abrir o aprofundamento. Atualize a página.', 'Atenção', 'fas fa-exclamation-triangle', 'bg-warning');
1528|        }
1529|    });
1530|
1531|    function ssmaOccViewCauseTreeViewUrl(treeId) {
1532|        var base = String(ssmaCauseTreeViewPath || '');
1533|        var sep = base.indexOf('?') >= 0 ? '&' : '?';
1534|        return base + sep + 'treeId=' + encodeURIComponent(String(treeId));
1535|    }
1536|
1537|    function ssmaOccViewResolveCauseNavigateUrl(res) {
1538|        if (!res || !res.tree) {
1539|            return null;
1540|        }
Request #35 deepseek-flash P:80.94K C:445 CR:80.13K CW:0 3181ms
Reasoning
OK. So the "+Aprofundamento" button exists for the 5 types only (`$requires` check). For occurrence types outside those 5, is there validation? Let's check whether other types (e.g., ABORDAGEM, INSPECAO, DIREITO_RECUSA) go through `maybeSubmitOccurrenceForValidation`. In the create path, `maybeSubmitOccurrenceForValidation` is called for all created events (line 27715) inside the loop... Let's check if it's conditioned by type. Line 27715 is inside a block; earlier there's an `if ($this->ssmaEventAprofundamentoPending($event))` for notify. It seems unconditional. So any type could be submitted if `isEventReadyForOccurrenceValidation` returns true: for non-5 types, `ssmaEventAprofundamentoPending` — `aprofundamentoPendingErrors` might return [] for types without aprofundamento → ready → markPending (if status not finalized; via line 12015) → the approver can validate → reject → readequação → and the user cannot resubmit because no UI path sends aprofundamento_complete and there's no +Aprofundamento button for those types. That's a potentially serious "stuck occurrence" regression, IF non-aprofundamento types are used with the occurrence validation flow. Let's check which types can be created. Look at EV_APROFUNDAMENTO_TYPE_KEYS and the type list in config.
Tool Calls (2)
code_search
Show Details
{"search_text": "EV_APROFUNDAMENTO_TYPE_KEYS"}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 2
1634|    var EV_APROFUNDAMENTO_TYPE_KEYS = ['ROS', 'QUASE_ACIDENTE', 'ACIDENTE_PESSOAL', 'ACIDENTE_MATERIAL', 'ACIDENTE_AMBIENTAL'];
1665|        return EV_APROFUNDAMENTO_TYPE_KEYS.indexOf(type || evSelectedType()) !== -1;

file_read
Show Details
{"file_path": "config/ssma/occurrence_ui_metadata.php", "start_line": 1, "end_line": 80}
File: config/ssma/occurrence_ui_metadata.php (Total lines: 95)
IS_TRUNCATED: false
LINE_RANGE: 1-80
1|<?php
2|
3|declare(strict_types=1);
4|
5|/**
6| * Textos de UI de ocorrências SSMA — fonte única UTF-8.
7| *
8| * @return array<string, mixed>
9| */
10|return [
11|    'type_labels' => [
12|        'acidente'            => 'Acidente',
13|        'quase-acidente'      => 'Quase Acidente',
14|        'condicao-insegura'   => 'Condição Insegura',
15|        'desvio-procedimento' => 'Desvio de procedimento',
16|        'ROS'                 => 'ROS',
17|        'QUASE_ACIDENTE'      => 'Quase Acidente',
18|        'ACIDENTE_PESSOAL'    => 'Acidente Pessoal',
19|        'ACIDENTE_MATERIAL'   => 'Acidente Material',
20|        'ACIDENTE_AMBIENTAL'  => 'Acidente Ambiental',
21|    ],
22|    'nature_labels' => [
23|        'relacionamento' => 'Relacionamento',
24|        'processo'       => 'Processo',
25|        'seguranca'      => 'Segurança',
26|        'infraestrutura' => 'Infraestrutura',
27|        'comportamento'  => 'Comportamento',
28|        'ambiental'      => 'Ambiental',
29|    ],
30|    'approach_labels' => [
31|        'correcao-operacional' => 'Correção Operacional',
32|        'mediacao'             => 'Mediação',
33|        'treinamento'          => 'Treinamento',
34|        'melhoria-processo'    => 'Melhoria de processo',
35|        'acao-disciplinar'     => 'Ação disciplinar',
36|        'CORRECAO_OPERACIONAL' => 'Correção Operacional',
37|        'MEDIACAO'             => 'Mediação',
38|        'TREINAMENTO'          => 'Treinamento',
39|        'MELHORIA_PROCESSO'    => 'Melhoria de processo',
40|        'ACAO_DISCIPLINAR'     => 'Ação disciplinar',
41|    ],
42|    'location_labels' => [
43|        'almoxarifado' => 'Almoxarifado',
44|        'producao'     => 'Produção',
45|        'escritorio'   => 'Escritório',
46|    ],
47|    'severity_map' => [
48|        'critica'  => ['label' => 'Crítica',  'dot' => '#dc3545', 'bg_light' => 'rgba(220,53,69,0.10)'],
49|        'grave'    => ['label' => 'Grave',    'dot' => '#F5A623', 'bg_light' => 'rgba(245,166,35,0.10)'],
50|        'moderada' => ['label' => 'Moderada', 'dot' => '#17a2b8', 'bg_light' => 'rgba(23,162,184,0.10)'],
51|        'leve'     => ['label' => 'Leve',     'dot' => '#6c757d', 'bg_light' => 'rgba(108,117,125,0.10)'],
52|    ],
53|    /** Gravidade na listagem — mesma escala do registro (PotentialSeverityEnum). */
54|    'occurrence_gravity_map' => [
55|        'Leve'   => ['label' => 'Leve',   'dot' => '#25AD52', 'bg_light' => 'rgba(37,173,82,0.12)'],
56|        'Baixo'  => ['label' => 'Baixo',  'dot' => '#129936', 'bg_light' => 'rgba(18,153,54,0.12)'],
57|        'Médio'  => ['label' => 'Médio',  'dot' => '#FFC107', 'bg_light' => 'rgba(255,193,7,0.15)'],
58|        'Moderado' => ['label' => 'Moderado', 'dot' => '#FFC107', 'bg_light' => 'rgba(255,193,7,0.15)'],
59|        'Alto'   => ['label' => 'Alto',   'dot' => '#F5A623', 'bg_light' => 'rgba(245,166,35,0.12)'],
60|        'Severo' => ['label' => 'Severo', 'dot' => '#dc3545', 'bg_light' => 'rgba(220,53,69,0.10)'],
61|        'Crítico' => ['label' => 'Crítico', 'dot' => '#dc3545', 'bg_light' => 'rgba(220,53,69,0.10)'],
62|    ],
63|    'status_map' => [
64|        'registrada'               => ['dot' => '#6f42c1', 'label' => 'Registrada'],
65|        'rascunho'                 => ['dot' => '#ED6C02', 'label' => 'Rascunho'],
66|        'readequacao'              => ['dot' => '#6c757d', 'label' => 'Readequação'],
67|        'nova'                     => ['dot' => '#49B8C8', 'label' => 'Nova'],
68|        'em_investigacao'          => ['dot' => '#186073', 'label' => 'Em Investigação'],
69|        'investigada'              => ['dot' => '#186073', 'label' => 'Em Investigação'],
70|        'aguard_validacao_medica'  => ['dot' => '#2A9DAD', 'label' => 'Aguard. Validação Médica'],
71|        'aguard_validacao_tecnica' => ['dot' => '#0D616E', 'label' => 'Aguard. Validação Técnica'],
72|        'finalizada'               => ['dot' => '#0D4A57', 'label' => 'Finalizada'],
73|        'resolvida'                => ['dot' => '#0D4A57', 'label' => 'Finalizada'],
74|        'nao_resolvida'            => ['dot' => '#ffc107', 'label' => 'Atrasada'],
75|    ],
76|    'severity_guidance' => [
77|        'leve' => [
78|            'dot'  => '#28a745',
79|            'text' => 'Esta ocorrência possui baixo impacto identificado. Recomenda-se registro e monitoramento, com ações preventivas simples, se necessário.',
80|        ],
Request #36 deepseek-flash P:83.14K C:1.36K CR:81.28K CW:0 6919ms
Reasoning
The event type keys for ssma_events are the 5 (ROS, QUASE_ACIDENTE, ACIDENTE_*). Other labels ('acidente', 'quase-acidente', 'condicao-insegura', 'desvio-procedimento') are legacy/etc. So essentially the occurrence validation flow covers the 5 types that all have an aprofundamento step. So the "no aprofundamento UI path" case reduces to ROS with "visto e resolvido" (skip 2nd stage) — where the author can still use the aprofundamento modal (since evRequiresAprofundamento returns true in only-mode and the button is shown for ROS). Hmm, but wait — for ROS-resolved the '+Aprofundamento' button: is it shown? Let's check the condition in occurrence_view for the button rendering: uses `can_aprof.show`. And `show => $canAccess` which for ROS-resolved: readequação check first (no author → fallthrough) → technical type → if the current user is a ROS technical specialist → true. So for the ROS specialist, yes. Now, is `evRequiresAprofundamento` true in only-mode for ROS-resolved? Yes, because the condition requires `!evAprofundamentoOnlyMode`. But then, in `openAprofundamento`, `evSetAprofundamentoReadonly(evShouldLockAprofundamentoFields(occType))`. If the fields are locked (readonly), can they finalize? evShouldLockAprofundamentoFields probably checks `evAprofundamentoCanEditFromServer === false`. For the author/tech it'd be true → editable. Fine. So for ROS-resolved, the resubmission requires opening the aprofundamento modal. It's a UX degradation, not necessarily a hard dead end. Given the ambiguity, should I report? Let me think about what a senior reviewer would say. The PR intentionally forces readequação through the aprofundamento flow. The plan explicitly wants that. The ROS-resolved case is an edge that the author didn't consider; the consequence is that the specialist must open "+Aprofundamento" to resubmit; if the user is the registrant without the ROS technical tag, they may lose the ability to resubmit (they can't even open the aprofundamento modal because `show` false). Hmm, but they could edit general data (canFullEdit) — no resubmission. Also, in readequação, would the registrant even be able to resubmit? Under the new rules, only the author + admin/tenant can. For a ROS-resolved there is no author → the "legacy" fallback grants the technical type, but NOT the registrant (unless also a technical type). So the registrant definitely loses the resubmission ability for ROS-resolved rejections. I think this is a legitimate finding to raise as medium ("attention: confirm the flow for ROS visto e resolvido / occurrences without aprofundamento, since the resubmission now requires the root-level aprofundamento_complete"). I'll phrase it as a risk/question. Hmm, but the review plan already lists 7 issues; my job is to produce comments. I should include the ones I can validate and drop the false ones. Let me decide my final comment list: 1. (drop) notify() with int memberId — false; notify supports both. 2. (drop) detailsOut persistence — verified persisted. 3. Legacy without author → no notification — intentional per the test (asserts getCreatedById absent). Drop? Hmm. The test explicitly asserts the absence of getCreatedById in the method, meaning intentional. I'll drop it to avoid a false positive... but the reviewer's checklist item #2 says lost side effects matter unless explicitly justified. It IS explicitly justified in the PR description ("não o registrante"). Drop. 4. (drop) 'rejected' literal matches the constant. But maybe mention duplication of the domain check (low maintainability)? The user rules prioritize duplicated domain logic. The new helper `isSsmaOccurrenceInReadequacao` reimplements the state check with a raw string instead of using the service's constant/getState. Note the controller elsewhere uses `SsmaOccurrenceApprovalService::STATUS_REJECTED` (lines 11998, 12011, 27940, 3996). So the new code introduces an inconsistency with the established pattern in the same file. That's a legit small maintainability comment (low). I'll include it as low severity. 5. (drop) rename residuals — verified none. 6. (drop) 4th arg semantics — correct. 7. (drop) precedence divergence — weak. So the only remaining potential issue is the resubmission gap. Let me be rigorous once more about whether a rejected occurrence can be re-submitted some other way that I've missed. `maybeSubmitOccurrenceForValidation` callers: 3484, 8382, 8528, 9966, 10056, 27715, 27947, 30277. Let's examine 9966 and 10056 and 30277 and 3484, 8382, 8528 — maybe there's an endpoint for "reenviar validação" that passes resubmitted. Let's check them.
Tool Calls (3)
file_read
Show Details
{"end_line": 9975, "file_path": "src/Controller/SsmaController.php", "start_line": 9950}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 9950-9975
9950|                $action->setClosingEvidence($evidence);
9951|            }
9952|
9953|            // Reavaliar (admin): fecha direto. Resolver (qualquer perfil): envia para validação.
9954|            if ($operation === 'evaluate' && $isTenant) {
9955|                if ($rating) {
9956|                    $action->setResolutionRating($rating);
9957|                }
9958|                $action->setSolved(true);
9959|                $action->setValidationStatus(null);
9960|                $this->entityManager->flush();
9961|                $parentFinalized = $this->ssmaOccurrenceAutoFinalizeService->tryFinalizeParentWhenAllActionsClosed($action);
9962|                if ($parentFinalized) {
9963|                    $event = $action->getEvent();
9964|                    $company = $user->getCompany();
9965|                    if ($event instanceof \App\Entity\SsmaEvent && $company instanceof Company) {
9966|                        $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
9967|                    }
9968|                }
9969|
9970|                return new JsonResponse([
9971|                    'success' => true,
9972|                    'message' => 'Ação reavaliada com sucesso.',
9973|                    'solved'  => true,
9974|                    'parent_occurrence_finalized' => $parentFinalized,
9975|                ]);
file_read
Show Details
{"end_line": 10060, "file_path": "src/Controller/SsmaController.php", "start_line": 10040}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 10040-10060
10040|                    'message' => 'Informe o responsável pela validação antes de enviar o fechamento.',
10041|                ], 422);
10042|            }
10043|
10044|            // Sem validador: fecha direto (somente reavaliação admin / legado)
10045|            if ($rating) {
10046|                $action->setResolutionRating($rating);
10047|            }
10048|            $action->setSolved(true);
10049|            $action->setValidationStatus(null);
10050|            $this->entityManager->flush();
10051|            $parentFinalized = $this->ssmaOccurrenceAutoFinalizeService->tryFinalizeParentWhenAllActionsClosed($action);
10052|            if ($parentFinalized) {
10053|                $event = $action->getEvent();
10054|                $company = $user->getCompany();
10055|                if ($event instanceof SsmaEvent && $company instanceof Company) {
10056|                    $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
10057|                }
10058|            }
10059|
10060|            return new JsonResponse([
file_read
Show Details
{"end_line": 30285, "file_path": "src/Controller/SsmaController.php", "start_line": 30260}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 30260-30285
30260|                $approvalNote = trim((string) ($data['approval_note'] ?? ''));
30261|                $rating = !empty($data['rating']) ? (string) $data['rating'] : null;
30262|                if (!$rating) {
30263|                    return new JsonResponse([
30264|                        'success' => false,
30265|                        'message' => 'Informe a satisfação/efetividade da ação antes de aprovar.',
30266|                    ], 422);
30267|                }
30268|                $this->ssmaActionValidationService->approve(
30269|                    $action,
30270|                    $user,
30271|                    $approvalNote !== '' ? $approvalNote : null,
30272|                    $rating
30273|                );
30274|                $event = $action->getEvent();
30275|                $company = $user->getCompany();
30276|                if ($event instanceof SsmaEvent && $company instanceof Company) {
30277|                    $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
30278|                }
30279|                return new JsonResponse([
30280|                    'success'           => true,
30281|                    'message'           => 'Fechamento aprovado. Ação encerrada com sucesso.',
30282|                    'validation_status' => 'approved',
30283|                    'solved'            => true,
30284|                ]);
30285|            }
Request #37 deepseek-flash P:85.57K C:319 CR:84.48K CW:0 3059ms
Reasoning
These other callers (action plan flows) call with `$resubmitted=false`. Note: with `$resubmitted=false` and status REJECTED → they return early (no resubmission). Under the old code the same — they always passed false except the 27947 site. So they were never able to resubmit a rejected occurrence either. Fine. So my analysis holds: the only resubmission path is `$wantsFinalize`. Now, does this create a hard dead-end for ROS-resolved? Let me verify the ROS-resolved status handling: after rejection, `aprofundamento_status` = 'draft' (set by decide). For a ROS-resolved occurrence, `resolveOccurrenceViewAprofundamentoFlags`: `$finalized` false, `$pending` = !finalized && (fieldsIncomplete || status === 'draft') → true. `canAccess` for a ROS technical type = true → can_edit = true. So the +Aprofundamento button shows for ROS technical types. For the registrant without the ROS tech type → canAccess false → `show` false → no button. But can the registrant do a full edit? `canEditSsmaEvent` — let's check whether that includes the registrant. Let me check quickly.
Tool Calls (1)
code_search
Show Details
{"search_text": "function canEditSsmaEvent"}
File: src/Controller/SsmaController.php
Match lines: 1
13362|    private function canEditSsmaEvent(Company $company, User $user, \App\Entity\SsmaEvent $event): bool

Request #38 deepseek-flash P:85.95K C:82 CR:85.76K CW:0 1893ms
Tool Calls (1)
file_read
Show Details
{"end_line": 13420, "file_path": "src/Controller/SsmaController.php", "start_line": 13362}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 13362-13420
13362|    private function canEditSsmaEvent(Company $company, User $user, \App\Entity\SsmaEvent $event): bool
13363|    {
13364|        return $this->canEditSsmaOccurrenceDetail()
13365|            || $this->canManageSsmaOccurrences()
13366|            || $this->isSsmaAprofundamentoAdmin($company, $user);
13367|    }
13368|
13369|    /**
13370|     * @return array{name: string, role: string}
13371|     */
13372|    private function resolveSsmaEditorMeta(Company $company, User $user): array
13373|    {
13374|        $name = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
13375|        if ($name === '') {
13376|            $name = (string) ($user->getEmail() ?? 'Sistema');
13377|        }
13378|
13379|        if (
13380|            $this->isGranted('ROLE_SUPER_ADMIN')
13381|            || $this->isGranted('ROLE_MANAGER')
13382|            || $this->isGranted('ROLE_MANAGER_GESTOR')
13383|        ) {
13384|            return ['name' => $name, 'role' => 'Administrador'];
13385|        }
13386|
13387|        if ($this->resolveCurrentUserTechnicalTypes($company, $user) !== []) {
13388|            return ['name' => $name, 'role' => 'Profissional'];
13389|        }
13390|
13391|        return ['name' => $name, 'role' => 'Colaborador'];
13392|    }
13393|
13394|    /**
13395|     * @param array{
13396|     *     occurrence_id?: int,
13397|     *     occurrence_kind?: string|null,
13398|     *     module?: 'full'|'occurrence'|'prevention'
13399|     * }|null $detailScope
13400|     *     - occurrence_id: carrega só a ocorrência/evento do detalhe (evita 504/500 em viewOccurrence).
13401|     *     - module=occurrence: hub Ocorrências — sem abordagens/metas de prevenção (evita 504 na listagem).
13402|     *     - module=prevention: hub Prevenção — carrega inspeções/abordagens/metas.
13403|     *     - omitido/full: comportamento completo (plano de ação, autorizações, etc.).
13404|     */
13405|    private function buildSsmaViewData(?array $detailScope = null): array
13406|    {
13407|        $scope = SsmaViewDataScope::fromDetailScope($detailScope);
13408|        $buildStartedAt = microtime(true);
13409|
13410|        /** @var User|null $user */
13411|        $user = $this->getUser();
13412|        $role = 'user';
13413|        $roles = $user ? $user->getRoles() : [];
13414|
13415|        if (in_array('ROLE_SUPER_ADMIN', $roles, true)) {
13416|            $role = 'superAdmin';
13417|        } elseif (in_array('ROLE_MANAGER', $roles, true)) {
13418|            $role = 'manager';
13419|        }
13420|
Request #39 deepseek-flash P:86.88K C:4.08K CR:86.02K CW:0 18547ms
Reasoning
`canEditSsmaEvent` doesn't consider the registrant explicitly; `canManageSsmaOccurrences` covers manager/gestor/admin/tenant and ROLE_MANAGER. `canEditSsmaOccurrenceDetail` — let's check quickly whether plain members with can_edit can edit details (this determines whether a plain member can perform a full edit). Actually, this level of detail isn't necessary. The core finding stands: after the change, resubmission requires the aprofundamento-finalize payload; if the occurrence has no viable aprofundamento finalize path for the person (ROS visto e resolvido, no author, non-technical user), it stays stuck. Hmm, but I realize I should double check: is ROS-resolved really able to get rejected? The validation gate: `maybeSubmitOccurrenceForValidation` on create for ROS-resolved → `isEventReadyForOccurrenceValidation` → ros_resolved → true → status '' ≠ pending/rejected → markPending(false) + notify approvers. Yes! The ROS-resolved occurrence goes to the approver immediately on create. The approver can reject it. Then readequação. Then the ROS specialist must open +Aprofundamento and finalize (which requires the technical fields, maybe empty for visto e resolvido → validator soft?) — in only-mode, `soft_aprofundamento` is only true when `$aprofundamentoOnly && !$wantsFinalize`. If they're finalizing, soft is false → the validator requires the full technical fields → for a ROS-resolved occurrence, would validation pass with the technical fields empty? `SsmaEventValidator::validate` with forceAccidentDeep etc. For ROS, required fields... If the fields are empty and the ROS was "visto e resolvido", finalizing might produce 422 errors → they can't finalize → stuck forever. Hmm, hm. That's deeper speculation. Given the uncertainty, I'll raise it as a medium-severity "confirm" finding focused on the concrete broken path: occurrences that went to validation without an aprofundamento finalize (ROS "visto e resolvido") lose the resubmission path via edit, since `$wantsFinalize` will always be false for them. Actually hold on. Let me reconsider whether to raise this at all, since the plan (review plan) didn't mention it, and my confidence about the product intent is limited. But the review plan isn't authoritative; my job is to find real issues. I'm fairly confident about the mechanism. I'll report it as medium and frame it as "confirm/verify", clearly explaining the mechanism and recommending that the resubmission condition also cover occurrences that never had an aprofundamento finalized (e.g., `ros_resolved`), or that the UI be adapted. Hmm, wait. Let me re-examine one thing: for a ROS-resolved occurrence that was rejected, would `$wantsFinalize` maybe be true because `$data['aprofundamento_complete']` is empty and `array_key_exists('aprofundamento_status', $data)`... no, `$wantsFinalize` requires `!empty($data['aprofundamento_complete'])` OR status 'finalized'. Both absent. So false. Confirmed. Now, another thought on this: maybe the intent is that ROS-resolved shouldn't reach validation at all? No, it does (skip gate). OK. Also, what about the case where the author is the one editing but through the full modal (the +Editar button in the list for the registrant)? That's the same as above. Let me write the comment. I'll place it on the changed lines 27946-27947 (the `if ($wantsFinalize) {`). Also, decide on the `isSsmaOccurrenceInReadequacao` literal comment. I'll include it as low maintainability, noting the existing constant/`getState()` usage in the same file. Actually, per the user rules "Valor de domínio com conjunto fechado (status...) é normalizado e validado na entrada, nunca comparado como string bruta" and "não recalcular a mesma regra de domínio com critério diferente" — here it's the same criterion but the string is duplicated. I'll make it low severity, one sentence. Hmm, is that comment worth making? It could be noise. The guidance says "Avoid commenting on correct code". The string matches the constant; if the constant changes, the check breaks silently (e.g., a future rename would leave the readequação gate dead → any specialist could edit, a permission leak). That's a decent justification. I'll include it as low. Now let me double check one more time the note that the notification breaks isolation: `notify()` without `$restrictToCompany`. Other callers of `notify` with member ids also omit it (e.g., notifyMetaAbonoApprovers, notifyActionValidators). So it's the norm. Skip. One more possible issue to verify in the diff: in the service, when `$memberId > 0` the code does NOT verify the member belongs to the event's company, whereas the same service's other helper `resolveMemberUser($memberId, $restrictToCompany)` supports it. But the notify() call doesn't pass the company. Since it's the norm, skip. Let me also double check whether `notifyAprofundamentoAuthorOnReject` should have kept a name/`$note` etc. Fine. Let me also verify `SsmaNotificationService` has `eventTitle`/`occurrenceViewUrl` — pre-existing. Now the second reviewed file: SsmaOccurrenceApprovalService — the diff only changes a comment and a message string, plus explicitly keeps the author fields. Is there a problem that the author fields aren't preserved when the occurrence is *approved*? Not needed. What about `decide()` on rejection: it sets `aprofundamento_status = 'draft'` and `complete = false` but keeps the author. Fine. One question: when rejected, the author fields stay, so on the next finalize the author is re-stamped. Fine. Hmm, one thing in the approval service: `$details['aprofundamento_status'] = 'draft'` etc. after `$event->setDetails($details)` at line 103 then again at 111 — redundant but existing. Is there an issue that on rejection, the *author* fields remain but `aprofundamento_status` = draft, so `resolveOccurrenceViewAprofundamentoFlags` shows can_edit for the author. Good. So the approval service has no issues. I'll skip commenting there (only textual changes). Wait — one more: the message changed from "voltou para rascunho para correção" to "voltou para readequação" — fine. Now let me reconsider issue #3 (legacy notification) once more before finalizing. The test asserts `assertStringNotContainsString('getCreatedById()', extractMethod(...))` — this shows the intent not to notify the registrant. So skip. But should I still mention that legacy occurrences (those rejected before the deploy, without the author fields) won't be notified? The test's exclusion of getCreatedById suggests the author deliberately doesn't want the registrant notified. I'll skip to respect precision. Hmm, but careful — one more consideration regarding notifications: `notify()` skips recipients equal to the sender. If the author == sender (the approver is the author, possible?), no notification. Fine. Let me also double check the `$memberId` path: `$recipients[] = $memberId;` and the docblock of notify says `list<int|User>`. Fine. Final decision: 2 comments: 1. Medium: resubmission now requires the root aprofundamento finalize payload; occurrences that reached validation without an aprofundamento finalize (ROS "visto e resolvido") can no longer be resubmitted via edit → stuck in Readequação. Suggest covering these or confirming the flow with the UI. 2. Low: `isSsmaOccurrenceInReadequacao` compares the raw literal 'rejected' instead of the service constant/getState — risk of silently disabling the permission gate if the status changes. Should I also add anything about `stampSsmaAprofundamentoAuthor` overwriting the author when an admin finalizes? The rule "quem readequa é quem fez o aprofundamento" — if an admin re-finalizes (e.g., the admin edits the aprofundamento after the specialist), the author becomes the admin, and then the specialist loses the ability to readequar. Is that a bug? Consider: a specialist finalizes the aprofundamento; the occurrence goes to validation; the admin edits the aprofundamento (allowed in the draft state... no, after finalize only admins can edit) and re-finalizes → the author becomes the admin. Then a rejection notifies the admin, and the specialist can't readequar. Hmm. That's a consequence of the "author = last finalizer" rule. The requirement says "quem readequa é quem enviou o aprofundamento" — the last one to send it is arguably correct. Also `_name` is stored, suggesting the "last finalizer" is the intent. I'd skip or mention as low. Skip. Actually, let me reconsider one more potential issue with the stamp location: it stamps at line 27908 inside the `$wantsFinalize` branch — good. But note that this branch is reached also when an admin/gestor does a full edit where `$data['aprofundamento_complete']` is truthy (e.g., the legacy existing details had `aprofundamento_complete` true? no, `$data['aprofundamento_complete']` is the root key from the payload, not details). Hmm, wait: `array_merge($this->eventToValidationArray($event), $data)` — eventToValidationArray has no root 'aprofundamento_complete'. OK. But consider the `aprofundamentoOnly` path with `mergeSsmaEventAprofundamentoOnlyPayload`: `$merged['aprofundamento_complete'] = $data['aprofundamento_complete']` only `if (array_key_exists(...))` — the front always sends it in only-mode. OK. One more: in the only-mode draft save ('draft' status), the else branch sets `$data['aprofundamento_complete']=false` — so a draft save after finalize (allowed for the author in readequação? For a finalized and not-rejected aprofundamento, only admins can save drafts — line 27840/27846 guards). And `stampSsmaAprofundamentoAuthor` is not called for drafts — so the previous author remains. Good. Now writing the comments. Be concise, in pt-BR, with the required structure. For comment 1, the existing_code must be lines from the diff (newly added lines). The added lines are: ``` + // Readequação só volta à fila ao reenviar o aprofundamento. Editar (gestor/tenant) + // em paralelo não starta esse fluxo. + if ($wantsFinalize) { + $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval); + } ``` I'll use `if ($wantsFinalize) {` plus the next line as existing_code? The tool matches consecutive lines. I'll provide: ``` if ($wantsFinalize) { $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval); } ``` That's three consecutive added lines. Good. For comment 2, existing_code: ``` return strtolower(trim((string) ($approval['status'] ?? ''))) === 'rejected'; ``` Also could include the function line. I'll use the single line. Let me write the content in pt-BR, structured. Comment 1 content: "Ao remover o reenvio pela edição completa, a fila de validação só reabre quando o payload traz `aprofundamento_complete` (ou `aprofundamento_status = finalized`) no nível raiz — ou seja, apenas no fluxo de Aprofundamento. Só que existem ocorrências que chegam à validação sem nunca ter um aprofundamento finalizado: é o caso do ROS 'visto e resolvido' (`ssmaEventSkipsAprofundamentoValidationGate`), que é enviado ao aprovador já no cadastro. Se o aprovador reprovar, o 'Editar' da tela cheia continua salvando, mas nunca reenvia (o formulário só manda essas chaves no modo `aprofundamento_only`, `_modal_event.html.twig` linha ~7420) — a ocorrência fica presa em Readequação. Antes, `if ($wasRejectedApproval)` cobria esse caminho. Sugestão: manter o reenvio para ocorrências que não têm aprofundamento finalizado (ex.: `ros_resolved` / sem `aprofundamento_finalized_by_*`) ou confirmar com o produto que o especialista deve obrigatoriamente passar pelo '+Aprofundamento' nesses casos." Hmm, "confirmar com o produto" — fine. Comment 2 content (low): "A checagem usa a string literal 'rejected' em vez da constante/`getState()` do `SsmaOccurrenceApprovalService` (usados no mesmo arquivo, ex.: `maybeSubmitOccurrenceForValidation`). Como esse teste é o que restringe a edição do aprofundamento na readequação, qualquer mudança futura no valor de status desliga a trava silenciosamente e libera a edição para todos os especialistas do tipo. Sugestão: comparar com `SsmaOccurrenceApprovalService::STATUS_REJECTED`." Category: maintainability, severity low... Actually a permission gate silently failing — maybe medium. But the risk is only on future changes. Keep low/medium. I'll use "maintainability"/"low". Hmm, since the same rule file for the notification service & approval service exists but the literal is in the controller — fine, the controller is in the reviewed files. Let me finalize with two comments. Actually, should I also mention the notification legacy fallback as a low? I decided no. Let me double check that `SsmaController` imports/uses the constant: line 11998 uses the FQCN. So the suggestion is consistent. One more sanity check on comment 1: is `ros_resolved` reachable for a rejected occurrence in practice? The e2e seeder may have such. Whatever — the mechanism is sound and I frame it as requiring confirmation of the intended flow. I'm comfortable. Let me write the tool call.
Vou concluir a revisão com base nas verificações feitas. **Verificações que descartaram achados do plano:** - `notifyOccurrenceRegistrantOnReject` → sem chamadores residuais (só asserções de teste que exigem a ausência). - `notify()` aceita `int|User` (`resolveMemberUser`), então passar `memberId` no array é contrato válido. - `$detailsOut` é `$data['details']`, e `SsmaEventService::updateFromPayload()` faz merge desse array em `existingDetails` → autoria é persistida. - `isSsmaOccurrenceInReadequacao()` compara `'rejected'`, que é exatamente `STATUS_REJECTED`. - 4º parâmetro `$wasRejectedApproval` → `$resubmitted=true` está semanticamente correto (`maybeSubmit` exige `STATUS_REJECTED` nesse ramo). Restaram dois pontos:
Tool Calls (1)
code_comment
Show Details
{"comments": [{"path": "src/Controller/SsmaController.php", "content": "A fila de validação agora só reabre quando o payload traz `aprofundamento_complete` (ou `aprofundamento_status = finalized`) no nível raiz — isto é, apenas no fluxo \"+Aprofundamento\". Existem ocorrências que chegam ao aprovador sem nunca ter tido aprofundamento finalizado: é o caso do ROS \"visto e resolvido\", liberado pelo gate em `isEventReadyForOccurrenceValidation()` (`ssmaEventSkipsAprofundamentoValidationGate`). Se o aprovador reprovar uma dessas, o \"Editar\" da tela cheia continua salvando normalmente, mas nunca reenvia, porque o formulário só envia essas chaves no modo `aprofundamento_only` (`templates/ssma/occurrence/partials/_modal_event.html.twig`, ~linha 7420). Resultado prático: a ocorrência fica presa em Readequação, sem caminho de volta pela edição — antes o `if ($wasRejectedApproval)` cobria esse cenário. Vale cobrir também o caso \"sem aprofundamento finalizado\" (ex.: `ros_resolved`, ou ausência de `aprofundamento_finalized_by_*`) ou confirmar com o produto que nesses casos o especialista deve obrigatoriamente passar pelo \"+Aprofundamento\" para reenviar.", "existing_code": "        if ($wantsFinalize) {\n            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);\n        }", "category": "bug", "severity": "medium"}, {"path": "src/Controller/SsmaController.php", "content": "Esta checagem compara a string literal `'rejected'` em vez de usar o valor canônico do `SsmaOccurrenceApprovalService` (constante ou `getState()`), que é o padrão já usado no mesmo arquivo (`maybeSubmitOccurrenceForValidation`). Como este teste é justamente o que restringe quem edita o aprofundamento na readequação, qualquer mudança futura no valor do status desliga a trava em silêncio e volta a liberar a edição para todos os especialistas do tipo. Sugestão: comparar com `SsmaOccurrenceApprovalService::STATUS_REJECTED`.", "existing_code": "        return strtolower(trim((string) ($approval['status'] ?? ''))) === 'rejected';", "category": "maintainability", "severity": "low"}]}
(async)
Request #40 deepseek-flash P:90.97K C:431 CR:90.75K CW:0 3195ms
**Resumo da revisão** Arquivos revisados individualmente: - **`src/Controller/SsmaController.php`** — a lógica de autoria (`stampSsmaAprofundamentoAuthor`, `isSsmaAprofundamentoAuthor`), o gate de readequação em `canAccessSsmaEventAprofundamento` e a fusão do reenvio em `if ($wantsFinalize)` foram verificados contra `SsmaEventService::updateFromPayload()` (persistência do stamp OK), `mergeSsmaEventAprofundamentoOnlyPayload()` e `maybeSubmitOccurrenceForValidation()`. Dois apontamentos: (1) a perda do reenvio pela edição para ocorrências que chegam à validação sem aprofundamento finalizado (ROS “visto e resolvido”); (2) uso de literal `'rejected'` em vez da constante do serviço. - **`src/Service/Ssma/SsmaNotificationService.php`** — `notify()` documenta e trata `list<int|User>` (`resolveMemberUser`), então o array com `memberId`/`User` é contrato válido; o envio ao autor (e não ao registrante) está justificado no diff e nos testes. Sem defeitos. - **`src/Service/Ssma/SsmaOccurrenceApprovalService.php`** — apenas textos/histórico e a preservação explícita de `aprofundamento_finalized_by_*`; a alternância para `draft` mantém o autor, coerente com o novo fluxo. Sem defeitos. Isolamento por empresa, migrations (nenhuma) e integridade de dados do JSON `details` foram checados e não apresentaram problemas.
Tool Calls (1)
task_done
Show Details
{"state": "DONE"}
Request #41 deepseek-flash P:15.41K C:372 CR:14.85K CW:0 1918ms
Reasoning
Let me analyze the diff carefully. Files: 1. src/Controller/SsmaController.php 2. src/Service/Ssma/SsmaNotificationService.php 3. src/Service/Ssma/SsmaOccurrenceApprovalService.php Let me look at the notification service change: ```php $recipients = []; if ($memberId > 0) { $recipients[] = $memberId; } elseif ($userId > 0) { $author = $this->entityManager->find(User::class, $userId); if ($author instanceof User) { $recipients[] = $author; } } ``` The `notify` method receives `$recipients` — previously it was `[$recipient]` where `$recipient` was a User entity. Now `$recipients[] = $memberId` is an int (member ID) instead of a User entity. That's a potential type mismatch. Need to check `notify()` signature to see whether it accepts member IDs or User entities. Let me search. Let me read the SsmaNotificationService.
Tool Calls (2)
file_read
Show Details
{"file_path": "src/Service/Ssma/SsmaNotificationService.php", "start_line": 1, "end_line": 420}
File: src/Service/Ssma/SsmaNotificationService.php (Total lines: 485)
IS_TRUNCATED: false
LINE_RANGE: 1-420
1|<?php
2|
3|declare(strict_types=1);
4|
5|namespace App\Service\Ssma;
6|
7|use App\Entity\Company;
8|use App\Entity\CompanyMembers;
9|use App\Entity\NotificationsCenter;
10|use App\Entity\SsmaAbordagem;
11|use App\Entity\SsmaEvent;
12|use App\Entity\SsmaInspection;
13|use App\Entity\SsmaMetaAbonoRequest;
14|use App\Entity\User;
15|use App\Enum\Ssma\EventTypeEnum;
16|use App\Service\NotificationsCenterService;
17|use Doctrine\ORM\EntityManagerInterface;
18|use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
19|
20|class SsmaNotificationService
21|{
22|    private const HUB = 'Hub de Operações';
23|    private const PRODUCT = 'Módulo de Segurança';
24|    private const PENDING = NotificationsCenter::TYPE_PENDING_TASK;
25|
26|    public function __construct(
27|        private EntityManagerInterface $entityManager,
28|        private NotificationsCenterService $notificationsCenterService,
29|        private SsmaAutomationService $ssmaAutomationService,
30|        private SsmaOccurrenceTypeConfigService $ssmaOccurrenceTypeConfig,
31|        private UrlGeneratorInterface $urlGenerator
32|    ) {
33|    }
34|
35|    public function notifyImmediateRisk(SsmaEvent $event): void
36|    {
37|        $details = $event->getDetails();
38|        $title = trim((string) ($details['title'] ?? '')) ?: ('Evento #' . $event->getId());
39|
40|        $this->notify(
41|            [(int) ($details['manager_id'] ?? 0)],
42|            sprintf(
43|                'Risco imediato reportado em "%s" (id %d). Verifique e tome as providências necessárias.',
44|                $title,
45|                $event->getId()
46|            ),
47|            NotificationsCenter::TYPE_PROBLEM,
48|            null,
49|            $this->occurrenceViewUrl((int) $event->getId(), 'event'),
50|            'ssma',
51|            'ocorrencias'
52|        );
53|    }
54|
55|    public function notifyAbordagemCoach(SsmaAbordagem $abordagem): void
56|    {
57|        $coachMemberId = $abordagem->getCoachMemberId();
58|        if ($coachMemberId === null) {
59|            return;
60|        }
61|
62|        $abordagemId = (int) $abordagem->getId();
63|        $this->notify(
64|            [$coachMemberId],
65|            sprintf(
66|                'Você foi selecionado como coach na abordagem %s (%s). Registre a pendência de coaching.',
67|                $abordagemId > 0 ? 'I' . $abordagemId : '',
68|                $abordagem->getObservadorNome()
69|            ),
70|            self::PENDING,
71|            null,
72|            '/manager/ssma#tab_approaches',
73|            'ssma',
74|            'prevencao'
75|        );
76|    }
77|
78|    public function notifyInspectionParticipants(
79|        SsmaInspection $inspection,
80|        User $sender,
81|        array $previousMemberIds = []
82|    ): void {
83|        $inspectionId = (int) ($inspection->getId() ?? 0);
84|        if ($inspectionId <= 0) {
85|            return;
86|        }
87|
88|        $previous = array_fill_keys($this->uniquePositiveIds($previousMemberIds), true);
89|        $memberIds = array_values(array_filter(
90|            $this->resolveInspectionRecipientMemberIds($inspection),
91|            static fn (int $id): bool => !isset($previous[$id])
92|        ));
93|
94|        $this->notify(
95|            $memberIds,
96|            'Você foi incluído em uma nova inspeção. Colabore com o preenchimento',
97|            self::PENDING,
98|            $sender,
99|            $this->urlGenerator->generate('ssma_inspection_view', ['id' => $inspectionId])
100|        );
101|    }
102|
103|    /**
104|     * @return list<int>
105|     */
106|    public function resolveInspectionRecipientMemberIds(SsmaInspection $inspection): array
107|    {
108|        return $this->uniquePositiveIds([
109|            ...$inspection->getParticipantsIds(),
110|            (int) ($inspection->getSafetyResponsible()?->getId() ?? 0),
111|        ]);
112|    }
113|
114|    public function notifyOccurrenceResponsible(
115|        int $managerId,
116|        int $occurrenceId,
117|        string $viewKind,
118|        User $sender
119|    ): void {
120|        if ($occurrenceId <= 0) {
121|            return;
122|        }
123|
124|        $this->notify(
125|            [$managerId],
126|            'Nova ocorrência registrada',
127|            NotificationsCenter::TYPE_GENERAL,
128|            $sender,
129|            $this->occurrenceViewUrl($occurrenceId, $viewKind)
130|        );
131|    }
132|
133|    public function notifyAprofundamentoSpecialists(
134|        Company $company,
135|        User $sender,
136|        string $typeRaw,
137|        int $occurrenceId,
138|        string $viewKind = 'event'
139|    ): void {
140|        $this->notify(
141|            $this->ssmaAutomationService->resolveTechnicalMemberIdsForType($company, $typeRaw),
142|            sprintf(
143|                'Nova ocorrência registrada - faça o aprofundamento técnico do %s',
144|                $this->resolveOccurrenceTypeLabel($company, $typeRaw)
145|            ),
146|            self::PENDING,
147|            $sender,
148|            $this->occurrenceViewUrl($occurrenceId, $viewKind)
149|        );
150|    }
151|
152|    /**
153|     * IDs explícitos do comitê (líder + integrantes). Sem auto-inclusão de gestor ou especialistas.
154|     *
155|     * @param list<int> $memberIds
156|     */
157|    public function notifyCauseTreeCommittee(array $memberIds, int $treeId, User $sender, ?Company $company = null): void
158|    {
159|        if ($treeId <= 0) {
160|            return;
161|        }
162|
163|        $this->notify(
164|            $memberIds,
165|            'Você faz parte do comitê de análise de causa de ocorrência',
166|            self::PENDING,
167|            $sender,
168|            $this->urlGenerator->generate('ssma_cause_tree_view', ['treeId' => $treeId]),
169|            self::HUB,
170|            self::PRODUCT,
171|            $company
172|        );
173|    }
174|
175|    /**
176|     * @param list<int> $approverMemberIds
177|     */
178|    public function notifyCauseTreeAnalysisAwaitingValidation(
179|        array $approverMemberIds,
180|        int $treeId,
181|        User $sender,
182|        ?Company $company = null
183|    ): void {
184|        if ($treeId <= 0 || $approverMemberIds === []) {
185|            return;
186|        }
187|
188|        $this->notify(
189|            $approverMemberIds,
190|            'A análise da árvore de causas foi finalizada e aguarda sua validação.',
191|            self::PENDING,
192|            $sender,
193|            $this->urlGenerator->generate('ssma_cause_tree_view', ['treeId' => $treeId]),
194|            self::HUB,
195|            self::PRODUCT,
196|            $company
197|        );
198|    }
199|
200|    public function notifyCauseTreeLeaderOfAnalysisDecision(
201|        int $leaderMemberId,
202|        int $treeId,
203|        string $decision,
204|        string $note,
205|        User $sender,
206|        ?Company $company = null
207|    ): void {
208|        if ($treeId <= 0 || $leaderMemberId <= 0) {
209|            return;
210|        }
211|
212|        $approved = $decision === SsmaCauseTreeAnalysisApproval::STATUS_APPROVED;
213|        $content = $approved
214|            ? 'A análise da árvore de causas foi aprovada.'
215|            : 'A análise da árvore de causas foi reprovada.';
216|        $note = trim($note);
217|        if (!$approved && $note !== '') {
218|            $content .= ' Observação: ' . $note;
219|        }
220|
221|        $this->notify(
222|            [$leaderMemberId],
223|            $content,
224|            $approved ? NotificationsCenter::TYPE_GENERAL : self::PENDING,
225|            $sender,
226|            $this->urlGenerator->generate('ssma_cause_tree_view', ['treeId' => $treeId]),
227|            self::HUB,
228|            self::PRODUCT,
229|            $company
230|        );
231|    }
232|
233|    /**
234|     * @param list<int> $memberIds
235|     */
236|    public function notifyActionExecutors(array $memberIds, int $actionId, User $sender): void
237|    {
238|        $this->notifyActionPlan($memberIds, $actionId, $sender, 'Uma nova ação foi registrada sob sua responsabilidade');
239|    }
240|
241|    /**
242|     * @param list<int> $memberIds
243|     */
244|    public function notifyActionValidators(array $memberIds, int $actionId, User $sender): void
245|    {
246|        $this->notifyActionPlan($memberIds, $actionId, $sender, 'Uma nova ação foi resolvida - faça a validação da mesma');
247|    }
248|
249|    /**
250|     * @param list<int> $memberIds
251|     */
252|    public function notifyMetaAbonoApprovers(array $memberIds, string $kind, ?User $sender): void
253|    {
254|        $this->notify(
255|            $memberIds,
256|            sprintf('Uma solicitação de abono de meta de %s foi registrada.', $this->metaAbonoKindLabel($kind)),
257|            self::PENDING,
258|            $sender,
259|            $this->urlGenerator->generate('ssma_prevencao_index') . '#tab_prev_metas'
260|        );
261|    }
262|
263|    public function notifyOccurrenceValidationApprovers(
264|        Company $company,
265|        SsmaEvent $event,
266|        User $sender,
267|        bool $resubmitted = false
268|    ): void {
269|        $title = $this->eventTitle($event);
270|        $this->notify(
271|            $this->ssmaOccurrenceTypeConfig->getFlashReportApproverIds($company),
272|            $resubmitted
273|                ? sprintf('A ocorrência "%s" foi corrigida e aguarda nova validação.', $title)
274|                : sprintf('A ocorrência "%s" foi finalizada e aguarda sua validação.', $title),
275|            self::PENDING,
276|            $sender,
277|            $this->occurrenceViewUrl((int) $event->getId(), 'event')
278|        );
279|    }
280|
281|    /**
282|     * Fluxo natural da readequação: avisa só quem enviou o aprofundamento reprovado.
283|     * Não dispara para todos os especialistas do tipo (isso é o cadastro novo).
284|     * Destinatários extras ficam a cargo da automação ssma_on_occurrence_rejected.
285|     */
286|    public function notifyAprofundamentoAuthorOnReject(SsmaEvent $event, User $sender, string $note): void
287|    {
288|        $details = is_array($event->getDetails()) ? $event->getDetails() : [];
289|        $memberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
290|        $userId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
291|
292|        $recipients = [];
293|        if ($memberId > 0) {
294|            $recipients[] = $memberId;
295|        } elseif ($userId > 0) {
296|            $author = $this->entityManager->find(User::class, $userId);
297|            if ($author instanceof User) {
298|                $recipients[] = $author;
299|            }
300|        }
301|
302|        if ($recipients === []) {
303|            return;
304|        }
305|
306|        $content = sprintf(
307|            'A ocorrência "%s" foi reprovada na validação e voltou para readequação do aprofundamento.',
308|            $this->eventTitle($event)
309|        );
310|        if (trim($note) !== '') {
311|            $content .= ' Observação: ' . trim($note);
312|        }
313|
314|        $this->notify(
315|            $recipients,
316|            $content,
317|            self::PENDING,
318|            $sender,
319|            $this->occurrenceViewUrl((int) $event->getId(), 'event')
320|        );
321|    }
322|
323|    public function notifyMetaAbonoRequester(int $memberId, string $kind, ?User $sender): void
324|    {
325|        $this->notify(
326|            [$memberId],
327|            sprintf('Seu pedido de revisão de metas de %s foi respondido', $this->metaAbonoKindLabel($kind)),
328|            NotificationsCenter::TYPE_GENERAL,
329|            $sender,
330|            $this->urlGenerator->generate('ssma_prevencao_index') . '#tab_prev_meta_reviews'
331|        );
332|    }
333|
334|    /**
335|     * @param list<int> $memberIds
336|     */
337|    private function notifyActionPlan(array $memberIds, int $actionId, User $sender, string $content): void
338|    {
339|        if ($actionId <= 0) {
340|            return;
341|        }
342|
343|        $this->notify(
344|            $memberIds,
345|            $content,
346|            self::PENDING,
347|            $sender,
348|            $this->urlGenerator->generate('ssma_plano_acao_index', ['action' => $actionId])
349|        );
350|    }
351|
352|    /**
353|     * Aceita IDs de CompanyMembers ou instâncias de User.
354|     *
355|     * @param list<int|User> $recipients
356|     */
357|    private function notify(
358|        array $recipients,
359|        string $content,
360|        string $type,
361|        ?User $sender,
362|        string $buttonUrl,
363|        string $hub = self::HUB,
364|        string $product = self::PRODUCT,
365|        ?Company $restrictToCompany = null
366|    ): void {
367|        $senderId = $sender instanceof User ? (int) $sender->getId() : 0;
368|        $created = 0;
369|
370|        foreach ($recipients as $recipient) {
371|            if (!$recipient instanceof User) {
372|                $recipient = $this->resolveMemberUser((int) $recipient, $restrictToCompany);
373|            }
374|            if (!$recipient instanceof User || ($senderId > 0 && (int) $recipient->getId() === $senderId)) {
375|                continue;
376|            }
377|
378|            try {
379|                $this->notificationsCenterService->createNotification(
380|                    recipient: $recipient,
381|                    hub: $hub,
382|                    product: $product,
383|                    content: $content,
384|                    type: $type,
385|                    sender: $sender,
386|                    buttonUrl: $buttonUrl,
387|                    flush: false
388|                );
389|                $created++;
390|            } catch (\Throwable) {
391|            }
392|        }
393|
394|        if ($created === 0) {
395|            return;
396|        }
397|
398|        try {
399|            $this->entityManager->flush();
400|        } catch (\Throwable) {
401|        }
402|    }
403|
404|    private function resolveMemberUser(int $memberId, ?Company $restrictToCompany = null): ?User
405|    {
406|        if ($memberId <= 0) {
407|            return null;
408|        }
409|
410|        $member = $this->entityManager->find(CompanyMembers::class, $memberId);
411|        if (!$member instanceof CompanyMembers || $member->getIsRemoved()) {
412|            return null;
413|        }
414|
415|        if ($restrictToCompany instanceof Company) {
416|            $memberCompany = $member->getCompany();
417|            if (
418|                !$memberCompany instanceof Company
419|                || (int) $memberCompany->getId() !== (int) $restrictToCompany->getId()
420|            ) {
code_search
Show Details
{"search_text": "private function notify\\(", "use_perl_regexp": true}
File: src/Service/Products/FinancialFlowDomainActionService.php
Match lines: 1
686|    private function notify(

File: src/Service/Ssma/SsmaNotificationService.php
Match lines: 1
357|    private function notify(

Request #42 deepseek-flash P:20.03K C:383 CR:15.74K CW:0 2250ms
Reasoning
The notify() accepts list<int|User>. So both usages are fine. Now, the key concern: `$memberId` path passes only member id without `$restrictToCompany`. That's fine given other usages. Note: in the new code, when memberId > 0, it uses the member; else userId. Hmm, potential inconsistency with the controller's `isSsmaAprofundamentoAuthor` which checks userId OR memberId. Minor. Now consider: `$this->entityManager->find(User::class, $userId)` — the user might belong to a different company? Not relevant. Now let's check the controller changes more deeply. The confirmed findings cover two issues. Let's look for others. Let me read the controller around the changed region to understand. Key areas: 1. `notifyAprofundamentoAuthorOnReject` rename — check all callers of the old name are updated. Confirmed diff shows one caller. Let's search codebase for `notifyOccurrenceRegistrantOnReject` remaining. 2. `canManageSsmaOccurrences`? Actually the changed method at 12761 — need to see what method it is. Let me read around 12700-12850.
Tool Calls (2)
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 12680, "end_line": 12850}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 12680-12850
12680|        return [
12681|            'can_submit' => $this->ssmaFlashReportService->resolveManualSubmitPermission(
12682|                $canManage,
12683|                $isSsmaAdmin,
12684|                $isGestorUser,
12685|                (bool) ($flashContext['can_submit'] ?? false)
12686|            ),
12687|            'can_direct_send' => $this->ssmaFlashReportService->canDirectSendFlashReport($canManage, $isSsmaAdmin),
12688|        ];
12689|    }
12690|
12691|    private function resolveSsmaFlashReportGestorUser(Company $company, User $user): bool
12692|    {
12693|        if ($this->isGranted('ROLE_MANAGER_GESTOR')) {
12694|            return true;
12695|        }
12696|
12697|        $member = $this->getCurrentCompanyMember($company, $user);
12698|        if (!$member) {
12699|            return false;
12700|        }
12701|
12702|        if ($this->memberIsSsmaGestorAdministrador($member)) {
12703|            return true;
12704|        }
12705|
12706|        try {
12707|            $tag = $this->resolveSsmaProductPermissionTagForMember($member);
12708|            $tagName = $tag instanceof \App\Entity\PermissionTag ? (string) $tag->getName() : '';
12709|
12710|            return in_array($tagName, ['Gestor de Equipe', 'Supervisor de Equipe', 'Supervisor'], true);
12711|        } catch (\Throwable) {
12712|            return false;
12713|        }
12714|    }
12715|
12716|    /**
12717|     * Membro/Inspetor comum do produto SSMA (não gestor admin / gestor de equipe).
12718|     * Resolve a tag mesmo quando o usuário tem ROLE_MANAGER de plataforma.
12719|     */
12720|    private function isSsmaPlainProductMember(?Company $company, ?User $user): bool
12721|    {
12722|        if (!$company || !$user) {
12723|            return false;
12724|        }
12725|
12726|        $member = $this->getCurrentCompanyMember($company, $user);
12727|        if (!$member) {
12728|            return false;
12729|        }
12730|
12731|        if ($this->memberIsSsmaGestorAdministrador($member)) {
12732|            return false;
12733|        }
12734|
12735|        try {
12736|            $tag = $this->resolveSsmaProductPermissionTagForMember($member);
12737|            $name = $tag instanceof \App\Entity\PermissionTag ? (string) $tag->getName() : '';
12738|
12739|            return in_array($name, ['Membro', 'Inspetor', 'Membro (default)'], true);
12740|        } catch (\Throwable) {
12741|            return false;
12742|        }
12743|    }
12744|
12745|    /**
12746|     * Quem pode preencher o Aprofundamento (2ª etapa) de ROS/Quase Acidente:
12747|     * Gestor Administrador SSMA / SUPER_ADMIN, técnico SSMA do tipo ou gestor direto do relator.
12748|     *
12749|     * Não usa canManageSsmaOccurrences()/ROLE_MANAGER — can_create de Membro não libera 2ª etapa.
12750|     *
12751|     * @param array<string, mixed> $details
12752|     */
12753|    private function canAccessSsmaEventAprofundamento(
12754|        Company $company,
12755|        User $user,
12756|        string $type,
12757|        array $details,
12758|        int $createdByUserId = 0
12759|    ): bool {
12760|        if ($this->isSsmaAprofundamentoAdmin($company, $user)) {
12761|            return true;
12762|        }
12763|
12764|        $loggedMember = $this->getCurrentCompanyMember($company, $user);
12765|
12766|        // Readequação: só quem enviou o aprofundamento (admin já passou acima).
12767|        if ($this->isSsmaOccurrenceInReadequacao($details)) {
12768|            $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0
12769|                || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0;
12770|            if ($hasAuthor) {
12771|                return $this->isSsmaAprofundamentoAuthor($details, $user, $loggedMember);
12772|            }
12773|        }
12774|
12775|        if (in_array($type, $this->resolveCurrentUserTechnicalTypes($company, $user), true)) {
12776|            return true;
12777|        }
12778|
12779|        if (!in_array($type, ['ROS', 'QUASE_ACIDENTE'], true)) {
12780|            return false;
12781|        }
12782|
12783|        if (!$loggedMember) {
12784|            return false;
12785|        }
12786|
12787|        return $this->isSsmaEventDirectManagerOfReporter(
12788|            $company,
12789|            $loggedMember,
12790|            $type,
12791|            $details,
12792|            $createdByUserId
12793|        );
12794|    }
12795|
12796|    /**
12797|     * @param array<string, mixed> $details
12798|     */
12799|    private function isSsmaOccurrenceInReadequacao(array $details): bool
12800|    {
12801|        $approval = is_array($details['occurrence_approval'] ?? null) ? $details['occurrence_approval'] : [];
12802|
12803|        return strtolower(trim((string) ($approval['status'] ?? ''))) === 'rejected';
12804|    }
12805|
12806|    /**
12807|     * @param array<string, mixed> $details
12808|     */
12809|    private function isSsmaAprofundamentoAuthor(array $details, User $user, ?CompanyMembers $member): bool
12810|    {
12811|        $authorUserId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
12812|        if ($authorUserId > 0 && (int) $user->getId() === $authorUserId) {
12813|            return true;
12814|        }
12815|
12816|        $authorMemberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
12817|
12818|        return $authorMemberId > 0 && $member instanceof CompanyMembers
12819|            && (int) $member->getId() === $authorMemberId;
12820|    }
12821|
12822|    /**
12823|     * @param array<string, mixed> $details
12824|     */
12825|    private function stampSsmaAprofundamentoAuthor(array &$details, Company $company, User $user): void
12826|    {
12827|        $details['aprofundamento_finalized_by_user_id'] = (int) $user->getId();
12828|        $memberId = $this->resolveCompanyMemberIdByUserId($company, (int) $user->getId());
12829|        if ($memberId) {
12830|            $details['aprofundamento_finalized_by_member_id'] = $memberId;
12831|        }
12832|        $name = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
12833|        $details['aprofundamento_finalized_by_name'] = $name !== '' ? $name : (string) ($user->getEmail() ?? '');
12834|    }
12835|
12836|    /**
12837|     * @param array<string, mixed> $details
12838|     */
12839|    private function resolveSsmaEventReporterMemberId(
12840|        Company $company,
12841|        string $type,
12842|        array $details,
12843|        int $createdByUserId = 0
12844|    ): ?int {
12845|        if ($type === 'ROS') {
12846|            $managerId = (int) ($details['manager_id'] ?? 0);
12847|            if ($managerId > 0) {
12848|                return $managerId;
12849|            }
12850|        }
code_search
Show Details
{"search_text": "notifyOccurrenceRegistrantOnReject"}
File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 1
33|        self::assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc);

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 1
378|    && fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')

Request #43 deepseek-flash P:22.6K C:251 CR:20.35K CW:0 2098ms
Reasoning
Now important: The `canAccessSsmaEventAprofundamento` early-returns before checking technical types when in readequação and has author. That means an admin/tenant passes above (isSsmaAprofundamentoAdmin). But what about gestor direto do relator or technical types? They are blocked by design (only author). That's the intended behavior. However: there's a subtle issue — the readequação check happens before `resolveCurrentUserTechnicalTypes`. Hmm, intended. Now the important regression: previously, the flow for legacy ocorrências (no author stamped) falls back to old rule since `$hasAuthor` false. Good. Now let's look at the other changed block in the controller around 27905-27960 — the save flow. Let me read that region.
Tool Calls (1)
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 27820, "end_line": 28020}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 27820-28020
27820|        $aprofundamentoFinalized = $aprofundamentoStatus === 'finalized'
27821|            || !empty($existingDetails['aprofundamento_complete']);
27822|
27823|        $canFullEdit = $this->canEditSsmaEvent($company, $user, $event);
27824|        $eventTypeForAccess = (string) ($data['type'] ?? $event->getType());
27825|        $canAprofundamento = $this->canAccessSsmaEventAprofundamento(
27826|            $company,
27827|            $user,
27828|            $eventTypeForAccess,
27829|            is_array($data['details'] ?? null) ? $data['details'] : $existingDetails,
27830|            (int) ($event->getCreatedById() ?? 0)
27831|        );
27832|        $isAprofundamentoUpdate = $aprofundamentoOnly
27833|            || !empty($data['aprofundamento_complete'])
27834|            || strtolower(trim((string) ($data['aprofundamento_status'] ?? ''))) === 'finalized';
27835|
27836|        if (!$canFullEdit) {
27837|            if (!$isAprofundamentoUpdate || !$canAprofundamento) {
27838|                return new JsonResponse(['success' => false, 'message' => 'Você não tem autorização para editar esse tipo de ocorrência.'], 403);
27839|            }
27840|            if ($aprofundamentoFinalized && !$this->isSsmaAprofundamentoAdmin($company, $user)) {
27841|                return new JsonResponse(['success' => false, 'message' => 'Aprofundamento finalizado. Somente um administrador ou gestor administrador pode alterar.'], 403);
27842|            }
27843|            $data = $this->mergeSsmaEventAprofundamentoOnlyPayload($event, $data);
27844|        } else {
27845|            $data = array_merge($this->eventToValidationArray($event), $data);
27846|            if ($aprofundamentoFinalized && !$this->isSsmaAprofundamentoAdmin($company, $user)) {
27847|                if ($aprofundamentoOnly || $isAprofundamentoUpdate) {
27848|                    return new JsonResponse(['success' => false, 'message' => 'Aprofundamento finalizado. Somente um administrador ou gestor administrador pode alterar.'], 403);
27849|                }
27850|                $data = $this->stripSsmaEventAprofundamentoFieldsFromPayload(
27851|                    $data,
27852|                    (string) ($data['type'] ?? $event->getType()),
27853|                    $existingDetails
27854|                );
27855|            }
27856|        }
27857|
27858|        $data = $this->normalizeSsmaEventPayload($data, $company);
27859|        $data = $this->normalizeSsmaEventManagerInDetails($data, $company);
27860|        if (!$aprofundamentoOnly) {
27861|            $data = $this->applySsmaEventHierarchyManagerForPlainMember($data, $company, $user, $existingDetails);
27862|            if (!empty($data['__ssma_event_hierarchy_blocked'])) {
27863|                return new JsonResponse([
27864|                    'success' => false,
27865|                    'message' => (string) ($data['__ssma_event_hierarchy_message'] ?? $this->ssmaEventHierarchyBlockMessage()),
27866|                ], 422);
27867|            }
27868|            if (!$this->isSsmaHierarchyLockedManagerProfile($company, $user)) {
27869|                $data = $this->applySsmaEventManagerAssignment($data, $company, $user, $existingDetails);
27870|            }
27871|        }
27872|        $data = $this->applySsmaEventAreaResponsibleFromLocation($data, $company);
27873|        $data = $this->applySsmaDescaracterPermissionGate(
27874|            $data,
27875|            $company,
27876|            $user,
27877|            $existingDetails
27878|        );
27879|        if (($data['__ssma_forbidden_reason'] ?? null) === 'descaracterizacao_sem_permissao') {
27880|            return new JsonResponse(['success' => false, 'message' => 'Você não tem permissão para descaracterizar/decidir sobre este acidente.'], 403);
27881|        }
27882|
27883|        $eventType = (string) ($data['type'] ?? $event->getType());
27884|        $eventDetails = is_array($data['details'] ?? null) ? $data['details'] : [];
27885|        // Em modo só-aprofundamento o especialista já está autorizado; não stripa o que acabou de enviar.
27886|        if (
27887|            !$aprofundamentoOnly
27888|            && !$this->canAccessSsmaEventAprofundamento($company, $user, $eventType, $eventDetails, $event->getCreatedById())
27889|            && in_array($eventType, ['ROS', 'QUASE_ACIDENTE', 'ACIDENTE_PESSOAL', 'ACIDENTE_MATERIAL', 'ACIDENTE_AMBIENTAL'], true)
27890|        ) {
27891|            $data = $this->stripSsmaEventAprofundamentoFieldsFromPayload(
27892|                $data,
27893|                $eventType,
27894|                $existingDetails
27895|            );
27896|        }
27897|
27898|        // Draft vs finalize do aprofundamento (especialista / admin).
27899|        $wantsFinalize = false;
27900|        if ($aprofundamentoOnly || !empty($data['aprofundamento_complete']) || array_key_exists('aprofundamento_status', $data)) {
27901|            $detailsOut = is_array($data['details'] ?? null) ? $data['details'] : [];
27902|            $wantsFinalize = !empty($data['aprofundamento_complete'])
27903|                || strtolower(trim((string) ($data['aprofundamento_status'] ?? ''))) === 'finalized';
27904|            if ($wantsFinalize) {
27905|                $data['aprofundamento_complete'] = true;
27906|                $detailsOut['aprofundamento_complete'] = true;
27907|                $detailsOut['aprofundamento_status'] = 'finalized';
27908|                $this->stampSsmaAprofundamentoAuthor($detailsOut, $company, $user);
27909|            } else {
27910|                $data['aprofundamento_complete'] = false;
27911|                $detailsOut['aprofundamento_complete'] = false;
27912|                $detailsOut['aprofundamento_status'] = 'draft';
27913|            }
27914|            $data['details'] = $detailsOut;
27915|        }
27916|
27917|        // Valida apenas se type ou details estiverem presentes no payload (atualização parcial ok)
27918|        if (!empty($data['type']) || !empty($data['details'])) {
27919|            $validator = new \App\Service\Ssma\SsmaEventValidator();
27920|            $errors    = $validator->validate($data, [
27921|                'allowed_type_keys' => $this->ssmaOccurrenceTypeConfig->getAllowedTypeKeys($company),
27922|                'soft_aprofundamento' => $aprofundamentoOnly && !$wantsFinalize,
27923|            ]);
27924|            if ($errors !== []) {
27925|                return new JsonResponse(['success' => false, 'errors' => $errors], 422);
27926|            }
27927|        }
27928|
27929|        $teamScopeErr = $this->validateSsmaEventPayloadAgainstTeamScope($data, $company, $user);
27930|        if ($teamScopeErr !== null) {
27931|            return new JsonResponse(['success' => false, 'message' => $teamScopeErr], 403);
27932|        }
27933|
27934|        $service = new \App\Service\Ssma\SsmaEventService($this->entityManager, $this->ssmaOccurrenceTypeConfig);
27935|        $prevType         = $event->getType();
27936|        $prevStatus       = $event->getStatus();
27937|        $prevImmediateRisk = !empty($event->getDetails()['immediate_risk']);
27938|        $prevManagerId = (int) ($event->getDetails()['manager_id'] ?? 0);
27939|        $wasRejectedApproval = $this->ssmaOccurrenceApprovalService->getState($event)['status']
27940|            === \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_REJECTED;
27941|        $editorMeta = $this->resolveSsmaEditorMeta($company, $user);
27942|        $event      = $service->updateFromPayload($event, $data, $user, $editorMeta);
27943|
27944|        // Readequação só volta à fila ao reenviar o aprofundamento. Editar (gestor/tenant)
27945|        // em paralelo não starta esse fluxo.
27946|        if ($wantsFinalize) {
27947|            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);
27948|        }
27949|
27950|        $registeredByName = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
27951|        if ($registeredByName === '') {
27952|            $registeredByName = $user->getEmail() ?? 'Sistema';
27953|        }
27954|        $automationContext = [
27955|            'registered_by_name' => $registeredByName,
27956|            'type_raw'           => $event->getType(),
27957|            'new_type'           => $event->getType(),
27958|        ];
27959|        $flashApproval = null;
27960|
27961|        try {
27962|            $this->ssmaAutomationService->triggerForEvent(
27963|                'ssma_on_occurrence_updated',
27964|                $event,
27965|                $company,
27966|                $automationContext
27967|            );
27968|            if ($prevStatus !== $event->getStatus()) {
27969|                $this->ssmaAutomationService->triggerForEvent(
27970|                    'ssma_on_status_change',
27971|                    $event,
27972|                    $company,
27973|                    array_merge($automationContext, ['old_status' => $prevStatus])
27974|                );
27975|            }
27976|            if ($prevType !== $event->getType()) {
27977|                $this->ssmaAutomationService->triggerForEvent(
27978|                    'ssma_on_occurrence_type_changed',
27979|                    $event,
27980|                    $company,
27981|                    array_merge($automationContext, [
27982|                        'old_type' => $prevType,
27983|                        'new_type' => $event->getType(),
27984|                    ])
27985|                );
27986|            }
27987|            $newImmediateRisk = !empty($event->getDetails()['immediate_risk']);
27988|            $newManagerId = (int) ($event->getDetails()['manager_id'] ?? 0);
27989|            // Dispara quando risco vira SIM, ou quando Relatado por é preenchido com risco já ativo.
27990|            if ($newImmediateRisk && (!$prevImmediateRisk || ($prevManagerId <= 0 && $newManagerId > 0))) {
27991|                $this->ssmaNotificationService->notifyImmediateRisk($event);
27992|            }
27993|            if ($newManagerId > 0 && $newManagerId !== $prevManagerId) {
27994|                $this->ssmaNotificationService->notifyOccurrenceResponsible($newManagerId, (int) $event->getId(), 'event', $user);
27995|            }
27996|
27997|            // P2: quando a ocorrência está pronta, tenta abrir aprovação na CC (não envia PDF).
27998|            // Idempotente: se já pending/sent, ou sem aprovador, maybeAutoRequestApproval só faz skip.
27999|            // Importante para ROS: já pode estar "ready" no create (sem aprovador) e precisar
28000|            // re-tentar no update depois que a automação ganha aprovador.
28001|            $eventPayload = $this->ssmaAutomationService->buildEventPayload($event, $company, $automationContext);
28002|            $nowReady = $this->ssmaAutomationService->passesFlashReportRequirements($eventPayload);
28003|            if ($nowReady) {
28004|                $flashApproval = $this->ssmaFlashReportService->maybeAutoRequestApproval($event, $user);
28005|                if (!empty($flashApproval['skipped'])) {
28006|                    $flashApproval = null;
28007|                }
28008|            } else {
28009|                $flashApproval = null;
28010|            }
28011|        } catch (\Throwable $automationError) {
28012|            $this->ssmaLogger->warning('Ssma updateEvent automations: ' . $automationError->getMessage());
28013|            $flashApproval = [
28014|                'success' => false,
28015|                'message' => 'Erro ao processar automações: ' . $automationError->getMessage(),
28016|            ];
28017|        }
28018|
28019|        return new JsonResponse([
28020|            'success' => true,
Request #44 deepseek-flash P:26.11K C:741 CR:22.78K CW:0 3985ms
Reasoning
Now, the key change: `stampSsmaAprofundamentoAuthor` is only called when `$wantsFinalize` is true in this code path (line 27904-27908). This is inside the block `if ($aprofundamentoOnly || !empty($data['aprofundamento_complete']) || array_key_exists('aprofundamento_status', $data))`. Wait — is this the only place where aprofundamento gets finalized? There could be other code paths, e.g., `createEvent`, or the "Validar sem Finalizar" flow, or flash report direct send. Let's search for `aprofundamento_status'] = 'finalized'` across the codebase. Also note: an important concern — `stampSsmaAprofundamentoAuthor` overwrites the author every time the aprofundamento is finalized. If an admin finalizes the aprofundamento on behalf of someone... hmm. But admin editing while in readequação and finalizing would become the author. Intended? Probably acceptable. Actually wait — think about the scenario: admin edits in parallel "sem startar esse fluxo". If admin uses "Editar" and the payload includes aprofundamento_complete... they'd be blocked by line 27847-27848 if finalized. But if aprofundamento was in draft (rejected case), and admin edits general data, `$wantsFinalize` false since data doesn't contain aprofundamento_complete. Actually `$aprofundamentoOnly` false, and `$data` won't contain aprofundamento_complete... Actually could contain from eventToValidationArray? `array_merge($this->eventToValidationArray($event), $data)` — if eventToValidationArray includes 'aprofundamento_complete' => false, then `!empty(false)` = false. And `array_key_exists('aprofundamento_status', $data)` — if eventToValidationArray includes 'aprofundamento_status' => 'draft', then this block executes and `$data['details'] = $detailsOut` gets rewritten but `$wantsFinalize` false. That's fine, but note line 27910-27912 would set aprofundamento_complete = false and status draft. Hmm — for a legacy event where the aprofundamento was finalized but status in details is missing... Actually `array_key_exists('aprofundamento_status', $data)` — if eventToValidationArray returns top-level aprofundamento_status? Let's check. Actually this is pre-existing behavior except the stamp added. Let me check `maybeSubmitOccurrenceForValidation` signature and semantics. Confirmed finding #1 already addresses the `if ($wantsFinalize)` change. Let me not repeat. Now let's look for other places where aprofundamento is finalized, to check whether the author stamp is missed. Search for "'finalized'".
Tool Calls (3)
code_search
Show Details
{"search_text": "aprofundamento_status'\\] = 'finalized'", "use_perl_regexp": true}
File: src/Controller/SsmaController.php
Match lines: 1
27907|                $detailsOut['aprofundamento_status'] = 'finalized';

code_search
Show Details
{"search_text": "aprofundamento_finalized_by_"}
File: src/Controller/SsmaController.php
Match lines: 7
12768|            $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0
12769|                || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0;
12811|        $authorUserId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
12816|        $authorMemberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
12827|        $details['aprofundamento_finalized_by_user_id'] = (int) $user->getId();
12830|            $details['aprofundamento_finalized_by_member_id'] = $memberId;
12833|        $details['aprofundamento_finalized_by_name'] = $name !== '' ? $name : (string) ($user->getEmail() ?? '');

File: src/Service/Ssma/SsmaNotificationService.php
Match lines: 2
289|        $memberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
290|        $userId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);

File: src/Service/Ssma/SsmaOccurrenceApprovalService.php
Match lines: 1
110|            // Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento.

File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 2
32|        self::assertStringContainsString('aprofundamento_finalized_by_member_id', $this->notificationSrc);
42|        self::assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc);

code_search
Show Details
{"search_text": "maybeSubmitOccurrenceForValidation"}
File: cypress/e2e/ssma/README.md
Match lines: 1
168|`occurrence_approval` ser marcado `pending` automaticamente (`maybeSubmitOccurrenceForValidation`),

File: cypress/e2e/ssma/ssmaPermissionScenarios.spec.js
Match lines: 1
249|    // reacionar a submissão automática para validação (maybeSubmitOccurrenceForValidation),

File: docs/engineering/pr/feature-ssma-ocorrencia-correcoes-new-production/PR_descricao_feature-ssma-ocorrencia-correcoes-new-production.md
Match lines: 1
22|- `SsmaController.php` — `maybeSubmitOccurrenceForValidation()`, bloqueio de validação em Readequação/finalizada, endpoint `deleteCauseTreeActionPlanEntry`, helpers de status de ação

File: docs/ssma/CHAT-BASE-hotfix-permission-ocorrencia.md
Match lines: 1
26|`pending` **não** é gravado ao finalizar o aprofundamento. `maybeSubmitOccurrenceForValidation()` só marca pendente se o **status do evento** já é `CONCLUIDO` (Finalizada) **e** o aprofundamento está pronto.

File: docs/ssma/e2e-permission-scenarios.md
Match lines: 1
173|  /events/{id}/resolve`, que dispara `maybeSubmitOccurrenceForValidation` e marca

File: docs/ssma/engineering/ssma-permissoes-membro-analise-cypress.md
Match lines: 2
246|2. Algum save de aprofundamento em #148 dispara `maybeSubmitOccurrenceForValidation` e **tira** o `rejected` (o teste de reedição do especialista não pode contaminar o fixture se rodar antes).
249|**Correção de produto:** Validar só quando `occurrence_approval.status === 'pending'` (finalizado + fila). Hoje o Twig mostra o botão se o status **não** é `approved` nem `rejected` — string **vazia** também abre Validar. `viewOccurrence` ainda chama `maybeSubmitOccurrenceForValidation` ao abrir o detalhe (efeito colateral: pode promover `''` → `pending`).

File: src/Controller/SsmaController.php
Match lines: 9
3484|                    $this->maybeSubmitOccurrenceForValidation($eventEntity, $company, $user);
8382|                $this->maybeSubmitOccurrenceForValidation($parent, $company, $user);
8528|            $this->maybeSubmitOccurrenceForValidation($event, $event->getCompany(), $user);
9966|                        $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
10056|                    $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
11977|    private function maybeSubmitOccurrenceForValidation(
27715|                $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
27947|            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);
30277|                    $this->maybeSubmitOccurrenceForValidation($event, $company, $user);

File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 1
50|            'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)',

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 1
372|    && fileContains($controller, 'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)')

Request #45 deepseek-flash P:28.22K C:677 CR:26.75K CW:0 3864ms
Reasoning
Interesting. There is only one place where approfundamento_status finalized is set (27907). But wait — what about flash report "finalizar" flow at 27715? Let me look at 27700-27730. Also maybe the aprofundamento gets finalized in the create path? The `stampSsmaAprofundamentoAuthor` is only applied in the update path at 27907. Wait, but the ROS aprofundamento might be done via a different endpoint? The search shows only one place. Let's check 27715 context and also line 30277. Actually the search for `aprofundamento_status'] = 'finalized'` found only line 27907, so the stamp is placed at the single place. Good. Now, at line 27908, the stamp is applied to `$detailsOut` and then `$data['details'] = $detailsOut`. But is `$detailsOut` derived from `$data['details']`, which may be `$data` merged with `eventToValidationArray($event)`? Yes at line 27901. Fine. Now consider: `stampSsmaAprofundamentoAuthor` sets member id via `resolveCompanyMemberIdByUserId`. If the user has no member in that company (e.g., admin of platform), memberId null → only user_id stamped. Then notification via notification service uses userId fallback. Fine. Now potential issue: in `notifyAprofundamentoAuthorOnReject`, when memberId > 0 it does NOT fall back to userId. The `notify()` will call `resolveMemberUser($memberId, null)` — no company restriction. Since the member id belongs to the company, ok. But what if the member was removed from company? `getIsRemoved()` → returns null → notification lost. Minor, not worth reporting maybe. Hmm, but here's a more serious thought: previously the notification targeted `$event->getCreatedById()` — the registrant. Now targets the author. That's the intent. Let's now check the `SsmaOccurrenceApprovalService` diff. It preserves author on reject. But: does the service also reset the author when the occurrence is submitted again? The reseubmit path: `maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)` - if resubmitted true, notify approvers with resubmitted message. Does it clear occurrence_approval status from rejected to pending? Let's read maybeSubmitOccurrenceForValidation and the reject flow. Let me read the relevant approval service and the controller methods.
Tool Calls (2)
file_read
Show Details
{"file_path": "src/Service/Ssma/SsmaOccurrenceApprovalService.php"}
File: src/Service/Ssma/SsmaOccurrenceApprovalService.php (Total lines: 158)
IS_TRUNCATED: false
LINE_RANGE: 1-158
1|<?php
2|
3|declare(strict_types=1);
4|
5|namespace App\Service\Ssma;
6|
7|use App\Entity\CompanyMembers;
8|use App\Entity\SsmaEvent;
9|use App\Entity\User;
10|use App\Util\Utf8MojibakeNormalizer;
11|use Doctrine\ORM\EntityManagerInterface;
12|
13|/**
14| * Aprovação da ocorrência (não do flash report).
15| * Depois de aprovada, o flash pode ser enviado.
16| */
17|final class SsmaOccurrenceApprovalService
18|{
19|    public const STATUS_PENDING = 'pending';
20|    public const STATUS_APPROVED = 'approved';
21|    public const STATUS_REJECTED = 'rejected';
22|
23|    public function __construct(private EntityManagerInterface $entityManager)
24|    {
25|    }
26|
27|    /**
28|     * @return array{status: string, approved_by_member_id: int|null, approved_by_name: string, approved_at: string, note: string}
29|     */
30|    public function getState(SsmaEvent $event): array
31|    {
32|        $details = $event->getDetails();
33|        $raw = is_array($details['occurrence_approval'] ?? null) ? $details['occurrence_approval'] : [];
34|
35|        return [
36|            'status' => (string) ($raw['status'] ?? ''),
37|            'approved_by_member_id' => isset($raw['approved_by_member_id']) ? (int) $raw['approved_by_member_id'] : null,
38|            'approved_by_name' => (string) ($raw['approved_by_name'] ?? ''),
39|            'approved_at' => (string) ($raw['approved_at'] ?? ''),
40|            'note' => (string) ($raw['note'] ?? ''),
41|        ];
42|    }
43|
44|    public function isApproved(SsmaEvent $event): bool
45|    {
46|        return $this->getState($event)['status'] === self::STATUS_APPROVED;
47|    }
48|
49|    public function decide(
50|        SsmaEvent $event,
51|        User $actor,
52|        ?CompanyMembers $member,
53|        string $decision,
54|        string $note = '',
55|    ): array {
56|        $decision = strtolower(trim($decision));
57|        if (!in_array($decision, [self::STATUS_APPROVED, self::STATUS_REJECTED], true)) {
58|            return ['success' => false, 'message' => 'Decisão inválida. Use approved ou rejected.'];
59|        }
60|
61|        if ($decision === self::STATUS_REJECTED && trim($note) === '') {
62|            return ['success' => false, 'message' => 'Informe a observação para reprovar a ocorrência.'];
63|        }
64|
65|        $currentStatus = $this->getState($event)['status'];
66|        if ($currentStatus === self::STATUS_REJECTED) {
67|            return [
68|                'success' => false,
69|                'message' => 'A ocorrência está em readequação. Corrija e reenvie antes de validar de novo.',
70|            ];
71|        }
72|        if ($currentStatus === self::STATUS_APPROVED) {
73|            return ['success' => false, 'message' => 'Esta ocorrência já foi validada.'];
74|        }
75|
76|        $details = $event->getDetails();
77|        if (!is_array($details)) {
78|            $details = [];
79|        }
80|        $normalizedDetails = Utf8MojibakeNormalizer::normalizeRecursive($details);
81|        $details = is_array($normalizedDetails) ? $normalizedDetails : [];
82|
83|        $actorName = trim((string) ($actor->getName() ?: $actor->getEmail() ?: 'Usuário'));
84|        $state = [
85|            'status' => $decision,
86|            'approved_by_member_id' => $member instanceof CompanyMembers ? (int) $member->getId() : null,
87|            'approved_by_name' => Utf8MojibakeNormalizer::normalize($actorName),
88|            'approved_at' => (new \DateTimeImmutable())->format('c'),
89|            'note' => Utf8MojibakeNormalizer::normalize(trim($note)),
90|        ];
91|        $details['occurrence_approval'] = $state;
92|
93|        $history = is_array($details['changelog'] ?? null) ? $details['changelog'] : [];
94|        $history[] = [
95|            'at' => $state['approved_at'],
96|            'message' => $decision === self::STATUS_APPROVED
97|                ? 'Ocorrência validada'
98|                : 'Ocorrência reprovada na validação',
99|            'source' => 'occurrence_approval',
100|        ];
101|        $details['changelog'] = $history;
102|
103|        $event->setDetails($details);
104|
105|        if ($decision === self::STATUS_REJECTED) {
106|            $prevStatus = $event->getStatus();
107|            $event->setStatus(SsmaEvent::STATUS_ABERTO);
108|            $details['aprofundamento_status'] = 'draft';
109|            $details['aprofundamento_complete'] = false;
110|            // Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento.
111|            $event->setDetails($details);
112|            if ($prevStatus !== SsmaEvent::STATUS_ABERTO) {
113|                $event->appendHistory('Ocorrência devolvida para readequação após reprovação.', [
114|                    'from' => $prevStatus,
115|                    'to' => SsmaEvent::STATUS_ABERTO,
116|                ]);
117|            }
118|        }
119|
120|        $this->entityManager->flush();
121|
122|        return [
123|            'success' => true,
124|            'message' => $decision === self::STATUS_APPROVED
125|                ? 'Ocorrência aprovada. As automações (flash e/ou comitê) podem seguir.'
126|                : 'Ocorrência reprovada. O registro voltou para readequação.',
127|            'occurrence_approval' => $state,
128|        ];
129|    }
130|
131|    public function markPending(SsmaEvent $event, bool $resubmitted = false): void
132|    {
133|        $details = $event->getDetails();
134|        if (!is_array($details)) {
135|            $details = [];
136|        }
137|
138|        $details['occurrence_approval'] = [
139|            'status' => self::STATUS_PENDING,
140|            'approved_by_member_id' => null,
141|            'approved_by_name' => '',
142|            'approved_at' => '',
143|            'note' => '',
144|        ];
145|        $history = is_array($details['changelog'] ?? null) ? $details['changelog'] : [];
146|        $history[] = [
147|            'at' => (new \DateTimeImmutable())->format('c'),
148|            'message' => $resubmitted
149|                ? 'Ocorrência reenviada para validação após correção'
150|                : 'Ocorrência enviada para validação',
151|            'source' => 'occurrence_approval',
152|        ];
153|        $details['changelog'] = $history;
154|        $event->setDetails($details);
155|        $this->entityManager->flush();
156|    }
157|}
158|
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 11960, "end_line": 12060}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 11960-12060
11960|        }
11961|
11962|        return true;
11963|    }
11964|
11965|    private function isSsmaEventStatusFinalized(\App\Entity\SsmaEvent $event): bool
11966|    {
11967|        $raw = trim((string) $event->getStatus());
11968|        $upper = mb_strtoupper($raw);
11969|
11970|        return $upper === SsmaEvent::STATUS_CONCLUIDO
11971|            || in_array(mb_strtolower($raw), ['finalizada', 'resolvida', 'concluida'], true);
11972|    }
11973|
11974|    /**
11975|     * Marca pendente e notifica aprovadores quando a ocorrência foi finalizada (não no rascunho).
11976|     */
11977|    private function maybeSubmitOccurrenceForValidation(
11978|        \App\Entity\SsmaEvent $event,
11979|        Company $company,
11980|        User $sender,
11981|        bool $resubmitted = false
11982|    ): void {
11983|        if ($this->ssmaOccurrenceApprovalService->isApproved($event)) {
11984|            return;
11985|        }
11986|
11987|        if (!$resubmitted && !$this->isSsmaEventStatusFinalized($event)) {
11988|            return;
11989|        }
11990|
11991|        if (!$this->isEventReadyForOccurrenceValidation($event)) {
11992|            return;
11993|        }
11994|
11995|        $status = $this->ssmaOccurrenceApprovalService->getState($event)['status'];
11996|
11997|        if ($resubmitted) {
11998|            if ($status !== \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_REJECTED) {
11999|                return;
12000|            }
12001|            $this->ssmaOccurrenceApprovalService->markPending($event, true);
12002|            $this->ssmaNotificationService->notifyOccurrenceValidationApprovers($company, $event, $sender, true);
12003|
12004|            return;
12005|        }
12006|
12007|        if ($status === \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_PENDING) {
12008|            return;
12009|        }
12010|
12011|        if ($status === \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_REJECTED) {
12012|            return;
12013|        }
12014|
12015|        $this->ssmaOccurrenceApprovalService->markPending($event, false);
12016|        $this->ssmaNotificationService->notifyOccurrenceValidationApprovers($company, $event, $sender, false);
12017|    }
12018|
12019|    private function notifyAprofundamentoAuthorOnReject(
12020|        SsmaEvent $event,
12021|        Company $company,
12022|        User $sender,
12023|        string $note
12024|    ): void {
12025|        $this->ssmaNotificationService->notifyAprofundamentoAuthorOnReject($event, $sender, $note);
12026|    }
12027|
12028|    private function canManageSsmaOccurrences(): bool
12029|    {
12030|        $user = $this->getUser();
12031|        $company = $this->getSsmaCompany() ?? ($user instanceof User ? $user->getCompany() : null);
12032|        if ($user instanceof User && $company instanceof Company
12033|            && $this->shouldStripSsmaManagementUiForUser($company, $user)) {
12034|            return false;
12035|        }
12036|
12037|        if (
12038|            $this->isGranted('ROLE_SUPER_ADMIN')
12039|            || $this->isGranted('ROLE_MANAGER')
12040|            || $this->isGranted('ROLE_MANAGER_GESTOR')
12041|            || $this->isGranted('ROLE_TENANT')
12042|        ) {
12043|            return true;
12044|        }
12045|
12046|        $member = null;
12047|        if ($user instanceof User) {
12048|            $company = $this->getSsmaCompany() ?? $user->getCompany();
12049|            if ($company instanceof Company) {
12050|                $member = $this->getCurrentCompanyMember($company, $user);
12051|            }
12052|        }
12053|        if ($this->memberIsSsmaGestorAdministrador($member)) {
12054|            return true;
12055|        }
12056|
12057|        $tag = $this->ssmaCurrentMemberPermissionTag();
12058|
12059|        $request = $this->requestStack->getCurrentRequest();
12060|        if ($request && (
Request #46 deepseek-flash P:32.01K C:1.44K CR:28.8K CW:0 6870ms
Reasoning
Now let's check the reject flow in controller around 4100-4150 to see context. Also check `isEventReadyForOccurrenceValidation` because after reject + readequação, the flow. Confirmed finding #1 already covers the "wantsFinalize" change risk. Let me now think about what other issues exist in the diff. Potential issue: In `canAccessSsmaEventAprofundamento`, the readequação check uses `$details` passed in. In `updateEvent` at line 27829, `$details` passed is `is_array($data['details'] ?? null) ? $data['details'] : $existingDetails`. Hmm — the incoming payload's details. If the payload's details doesn't include `occurrence_approval`, the fallback to existingDetails only occurs when data['details'] is not an array. So if a front-end sends `details` without `occurrence_approval`, the readequação detection fails and the check falls to technical types → any specialist of the type could edit. But wait, is that a real risk? The front-end presumably sends the full details. Hmm, this could be a bypass: a non-author specialist could send a payload with `details` not containing `occurrence_approval`, thus bypassing the author-only restriction. But that's speculative; the front-end likely posts full details. Hmm, but a malicious client could. This is a permission check on an authorization boundary; a tampered payload could bypass. But was it already the case for `isSsmaEventDirectManagerOfReporter` etc.? Actually wait: look more carefully. `$data = array_merge($this->eventToValidationArray($event), $data);` in the `$canFullEdit` branch (line 27845) which merges event data. But the `$canAprofundamento` computation at 27825 happens BEFORE that merge, using raw `$data['details']`. So a full-edit user (gestor) with a payload lacking occurrence_approval would compute canAprofundamento based on their own details... but they already have canFullEdit, so it doesn't matter. For a non-full-edit user (specialist), at line 27836-27839, they need `$isAprofundamentoUpdate && $canAprofundamento`. So canAprofundamento was computed from payload details, not merged. If the specialist sends `details` without `occurrence_approval`, `isSsmaOccurrenceInReadequacao` returns false → then checks `in_array($type, resolveCurrentUserTechnicalTypes)` → if the user's type is the same as the event type, returns true → bypass of author-only restriction. So yes, a specialist of the same type could bypass by not sending occurrence_approval in details. Is that a real concern worth reporting? It's a permission boundary. But the front-end sends `details`... Hmm. Hmm, but actually also at line 27888, there's a second call to canAccessSsmaEventAprofundamento with `$eventDetails` (merged/processed) but that's used for stripping fields, and only when `!$aprofundamentoOnly`. In the aprofundamentoOnly case, it skips. So the authorization gate is at line 27837. Is the payload details guaranteed to contain occurrence_approval? Let me check the JS that builds the aprofundamento payload. Probably it sends the full details from the form. Let me check how updateEvent front-end builds details. That could be a lot of work. Let me search the JS for 'aprofundamento_status'. Actually, let's focus. There may be more concrete issues. Another observation: in `notifyAprofundamentoAuthorOnReject`, if `$memberId > 0` it uses member. But it's possible the author user belongs to the company but the member id was recorded under a different company (multi-tenant)? `stampSsmaAprofundamentoAuthor` resolves member id within `$company`, so consistent. Now the `SsmaOccurrenceApprovalService::decide` sets `$details['aprofundamento_status'] = 'draft'` and `aprofundamento_complete = false`, keeps author. Fine. But: `$event->setDetails($details)` is called in the reject branch twice — redundant, not a bug. Now the notification service: when the approval is rejected and the author is also the sender (the approver)? `notify()` skips if recipient == sender. Fine. Hmm, what about `$recipients` docblock? `notify` accepts `list<int|User>`. Passing memberId int or User. Fine. Now let's consider confirmed finding #1 more: "if ($wantsFinalize)". It says the queue only reopens when payload has aprofundamento_complete. Right. Another potential issue introduced: previously `if ($wasRejectedApproval) maybeSubmit(..., true)` was called regardless of wantsFinalize. Now it's nested inside `$wantsFinalize`. That's finding #1. Let me look for other issues: 1. In `canAccessSsmaEventAprofundamento`, the readequação branch returns early only if `$hasAuthor`. For old occurrences without author, falls through. Fine (documented as intended). 2. But note: when in readequação and has author, non-author admins pass because `isSsmaAprofundamentoAdmin` is checked first. The PR says "admin/tenant edita". OK. 3. Now the notification: the `sts` message changed from "voltou para rascunho" to "voltou para readequação". Fine. 4. `isSsmaOccurrenceInReadequacao` compares 'rejected'. Confirmed finding #2. 5. The `stampSsmaAprofundamentoAuthor` uses `$user->getFirstName()` / `getLastName()`. Does User have these methods? Let's verify. The diff elsewhere uses `$user->getFirstName()` (line 27950), so yes. 6. `resolveCompanyMemberIdByUserId` — exists? Search. Let me verify that method exists and its return type.
Tool Calls (3)
code_search
Show Details
{"search_text": "function resolveCompanyMemberIdByUserId"}
File: src/Controller/SsmaController.php
Match lines: 1
12895|    private function resolveCompanyMemberIdByUserId(Company $company, int $userId): ?int

code_search
Show Details
{"search_text": "function isSsmaAprofundamentoAdmin"}
File: src/Controller/SsmaController.php
Match lines: 1
12632|    private function isSsmaAprofundamentoAdmin(?Company $company, ?User $user): bool

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 1
501|fileContains($ssmaCtrl, 'function isSsmaAprofundamentoAdmin')

code_search
Show Details
{"search_text": "function resolveCurrentUserTechnicalTypes"}
File: src/Controller/SsmaController.php
Match lines: 1
12580|    private function resolveCurrentUserTechnicalTypes(Company $company, User $user): array

Request #47 deepseek-flash P:33.65K C:173 CR:33.41K CW:0 1598ms
Reasoning
Let's read resolveCompanyMemberIdByUserId and isSsmaAprofundamentoAdmin.
Tool Calls (2)
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 12600, "end_line": 12680}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 12600-12680
12600|        return in_array($type, $this->resolveCurrentUserTechnicalTypes($company, $user), true);
12601|    }
12602|
12603|    /**
12604|     * Quem pode chamar POST /events/{id}/resolve e ver “Finalizar” no detalhe.
12605|     * Especialista/admin do tipo ({@see canCompleteSsmaTechnicalType}) ou o Gestor
12606|     * responsável gravado em manager_id. O menu ⋮ do hub ainda usa
12607|     * canManageOccurrences (mais largo); este gate é o da API e do botão do detalhe.
12608|     */
12609|    private function canFinalizeSsmaOccurrence(Company $company, User $user, string $type, ?int $managerMemberId): bool
12610|    {
12611|        if ($this->canCompleteSsmaTechnicalType($company, $user, $type)) {
12612|            return true;
12613|        }
12614|
12615|        $viewerId = (int) ($this->getCurrentCompanyMember($company, $user)?->getId() ?? 0);
12616|
12617|        return $viewerId > 0 && $managerMemberId !== null && $viewerId === (int) $managerMemberId;
12618|    }
12619|
12620|    /**
12621|     * Admin real do aprofundamento SSMA:
12622|     * - SUPER_ADMIN / ROLE_TENANT / ROLE_ADMIN
12623|     * - conta admin da empresa (ROLE_MANAGER) que NÃO é membro físico plain (Palloma)
12624|     * - tag de produto "Gestor Administrador"
12625|     *
12626|     * Felipe (05/08): Tenant não via 2ª barra/botão "Aprofundamento" e precisava
12627|     * se adicionar como técnico para testar — admin da tenant libera direto.
12628|     *
12629|     * Não libera colaborador físico ROLE_USER + tag Membro/Inspetor (Palloma),
12630|     * mesmo que herde ROLE_MANAGER de plataforma.
12631|     */
12632|    private function isSsmaAprofundamentoAdmin(?Company $company, ?User $user): bool
12633|    {
12634|        if (!$user) {
12635|            return false;
12636|        }
12637|
12638|        if (
12639|            $this->isGranted('ROLE_SUPER_ADMIN')
12640|            || $this->isGranted('ROLE_TENANT')
12641|            || $this->isGranted('ROLE_ADMIN')
12642|        ) {
12643|            return true;
12644|        }
12645|
12646|        $member = $company ? $this->getCurrentCompanyMember($company, $user) : null;
12647|
12648|        // ROLE_MANAGER de conta admin (Aura) — não plain member físico.
12649|        if (
12650|            \in_array('ROLE_MANAGER', $user->getRoles(), true)
12651|            && !$this->ssmaOccurrenceCreatePermissionService->isPlainSsmaProductMember($member, $user)
12652|        ) {
12653|            return true;
12654|        }
12655|
12656|        if (!$company || !$member) {
12657|            return false;
12658|        }
12659|
12660|        return $this->memberIsSsmaGestorAdministrador($member);
12661|    }
12662|
12663|    /**
12664|     * @param array<string, mixed>|null $flashContext
12665|     *
12666|     * @return array{can_submit: bool, can_direct_send: bool}
12667|     */
12668|    private function resolveSsmaFlashReportUiPermissions(
12669|        Company $company,
12670|        User $user,
12671|        ?array $flashContext,
12672|        ?bool $isGestorUser = null
12673|    ): array {
12674|        $canManage = $this->canManageSsmaOccurrences();
12675|        $isSsmaAdmin = $this->isSsmaAprofundamentoAdmin($company, $user);
12676|        if ($isGestorUser === null) {
12677|            $isGestorUser = $this->resolveSsmaFlashReportGestorUser($company, $user);
12678|        }
12679|
12680|        return [
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 12890, "end_line": 12935}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 12890-12935
12890|        }
12891|
12892|        return $this->resolveCompanyMemberIdByUserId($company, (int) $event->getCreatedById());
12893|    }
12894|
12895|    private function resolveCompanyMemberIdByUserId(Company $company, int $userId): ?int
12896|    {
12897|        if ($userId <= 0) {
12898|            return null;
12899|        }
12900|
12901|        $cacheKey = ((int) $company->getId()) . ':' . $userId;
12902|        if (array_key_exists($cacheKey, $this->ssmaMemberIdByUserIdCache)) {
12903|            return $this->ssmaMemberIdByUserIdCache[$cacheKey];
12904|        }
12905|
12906|        $members = $this->entityManager->getRepository(CompanyMembers::class)
12907|            ->findBy(['company' => $company, 'user' => $userId, 'isRemoved' => 0]);
12908|
12909|        $resolved = null;
12910|        foreach ($members as $member) {
12911|            if ($member instanceof CompanyMembers) {
12912|                $resolved = (int) $member->getId();
12913|                break;
12914|            }
12915|        }
12916|
12917|        $this->ssmaMemberIdByUserIdCache[$cacheKey] = $resolved;
12918|
12919|        return $resolved;
12920|    }
12921|
12922|    /**
12923|     * Quem troca Gestor responsável / Relatado por: tag do produto ssma-occurrences.
12924|     * ROLE_MANAGER de plataforma + tag Membro continua Membro (caso Palloma).
12925|     * Tag técnica de aprofundamento não transforma em Gestor.
12926|     */
12927|    private function canSelectSsmaEventResponsibleManager(?Company $company, ?User $user): bool
12928|    {
12929|        if ($this->isGranted('ROLE_SUPER_ADMIN') || $this->isGranted('ROLE_TENANT')) {
12930|            return true;
12931|        }
12932|        if ($this->isSsmaHierarchyLockedManagerProfile($company, $user)) {
12933|            return false;
12934|        }
12935|        if (!$company || !$user) {
Request #48 deepseek-flash P:35.51K C:637 CR:33.79K CW:0 3927ms
Reasoning
Now, let me check the reject endpoint in the controller around 4100-4150 to see if it uses `notifyAprofundamentoAuthorOnReject` and whether the note is passed. Also whether the rejection happens for the "pending" state only. Also, a critical thing: the notification service is called with `$recipients` containing member ID. But `notify()` is called WITHOUT `$restrictToCompany`, so no company restriction. Pre-existing pattern elsewhere. Not an issue. Let's look at the reject endpoint and the flow to see if the notification recipient resolution could fail when the author member belongs to a different company. Not likely. Now let me consider the "readequação" semantics more: `isSsmaOccurrenceInReadequacao` checks `occurrence_approval.status === 'rejected'`. But the status `rejected` remains after the specialist re-submits? No — `markPending` resets it to pending. So after re-submit, status is pending → not in readequação → technical types can edit again. Hmm, that might be a bug: after the author re-submits, other specialists of the type regain edit access. But the aprofundamento is finalized now, and line 27840 blocks non-admin from editing finalized aprofundamento... Actually `$aprofundamentoFinalized` is computed at line 27820 from `$aprofundamentoStatus` / `existingDetails['aprofundamento_complete']`. After re-submit, approfundamento_complete is true → finalized → non-admin blocked. So that's covered. OK. Now, another important consideration: when in readequação (`rejected`), a non-author specialist of the same type is blocked from editing. Good. But what about the "canFullEdit" path — `canEditSsmaEvent`? That might let gestor/tenant edit. Intended. Now let's think about the notification recipient fallback. In the notification service, when memberId>0 it uses member id. But what if the member was removed (left company)? Then resolveMemberUser returns null → no notification at all, even though userId is available. The controller's `isSsmaAprofundamentoAuthor` first checks userId then memberId. Slight inconsistency but low severity. Probably not worth reporting. Hmm, but there's a more meaningful issue: the `notify()` method skips recipient if it equals the sender. If the author is the sender... fine. Let me now examine the rejected flow endpoint at ~4100 to double check the note and whether the new method reference is correct (rename consistency). Let me read.
Tool Calls (1)
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 4070, "end_line": 4160}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 4070-4160
4070|                throw new \RuntimeException('event_dispatcher indisponível');
4071|            }
4072|            $dispatcher->addListener(KernelEvents::TERMINATE, static function () use ($safe): void {
4073|                $safe();
4074|            });
4075|        } catch (\Throwable $e) {
4076|            $this->ssmaLogger->warning('Ssma deferAfterResponse fallback sync: ' . $e->getMessage());
4077|            $safe();
4078|        }
4079|    }
4080|
4081|    private function runDeferredOccurrenceApprovedSideEffects(int $eventId, int $companyId, int $userId): void
4082|    {
4083|        $event = $this->entityManager->getRepository(SsmaEvent::class)->find($eventId);
4084|        $company = $this->entityManager->getRepository(Company::class)->find($companyId);
4085|        $user = $this->entityManager->getRepository(User::class)->find($userId);
4086|        if (!$event instanceof SsmaEvent || !$company instanceof Company || !$user instanceof User) {
4087|            return;
4088|        }
4089|
4090|        $automationContext = [
4091|            'registered_by_name' => trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''))
4092|                ?: ($user->getEmail() ?? 'Sistema'),
4093|            'type_raw' => $event->getType(),
4094|            'new_type' => $event->getType(),
4095|        ];
4096|        try {
4097|            $this->ssmaAutomationService->triggerForEvent(
4098|                'ssma_on_occurrence_approved',
4099|                $event,
4100|                $company,
4101|                $automationContext
4102|            );
4103|            $this->ssmaAutomationService->triggerForEvent(
4104|                'ssma_on_occurrence_updated',
4105|                $event,
4106|                $company,
4107|                $automationContext
4108|            );
4109|        } catch (\Throwable $automationError) {
4110|            $this->ssmaLogger->warning('Ssma approveOccurrence automations: ' . $automationError->getMessage());
4111|        }
4112|
4113|        try {
4114|            $this->ssmaFlashReportService->maybeAutoRequestApproval(
4115|                $event,
4116|                $user,
4117|                'Envio automático após validação da ocorrência.'
4118|            );
4119|        } catch (\Throwable $flashError) {
4120|            $this->ssmaLogger->warning('Ssma approveOccurrence flash auto: ' . $flashError->getMessage());
4121|        }
4122|    }
4123|
4124|    private function runDeferredOccurrenceRejectedSideEffects(int $eventId, int $companyId, int $userId, string $note): void
4125|    {
4126|        $event = $this->entityManager->getRepository(SsmaEvent::class)->find($eventId);
4127|        $company = $this->entityManager->getRepository(Company::class)->find($companyId);
4128|        $user = $this->entityManager->getRepository(User::class)->find($userId);
4129|        if (!$event instanceof SsmaEvent || !$company instanceof Company || !$user instanceof User) {
4130|            return;
4131|        }
4132|
4133|        try {
4134|            $this->notifyAprofundamentoAuthorOnReject($event, $company, $user, $note);
4135|        } catch (\Throwable $notifyError) {
4136|            $this->ssmaLogger->warning('Ssma rejectOccurrence notify: ' . $notifyError->getMessage());
4137|        }
4138|        $automationContext = [
4139|            'registered_by_name' => trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''))
4140|                ?: ($user->getEmail() ?? 'Sistema'),
4141|            'type_raw' => $event->getType(),
4142|            'new_type' => $event->getType(),
4143|        ];
4144|        try {
4145|            $this->ssmaAutomationService->triggerForEvent(
4146|                'ssma_on_occurrence_rejected',
4147|                $event,
4148|                $company,
4149|                $automationContext
4150|            );
4151|        } catch (\Throwable $automationError) {
4152|            $this->ssmaLogger->warning('Ssma rejectOccurrence automations: ' . $automationError->getMessage());
4153|        }
4154|    }
4155|
4156|    /**
4157|     * GET /manager/ssma/occurrence/{id}/flash-report/context?kind=event
4158|     * Carrega destinatários/aprovadores do flash report sob demanda (Fase D).
4159|     */
4160|    public function occurrenceFlashReportContext(Request $request, int $id): JsonResponse
Request #49 deepseek-flash P:37.41K C:1.1K CR:36.1K CW:0 5859ms
Reasoning
Now let's check the doc rule file `.opencodereview/rules/ssma/occurrence-approve.md` was modified (mentioned in other changed files? Actually the docs list includes `.opencodereview/rules/ssma/occurrence-approve.md` but it's not in the "other changed files" list... The other_changed_files only lists tests. Hmm, the PR description lists 7 files including the md and cypress js, but the "Other files changed in this update" list only shows tests. Whatever. Now let me focus on finding genuine issues beyond the two confirmed. Consider the notification service change: previously `notifyOccurrenceRegistrantOnReject` was the only notification. Now it's the author. But there's a subtlety: legacy events. For occurrences created before this change, `aprofundamento_finalized_by_*` is absent. The controller falls back to old rule for permissions (documented). But the notification service does NOT fall back: if no author, it returns early — meaning no notification at all for legacy rejected occurrences (previously the registrant was notified). Is that intended? The PR says "ocorrências antigas sem autor gravado seguem a regra antiga de acesso (legado)" — access rule, not notification. So for a legacy occurrence rejected after deploy, the author stamp exists because the specialist finalizes the aprofundamento now... Hmm, but the aprofundamento was finalized before the deploy → rejection would notify nobody. That's a behavior regression: the registrant no longer gets notified for legacy occurrences. Is that worth flagging? It's a plausible real regression. However, given "readequação avisa quem fez o aprofundamento" and legacy without author... Arguably by design (the PR says only author). But the notification silently disappears. Hmm, medium/low. Could mention as attention. Actually wait — more relevant: in the flow, when a specialist finalizes the aprofundamento NOW, the `stampSsmaAprofundamentoAuthor` stamps the author. Then the approver rejects → notification goes to the author. Good. Legacy events that were already finalized before the deploy and then rejected → no author → no in-app notification. Previously they'd notify the registrant. Hmm. But after a rejection, the author stamped? No. So indeed, no notification. This might be considered an acceptable edge case, but it's a genuine behavior change. Worth a low/medium comment? The rules say favor precision over recall. The PR explicitly acknowledges legacy for access rule. I'll consider mentioning it briefly. Let me now look at the bigger picture: is there a case where `stampSsmaAprofundamentoAuthor` is called but the author should NOT be updated? E.g., admin/gestor edits and finalizes the aprofundamento (e.g., the admin finishes what the specialist started). Then the author becomes the admin. Then upon rejection, the admin gets notified and only the admin can readequar. That seems acceptable. Hmm, but consider: the admin CAN edit finalized aprofundamento (line 27846-27848 allows admin to edit even when finalized... wait no: if `$aprofundamentoFinalized && !isSsmaAprofundamentoAdmin` and `$aprofundamentoOnly || $isAprofundamentoUpdate` → 403. For admin, they proceed. Then if `$wantsFinalize` (payload has aprofundamento_complete=true), the stamp overwrites the author with the admin. So an admin just tweaking and saving with the finalize flag steals authorship. Hmm — that's a real scenario: the front-end may always send `aprofundamento_complete=true` when saving an already-finalized aprofundamento (since the field is set). Then the admin's edit would overwrite `aprofundamento_finalized_by_*`, changing who is authorized and notified. That's a data integrity concern. Is that likely? The admin edit path is "Editar" (general data), where `$data` is merged with `eventToValidationArray($event)`, which may include `aprofundamento_complete`. Let's check `eventToValidationArray` and how the front-end builds payload. Also line 27900: `if ($aprofundamentoOnly || !empty($data['aprofundamento_complete']) || array_key_exists('aprofundamento_status', $data))`. If eventToValidationArray includes 'aprofundamento_status' with 'finalized', then `array_key_exists` true, and `$wantsFinalize` = `!empty($data['aprofundamento_complete'])`... which depends on eventToValidationArray including aprofundamento_complete as true. Let's check.
Tool Calls (2)
code_search
Show Details
{"search_text": "function eventToValidationArray"}
File: src/Controller/SsmaController.php
Match lines: 1
29264|    private function eventToValidationArray(\App\Entity\SsmaEvent $event): array

code_search
Show Details
{"search_text": "aprofundamento_complete", "file_patterns": ["public/js/ssma/"]}
No matches found
Request #50 deepseek-flash P:38.58K C:142 CR:38.4K CW:0 1286ms
Tool Calls (2)
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 29264, "end_line": 29340}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 29264-29340
29264|    private function eventToValidationArray(\App\Entity\SsmaEvent $event): array
29265|    {
29266|        return [
29267|            'type'        => $event->getType(),
29268|            'origin'      => $event->getOrigin(),
29269|            'datetime'    => $event->getEventDatetime()->format('Y-m-d\\TH:i:s'),
29270|            'location'    => $event->getLocation(),
29271|            'description' => $event->getDescription(),
29272|            'nature'      => $event->getNature(),
29273|            'agent'       => $event->getAgent(),
29274|            'consequence' => $event->getConsequence(),
29275|            'impacts'     => $event->getImpacts(),
29276|            'details'     => $event->getDetails(),
29277|        ];
29278|    }
29279|
29280|    /**
29281|     * Garante título quando o campo vier vazio: gera via IA a partir da atividade/descrição.
29282|     *
29283|     * @param array<string, mixed> $data
29284|     *
29285|     * @return array<string, mixed>
29286|     */
29287|    private function ensureSsmaEventTitle(array $data): array
29288|    {
29289|        $details = isset($data['details']) && is_array($data['details']) ? $data['details'] : [];
29290|        $title   = trim((string) ($details['title'] ?? $data['title'] ?? ''));
29291|        if ($title !== '') {
29292|            return $data;
29293|        }
29294|
29295|        // Default determinístico (sem PII): "Acidente Pessoal · 15/07/2026"
29296|        $generated = $this->ssmaOccurrenceLlmService->buildDeterministicTitle(
29297|            isset($data['type']) ? (string) $data['type'] : null,
29298|            isset($data['datetime']) ? (string) $data['datetime'] : null
29299|        );
29300|        if ($generated === '') {
29301|            return $data;
29302|        }
29303|
29304|        $details['title'] = $generated;
29305|        $data['details']  = $details;
29306|        $data['title']    = $generated;
29307|
29308|        return $data;
29309|    }
29310|
29311|    /**
29312|     * @param mixed $rawIds
29313|     *
29314|     * @return int[]
29315|     */
29316|    private function normalizeSsmaActionResponsibleIds($rawIds): array
29317|    {
29318|        $ids = array_values(array_unique(array_filter(array_map('intval', (array) $rawIds))));
29319|        if (count($ids) > 1) {
29320|            $ids = [reset($ids)];
29321|        }
29322|
29323|        return $ids;
29324|    }
29325|
29326|    /**
29327|     * O formulário envia campos de detalhe no nível raiz; validador e persistência usam `details`.
29328|     *
29329|     * @param array<string, mixed> $data
29330|     *
29331|     * @return array<string, mixed>
29332|     */
29333|    private function normalizeSsmaEventPayload(array $data, ?Company $company = null): array
29334|    {
29335|        $detailKeys = [
29336|            'activity', 'approach', 'responsible_ids',
29337|            'title', 'manager_id', 'people_ids', 'team_id', 'evidences',
29338|            'strategic_nature', 'area_label', 'potential_consequence',
29339|            'classifier_dano', 'classifier_risco', 'classifier_afetado',
29340|            'category',
code_search
Show Details
{"search_text": "aprofundamento_complete", "file_patterns": ["public/js/", "templates/"]}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 1
7422|            payload.aprofundamento_complete = !!finalizeAprofundamento;

Request #51 deepseek-flash P:39.74K C:164 CR:38.66K CW:0 1424ms
Reasoning
So the front-end sets `payload.aprofundamento_complete = !!finalizeAprofundamento`. Let's read around that in the twig template to understand when it's set.
Tool Calls (2)
file_read
Show Details
{"file_path": "templates/ssma/occurrence/partials/_modal_event.html.twig", "start_line": 7360, "end_line": 7460}
File: templates/ssma/occurrence/partials/_modal_event.html.twig (Total lines: 8167)
IS_TRUNCATED: false
LINE_RANGE: 7360-7460
7360|            people_ids:       peopleIds,
7361|            witness_ids:      (document.getElementById('ev_witness_ids') || { value: '' }).value,
7362|            team_id:          teamVal,
7363|            unit_id:          teamVal || null,
7364|            responsible_ids:  (document.getElementById('ev_responsible_ids') || { value: '' }).value,
7365|            strategic_nature: (document.getElementById('ev_strategic_nature') || { value: '' }).value,
7366|            area_label:       (document.getElementById('ev_area_label') || { value: '' }).value.trim(),
7367|            evidences:        evEvidences.slice()
7368|        };
7369|
7370|        var catWrap = document.getElementById('ev-category-wrap');
7371|        var catSel  = document.getElementById('ev_category');
7372|        if (catWrap && catSel && !catWrap.classList.contains('d-none')) {
7373|            var catVal = (catSel.value || '').trim();
7374|            if (catVal) {
7375|                payload.category = catVal;
7376|            }
7377|        }
7378|
7379|        var cd = document.getElementById('ev_classifier_dano');
7380|        var cr = document.getElementById('ev_classifier_risco');
7381|        var ca = document.getElementById('ev_classifier_afetado');
7382|        if (cd && cd.value) payload.classifier_dano    = cd.value;
7383|        if (cr && cr.value) payload.classifier_risco   = cr.value;
7384|        if (ca && ca.value) payload.classifier_afetado = ca.value;
7385|
7386|        if (evRequiresAccidentTechnical(type)) {
7387|            var derivedCrit = evResolvePotentialSeverity(
7388|                (document.getElementById('ev_derived_criticality_select') || {}).value,
7389|                (document.getElementById('ev_derived_criticality') || {}).value,
7390|                payload.consequence,
7391|                payload.potential_consequence
7392|            );
7393|            if (derivedCrit) {
7394|                payload.potential_severity = derivedCrit;
7395|            }
7396|        } else if (type === 'ROS') {
7397|            // Campo já é a escala de gravidade (LEVE…CRITICO).
7398|            var rosPc = (document.getElementById('ev_ros_potential_consequence') || { value: '' }).value;
7399|            var rosSev = evResolvePotentialSeverity(rosPc);
7400|            if (rosSev) payload.potential_severity = rosSev;
7401|        } else if (type === 'QUASE_ACIDENTE') {
7402|            var qaPc = (document.getElementById('ev_qa_potential_consequence') || { value: '' }).value;
7403|            var qaCrit = evResolvePotentialSeverity(
7404|                qaPc,
7405|                (document.getElementById('ev_criticality') || {}).value
7406|            );
7407|            if (qaCrit) payload.potential_severity = qaCrit;
7408|        } else {
7409|            var critEl = document.getElementById('ev_criticality');
7410|            if (critEl && critEl.value) {
7411|                payload.potential_severity = evResolvePotentialSeverity(critEl.value) || critEl.value;
7412|            }
7413|        }
7414|
7415|        if (evRequiresAprofundamento(type)) {
7416|            payload.corrective_actions = evCollectCorrectiveActions();
7417|        }
7418|
7419|        // Marca 2ª etapa: backend exige campos técnicos só quando o médico finaliza o Aprofundamento.
7420|        if (evCurrentStep === 'aprofundamento' && evAprofundamentoOnlyMode) {
7421|            payload.aprofundamento_only = true;
7422|            payload.aprofundamento_complete = !!finalizeAprofundamento;
7423|            payload.aprofundamento_status = finalizeAprofundamento ? 'finalized' : 'draft';
7424|        }
7425|
7426|        // Etapa 1 de acidentes: não envia campos técnicos vazios (evita disparar validação da 2ª etapa).
7427|        if (
7428|            (type === 'ACIDENTE_PESSOAL' || type === 'ACIDENTE_MATERIAL' || type === 'ACIDENTE_AMBIENTAL')
7429|            && evCurrentStep !== 'aprofundamento'
7430|            && !evCanEditAprofundamento(type)
7431|        ) {
7432|            payload.consequence = '';
7433|            payload.potential_consequence = '';
7434|            delete payload.potential_severity;
7435|            delete payload.injury_type;
7436|            delete payload.injury_classification;
7437|            delete payload.injury_severity;
7438|            delete payload.descaracterizado;
7439|            delete payload.descaracter_comment;
7440|            delete payload.body_parts;
7441|            delete payload.strategic_nature;
7442|            delete payload.corrective_actions;
7443|            delete payload.asset_type;
7444|            delete payload.failed_barrier;
7445|            delete payload.barrier_type;
7446|            delete payload.environmental_medium;
7447|            delete payload.pollutant_type;
7448|        }
7449|
7450|        if (mode === 'edit') {
7451|            payload.status = document.getElementById('ev_status').value;
7452|        } else {
7453|            var stEl = document.getElementById('ev_status');
7454|            payload.status = (stEl && stEl.value) ? stEl.value : 'ABERTO';
7455|        }
7456|
7457|        // ROS "Visto e resolvido" = SIM: a ocorrência já nasce (ou é salva) como resolvida.
7458|        if (type === 'ROS' && evIsRosResolvedChecked()) {
7459|            payload.status = 'CONCLUIDO';
7460|        }
code_search
Show Details
{"search_text": "finalizeAprofundamento"}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 4
7142|        var finalizeAprofundamento = !evAprofundamentoOnlyMode || !!evAprofundamentoFinalizeIntent;
7176|        var softAprofundamentoDraft = evAprofundamentoOnlyMode && !finalizeAprofundamento;
7422|            payload.aprofundamento_complete = !!finalizeAprofundamento;
7423|            payload.aprofundamento_status = finalizeAprofundamento ? 'finalized' : 'draft';

Request #52 deepseek-flash P:41.54K C:201 CR:39.81K CW:0 1825ms
Reasoning
So the front-end only sets `aprofundamento_complete` in aprofundamento-only mode. For general edit mode (admin "Editar"), it doesn't set aprofundamento fields. But `$aprofundamentoOnly` is derived server-side presumably from `$data['aprofundamento_only']`. Let's check: at 27832 `$isAprofundamentoUpdate = $aprofundamentoOnly || !empty($data['aprofundamento_complete']) || ...`. Where is `$aprofundamentoOnly` set? Let's read above 27820.
Tool Calls (1)
file_read
Show Details
{"end_line": 27825, "file_path": "src/Controller/SsmaController.php", "start_line": 27740}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 27740-27825
27740|            $msg = $this->getParameter('kernel.debug') ? $e->getMessage() : 'Erro ao salvar o evento. Tente novamente.';
27741|
27742|            return new JsonResponse(['success' => false, 'message' => $msg], 500);
27743|        }
27744|    }
27745|
27746|    /**
27747|     * GET /manager/ssma/events/{id}
27748|     * Retorna um evento pelo ID.
27749|     */
27750|    public function getEvent(int $id): JsonResponse
27751|    {
27752|        /** @var \App\Entity\User|null $user */
27753|        $user    = $this->getUser();
27754|        $company = $this->getSsmaCompany() ?? $user?->getCompany();
27755|        if (!$user || !$company) {
27756|            return new JsonResponse(['success' => false, 'message' => 'Não autenticado.'], 401);
27757|        }
27758|
27759|        $event = $this->entityManager->getRepository(\App\Entity\SsmaEvent::class)->find($id);
27760|
27761|        if (!$event || $event->getCompany()->getId() !== $company->getId()) {
27762|            return new JsonResponse(['success' => false, 'message' => 'Evento não encontrado.'], 404);
27763|        }
27764|
27765|        if (!$this->isSsmaEventVisibleUnderOccurrenceTeamDashboardScope($event, $company, $user)) {
27766|            return new JsonResponse(['success' => false, 'message' => 'Evento não encontrado.'], 404);
27767|        }
27768|
27769|        $service = new \App\Service\Ssma\SsmaEventService($this->entityManager, $this->ssmaOccurrenceTypeConfig);
27770|        $serialized = $service->serialize($event);
27771|        $registeredByMemberId = $this->resolveSsmaEventRegisteredByMemberId($company, $event);
27772|        $serialized['created_by_member_id'] = $registeredByMemberId;
27773|        if (is_array($serialized['details'] ?? null) && $registeredByMemberId && empty($serialized['details']['registered_by_member_id'])) {
27774|            $serialized['details']['registered_by_member_id'] = $registeredByMemberId;
27775|        }
27776|        $detailsForAccess = is_array($serialized['details'] ?? null) ? $serialized['details'] : [];
27777|        $eventType = strtoupper(trim((string) ($serialized['type'] ?? $event->getType() ?? '')));
27778|        $aprofStatus = strtolower(trim((string) ($detailsForAccess['aprofundamento_status'] ?? '')));
27779|        $canAccessAprof = $this->canAccessSsmaEventAprofundamento(
27780|            $company,
27781|            $user,
27782|            $eventType,
27783|            $detailsForAccess,
27784|            (int) ($event->getCreatedById() ?? 0)
27785|        );
27786|        $serialized['_can_edit_aprofundamento'] = $canAccessAprof
27787|            && ($aprofStatus !== 'finalized' || $this->isSsmaAprofundamentoAdmin($company, $user));
27788|        $serialized['_user_technical_types'] = $this->resolveCurrentUserTechnicalTypes($company, $user);
27789|
27790|        return new JsonResponse(['success' => true, 'event' => $serialized]);
27791|    }
27792|
27793|    /**
27794|     * POST /manager/ssma/events/{id}
27795|     * Atualiza um evento existente.
27796|     */
27797|    public function updateEvent(Request $request, int $id): JsonResponse
27798|    {
27799|        /** @var \App\Entity\User|null $user */
27800|        $user    = $this->getUser();
27801|        $company = $user?->getCompany();
27802|        if (!$user || !$company) {
27803|            return new JsonResponse(['success' => false, 'message' => 'Não autenticado.'], 401);
27804|        }
27805|
27806|        $event = $this->entityManager->getRepository(\App\Entity\SsmaEvent::class)->find($id);
27807|
27808|        if (!$event || $event->getCompany()->getId() !== $company->getId()) {
27809|            return new JsonResponse(['success' => false, 'message' => 'Evento não encontrado.'], 404);
27810|        }
27811|
27812|        if (!$this->isSsmaEventVisibleUnderOccurrenceTeamDashboardScope($event, $company, $user)) {
27813|            return new JsonResponse(['success' => false, 'message' => 'Evento não encontrado.'], 404);
27814|        }
27815|
27816|        $data = json_decode($request->getContent(), true) ?? [];
27817|        $aprofundamentoOnly = !empty($data['aprofundamento_only']);
27818|        $existingDetails = is_array($event->getDetails()) ? $event->getDetails() : [];
27819|        $aprofundamentoStatus = strtolower(trim((string) ($existingDetails['aprofundamento_status'] ?? '')));
27820|        $aprofundamentoFinalized = $aprofundamentoStatus === 'finalized'
27821|            || !empty($existingDetails['aprofundamento_complete']);
27822|
27823|        $canFullEdit = $this->canEditSsmaEvent($company, $user, $event);
27824|        $eventTypeForAccess = (string) ($data['type'] ?? $event->getType());
27825|        $canAprofundamento = $this->canAccessSsmaEventAprofundamento(
Request #53 deepseek-flash P:43.16K C:292 CR:41.73K CW:0 2257ms
Reasoning
OK, so `$aprofundamentoOnly = !empty($data['aprofundamento_only'])`. Now `$data['aprofundamento_complete']` only set in aprofundamento-only mode from the front-end. So the admin general edit path won't set it. Then at line 27900: `if ($aprofundamentoOnly || !empty($data['aprofundamento_complete']) || array_key_exists('aprofundamento_status', $data))`. For the admin edit (not aprofundamentoOnly), the merged `$data` (which includes `details` from the event, but top-level aprofundamento_status?). `normalizeSsmaEventPayload` might promote detail keys to root. Let's see if 'aprofundamento_status' is among `$detailKeys` in normalizeSsmaEventPayload. If yes, then `$data['aprofundamento_status']` would be set from `$data['details']`... Hmm. Let's read normalizeSsmaEventPayload fully.
Tool Calls (1)
file_read
Show Details
{"end_line": 29430, "file_path": "src/Controller/SsmaController.php", "start_line": 29333}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 29333-29430
29333|    private function normalizeSsmaEventPayload(array $data, ?Company $company = null): array
29334|    {
29335|        $detailKeys = [
29336|            'activity', 'approach', 'responsible_ids',
29337|            'title', 'manager_id', 'people_ids', 'team_id', 'evidences',
29338|            'strategic_nature', 'area_label', 'potential_consequence',
29339|            'classifier_dano', 'classifier_risco', 'classifier_afetado',
29340|            'category',
29341|            'occurrence_time',
29342|            'deviation_type', 'involvement_type', 'potential_severity', 'immediate_risk',
29343|            'failed_barrier', 'failed_barrier_other', 'barrier_type',
29344|            'person_id', 'person_type', 'had_injury', 'injury_type', 'injury_severity', 'injury_classification',
29345|            'work_leave', 'fatal', 'immediate_cause', 'body_parts', 'body_location_detail', 'hand_finger', 'foot_toe',
29346|            'injured_person_details', 'descaracter_suspect', 'descaracterizado', 'descaracter_comment', 'witness_ids',
29347|            'asset_type', 'operational_impact', 'estimated_loss', 'downtime',
29348|            'environmental_medium', 'pollutant_type', 'estimated_volume',
29349|            'containment_done', 'containment_time', 'external_impact', 'affected_area',
29350|            'gmr',
29351|            'corrective_actions',
29352|            'improvement_suggestions',
29353|            'ros_resolved',
29354|            'ros_resolution_notes',
29355|            'ros_resolution_evidences',
29356|        ];
29357|
29358|        $details = isset($data['details']) && is_array($data['details']) ? $data['details'] : [];
29359|
29360|        foreach ($detailKeys as $key) {
29361|            if (array_key_exists($key, $data)) {
29362|                $details[$key] = $data[$key];
29363|            }
29364|        }
29365|
29366|        $footCodes = ['HALUX', 'SEGUNDO_DEDO', 'TERCEIRO_DEDO', 'QUARTO_DEDO', 'MINIMO_PE'];
29367|        $hfRaw = isset($details['hand_finger']) ? trim((string) $details['hand_finger']) : '';
29368|        $ftRaw = isset($details['foot_toe']) ? trim((string) $details['foot_toe']) : '';
29369|        /* Swap antigo (single-value): mantêm compatibilidade apenas se não houver vírgula */
29370|        if ($ftRaw === '' && $hfRaw !== '' && !str_contains($hfRaw, ',') && \in_array($hfRaw, $footCodes, true)) {
29371|            $details['foot_toe'] = $hfRaw;
29372|            $details['hand_finger'] = '';
29373|        }
29374|
29375|        $type = $data['type'] ?? '';
29376|        $categoryLabel = trim((string) ($details['category'] ?? ''));
29377|        if ($categoryLabel !== '') {
29378|            $natureFromCategory = \App\Enum\Ssma\EventNatureEnum::resolveFromLabel($categoryLabel);
29379|            if ($natureFromCategory !== null) {
29380|                $data['nature'] = $natureFromCategory;
29381|            }
29382|        }
29383|
29384|        if ($type === EventTypeEnum::ROS && isset($details['involvement_type'])) {
29385|            $normalizedInv = RosInvolvementTypeEnum::normalizeLegacy((string) $details['involvement_type']);
29386|            if (RosInvolvementTypeEnum::isValid($normalizedInv)) {
29387|                $details['involvement_type'] = $normalizedInv;
29388|            }
29389|        }
29390|
29391|        if (in_array($type, [\App\Enum\Ssma\EventTypeEnum::ROS, \App\Enum\Ssma\EventTypeEnum::QUASE_ACIDENTE], true)) {
29392|            $n = $details['nature'] ?? '';
29393|            if ($n === '' || !\App\Enum\Ssma\EventClassNatureEnum::isValid((string) $n)) {
29394|                $approach = (string) ($details['approach'] ?? '');
29395|                $details['nature'] = match ($approach) {
29396|                    'MEDIACAO' => \App\Enum\Ssma\EventClassNatureEnum::ORGANIZACIONAL,
29397|                    'TREINAMENTO', 'ACAO_DISCIPLINAR' => \App\Enum\Ssma\EventClassNatureEnum::COMPORTAMENTAL,
29398|                    'MELHORIA_PROCESSO' => \App\Enum\Ssma\EventClassNatureEnum::TECNICA,
29399|                    default => \App\Enum\Ssma\EventClassNatureEnum::OPERACIONAL,
29400|                };
29401|            }
29402|        }
29403|
29404|        if ($type === EventTypeEnum::ACIDENTE_AMBIENTAL) {
29405|            $em = (string) ($details['environmental_medium'] ?? '');
29406|            if ($em === 'AGUA') {
29407|                $details['environmental_medium'] = EnvironmentalMediumEnum::AGUA_SUPERFICIAL;
29408|            } elseif ($em === 'MULTIPLO') {
29409|                $details['environmental_medium'] = EnvironmentalMediumEnum::OUTRO;
29410|            }
29411|        }
29412|
29413|        $sn = $details['strategic_nature'] ?? '';
29414|        if ($sn === '' || $sn === null) {
29415|            $details['strategic_nature'] = \App\Enum\Ssma\EventStrategicNatureEnum::PROCESSO;
29416|        }
29417|
29418|        if (isset($details['manager_id']) && $details['manager_id'] !== '' && $details['manager_id'] !== null) {
29419|            $details['manager_id'] = (int) $details['manager_id'];
29420|        }
29421|
29422|        // Caixinhas do acidentado: form envia JSON string; persiste como objeto.
29423|        if (isset($details['injured_person_details']) && is_string($details['injured_person_details'])) {
29424|            $decodedInj = json_decode($details['injured_person_details'], true);
29425|            $details['injured_person_details'] = is_array($decodedInj) ? $decodedInj : [];
29426|        }
29427|
29428|        $consequenceReal = strtoupper(trim((string) ($data['consequence'] ?? $details['consequence'] ?? '')));
29429|        if ($consequenceReal === 'SEM_DANO') {
29430|            $details['had_injury'] = 0;
Request #54 deepseek-flash P:45.17K C:1.47K CR:43.39K CW:0 7690ms
Reasoning
`aprofundamento_status` isn't in detailKeys, so it stays in details only. So for the admin "Editar" path, `$data['aprofundamento_status']` is not set at root, and `aprofundamento_complete` not set at root. So the block at 27900 is skipped. Good — the stamp isn't triggered by admin general edits. Now what about the `$aprofundamentoOnly` mode where the author is a non-admin specialist in readequação? They finalize → stamp with their own id → fine. What about admin using the aprofundamento-only mode (admin finalizing on behalf)? Then stamp = admin. Acceptable. Now, back to the notification service. Let me check `eventTitle` and `occurrenceViewUrl` - fine. Now consider the case in `notifyAprofundamentoAuthorOnReject`: `$memberId` from details could be a member of a different company if the occurrence was moved? Unlikely. Let me consider a more subtle issue: In the controller, `isSsmaAprofundamentoAuthor($details, $user, $loggedMember)` — `$loggedMember` is resolved via `getCurrentCompanyMember($company, $user)`. Fine. Now, is there any place that reads `aprofundamento_finalized_by_*` for display, e.g., in the UI (Twig) to show "quem readequa"? Not needed. Let me now think about the notification fallback inconsistency more concretely and whether to report it. Actually there's a more interesting bug: in the notification service, when `$memberId > 0`, `$recipients[] = $memberId` — but `notify()` resolves the member to a user via `resolveMemberUser((int) $recipient, $restrictToCompany)` with `$restrictToCompany = null` (since the call doesn't pass company). Then no company check. Fine. Hmm, wait — the call to `$this->notify(...)` passes 5 args; `$restrictToCompany` defaults to null. Fine. Now, `$userId` fallback: `$this->entityManager->find(User::class, $userId)`. If the user has been deleted → null → return with empty recipients... Actually if memberId==0 and userId>0 but user not found, `$recipients` stays empty → return. Fine. OK so let me now examine the `assert_branch_ui_fixes.php` and the unit test? They're not in review files. Only 3 files to review. Let me re-examine the controller diff hunk for the readequação check placement once more for a real bug: ```php $loggedMember = $this->getCurrentCompanyMember($company, $user); // Readequação: só quem enviou o aprofundamento (admin já passou acima). if ($this->isSsmaOccurrenceInReadequacao($details)) { $hasAuthor = ...; if ($hasAuthor) { return $this->isSsmaAprofundamentoAuthor($details, $user, $loggedMember); } } ``` Note: `$loggedMember` moved above (previously defined later at 12783 before the `if (!$loggedMember)` check). This is fine. But there's a subtle behavior: previously `$loggedMember` was resolved after the technical-types check; now it's resolved always at the start (extra query?). `getCurrentCompanyMember` is probably cached. Minor. Now think about whether readequação blocks the *registrant* from editing when the registrant is not the author. In the old flow, the registrant could edit (probably via other means). Now blocked. That's the intent. Hmm, but there's an interesting concern: the readequação check applies to ALL event types, including ACIDENTE_*, not just ROS/QUASE_ACIDENTE. Previously technical types could edit. Now, in readequação with author stamped, only the author. That's intended. Another consideration: `isSsmaOccurrenceInReadequacao` returns true whenever occurrence_approval.status === 'rejected' — regardless of whether the aprofundamento was finalized for this event. If an event was rejected and then the author re-submits (status pending), fine. But what if an event is rejected and the author is the only one who can edit — but the author can't because `$canFullEdit` false and `$isAprofundamentoUpdate` requires aprofundamento fields? Not our concern. Now, let's double check the changed notification in the service: the docblock says "Não dispara para todos os especialistas do tipo". Fine. Now let's consider whether the notification should include a fallback to registrant for legacy. Let me check if there's any other notification for the rejected path (automation `ssma_on_occurrence_rejected` can notify others). The PR states automation handles extras. But the automation is opt-in config; if not configured, legacy occurrences' authors... Actually for legacy occurrences there IS no author. Hmm, the PR says "ocorrências antigas sem autor gravado seguem a regra antiga de acesso (legado)" — but for notification, they follow "no notification". This is a potential functional regression. I think it's worth a medium/low comment: for occurrences whose aprofundamento was finalized before this deploy (no author recorded), nobody gets the in-app reject notification anymore, whereas previously the registrant was notified. Suggest falling back to `$event->getCreatedById()` when no author is stamped. Hmm, but wait — is that truly the case? When an ocurrence is rejected, the aprofundamento must have been finalized (per business rule: readequação only after aprofundamento finalized + Reprovar). Actually is that enforced? `decide()` can reject any pending occurrence. Pending is set by maybeSubmit which requires `isEventReadyForOccurrenceValidation`. Let's check that function to see if it requires aprofundamento. If it requires aprofundamento finalizado, then any rejected occurrence had the aprofundamento finalized — but possibly before the deploy (legacy), so no author stamp. So yes, legacy rejected occurrences → no notification. Actually hold on: after this PR, rejecting an occurrence that had aprofundamento finalized before the deploy means nobody can readequar either (hasAuthor false → old rule → technical types can edit, which is the legacy behavior). And notification → nobody. So there's an asymmetry: access falls back to legacy, notification doesn't. Reporting this as a maintainability/bug finding seems reasonable. Let me check `isEventReadyForOccurrenceValidation`.
Tool Calls (1)
code_search
Show Details
{"search_text": "function isEventReadyForOccurrenceValidation"}
File: src/Controller/SsmaController.php
Match lines: 1
11944|    private function isEventReadyForOccurrenceValidation(\App\Entity\SsmaEvent $event): bool

Request #55 deepseek-flash P:46.69K C:82 CR:46.21K CW:0 1451ms
Tool Calls (1)
file_read
Show Details
{"end_line": 11965, "file_path": "src/Controller/SsmaController.php", "start_line": 11890}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 11890-11965
11890|        }
11891|
11892|        $member = $this->getCurrentCompanyMember($company, $user);
11893|        if (!$member instanceof CompanyMembers) {
11894|            return false;
11895|        }
11896|
11897|        // BUG CONFIRMADO em reunião de alinhamento (2026-09-03, seção "Aprovador de ocorrência"):
11898|        // validar depende exclusivamente de quem está listado na configuração de aprovadores —
11899|        // inclusive Gestor Administrador. "O gestor administrativo se ele não tiver [configurado
11900|        // na lista], nem aparece pra ele." Removido o bypass automático por tag
11901|        // (memberIsSsmaGestorAdministrador) e por ROLE_MANAGER_GESTOR, que concediam o botão
11902|        // "Validar ocorrência" sem passar pela lista de aprovadores.
11903|        $approverIds = $this->ssmaOccurrenceTypeConfig->getFlashReportApproverIds($company);
11904|
11905|        return in_array((int) $member->getId(), array_map('intval', $approverIds), true);
11906|    }
11907|
11908|    private function ssmaEventTypeRequiresAprofundamentoFinalizeGate(string $type): bool
11909|    {
11910|        return in_array(strtoupper(trim($type)), [
11911|            'ROS',
11912|            'QUASE_ACIDENTE',
11913|            'ACIDENTE_PESSOAL',
11914|            'ACIDENTE_MATERIAL',
11915|            'ACIDENTE_AMBIENTAL',
11916|        ], true);
11917|    }
11918|
11919|    private function isSsmaEventAprofundamentoExplicitlyFinalized(\App\Entity\SsmaEvent $event): bool
11920|    {
11921|        $details = is_array($event->getDetails()) ? $event->getDetails() : [];
11922|        $status = strtolower(trim((string) ($details['aprofundamento_status'] ?? '')));
11923|
11924|        return $status === 'finalized' || !empty($details['aprofundamento_complete']);
11925|    }
11926|
11927|    /**
11928|     * ROS "visto e resolvido" dispensa o aprofundamento técnico no gate de validação.
11929|     */
11930|    private function ssmaEventSkipsAprofundamentoValidationGate(\App\Entity\SsmaEvent $event): bool
11931|    {
11932|        if (strtoupper(trim((string) $event->getType())) !== 'ROS') {
11933|            return false;
11934|        }
11935|
11936|        $details = is_array($event->getDetails()) ? $event->getDetails() : [];
11937|
11938|        return !empty($details['ros_resolved']);
11939|    }
11940|
11941|    /**
11942|     * Ocorrência pronta para o gate de validação (aprofundamento completo ou tipo sem essa etapa).
11943|     */
11944|    private function isEventReadyForOccurrenceValidation(\App\Entity\SsmaEvent $event): bool
11945|    {
11946|        if ($this->ssmaEventSkipsAprofundamentoValidationGate($event)) {
11947|            return true;
11948|        }
11949|
11950|        if ($this->ssmaEventAprofundamentoPending($event)) {
11951|            return false;
11952|        }
11953|
11954|        if ($this->ssmaEventTypeRequiresAprofundamentoFinalizeGate($event->getType())) {
11955|            $details = is_array($event->getDetails()) ? $event->getDetails() : [];
11956|            $status = strtolower(trim((string) ($details['aprofundamento_status'] ?? '')));
11957|            if ($status === 'draft') {
11958|                return false;
11959|            }
11960|        }
11961|
11962|        return true;
11963|    }
11964|
11965|    private function isSsmaEventStatusFinalized(\App\Entity\SsmaEvent $event): bool
Request #56 deepseek-flash P:47.81K C:2.41K CR:46.72K CW:0 11838ms
Reasoning
Confirmed: ROS "visto e resolvido" skips the aprofundamento gate → such occurrences can be rejected without ever having an author stamped. Then: - Access: hasAuthor false → falls back to technical types (legacy rule) — so the author restriction doesn't apply. That's the confirmed finding #1 context? No, finding #1 is about the queue re-open. Hmm, finding #1 mentions "ROS visto e resolvido" too. - Notification: no author → no in-app notification at all. Previously the registrant was notified. So for ROS visto-e-resolvido and any legacy/rejected event without author stamp, the reject notification disappears entirely. This is a genuine functional regression worth reporting (medium/high). It's not among the confirmed findings. The confirmed findings are about (1) queue reopen, (2) string literal 'rejected'. Neither covers the notification fallback. So I'll report: in `notifyAprofundamentoAuthorOnReject`, add a fallback to the registrant (`$event->getCreatedById()`) when there's no stamped author, to avoid losing the reject notification for occurrences that never had an aprofundamento finalized by the new flow (ROS visto e resolvido, legacy events). Hmm, but is that "the intended design"? The PR states: "Aviso in-app só para quem enviou o aprofundamento (não o registrante...)". For the case where there is no author (no aprofundamento), the "who did the aprofundamento" is undefined. The registrant may be the appropriate fallback (that's what the old code did). I think it's a legitimate finding: silently notifying nobody. Severity medium. Now, are there other issues? Let's check the notification ordering: memberId takes precedence over userId. If memberId > 0 but the member belongs to a different company than the event's company... can't happen from stamping. Let me also double check `CompanyMembers::getIsRemoved()` exists — used in resolveMemberUser. Not changed. Let me now double-check the change in `SsmaOccurrenceApprovalService`: ```php $event->setDetails($details); // line 103 ... if ($decision === self::STATUS_REJECTED) { ... $details['aprofundamento_status'] = 'draft'; $details['aprofundamento_complete'] = false; ... // keep author $event->setDetails($details); // line 111 ``` Note line 103 already called setDetails with $details (same array reference? In PHP, arrays are copy-on-write; `$details` local var — `setDetails($details)` copies. Then modifying `$details` after does not affect the entity's stored array unless setDetails is called again. Line 111 calls it again. Fine). Now, is there a problem that `aprofundamento_status` draft + `aprofundamento_complete=false` while author kept — but the "isSsmaOccurrenceInReadequacao" is based on occurrence_approval status, not aprofundamento_status. Fine. Hmm — but wait: line 27820 `$aprofundamentoFinalized = $aprofundamentoStatus === 'finalized' || !empty($existingDetails['aprofundamento_complete'])`. After rejection, aprofundamento_complete=false and status draft → so non-admins can edit the aprofundamento (the author). Good, that's needed for the readequação. Now, another thought on the controller: `canAccessSsmaEventAprofundamento` in readequação returns false for non-author technical specialists. But note `$isSsmaAprofundamentoAdmin` first. What about the "gestor direto do relator"? Blocked. OK. Now let's consider the `_can_edit_aprofundamento` flag in getEvent (line 27786) — it uses canAccessAprofundamento, so the UI would reflect. Fine. Now potential issue: is the readequação restriction also applied to `canEditSsmaEvent` (full edit)? No — gestor/tenant can full edit. Intended. Another issue: Since `isSsmaOccurrenceInReadequacao` is checked before `resolveCurrentUserTechnicalTypes`, consider an ADMIN who is also... no, admin returns earlier. Now let's think about whether `isSsmaOccurrenceInReadequacao` should also consider `aprofundamento_status === 'draft'`. Not our call. Let me now scan the rest of the notification service beyond line 420 to see if there's anything else related (e.g., automation recipients). Not needed. Let me reconsider: Are there other callers of `notifyOccurrenceRegistrantOnReject`? The search showed only tests reference the old name (asserting absence). And the controller now calls the new name. Good — consistent. Wait, but there's a risk: are there other services/controllers (outside diff) that call `notifyOccurrenceRegistrantOnReject` on the notification service? The search across the repo found no other matches (only tests). Good. Now, let's think about the "God object / duplicated logic" priority rule. Is there duplicated author-resolution logic between `isSsmaAprofundamentoAuthor` (controller) and `notifyAprofundamentoAuthorOnReject` (service)? They both read `aprofundamento_finalized_by_*`. But they're in different layers and the service reads event details directly. Hmm, the service reaching into `$event->getDetails()` raw keys duplicates knowledge of the detail key contract that the controller defines in `stampSsmaAprofundamentoAuthor`. That's a mild maintainability concern (duplicated domain knowledge of key names across layers). The user rules mention "Não deixe a mesma regra de ... recalculada com critério diferente em outro service... deve existir uma única fonte de verdade". Here the recipient resolution: the controller's author check prefers userId first then memberId; the service prefers memberId first then userId. Slightly different priority but effectively equivalent. Not worth a separate comment perhaps. Hmm, one more: in the service, when only `aprofundamento_finalized_by_name` exists... no. Let me consider the case: the author user is a member of company A; event company B? no. Now, are there tests? The other changed test file `SsmaOccurrenceReadequacaoAuthorTest.php` is out of the review group. So I won't comment on tests. Let me also check: does the `notify()` call in the service pass a `Company` restriction for the notification center (multi-tenant isolation)? Other methods pass `$company` sometimes. Here it's null. Pre-existing style. Not flagging. Now, one more potential real bug in the controller: the readequação branch returns `isSsmaAprofundamentoAuthor(...)` only when `$hasAuthor`. But consider an occurrence in readequação where the author is stamped with ONLY `aprofundamento_finalized_by_user_id` (no member id, e.g., the admin/platform user had no CompanyMembers). Then `$hasAuthor` true, and `isSsmaAprofundamentoAuthor` matches on userId. Fine. Now consider the ROS visto-e-resolvido flow and finding #1 — already covered. Let me double check the "if ($wantsFinalize)" combined with `$wasRejectedApproval`. Actually wait, there's something interesting: previously when `$wasRejectedApproval` and NOT wantsFinalize (e.g., the author re-saves a draft), it called `maybeSubmitOccurrenceForValidation(..., true)` which internally requires status === REJECTED and readiness. Now that path is removed → the queue won't reopen when the author saves the aprofundamento as draft. Fine per design (only on re-submit/finalize). Hmm, but hold on: what if the author is in readequação and the aprofundamento is ALREADY finalized (they didn't change it) — is `$wantsFinalize` true? In aprofundamento-only mode, `finalizeAprofundamento` depends on `evAprofundamentoOnlyMode && finalizeAprofundamento`... `var finalizeAprofundamento = !evAprofundamentoOnlyMode || !!evAprofundamentoFinalizeIntent;`. So in aprofundamento-only mode, it depends on the user's intent (clicking "Finalizar"). Presumably the readequação UI shows the same buttons. OK. Now let's verify: is `$wasRejectedApproval` computed before `updateFromPayload` (yes, line 27939) — correct, since updateFromPayload may change the status. Fine. I think I have two candidate comments: A) Notification lost when no author stamped (legacy / ROS visto-e-resolvido) — the registrant is no longer notified. Medium. B) Maybe the duplicate domain knowledge in the service — low, skip. Is (A) really valid? Let's double check that previously the registrant was notified on reject, and now for events without a stamped author nothing is notified. Yes: old code `$recipient = $this->entityManager->find(User::class, $event->getCreatedById()); if (!$recipient instanceof User) return; notify([$recipient], ...)`. New code returns early if no author. So for legacy events, notification is dropped. I'll report it as a bug (medium-high) with suggestion to fall back to createdById. Hmm, but the PR explicitly says the natural flow notifies only the author, and extras are handled by automation. For legacy events the "author" is unknown. Falling back to the registrant for legacy is a reasonable suggestion. But is it "likely real"? Yes — the notification disappears silently. I'll report as medium severity. Now, let me look for anything else. Let me check the second controller hunk once more for a potential issue with `array_key_exists('aprofundamento_status', $data)` and `stampSsmaAprofundamentoAuthor`. Note: the stamp is inside the `if ($wantsFinalize)` block *within* the outer `if ($aprofundamentoOnly || ... )`. So the stamp only happens in updateEvent. But is there any other endpoint that finalizes the aprofundamento (e.g., `resolveEvent` / POST /events/{id}/resolve, or the create endpoint)? The search for `aprofundamento_status'] = 'finalized'` found only one place, but maybe the create path sets `aprofundamento_complete`. Let's search for `'aprofundamento_complete' => true` or `['aprofundamento_complete'] =`.
Tool Calls (2)
code_search
Show Details
{"search_text": "aprofundamento_complete'\\] = true|aprofundamento_complete' => true", "use_perl_regexp": true}
File: src/Controller/SsmaController.php
Match lines: 2
27905|                $data['aprofundamento_complete'] = true;
27906|                $detailsOut['aprofundamento_complete'] = true;

File: src/Service/Ssma/SsmaE2ePermissionSeeder.php
Match lines: 1
248|                'aprofundamento_complete' => true,

File: tests/Unit/Product/Ssma/SsmaEventValidatorTest.php
Match lines: 10
435|            'aprofundamento_complete' => true,
557|            'aprofundamento_complete' => true,
584|            'aprofundamento_complete' => true,
620|            'aprofundamento_complete' => true,
651|            'aprofundamento_complete' => true,
677|            'aprofundamento_complete' => true,
743|            'aprofundamento_complete' => true,
774|            'aprofundamento_complete' => true,
804|            'aprofundamento_complete' => true,
859|            'aprofundamento_complete' => true,

code_search
Show Details
{"search_text": "aprofundamento_only"}
File: cypress/e2e/ssma/README.md
Match lines: 1
166|`POST /manager/ssma/events/{id}` com `aprofundamento_only + aprofundamento_complete=true` só seta

File: cypress/e2e/ssma/ssmaOccurrenceFinalizeValidateUi.spec.js
Match lines: 1
41|        aprofundamento_only: true,

File: cypress/e2e/ssma/ssmaOccurrenceRegisterEditUi.spec.js
Match lines: 1
239|        aprofundamento_only: true,

File: cypress/e2e/ssma/ssmaPermissionScenarios.spec.js
Match lines: 6
237|      aprofundamento_only: true,
273|      aprofundamento_only: true,
320|        aprofundamento_only: true,
339|      aprofundamento_only: true,
388|        aprofundamento_only: true,
687|        aprofundamento_only: true,

File: cypress/support/ssmaHub.js
Match lines: 1
214|    aprofundamento_only: true,

File: docs/engineering/pr/hotfix-ssma-occ-type-perms-datatables-new-production/PR_descricao_hotfix-ssma-occ-type-perms-datatables-new-production.md
Match lines: 2
93|| `templates/ssma/occurrence/partials/_modal_event.html.twig` | `EvModal.openAprofundamento`, botões rascunho/finalizar, payload `aprofundamento_only` |
136|5. **+Aprofundamento — rascunho e finalizar:** especialista autorizado pode salvar rascunho (`soft_aprofundamento` / status draft) e depois finalizar. Payload parcial via `aprofundamento_only: true` no update.

File: docs/ssma/CHAT-BASE-hotfix-permission-ocorrencia.md
Match lines: 2
129|No **update só-aprofundamento** (`aprofundamento_only`) a hierarquia **não** roda de novo (especialista sem superior não toma 422).
187|- Aprofundamento Quase: sem 500 no responsável da área; `nature` na mescla; hierarquia ignorada em `aprofundamento_only`.

File: docs/ssma/e2e-permission-scenarios.md
Match lines: 2
135|- Após finalizado (#147), edição via `POST /manager/ssma/events/{id}` com `aprofundamento_only`
172|  com `aprofundamento_only` + `aprofundamento_complete=true`) → concluir (`POST

File: docs/ssma/engineering/ssma-permissoes-membro-analise-cypress.md
Match lines: 1
455|**Aprofundamento:** especialista vê **todas** as ocorrências do tipo da tag, não só pendentes. Sem filtro “só pendentes” no hub. Save = `POST /manager/ssma/events/{id}` com `aprofundamento_only` / `aprofundamento_status`.  

File: src/Controller/SsmaController.php
Match lines: 2
27817|        $aprofundamentoOnly = !empty($data['aprofundamento_only']);
29986|        $merged['aprofundamento_only'] = true;

File: src/Service/Ssma/SsmaEventValidator.php
Match lines: 1
717|        if (!empty($data['aprofundamento_only']) && \in_array($descVal, ['0', '1'], true)) {

File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 1
7421|            payload.aprofundamento_only = true;

File: tests/Unit/Product/Ssma/SsmaEventValidatorTest.php
Match lines: 1
860|            'aprofundamento_only'     => true,

Request #57 deepseek-flash P:51.35K C:980 CR:50.18K CW:0 4842ms
Reasoning
Good — the only production place is the updateEvent. So the stamp is correctly placed. Let me also double check line 29986 `$merged['aprofundamento_only'] = true;` — that's `mergeSsmaEventAprofundamentoOnlyPayload`. Fine. Now let's reconsider whether there are other issues with the "stamp" logic: `stampSsmaAprofundamentoAuthor` is called for every finalize, including the initial finalization. That means the author is (re)stamped each time it's finalized. Good for readequação. Potential issue: the stamp happens BEFORE `updateFromPayload` — `$detailsOut` becomes `$data['details']`, and `updateFromPayload` persists it. Good. But careful: after line 27886-27896, `$data` might get stripped of aprofundamento fields (`stripSsmaEventAprofundamentoFieldsFromPayload`) — for non-authorized users editing. Then `$data['details']` no longer contains aprofundamento fields. Then line 27900's block: `$aprofundamentoOnly` false, `!empty($data['aprofundamento_complete'])` false (stripped), `array_key_exists('aprofundamento_status', $data)` — is that stripped at root level? strip probably operates on details. Anyway if false, block skipped → no stamp. Good. Now, what about the case where the author is finalized and later an admin edits general data (not aprofundamento-only): block skipped → no stamp overwrite. Good. Now let me consider whether the notification service should use `$event->getDetails()` — yes. I want to confirm whether `eventTitle` exists. Yes used elsewhere. Now let me think about whether to also flag that the readequação check in `canAccessSsmaEventAprofundamento` uses `$details` from the request payload (line 27829 fallback), which can be tampered to bypass the author-only restriction. Let me evaluate reachability: In updateEvent, `$canAprofundamento` is computed from `$data['details']` if it's an array. The front-end sends details. But a specialist (non-author) could craft a request with `details` omitting `occurrence_approval` → `isSsmaOccurrenceInReadequacao` false → falls to `resolveCurrentUserTechnicalTypes` → if their type matches, returns true → `!$canFullEdit` && `$isAprofundamentoUpdate` && `$canAprofundamento` passes → they can edit the aprofundamento. But then line 27840: `$aprofundamentoFinalized` — after rejection, false → no block. So yes, a non-author specialist of the same type could bypass the new restriction by omitting `occurrence_approval` from the payload details. Is this a real security/permission issue? The check's input comes from client-controlled payload. That's the "Authorization enforced only..." pattern. However, the check would be better computed against `$existingDetails` (server-side state) instead of the payload details. Actually note that the payload details are merged into the event later; but `eventToValidationArray` provides server details only in the `canFullEdit` branch. Hmm, but would the front-end send `occurrence_approval`? Yes, in aprofundamento-only mode the payload likely includes the full details object (EvModal builds details from the form?). Let me check how details is built in the twig for aprofundamento mode. Actually `payload.aprofundamento_only = true` and does the payload include `details`? Let me search where payload.details is set. If the front-end does NOT send `occurrence_approval` in details, then the whole readequação check would never trigger for legit users either — meaning the feature wouldn't work! That would be a serious bug. Let me check the payload details construction.
Tool Calls (2)
code_search
Show Details
{"search_text": "payload.details", "file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"]}
No matches found
code_search
Show Details
{"search_text": "details:", "file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"]}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 1
6870|            // Re-sync depois das tags + details: garante caixinhas médicas mesmo se

Request #58 deepseek-flash P:52.4K C:211 CR:52.22K CW:0 1920ms
Reasoning
Let me look at how the payload is built — search for `var payload = {`.
Tool Calls (2)
code_search
Show Details
{"search_text": "var payload = \\{", "file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"], "use_perl_regexp": true}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 1
7345|        var payload = {

code_search
Show Details
{"search_text": "payload\\.details|details  *=|details:", "file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"], "use_perl_regexp": true}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 9
2454|            var existingDetails = evGetInjuredDetailsObj();
2822|        var details = evGetInjuredDetailsObj();
2889|        var details = evGetInjuredDetailsObj();
2956|            data.__injured_details = (document.getElementById('ev_injured_person_details') || {}).value || '';
5538|            var det = (data.details && typeof data.details === 'object') ? data.details : data;
6546|        var detEarly = (data && data.details && typeof data.details === 'object') ? data.details : (data || {});
6556|        var det  = (data.details && typeof data.details === 'object') ? data.details : data;
6870|            // Re-sync depois das tags + details: garante caixinhas médicas mesmo se
7554|            payload.injured_person_details = (document.getElementById('ev_injured_person_details') || { value: '' }).value;

Request #59 deepseek-flash P:52.91K C:190 CR:52.61K CW:0 1781ms
Tool Calls (2)
file_read
Show Details
{"file_path": "templates/ssma/occurrence/partials/_modal_event.html.twig", "start_line": 7330, "end_line": 7360}
File: templates/ssma/occurrence/partials/_modal_event.html.twig (Total lines: 8167)
IS_TRUNCATED: false
LINE_RANGE: 7330-7360
7330|        if (titleTxt) {
7331|            descCombined = actTxt ? (titleTxt + '\n\n' + actTxt) : titleTxt;
7332|        }
7333|
7334|        /* Impactos: UI removida; backend pode inferir a partir da consequência */
7335|        var impacts = [];
7336|
7337|        // Select2 armazena o valor (digitado ou selecionado) diretamente no <select>
7338|        var approachVal = document.getElementById('ev_approach').value || '';
7339|
7340|        var occurrenceTime = '';
7341|        if (dtIso && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(dtIso)) {
7342|            occurrenceTime = String(dtIso).slice(11, 16);
7343|        }
7344|
7345|        var payload = {
7346|            type:             type,
7347|            origin:           'MANUAL',
7348|            datetime:         dtIso,
7349|            occurrence_time:  occurrenceTime,
7350|            location:         document.getElementById('ev_location').value,
7351|            gmr:              (document.getElementById('ev_gmr') || { value: '' }).value,
7352|            description:      descCombined,
7353|            consequence:      document.getElementById('ev_consequence').value,
7354|            potential_consequence: document.getElementById('ev_potential_consequence').value,
7355|            impacts:          impacts,
7356|            activity:         document.getElementById('ev_activity').value,
7357|            approach:         approachVal,
7358|            title:            titleTxt,
7359|            manager_id:       document.getElementById('ev_manager').value,
7360|            people_ids:       peopleIds,
code_search
Show Details
{"search_text": "evCollectPayload|function evBuildPayload|JSON.stringify\\(payload\\)", "file_patterns": ["templates/ssma/occurrence/partials/_modal_event.html.twig"], "use_perl_regexp": true}
File: templates/ssma/occurrence/partials/_modal_event.html.twig
Match lines: 1
7776|            body: JSON.stringify(payload)

Request #60 deepseek-flash P:53.62K C:553 CR:52.99K CW:0 3488ms
Reasoning
The payload does NOT include `details`. Let's check if there's something after that adds details. Let me search further for `payload.details` — none found. So how does the backend get `$data['details']`? Via normalizeSsmaEventPayload building details from root keys. Then for the `canAprofundamento` computation at line 27825, `is_array($data['details'] ?? null) ? $data['details'] : $existingDetails` — since `$data['details']` is not set (payload has no details), it falls back to `$existingDetails`! So for the legit front-end flow, `$details = $existingDetails` (server-side state including `occurrence_approval`). So `isSsmaOccurrenceInReadequacao` works correctly. And for a tampered request that includes `details: {...}` without occurrence_approval, it would use those. But the attacker is a specialist of the type who previously could edit; the restriction is a product rule. Is it a security boundary? It's an authorization restriction compared to prior behavior. A specialist of the same type bypassing by sending `details` tampered... they'd need to know the shape. Hmm. Actually, could a non-specialist bypass? No — for non-specialists, other checks apply (line 27879 admin only, isSsmaEventDirectManagerOfReporter for ROS/QUASE). So the bypass only lets peers of the same technical type edit — which was the prior behavior, and they'd also need `$isAprofundamentoUpdate`. Also, the tamper would then strip... Actually if they pass `details` in the payload, the merge at 27845 replaces event details? For non-full-edit branch, `$data = $this->mergeSsmaEventAprofundamentoOnlyPayload($event, $data)` at line 27843 (since aprofundamentoFinalized false after reject → not blocked). Let me check what mergeSsmaEventAprofundamentoOnlyPayload does — line 29986 area. It probably merges event details with incoming. If the attacker sends partial details, could they wipe occurrence_approval and other fields? Let me read it.
Tool Calls (1)
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 29960, "end_line": 30060}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 29960-30060
29960|        $existingDetails = is_array($merged['details'] ?? null) ? $merged['details'] : [];
29961|        $incomingDetails = is_array($data['details'] ?? null) ? $data['details'] : [];
29962|        $keys = $this->ssmaEventAprofundamentoFieldKeys();
29963|
29964|        foreach ($keys as $key) {
29965|            if (array_key_exists($key, $data)) {
29966|                $merged[$key] = $data[$key];
29967|            }
29968|            if (array_key_exists($key, $incomingDetails)) {
29969|                $existingDetails[$key] = $incomingDetails[$key];
29970|            }
29971|        }
29972|
29973|        // Consequência pode vir no nível raiz do formulário (não só em details).
29974|        if (array_key_exists('consequence', $data) && $data['consequence'] !== null && $data['consequence'] !== '') {
29975|            $merged['consequence'] = $data['consequence'];
29976|        }
29977|
29978|        if (array_key_exists('aprofundamento_complete', $data)) {
29979|            $merged['aprofundamento_complete'] = $data['aprofundamento_complete'];
29980|        }
29981|        if (array_key_exists('aprofundamento_status', $data)) {
29982|            $merged['aprofundamento_status'] = $data['aprofundamento_status'];
29983|        }
29984|
29985|        $merged['details'] = $existingDetails;
29986|        $merged['aprofundamento_only'] = true;
29987|
29988|        return $merged;
29989|    }
29990|
29991|    /**
29992|     * S/N de descaracterização só pode ser gravado por membro com flag na coluna Descaracterizar.
29993|     *
29994|     * @param array<string, mixed>      $data
29995|     * @param array<string, mixed>|null $existingDetails details já persistidos (update)
29996|     *
29997|     * @return array<string, mixed>
29998|     */
29999|    private function applySsmaDescaracterPermissionGate(
30000|        array $data,
30001|        Company $company,
30002|        $user,
30003|        ?array $existingDetails = null
30004|    ): array {
30005|        $member = $this->getCurrentCompanyMember($company, $user);
30006|        $type = strtoupper(trim((string) ($data['type'] ?? '')));
30007|        $can = $member instanceof CompanyMembers
30008|            && $type !== ''
30009|            && $this->ssmaOccurrenceTypeConfig->memberCanDescharacterizeForType(
30010|                $company,
30011|                (int) $member->getId(),
30012|                $type
30013|            );
30014|        if ($can) {
30015|            return $data;
30016|        }
30017|
30018|        // Sem a permissão "Descaracterizar": o front (ev_descaracter_*) já desabilita o botão
30019|        // "Não" e pré-seleciona "Sim" ('0') quando o campo está vazio — resubmeter '0', a
30020|        // suspeita ou o comentário não é uma tentativa de decisão, é o próprio fluxo normal do
30021|        // especialista sem a flag. O que precisa ser bloqueado (403, nunca reescrito
30022|        // silenciosamente para o front) é a tentativa de gravar 'descaracterizado' = '1' (Não é
30023|        // acidente) quando essa decisão ainda não existia — ver
30024|        // docs/ssma/engineering/ssma-permissoes-membro-analise-cypress.md — P0.
30025|        $details = isset($data['details']) && is_array($data['details']) ? $data['details'] : [];
30026|        $existingDesc = ($existingDetails !== null && array_key_exists('descaracterizado', $existingDetails)
30027|            && \in_array((string) $existingDetails['descaracterizado'], ['0', '1'], true))
30028|            ? (string) $existingDetails['descaracterizado']
30029|            : null;
30030|
30031|        if (array_key_exists('descaracterizado', $details)) {
30032|            $incomingDesc = \in_array((string) $details['descaracterizado'], ['0', '1'], true)
30033|                ? (string) $details['descaracterizado']
30034|                : null;
30035|
30036|            if ($incomingDesc === '1' && $existingDesc !== '1') {
30037|                $data['__ssma_forbidden_reason'] = 'descaracterizacao_sem_permissao';
30038|
30039|                return $data;
30040|            }
30041|
30042|            // Mantém o valor já persistido (se houver); sem isso, aceita o default seguro '0'
30043|            // que o próprio front já pré-seleciona — sem a flag não há como decidir "Não".
30044|            $details['descaracterizado'] = $existingDesc ?? ($incomingDesc ?? '0');
30045|        } elseif ($existingDetails !== null && array_key_exists('descaracterizado', $existingDetails)) {
30046|            $details['descaracterizado'] = $existingDetails['descaracterizado'];
30047|        }
30048|
30049|        if ($existingDetails !== null && array_key_exists('descaracter_comment', $existingDetails)) {
30050|            $details['descaracter_comment'] = $existingDetails['descaracter_comment'];
30051|        } else {
30052|            unset($details['descaracter_comment']);
30053|        }
30054|        unset($data['descaracterizado'], $data['descaracter_comment']);
30055|        $data['details'] = $details;
30056|
30057|        return $data;
30058|    }
30059|
30060|    /**
Request #61 deepseek-flash P:55.66K C:3.16K CR:54.14K CW:0 14808ms
Reasoning
OK, mergeSsmaEventAprofundamentoOnlyPayload merges into server details, so occurrence_approval isn't wiped. Fine. The tamper scenario requires a peer specialist sending an explicit `details` object to skip the readequação branch; but even so, `$details` used later is merged from existing. This is a permission-boundary weakness but the impact is limited to "peer specialist of the same type can still edit" — which is the legacy behavior the PR intends to restrict. Hmm. Should I report? It's borderline. The rules emphasize precision. The attack requires crafting a payload; the gate uses client-supplied data as the source of truth for a permission decision. I think it's a legitimate finding but maybe too speculative. Given "favor precision over recall", and that the fallback to `$existingDetails` only occurs when `data['details']` is not an array... A malformed/malicious payload with `details` as an array skipping occurrence_approval would disable the restriction. Hmm. But the diff didn't introduce this pattern for the whole method; it did introduce the readequação branch which relies on it. Actually wait. Let's think about whether this could break in a more mundane way: some clients (mobile? e2e tests?) send `details` explicitly. E.g., `cypress/support/ssmaHub.js` at line 214 sends `aprofundamento_only: true`. Let me check whether it sends details. If a legit test client sends `details` without occurrence_approval, the restriction silently doesn't apply — but for legit tests that's the legacy path anyway. I'll consider mentioning it as a medium finding: permission gate reads client-controlled payload instead of persisted details. Actually the check is at line 27825-27831: `is_array($data['details'] ?? null) ? $data['details'] : $existingDetails`. Given that the new restriction (author-only) is a permission rule, using `$existingDetails` would be more robust. This is a legitimate security-ish finding. But confirmed finding #2 is about the same check line? No, #2 is about `'rejected'` literal. #1 about wantsFinalize. Hmm, I worry about over-reporting. Let me weigh: the reviewer rules say correctness and security findings are blocking. A permission check that can be bypassed by omitting a field from the request body is a real vulnerability pattern (though it only allows a peer specialist to do what they could do before). I think it's worth a medium comment. But is it "likely real"? The pre-existing code already used `$data['details']` for the canAccess computation before this PR (line 27825 existed before? Let me verify with the diff — the diff didn't touch that call; the call at 27825 presumably existed). So using payload details is pre-existing. The new code just adds a branch inside the method. Commenting on the new branch's reliance on it is fair, since the new branch is the authorization restriction. Hmm, but the branch's input parameter `$details` was already the parameter's semantics. I'd rather report the notification regression (more concrete) and maybe this one. Let me reconsider: is there a scenario where the new branch causes a legit author to be BLOCKED incorrectly? If `$details` = payload details (when client sends `details` array, e.g., some clients do send details), and that details lacks `occurrence_approval`, then `isSsmaOccurrenceInReadequacao` false → falls to technical types → for the author (a specialist of the type) → true → OK, still allowed. So no false-block for authors. For non-author specialists → allowed (bypass). So the only failure mode is restriction bypass. Now, does any legit client send `details` explicitly? Search in public/js and templates for `details:` in payload for the occurrence form. Let me check EvModal save function — search "aprofundamento_only" in that twig at 7421 context showed payload built without details. Let me search other places like `evSaveEvent`. Let me grep "details" near payload in the twig. Search "payload.details" gave none. So the occurrence modal doesn't send details. Other clients: the e2e tests (cypress) send payloads; `ssmaHub.js`. Let's check whether any test sends `details`. Not production. OK. Given the security-adjacent nature, I'll report it as a medium finding with a suggestion to derive readequação from `$existingDetails` (persisted state) rather than request payload. Hmm, but the method signature takes `$details` as a param; the caller decides. The fix should be at the caller (line 27825-27831) — which IS in the diff context? No, lines 27825-27831 are unchanged lines (not in the diff). The rules say comments must address code within the review files, but the diff hunk lines... The comment must be attached to newly added lines. The added lines are the readequação branch in `canAccessSsmaEventAprofundamento`. I can attach the comment to: ``` + if ($this->isSsmaOccurrenceInReadequacao($details)) { ``` and explain that `$details` may come from the request payload. That's within the added code. Fine. Hmm, but is it accurate that `$details` can come from the request? In updateEvent: `is_array($data['details'] ?? null) ? $data['details'] : $existingDetails`. Also getEvent passes `$detailsForAccess` from serialized details (server-side). So yes, in updateEvent a request-provided `details` array is used. Let me decide: I'll report two comments: 1. (bug, medium) notification dropped when no author stamped — no fallback to registrant. 2. (security, medium) readequação gate based on possibly request-supplied details. Hmm, for #2, maybe better to phrase carefully. Actually, let me reconsider its severity. Since the impact is that "outro especialista do mesmo tipo" can still edit (the very thing the PR wants to prevent), and the bypass is trivial (just omit `occurrence_approval` from `details` in the body), I'd say medium. Wait — but let me double-check `$isAprofundamentoUpdate` for the peer specialist. They must send `aprofundamento_only: true` or `aprofundamento_complete`/`aprofundamento_status`. They can send `aprofundamento_only: true` and `aprofundamento_complete: false`. Then `$isAprofundamentoUpdate` true. `$canAprofundamento` computed from their `details` (without occurrence_approval) → readequação false → technical types → if peer's type matches event type → true. Then `$aprofundamentoFinalized`: after reject, aprofundamento_complete false, status draft → false → no admin block. Then merge payload → they can save draft. So yes, a peer can modify the draft aprofundamento. Confirmed bypass. But note: is it a "vulnerability" or "the old behavior persists"? The PR intends to restrict. A reviewer should flag it. I'll include it. Actually hold on. Let me double check that `resolveCurrentUserTechnicalTypes` includes the type for a peer specialist. That's how the old behavior worked ("qualquer especialista do tipo podia alterar"). Yes. Now, are these overlapping with confirmed finding #1? #1 is about ROS visto-e-resolvido and wantsFinalize gating. Not the same. Let me also make sure I'm not missing a cross-file issue: the doc `.opencodereview/rules/ssma/occurrence-approve.md` was modified (not in review group). Not our concern. Let me reconsider the notification service once more for a concrete bug: `$recipients[] = $memberId;` — the `notify()` normalizes via `resolveMemberUser(int)`. OK. But wait: is `CompanyMembers` the right entity id? `stampSsmaAprofundamentoAuthor` stores `resolveCompanyMemberIdByUserId` → CompanyMembers id. Yes consistent. One more: `notifyAprofundamentoAuthorOnReject`'s `$company` param in the controller wrapper is unused (the wrapper takes `Company $company` but doesn't pass it). Was it unused before too? Old: `notifyOccurrenceRegistrantOnReject(SsmaEvent $event, Company $company, User $sender, string $note)` → `$this->ssmaNotificationService->notifyOccurrenceRegistrantOnReject($event, $sender, $note);` — also unused. Pre-existing; not flagging. Now let me finalize content for the comments in Portuguese, following the required format (simple first sentence, then why, then what to do). Comment 1 (SsmaNotificationService::notifyAprofundamentoAuthorOnReject): "Quando a ocorrência não tem autor de aprofundamento gravado, ninguém recebe o aviso de reprovação..." Suggestion: fallback to `$event->getCreatedById()`. Careful with existing_code matching: the added lines are: ``` + $recipients = []; + if ($memberId > 0) { + $recipients[] = $memberId; + } elseif ($userId > 0) { + $author = $this->entityManager->find(User::class, $userId); + if ($author instanceof User) { + $recipients[] = $author; + } + } + + if ($recipients === []) { return; } ``` I'll anchor to `if ($recipients === []) {` line? That line appears in the diff as added (`+ if ($recipients === []) {`). Yes it's added. But `return;` is unchanged. I'll use the added line `+ $recipients = [];`? Hmm, need the existing_code to be consecutive added lines. I'll use: ``` if ($recipients === []) { return; } ``` But `return;` is not added (it's context, unchanged). The tool says existing_code should contain only newly added lines. So better use multiple added lines: ``` $recipients = []; if ($memberId > 0) { $recipients[] = $memberId; ``` That's 3 consecutive added lines. Good anchor. Comment 2 (SsmaController): Anchor: ``` // Readequação: só quem enviou o aprofundamento (admin já passou acima). if ($this->isSsmaOccurrenceInReadequacao($details)) { ``` Good. Let me double check the diff added lines exactly. From the diff: ``` + $loggedMember = $this->getCurrentCompanyMember($company, $user); + + // Readequação: só quem enviou o aprofundamento (admin já passou acima). + if ($this->isSsmaOccurrenceInReadequacao($details)) { + $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0 + || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0; + if ($hasAuthor) { + return $this->isSsmaAprofundamentoAuthor($details, $user, $loggedMember); + } + } ``` Good. Now, hmm, one more consideration on comment 2: is it truly within scope and likely? I'm somewhat on the fence. The tool rules say "Favor precision over recall: report only defects that are likely real in the changed code and its reachable context." The bypass is real and reachable. I'll keep it but as medium. Actually, wait. Let me reconsider: in `updateEvent`, is the `$details` argument at 27829 really the request payload? `is_array($data['details'] ?? null) ? $data['details'] : $existingDetails`. The front-end payload has no `details` key... but `normalizeSsmaEventPayload` is called AFTER (line 27858), so at 27825 the raw `$data` is used. Right, so for the standard UI flow, `$data['details']` is absent → falls back to $existingDetails. So the gate works. For a crafted request, it doesn't. That's the bypass. Hmm, but hold on — could a crafted request with `details` also affect other security gates? Yes, but out of scope. I'll include it. Let me also mention in that comment that the same method is called at line 27888 with merged details (server-side), so checking persisted details would be more robust. Actually, I realize I should double-check whether `isSsmaOccurrenceInReadequacao` gate placement also affects `getEvent`'s `_can_edit_aprofundamento` — there details come from the DB. Fine. Let me also consider whether reporting comment 2 might be considered "duplicating finding #2" (both about the readequação check). #2 is about the string literal. Mine is about the source of `$details`. Distinct. Now, any issue with the third file (SsmaOccurrenceApprovalService)? The removal of the history append? No, it just changed text and kept setDetails. Let's re-read: ```php $event->setDetails($details); if ($prevStatus !== SsmaEvent::STATUS_ABERTO) { $event->appendHistory('Ocorrência devolvida para readequação após reprovação.', [...]); } ``` Wait — actually I notice at line 103 `$event->setDetails($details)` happens BEFORE the reject block. Then in the reject block, `$details['aprofundamento_status'] = 'draft'` and `$event->setDetails($details)` again, then `appendHistory`. Does `appendHistory` read from the entity's details (after setDetails) or from the local `$details`? Let me check `appendHistory` in SsmaEvent — if it reads `$this->details`, then fine since setDetails was called. Let me check quickly.
Tool Calls (1)
code_search
Show Details
{"search_text": "function appendHistory", "file_patterns": ["src/Entity/SsmaEvent.php"]}
File: src/Entity/SsmaEvent.php
Match lines: 1
212|    public function appendHistory(string $message, array $meta = []): void

Request #62 deepseek-flash P:58.87K C:80 CR:58.62K CW:0 1619ms
Tool Calls (1)
file_read
Show Details
{"file_path": "src/Entity/SsmaEvent.php", "start_line": 200, "end_line": 245}
File: src/Entity/SsmaEvent.php (Total lines: 247)
IS_TRUNCATED: false
LINE_RANGE: 200-245
200|    public function getImpacts(): array { return $this->impacts; }
201|    public function setImpacts(array $impacts): self { $this->impacts = array_values(array_unique($impacts)); return $this; }
202|
203|    public function getDetails(): array { return $this->details; }
204|    public function setDetails(array $details): self { $this->details = $details; return $this; }
205|
206|    /** @return array<int, array{at: string, message: string, meta?: array}> */
207|    public function getHistory(): array { return $this->history ?? []; }
208|
209|    /** @param array<int, array{at: string, message: string, meta?: array}> $history */
210|    public function setHistory(array $history): self { $this->history = $history; return $this; }
211|
212|    public function appendHistory(string $message, array $meta = []): void
213|    {
214|        $h   = $this->getHistory();
215|        $h[] = [
216|            'at'      => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
217|            'message' => $message,
218|            'meta'    => $meta,
219|        ];
220|        $this->history = $h;
221|    }
222|
223|    public function getCreatedAt(): \DateTimeInterface { return $this->createdAt; }
224|    public function getUpdatedAt(): \DateTimeInterface { return $this->updatedAt; }
225|
226|    // ── Helpers de domínio ────────────────────────────────────────────────────
227|
228|    public function isAcidentePessoal(): bool   { return $this->type === self::TYPE_ACIDENTE_PESSOAL; }
229|    public function isAcidenteAmbiental(): bool  { return $this->type === self::TYPE_ACIDENTE_AMBIENTAL; }
230|    public function isAcidenteMaterial(): bool   { return $this->type === self::TYPE_ACIDENTE_MATERIAL; }
231|    public function isRos(): bool                { return $this->type === self::TYPE_ROS; }
232|    public function isQuaseAcidente(): bool      { return $this->type === self::TYPE_QUASE_ACIDENTE; }
233|
234|    public function requiresMedicalValidation(): bool
235|    {
236|        return $this->isAcidentePessoal()
237|            && ($this->details['medical_required'] ?? false) === true;
238|    }
239|
240|    public function hasBodyMap(): bool
241|    {
242|        return $this->isAcidentePessoal()
243|            && !empty($this->details['had_injury'])
244|            && !empty($this->details['body_parts']);
245|    }
Request #63 deepseek-flash P:59.64K C:491 CR:58.88K CW:0 2983ms
Reasoning
Fine. No issue there. Now let me finalize. Also consider whether to comment on the redundant double `$event->setDetails($details)` in the service — it's not in the diff (line 111 is context, unchanged? Let's check: the diff hunk for the approval service: ``` $event->setStatus(SsmaEvent::STATUS_ABERTO); $details['aprofundamento_status'] = 'draft'; $details['aprofundamento_complete'] = false; + // Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento. $event->setDetails($details); if ($prevStatus !== SsmaEvent::STATUS_ABERTO) { - $event->appendHistory('Ocorrência devolvida para rascunho após reprovação.', [ + $event->appendHistory('Ocorrência devolvida para readequação após reprovação.', [ ``` So only the comment and the history text changed. Fine — the change is consistent with the new naming. Note: the history message changed from "voltou para rascunho" to "readequação". Is the status actually readequação? The status is set to STATUS_ABERTO. Hmm, and the message in the return says "voltou para readequação". The front-end probably maps rejected → "Readequação" tag. Since the PR description says status visual Readequação, fine. Now one thought: `decide()` returns the message "Ocorrência reprovada. O registro voltou para readequação." but the event status is STATUS_ABERTO. Fine. Is there any consumer of the old history string "Ocorrência devolvida para rascunho após reprovação." that now breaks? E.g., tests asserting the string. Search.
Tool Calls (2)
code_search
Show Details
{"search_text": "devolvida para rascunho"}
No matches found
code_search
Show Details
{"search_text": "voltou para rascunho"}
No matches found
Request #64 deepseek-flash P:60.16K C:2K CR:59.9K CW:0 8801ms
Reasoning
Good, no consumers. Now let me also verify there's no remaining reference to `notifyOccurrenceRegistrantOnReject` in docs that would be broken — but docs aren't code. Let me also check the `.opencodereview/rules/ssma/occurrence-approve.md` (not in review group) — skip. I think two comments are appropriate. Let me also double check whether the notification service has a fallback concern about `notify()` skipping when the recipient is the sender (the approver). If the author is the approver who rejected... they'd not be notified. Edge case, ignore. Let me write the comments. Comment 1 — SsmaNotificationService.php existing_code (added lines): ``` $recipients = []; if ($memberId > 0) { $recipients[] = $memberId; ``` Hmm, that anchor points where recipients is built. Good. Content: "Ao reprovar uma ocorrência que não tem autor de aprofundamento gravado (ex.: ROS 'visto e resolvido' ou registros anteriores a esta mudança), o aviso in-app deixa de ser enviado para qualquer pessoa — antes o cadastrante recebia. Na prática, o reprovado nunca fica sabendo que precisa readequar. Sugiro manter o fallback para `$event->getCreatedById()` quando não houver autor gravado, preservando o comportamento antigo nos casos legados." Severity: medium. Category: bug. suggestion_code: ``` $recipients = []; if ($memberId > 0) { $recipients[] = $memberId; } elseif ($userId > 0) { $author = $this->entityManager->find(User::class, $userId); if ($author instanceof User) { $recipients[] = $author; } } elseif ((int) $event->getCreatedById() > 0) { $registrant = $this->entityManager->find(User::class, (int) $event->getCreatedById()); if ($registrant instanceof User) { $recipients[] = $registrant; } } ``` Comment 2 — SsmaController.php existing_code: ``` // Readequação: só quem enviou o aprofundamento (admin já passou acima). if ($this->isSsmaOccurrenceInReadequacao($details)) { ``` Content: "A checagem de 'está em readequação' usa o array `$details` que, no `updateEvent`, pode vir do próprio corpo da requisição (`$data['details']`) e não do estado salvo. Basta o cliente enviar um `details` sem a chave `occurrence_approval` para a condição cair no `false` e a edição voltar a ser liberada para qualquer especialista do tipo — exatamente o que a PR quer impedir. Sugiro decidir a readequação a partir dos detalhes persistidos do evento (`$existingDetails`), não do payload." Severity: medium (or high?) — it's an authorization bypass. I'd say medium-high. I'll use "medium"... Actually the rules treat security findings as blocking. But the exploit only restores previous behavior for peers. I'll set severity "medium" to be measured. Hmm, maybe "high"? I'll go with medium to be conservative? The guidance says treat correctness and security findings as blocking. I'll mark as high for security bypass. Hmm... Let me think about whether it's truly exploitable by someone who wouldn't otherwise have access. Only same-type specialists, who previously had access. So it's a policy-enforcement gap, not privilege escalation to a new actor. I'll use medium. suggestion_code: hard to give inline since fix is at the call site. I can suggest: ``` // Readequação: só quem enviou o aprofundamento (admin já passou acima). // Usar o estado persistido, não o payload, para não permitir bypass do gate. if ($this->isSsmaOccurrenceInReadequacao($details)) { ``` Hmm, that's not a real fix. I'd better omit suggestion_code or provide a caller-level fix. I'll omit suggestion_code and just describe. Actually, maybe I should skip comment 2 and think about whether it's too speculative... Let me verify once more that the fallback is `$existingDetails` only when details is not an array. Yes, line 27829: `is_array($data['details'] ?? null) ? $data['details'] : $existingDetails`. Confirmed. Hmm, but one could argue that in the real UI, details is never sent, so the bypass requires deliberate tampering with a valid session as a same-type specialist — a realistic insider scenario given the feature is about restricting peers. I'll report it as medium. Let me write both comments now. Also, should I flag anything about the `$memberId` priority in the service vs the controller (userId checked first)? Not necessary. One more possible finding worth checking: `stampSsmaAprofundamentoAuthor` overwrites the author on every finalize, including when an ADMIN re-finalizes on behalf of someone. This means the original author is replaced and only the admin can readequar/be notified afterwards. Is that a bug? The admin already can edit. But it would replace e.g. the specialist's authorship if the admin saves the aprofundamento in aprofundamento-only mode. Hmm — the admin's "Editar" is a general edit (no aprofundamento_only), so no stamp. But the admin could also open "+Aprofundamento" and finalize, which legitimately means they did the aprofundamento. I think it's fine. Skip. Let me finalize the two comments.
Analisei os três arquivos do grupo e o contexto relacionado (fluxo de `updateEvent`, `maybeSubmitOccurrenceForValidation`, `getState`/`decide`, `notify()` e template do modal). Segue o retorno.
Tool Calls (1)
code_comment
Show Details
{"comments": [{"path": "src/Service/Ssma/SsmaNotificationService.php", "content": "Ao reprovar uma ocorrência que não tem autor de aprofundamento gravado, o aviso in-app deixa de ser enviado para qualquer pessoa — antes o cadastrante era notificado. Isso acontece em registros legados (aprofundamento finalizado antes desta mudança) e também no ROS \"visto e resolvido\", que passa no gate de validação sem nunca ter aprofundamento finalizado (`ssmaEventSkipsAprofundamentoValidationGate`), portanto sem `aprofundamento_finalized_by_*`. Na prática a ocorrência volta para readequação e ninguém é avisado, o que é regressão em relação ao comportamento anterior. Sugiro manter o fallback para o cadastrante quando não houver autor gravado.", "existing_code": "        $recipients = [];\n        if ($memberId > 0) {\n            $recipients[] = $memberId;", "category": "bug", "severity": "medium", "suggestion_code": "        $recipients = [];\n        if ($memberId > 0) {\n            $recipients[] = $memberId;\n        } elseif ($userId > 0) {\n            $author = $this->entityManager->find(User::class, $userId);\n            if ($author instanceof User) {\n                $recipients[] = $author;\n            }\n        } elseif ((int) $event->getCreatedById() > 0) {\n            $registrant = $this->entityManager->find(User::class, (int) $event->getCreatedById());\n            if ($registrant instanceof User) {\n                $recipients[] = $registrant;\n            }\n        }"}, {"path": "src/Controller/SsmaController.php", "content": "A decisão de \"está em readequação\" usa o array `$details` recebido por parâmetro, mas no `updateEvent` esse valor pode vir do corpo da requisição (`is_array($data['details'] ?? null) ? $data['details'] : $existingDetails`, na linha ~27829), não do estado salvo do evento. Basta o cliente enviar `details` sem a chave `occurrence_approval` para a condição cair no `false` e o fluxo voltar a liberar a edição do aprofundamento para qualquer especialista do mesmo tipo — exatamente o que a PR pretende restringir. Como é um gate de autorização, o mais seguro é avaliar a readequação a partir dos detalhes persistidos (`$existingDetails`), e não do payload.", "existing_code": "        // Readequação: só quem enviou o aprofundamento (admin já passou acima).\n        if ($this->isSsmaOccurrenceInReadequacao($details)) {", "category": "security", "severity": "medium"}]}
(async)
Request #65 deepseek-flash P:62.18K C:601 CR:61.95K CW:0 3750ms
Reasoning
Let me do a final pass to see if there's anything else in the review files I should mention. The third file (SsmaOccurrenceApprovalService) — only text changes + comment; nothing to flag. Let me double-check the `notify()` docblock says `list<int|User>` — yes. I think I'm done. Call task_done.
Revisão concluída. Resumo do que foi verificado nos 3 arquivos do grupo: **`src/Controller/SsmaController.php`** - Rename `notifyOccurrenceRegistrantOnReject` → `notifyAprofundamentoAuthorOnReject`: verifiquei que não restaram chamadas ao nome antigo em código de produção (só asserts de teste que conferem a ausência), e que o wrapper repassa corretamente os argumentos. - `stampSsmaAprofundamentoAuthor` está no único ponto produtivo onde o aprofundamento é finalizado (`aprofundamento_status = 'finalized'`), grava as três chaves em `details` e resolve o membro pela empresa correta — sem migration, ok. - `isSsmaOccurrenceInReadequacao` / `isSsmaAprofundamentoAuthor`: além dos dois achados já confirmados (literal `'rejected'` e o gate por `$wantsFinalize`), apontei um terceiro problema: o gate de autorização pode ser alimentado pelo payload do cliente. - Lógica mantém fallback correto para ocorrências legadas sem autor no controle de acesso (admin/tenant continuam passando pelo `isSsmaAprofundamentoAdmin`). **`src/Service/Ssma/SsmaNotificationService.php`** - `notifyAprofundamentoAuthorOnReject` usa `notify()`, cujo contrato aceita `list<int|User>`, então passar `memberId` (int) ou `User` está correto. Porém, sem autor gravado a notificação some (nem autor, nem cadastrante) — reportado. **`src/Service/Ssma/SsmaOccurrenceApprovalService.php`** - Preservação do autor na reprovação está coerente com o novo fluxo; a troca de texto do histórico não tem consumidores que dependam da string antiga (busca sem resultados). Nada a sinalizar. Não comentei os testes (`SsmaOccurrenceReadequacaoAuthorTest.php`, `assert_branch_ui_fixes.php`) por estarem fora do grupo de revisão.
Tool Calls (1)
task_done
Show Details
{"state": "DONE"}

review_filter_task

Request #1 deepseek-flash P:5.18K C:507 CR:1.79K CW:0 3051ms
Reasoning
Let me analyze the two comments. Comment c-0: targets src/Controller/SsmaController.php, about the `if ($wantsFinalize)` change. It claims that occurrences reaching the approver without finalized aprofundamento (e.g., ROS "visto e resolvido" via gate in isEventReadyForOccurrenceValidation) won't be resent. This is a behavioral change concern. Is it Ground A or B? The code is present in the diff — the `if ($wantsFinalize)` line is in the diff. So Ground A fails. Ground B: is there a diff line literally contradicting? No, it's a claim about behavior/pre-existing logic in files we can't see (the twig template, isEventReadyForOccurrenceValidation). Also, this is arguably a behavioral change subject — protected? "Behavioral or compatibility change — ... an altered error path". Hmm. The comment is about behavioral change, which is a protected subject. Actually the veto applies to comments whose subject is behavioral change — this is a comment pointing out a behavioral change. Under Step 1, approve and stop. Even without the veto, it's unverifiable from the diff—approve. Comment c-1: about the string literal 'rejected' comparison. The diff line: `return strtolower(trim((string) ($approval['status'] ?? ''))) === 'rejected';` — that's exactly the literal `'rejected'`. The comment says it compares the string literal instead of using canonical value. Is that literally contradicted by the diff? The diff shows the literal 'rejected' as claimed. So the comment is accurate. It's a style/maintainability comment (value veto → approve). Also could be considered... it suggests using a constant. This isn't wrong. So approve both. The output must call approve_all_comments. Wait — but the task says "You must call exactly one tool". So approve_all_comments. Let me double check c-1: could Ground B apply in reverse? The comment says the same file uses getState()/constants in maybeSubmitOccurrenceForValidation. We can't see that. The comment's central claim — that the code compares literal 'rejected' — is confirmed by the diff. Approve. Both approved.
Tool Calls (1)
approve_all_comments
Show Details
{}
Request #2 deepseek-flash P:5.09K C:1.09K CR:4.35K CW:0 6238ms
Reasoning
Let me analyze the comments. Comment c-0: about SsmaController.php, the `$details` parameter in isSsmaOccurrenceInReadequacao. It claims the details may come from request body rather than persisted state, so the gate can be bypassed. Is this Ground A or B? The comment targets code present in the diff (the new gate). It reasons about runtime behavior in the file. We cannot verify the line ~27829 claim from the diff shown — the diff doesn't show that line. The comment is about authorization logic; it's not one of the protected subjects (memory safety, concurrency, linkage, behavioral change? Hmm — it's about an authorization gate, not really "behavioral or compatibility change" as defined... Actually the protected subject "Behavioral or compatibility change" refers to messages, fields, statuses, defaults, error paths, counters. This comment is about an authorization gate potentially being bypassable. Is that a "behavioral change"? Not exactly. Hmm, but it could be considered a security/authorization issue. Not listed. So Step 1 doesn't obviously apply... Actually wait, could it be considered "behavioral change"? The veto list: "a message, field, status, or default that the old code produced and the new code no longer does; an altered error path; a counter whose update moved to a different point in the lifecycle". This comment doesn't fit those shapes. Anyway, Step 3: is the code described absent from the file's diff? No, the gate is in the diff. Step 4: is there a diff line that literally contradicts the claim? The claim is about `updateEvent` reading details from the request body. We can't see line 27829 in the diff. The diff does show `$detailsOut` and `stampSsmaAprofundamentoAuthor($detailsOut, ...)`, and `$existingDetails` is not visible. So no contradiction readable from diff. Approve. Comment c-1: about SsmaNotificationService.php. Claims that when rejecting an occurrence without a recorded author, no in-app notification is sent to anyone — before, the registrant (creator) was notified. This is a behavioral change... "a message, field, status, or default that the old code produced and the new code no longer does". Hmm. The comment says the in-app notification no longer goes to the creator — that is a behavioral change. So Step 1 veto: protected subject — behavioral change. Approve. Also, is it factually correct? The old code: `$recipient = $this->entityManager->find(User::class, $event->getCreatedById());` — notified the creator. New code: recipients from aprofundamento_finalized_by_*; if empty, return. So yes, when no author recorded, returns without notifying. The comment's claim is true. Either way, protected subject → approve. Both comments approved. Wait, but the task says to run steps and report only those that qualify. Both should be approved. So call approve_all_comments. Actually let me double check whether c-0 could qualify for removal under Ground A: The comment says the decision uses `$details` received by parameter, but in updateEvent, the value may come from the request body. Is this describing code in another function (updateEvent) not in the diff? Ground A is about the subject file's diff — the code it describes (the gate and the details source) — the gate is present in the diff. The claim about the request body source is about code we can't see. So no removal. Approve. So final answer: approve_all_comments.
I'll analyze both comments against the diffs. **c-0** (SsmaController.php): The comment targets the newly added authorization gate `isSsmaOccurrenceInReadequacao($details)`, which *is* present in the subject file's diff, so Ground A does not apply. Its central claim — that `$details` may originate from the request payload rather than persisted state in `updateEvent` (line ~27829, `is_array($data['details'] ?? null) ? $data['details'] : $existingDetails`) — concerns code not shown in this diff; the visible diff (which references `$detailsOut` and `$existingDetails` only indirectly) contains no line that literally contradicts it. This is a runtime/authorization reasoning about code I cannot see, not a provable falsehood. Approve. **c-1** (SsmaNotificationService.php): The subject — removing the recipient fallback so that, absent a recorded author, no in-app notification is delivered — is a "message/recipient the old code produced and the new code no longer does," i.e., a behavioral/compatibility change. That is a protected subject, so the veto applies before correctness is judged. Independently, the diff confirms the claim: the old code resolved `$event->getCreatedById()` and the new code returns early when `$recipients === []`. Approve. Neither comment reaches Ground A or Ground B.
Tool Calls (1)
approve_all_comments
Show Details
{}
tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php,tests/Unit/Product/Ssma/assert_branch_ui_fixes.php 29 requests

plan_task

Request #1 deepseek-flash P:7.3K C:4.91K CR:1.28K CW:0 21479ms
Reasoning
Let me analyze the changes. The review group contains two test files: 1. `tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php` (new file) 2. `tests/Unit/Product/Ssma/assert_branch_ui_fixes.php` (modified) The other changed files (not in this review group) are the actual source: SsmaController.php, SsmaNotificationService.php, SsmaOccurrenceApprovalService.php. The review group is only the tests. So issues should be about the tests. But we should consider whether tests properly cover behavior, whether they're brittle string assertions, etc. Key observations: 1. Both test files perform string-based assertions on source code (`assertStringContainsString` on file contents, `fileNotContains`). This is a brittle anti-pattern: tests that check source text rather than behavior. They don't test real integration paths. This is a strong finding: the test doesn't exercise controller/service; it just greps source. According to user rules: "Teste que só instancia objeto de domínio sem passar pelo controller/service que decide acesso não substitui teste funcional do endpoint." And "Teste deve cobrir o caminho de integração real (endpoint, service completo)". So these are source-text smoke tests — medium/low severity. 2. The new test uses `dirname(__DIR__, 4)` to compute root. Let's verify: `tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php`. `__DIR__` = tests/Unit/Product/Ssma. dirname(...,1) = tests/Unit/Product, 2 = tests/Unit, 3 = tests, 4 = repo root. Correct. 3. `extractMethod` uses `substr($src, $start, 1800)` — a fixed 1800 char window. If the method body exceeds 1800 chars, the assertion could fail or pass incorrectly. Also `assertStringNotContainsString('getCreatedById()', ...)` checks only in that window. Fragile. Also, if method is shorter and next method contains getCreatedById, false positive. Low/medium. 4. The new test asserts on strings that don't exist in the test group's files but in source files (controller, notification, approval). It's coupled to source implementation details - any refactor breaks tests. Also `assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc)` — checking for literal glob-like string 'aprofundamento_finalized_by_*'. Hmm. 5. Potential issue: test in `assert_branch_ui_fixes.php` uses `fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')` while the new PHPUnit test asserts the same. But the source `SsmaNotificationService.php` — if the method still exists but is unused, the test fails. Actually the assertion is string-not-contains; if the old method remains defined in the file (e.g., still declared but not called), the test fails. That could be a false assertion issue — but that's about the test design. Actually wait, we should verify whether these claims are accurate. The tests aren't in the group... Let me think about what to verify with tools. The instruction: analyze code changes (tests). Identify risk points and plan tool calls. Let me think about potential real defects in these test files: - `assert_branch_ui_fixes.php` is a standalone script (not PHPUnit). It builds `$notificationService` variable and uses it. Fine. - In the new PHPUnit test, `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc)` — this checks the whole notification service file. If the legit method was renamed, fine. But if a comment mentions it, fail. - Test `testRejectKeepsAuthorAndUsesReadequacaoCopy` asserts 'aprofundamento_finalized_by_*' in approval service. Hmm, that star pattern is weird. - The test class is not verifying behavior at all, only text presence. If the controller renames a method or changes implementation, test breaks even though behavior is fine, and conversely the test could pass without behavior being correct. That's the "test asserts source text not behavior" finding - medium. - Another: These tests read files at absolute path via `dirname(__DIR__, 4)`. If run in a different structure, could be fine. - Potential: The new test is in namespace `App\Tests\Unit\Product\Ssma` but PHPUnit TestCase; setUp reads files each test — performance negligible. - `extractMethod` calls `self::assertNotFalse` inside a helper, returning chunk. Fine. - A significant concern: the test suite asserts negative strings, e.g., `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc)`. If the old method is still defined and used elsewhere (e.g., registration flow for new cadastro), removing it may break other behavior. Actually the background says cadastro novo still notifies all specialists of the type. Hmm, but the assert says the notification service no longer contains 'notifyOccurrenceRegistrantOnReject'. If the new-registration flow used that method, removing it would break. But the other changed files aren't in scope. We can flag as verification: check whether `notifyOccurrenceRegistrantOnReject` is still referenced elsewhere (e.g., other controllers), because asserting its absence from the service may conflict with the new-registration flow that must still notify all specialists. Let me plan tool calls: - `code_search` for `notifyOccurrenceRegistrantOnReject` across codebase to see if still referenced. - `code_search` for `notifyAprofundamentoAuthorOnReject` to see definition/callers. - `code_search` for `getCreatedById` in notification service to verify assertion. - `file_read_diff` on the source files? They're not in the review group; but we have the "other changed files" list. The tool file_read_diff can view changes in other files. Actually file_read_diff "view the changes made to other files in the list of modifications". The list of other changed files is given. We can plan to read them. Let me structure the review. Issues: 1. [medium] Tests are text-based source greps rather than behavioral. The new test and smoke script only assert that certain strings appear/disappear in source files. They don't exercise the controller/service, so they can't actually verify the business rule (author-only readequação, admin parallel edit not re-submitting queue). Impact: regressions in real behavior pass tests; refactors break tests falsely. Plan: search for existing functional/integration tests for the endpoint. 2. [medium] `extractMethod` reads only a fixed 1800-char window of the file, so the negative assertion `assertStringNotContainsString('getCreatedById()', ...)` can both miss code beyond the window (false pass) and pick up a nearby unrelated method (false fail). Location: helper at bottom of new test. 3. [medium/low] Negative assertions like `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', ...)` may conflict with the new-registration flow that must still notify all specialists of the type. Verify whether that method is still referenced elsewhere; if so the assertion is wrong / removal breaks cadastro novo. Plan: code_search. 4. [low] `assertStringContainsString('aprofundamento_finalized_by_*', ...)` asserts a literal string with an asterisk, which is likely a placeholder/wildcard not actual source text — this assertion may be meaningless or wrong. Verify actual key names in approval service. 5. [low] Tests coupled to source internals/messages strings — fragile. Also consider: The new test declares `private string $controllerSrc;` and reads files in setUp without checking file existence beyond casting to string; if file missing, `file_get_contents` returns false → cast to '' empty string → assertions fail with unclear messages. Minor. Also: `fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')` in assert_branch_ui_fixes.php — plus the check `fileContains($notificationService, 'notifyAprofundamentoAuthorOnReject')`. Let me also think about whether the tests should cover the security/permission rule: "Em readequação: só o autor (e admin/tenant) edita o aprofundamento. Outros especialistas do tipo não." There's no test covering authorization enforcement in the controller for editing by non-author. The new test only checks that controller contains `isSsmaOccurrenceInReadequacao` and `stampSsmaAprofundamentoAuthor` strings, not that the authorization actually denies other specialists. This is a gap: no test verifies that another specialist of the type is denied editing. Per user rules: authorization flow lacking test = Crítico. So flag as high/medium: no functional test verifying the access-restriction rule (non-author denied). Actually the background lists "Permissões" as a risk. The tests should cover that but only grep for strings. That's a strong finding — medium/high. Let me set that as high? The rule says: "Se a PR alterou comportamento e não trouxe teste novo/atualizado cobrindo esse comportamento, sinalize como pendência (Atenção; Crítico se o fluxo for autorização, dinheiro, exclusão ou estado)." The change altered an authorization behavior (only author can edit in readequação). The tests added don't functionally verify it. So Crítico. But since this is a test-review group and the actual change is in other files, we can still flag the missing coverage. I'd rate high. Let me order: high first. Issue A (high): No test verifies authorization/access restriction behavior (only author + admin/tenant can edit aprofundamento in readequação); the new tests only grep for the presence of method names (`isSsmaOccurrenceInReadequacao`, `stampSsmaAprofundamentoAuthor`), which doesn't prove the permission is enforced. Impact: an auth regression could pass; also non-author specialists could silently gain edit access. Issue B (medium): Tests assert on source strings instead of behavior; can produce false positives/negatives and break on refactor (e.g., message text change). Issue C (medium): extractMethod 1800-char window fragility. Issue D (medium): Negative assertion about notifyOccurrenceRegistrantOnReject may be wrong if new-registration flow still uses it → need verification. Issue E (low): 'aprofundamento_finalized_by_*' literal with asterisk. Maybe combine some. Let me also mention the smoke script's `fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')` — asserting absence of a comment string. That's testing that a specific comment was removed, which is odd/brittle; a comment isn't behavior. Flag low. Let's also verify: `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', $this->controllerSrc)` — checking that a comment no longer exists. That's extremely brittle (checking a comment). And `fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')`. So a finding: the test checks absence of a human comment, which has no relation to behavior; if someone re-adds a similar comment the test fails, and it doesn't guarantee the logic changed. I'll produce maybe 6 issues. Now decide which tool calls to plan. For issue A: `code_search` for `isSsmaOccurrenceInReadequacao` and `aprofundamento_finalized_by_member_id` to see how it's used, and `file_find` for functional tests. Also `file_read_diff` of SsmaController.php to see the actual authorization logic. For issue B: `file_read_diff` SsmaNotificationService.php and SsmaOccurrenceApprovalService.php to see whether the assertions match actual behavior — confirm the text-based tests don't verify behavior. For issue C: no tool needed maybe; it's self-evident. Could read the source to see method length. Use `code_search` for 'function notifyAprofundamentoAuthorOnReject' to check length? Not necessary but we can plan to read. For issue D: `code_search` for `notifyOccurrenceRegistrantOnReject` across codebase. For issue E: `code_search` for `aprofundamento_finalized_by` in the approval service to verify key names. Let me write in Portuguese (pt-BR) per instruction. The output format: Summary line, Issues numbered with severity and → lines. Let me be careful with the arguments for code_search: search_text and file_patterns. Write concise but complete. Let me draft. Summary: A PR adiciona/atualiza testes que apenas verificam strings no código-fonte para garantir que, na reprovação, o aviso vai para quem finalizou o aprofundamento (e não para o cadastrante) e que a edição admin não reenvia a fila; não há testes funcionais de comportamento/autorização. Issues: 1. [high] Nenhum teste verifica de fato a restrição de acesso... new test only asserts presence of method-name strings `isSsmaOccurrenceInReadequacao`/`stampSsmaAprofundamentoAuthor`; não confirma que outro especialista do tipo é impedido de editar nem que gestor/tenant editam em paralelo. Impact: regressão de autorização passa despercebida (fluxo de permissão). → code_search 'isSsmaOccurrenceInReadequacao' — localizar onde a regra é aplicada e confirmar se há enforcement real → file_read_diff src/Controller/SsmaController.php — inspecionar a lógica de permissão alterada e ver se é testável → file_find 'SsmaOccurrenceReadequacao' — procurar testes funcionais/integração existentes do fluxo 2. [medium] Testes baseados em conteúdo de arquivo (grep) em vez de comportamento. `assertStringContainsString`/`fileContains` sobre o texto do controller e services. Impact: qualquer refatoração (renomear método, alterar mensagem) quebra o teste; e a mudança pode estar errada que o teste passa. → file_read_diff src/Service/Ssma/SsmaNotificationService.php — confirmar que as strings verificadas são só texto e não comportamento observável → file_read_diff src/Service/Ssma/SsmaOccurrenceApprovalService.php — idem para os textos de readequação 3. [medium] `extractMethod` lê apenas 1800 caracteres fixos a partir de `function notifyAprofundamentoAuthorOnReject`; a asserção negativa `assertStringNotContainsString('getCreatedById()')` pode passar por engano (código fora da janela) ou falhar por capturar método vizinho. → code_search 'function notifyAprofundamentoAuthorOnReject' — verificar tamanho/limites do método e se 1800 chars cobrem o corpo inteiro 4. [medium] A asserção negativa `notifyOccurrenceRegistrantOnReject` presumir que o método pode ser removido do service; se o fluxo de cadastro novo ainda o usa, a remoção quebra o aviso a todos os especialistas. Verificar referências. → code_search 'notifyOccurrenceRegistrantOnReject' — checar se ainda é chamado em outro ponto (cadastro novo) 5. [low] A asserção `assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc)` usa um asterisco literal, que provavelmente não corresponde ao texto real do código; o teste pode passar/falhar sem indicar o comportamento. → code_search 'aprofundamento_finalized_by' src/Service/Ssma/SsmaOccurrenceApprovalService.php — confirmar os nomes reais das chaves 6. [low] Testes verificam ausência de comentário humano (`'Readequação: reenvia tanto na edição completa'`) e presença de strings de mensagem; comentário não é comportamento e a checagem é frágil. → code_search 'Readequação: reenvia tanto na edição completa' — confirmar se ainda existe no controller e se a garantia é apenas textual Hmm, item 6 overlaps with 2. Maybe keep but fine. Also consider potential issue: `file_get_contents` returns false cast to '' — silent. Could mention low. Maybe fold into issue. Let me also check: are there other real bugs? The test `testRejectNotifiesAprofundamentoAuthorNotRegistrant` asserts controller contains 'notifyAprofundamentoAuthorOnReject'. Fine. I think six issues is good. Ordering by severity: high, then mediums, then lows. Let me finalize. Ensure no markdown headings/code fences. Use plain text. For the → lines format: `→ code_search search_text='...' file_patterns=[...] — reason`. Provide invocation arguments. I'll write them.
Summary: A PR altera o fluxo de readequação do SSMA (aviso de reprovação vai para quem finalizou o aprofundamento, e edição de gestor/tenant não reenvia a fila). Este grupo de revisão contém apenas os testes: um novo PHPUnit (SsmaOccurrenceReadequacaoAuthorTest) e o smoke script assert_branch_ui_fixes.php. Ambos verificam o comportamento lendo o texto dos arquivos-fonte, sem exercitar controller/serviço. Issues 1. [high] Nenhum teste cobre de fato a regra de permissão que a PR muda — que em readequação só quem enviou o aprofundamento (e admin/tenant) pode editar. O novo teste apenas confirma que o controller contém as strings `isSsmaOccurrenceInReadequacao` e `stampSsmaAprofundamentoAuthor`; a mera presença do nome do método não prova que outro especialista do mesmo tipo seja impedido nem que gestor/tenant editem em paralelo. Se a verificação de autor fosse removida ou invertida, os testes continuariam verdes. → code_search search_text='isSsmaOccurrenceInReadequacao' — localizar onde a regra de acesso é aplicada para avaliar se ela é realmente testável via endpoint/serviço → file_read_diff path_array=['src/Controller/SsmaController.php'] — inspecionar a lógica de permissão alterada e confirmar o ponto de enforcement → file_find query_name='SsmaOccurrenceReadequacao' — procurar teste funcional/integração existente do fluxo de readequação 2. [medium] Os testes são "grep de código-fonte" e não comportamento: usam `assertStringContainsString`/`fileContains` sobre o texto do controller e dos services. Isso não exercita o caminho de integração real, então a mudança pode estar funcionalmente errada e ainda passar, e qualquer refatoração (renomear método, ajustar mensagem) quebra o teste sem que haja defeito. → file_read_diff path_array=['src/Service/Ssma/SsmaNotificationService.php'] — confirmar quais trechos são apenas texto e não comportamento observável → file_read_diff path_array=['src/Service/Ssma/SsmaOccurrenceApprovalService.php'] — idem para os textos de readequação 3. [medium] O helper `extractMethod` lê apenas 1800 caracteres fixos a partir de `function notifyAprofundamentoAuthorOnReject`. A asserção negativa `assertStringNotContainsString('getCreatedById()', ...)` pode passar por engano (se o código relevante estiver além da janela) ou falhar por capturar um método vizinho, tornando o teste não confiável. → code_search search_text='function notifyAprofundamentoAuthorOnReject' file_patterns=['src/Service/Ssma/SsmaNotificationService.php'] — verificar o tamanho e os limites do método para saber se a janela de 1800 caracteres cobre o corpo inteiro 4. [medium] A asserção negativa `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', ...)` (no novo teste e no smoke `fileNotContains`) pressupõe que esse método pode desaparecer do service. Se o fluxo de cadastro novo ainda precisar avisar todos os especialistas do tipo reutilizando esse método, a remoção/ausência quebra o aviso esperado — o teste então estaria induzindo a erro em vez de proteger. → code_search search_text='notifyOccurrenceRegistrantOnReject' — verificar se o método ainda é referenciado em outro ponto (cadastro novo) antes de exigir sua ausência 5. [low] A asserção `assertStringContainsString('aprofundamento_finalized_by_*', ...)` usa um asterisco literal, que provavelmente não corresponde ao texto real do código (placeholder/wildcard). O teste pode passar ou falhar sem relação com o comportamento pretendido. → code_search search_text='aprofundamento_finalized_by' file_patterns=['src/Service/Ssma/SsmaOccurrenceApprovalService.php'] — confirmar os nomes reais das chaves gravadas em details 6. [low] Parte das verificações testa ausência de comentário humano (`'Readequação: reenvia tanto na edição completa'`) e presença de strings de mensagem. Comentário não expressa comportamento: o teste não garante que a fila deixou de reabrir, e volta a falhar se alguém reintroduzir um texto parecido. → code_search search_text='Readequação: reenvia tanto na edição completa' — confirmar se o comentário foi removido e que a garantia é apenas textual, não funcional

main_task

Request #1 deepseek-flash P:9.67K C:154 CR:2.56K CW:0 1280ms
Reasoning
Let me review the changes in the review files. These are test files. Let me investigate the actual source code to verify claims. Let me look at the source files.
Tool Calls (2)
file_read_diff
Show Details
{"path_array": ["src/Service/Ssma/SsmaNotificationService.php", "src/Service/Ssma/SsmaOccurrenceApprovalService.php", "src/Controller/SsmaController.php"]}
==== FILE: src/Service/Ssma/SsmaNotificationService.php ====
diff --git a/src/Service/Ssma/SsmaNotificationService.php b/src/Service/Ssma/SsmaNotificationService.php
--- a/src/Service/Ssma/SsmaNotificationService.php
+++ b/src/Service/Ssma/SsmaNotificationService.php
@@ -278,15 +278,33 @@ class SsmaNotificationService
         );
     }
 
-    public function notifyOccurrenceRegistrantOnReject(SsmaEvent $event, User $sender, string $note): void
+    /**
+     * Fluxo natural da readequação: avisa só quem enviou o aprofundamento reprovado.
+     * Não dispara para todos os especialistas do tipo (isso é o cadastro novo).
+     * Destinatários extras ficam a cargo da automação ssma_on_occurrence_rejected.
+     */
+    public function notifyAprofundamentoAuthorOnReject(SsmaEvent $event, User $sender, string $note): void
     {
-        $recipient = $this->entityManager->find(User::class, $event->getCreatedById());
-        if (!$recipient instanceof User) {
+        $details = is_array($event->getDetails()) ? $event->getDetails() : [];
+        $memberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
+        $userId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
+
+        $recipients = [];
+        if ($memberId > 0) {
+            $recipients[] = $memberId;
+        } elseif ($userId > 0) {
+            $author = $this->entityManager->find(User::class, $userId);
+            if ($author instanceof User) {
+                $recipients[] = $author;
+            }
+        }
+
+        if ($recipients === []) {
             return;
         }
 
         $content = sprintf(
-            'A ocorrência "%s" foi reprovada na validação e voltou para rascunho.',
+            'A ocorrência "%s" foi reprovada na validação e voltou para readequação do aprofundamento.',
             $this->eventTitle($event)
         );
         if (trim($note) !== '') {
@@ -294,7 +312,7 @@ class SsmaNotificationService
         }
 
         $this->notify(
-            [$recipient],
+            $recipients,
             $content,
             self::PENDING,
             $sender,
==== FILE: src/Service/Ssma/SsmaOccurrenceApprovalService.php ====
diff --git a/src/Service/Ssma/SsmaOccurrenceApprovalService.php b/src/Service/Ssma/SsmaOccurrenceApprovalService.php
--- a/src/Service/Ssma/SsmaOccurrenceApprovalService.php
+++ b/src/Service/Ssma/SsmaOccurrenceApprovalService.php
@@ -107,9 +107,10 @@ final class SsmaOccurrenceApprovalService
             $event->setStatus(SsmaEvent::STATUS_ABERTO);
             $details['aprofundamento_status'] = 'draft';
             $details['aprofundamento_complete'] = false;
+            // Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento.
             $event->setDetails($details);
             if ($prevStatus !== SsmaEvent::STATUS_ABERTO) {
-                $event->appendHistory('Ocorrência devolvida para rascunho após reprovação.', [
+                $event->appendHistory('Ocorrência devolvida para readequação após reprovação.', [
                     'from' => $prevStatus,
                     'to' => SsmaEvent::STATUS_ABERTO,
                 ]);
@@ -122,7 +123,7 @@ final class SsmaOccurrenceApprovalService
             'success' => true,
             'message' => $decision === self::STATUS_APPROVED
                 ? 'Ocorrência aprovada. As automações (flash e/ou comitê) podem seguir.'
-                : 'Ocorrência reprovada. O registro voltou para rascunho para correção.',
+                : 'Ocorrência reprovada. O registro voltou para readequação.',
             'occurrence_approval' => $state,
         ];
     }
==== FILE: src/Controller/SsmaController.php ====
diff --git a/src/Controller/SsmaController.php b/src/Controller/SsmaController.php
--- a/src/Controller/SsmaController.php
+++ b/src/Controller/SsmaController.php
@@ -4131,7 +4131,7 @@ class SsmaController extends AbstractController
         }
 
         try {
-            $this->notifyOccurrenceRegistrantOnReject($event, $company, $user, $note);
+            $this->notifyAprofundamentoAuthorOnReject($event, $company, $user, $note);
         } catch (\Throwable $notifyError) {
             $this->ssmaLogger->warning('Ssma rejectOccurrence notify: ' . $notifyError->getMessage());
         }
@@ -12016,13 +12016,13 @@ class SsmaController extends AbstractController
         $this->ssmaNotificationService->notifyOccurrenceValidationApprovers($company, $event, $sender, false);
     }
 
-    private function notifyOccurrenceRegistrantOnReject(
+    private function notifyAprofundamentoAuthorOnReject(
         SsmaEvent $event,
         Company $company,
         User $sender,
         string $note
     ): void {
-        $this->ssmaNotificationService->notifyOccurrenceRegistrantOnReject($event, $sender, $note);
+        $this->ssmaNotificationService->notifyAprofundamentoAuthorOnReject($event, $sender, $note);
     }
 
     private function canManageSsmaOccurrences(): bool
@@ -12761,6 +12761,17 @@ SQL;
             return true;
         }
 
+        $loggedMember = $this->getCurrentCompanyMember($company, $user);
+
+        // Readequação: só quem enviou o aprofundamento (admin já passou acima).
+        if ($this->isSsmaOccurrenceInReadequacao($details)) {
+            $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0
+                || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0;
+            if ($hasAuthor) {
+                return $this->isSsmaAprofundamentoAuthor($details, $user, $loggedMember);
+            }
+        }
+
         if (in_array($type, $this->resolveCurrentUserTechnicalTypes($company, $user), true)) {
             return true;
         }
@@ -12769,7 +12780,6 @@ SQL;
             return false;
         }
 
-        $loggedMember = $this->getCurrentCompanyMember($company, $user);
         if (!$loggedMember) {
             return false;
         }
@@ -12783,6 +12793,46 @@ SQL;
         );
     }
 
+    /**
+     * @param array<string, mixed> $details
+     */
+    private function isSsmaOccurrenceInReadequacao(array $details): bool
+    {
+        $approval = is_array($details['occurrence_approval'] ?? null) ? $details['occurrence_approval'] : [];
+
+        return strtolower(trim((string) ($approval['status'] ?? ''))) === 'rejected';
+    }
+
+    /**
+     * @param array<string, mixed> $details
+     */
+    private function isSsmaAprofundamentoAuthor(array $details, User $user, ?CompanyMembers $member): bool
+    {
+        $authorUserId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
+        if ($authorUserId > 0 && (int) $user->getId() === $authorUserId) {
+            return true;
+        }
+
+        $authorMemberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
+
+        return $authorMemberId > 0 && $member instanceof CompanyMembers
+            && (int) $member->getId() === $authorMemberId;
+    }
+
+    /**
+     * @param array<string, mixed> $details
+     */
+    private function stampSsmaAprofundamentoAuthor(array &$details, Company $company, User $user): void
+    {
+        $details['aprofundamento_finalized_by_user_id'] = (int) $user->getId();
+        $memberId = $this->resolveCompanyMemberIdByUserId($company, (int) $user->getId());
+        if ($memberId) {
+            $details['aprofundamento_finalized_by_member_id'] = $memberId;
+        }
+        $name = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
+        $details['aprofundamento_finalized_by_name'] = $name !== '' ? $name : (string) ($user->getEmail() ?? '');
+    }
+
     /**
      * @param array<string, mixed> $details
      */
@@ -27855,6 +27905,7 @@ SQL;
                 $data['aprofundamento_complete'] = true;
                 $detailsOut['aprofundamento_complete'] = true;
                 $detailsOut['aprofundamento_status'] = 'finalized';
+                $this->stampSsmaAprofundamentoAuthor($detailsOut, $company, $user);
             } else {
                 $data['aprofundamento_complete'] = false;
                 $detailsOut['aprofundamento_complete'] = false;
@@ -27890,11 +27941,10 @@ SQL;
         $editorMeta = $this->resolveSsmaEditorMeta($company, $user);
         $event      = $service->updateFromPayload($event, $data, $user, $editorMeta);
 
-        if ($wasRejectedApproval) {
-            // Readequação: reenvia tanto na edição completa quanto ao finalizar só o aprofundamento.
-            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, true);
-        } elseif ($wantsFinalize) {
-            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, false);
+        // Readequação só volta à fila ao reenviar o aprofundamento. Editar (gestor/tenant)
+        // em paralelo não starta esse fluxo.
+        if ($wantsFinalize) {
+            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);
         }
 
         $registeredByName = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
code_search
Show Details
{"search_text": "notifyOccurrenceRegistrantOnReject"}
File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 1
33|        self::assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc);

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 1
378|    && fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')

Request #2 deepseek-flash P:12.49K C:944 CR:9.73K CW:0 4139ms
Reasoning
So `notifyOccurrenceRegistrantOnReject` is no longer referenced anywhere in src. Fine. Now let's check the `{...}` checks. Let me verify the assertions in the new test: 1. `assertStringContainsString('notifyAprofundamentoAuthorOnReject', $this->controllerSrc);` ✓ 2. `assertStringContainsString('notifyAprofundamentoAuthorOnReject', $this->notificationSrc);` ✓ 3. `assertStringContainsString('aprofundamento_finalized_by_member_id', $this->notificationSrc);` ✓ 4. `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc);` ✓ (removed) 5. `assertStringNotContainsString('getCreatedById()', extractMethod(...))` — need to check the method body of `notifyAprofundamentoAuthorOnReject` for getCreatedById and whether it's within 1800 chars. The method does not contain getCreatedById. But the extract is 1800 chars from the start of function; the next method after may contain getCreatedById()? Let's check. Let me read the file around there. Now let's verify the second test: - `assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc);` — This is a literal string with `*`. Does the approval service source contain the literal `aprofundamento_finalized_by_*`? Yes! The comment added: "Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento." That's in approvalSrc. So the test passes because of a comment. That's a dubious assertion — it tests a comment text. Indeed the plan's point 5/6. - `assertStringContainsString('voltou para readequação', $this->approvalSrc);` ✓ in message - `assertStringContainsString('devolvida para readequação após reprovação', $this->approvalSrc);` ✓ in history. Third test: - `assertStringContainsString('maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)', $this->controllerSrc);` ✓ - `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', $this->controllerSrc);` ✓ (comment removed) - `assertStringContainsString('stampSsmaAprofundamentoAuthor', $this->controllerSrc);` ✓ - `assertStringContainsString('isSsmaOccurrenceInReadequacao', $this->controllerSrc);` ✓ Now the review group is only the tests. What issues can I report? Let me think about the key ones. Issue A: Test `testRejectKeepsAuthorAndUsesReadequacaoCopy` asserts `'aprofundamento_finalized_by_*'` literal — this is a comment in the approval service. The assertion is coupled to a comment text. If the comment is reformatted/removed, the test fails even though behavior is fine. This is a maintainability/test issue. Reasonable to report as medium/low. Actually wait: also more importantly, this test asserting on a comment string passes but doesn't verify the actual behavior (author not wiped). It's a false sense of coverage. Issue B: Brace the assertion `assertStringNotContainsString('getCreatedById()', extractMethod(...))` — the 1800-char window may include the next method. Let me check whether the next method contains `getCreatedById()`. If yes, the test would fail. Let's read the notification service. Let me read the file.
Tool Calls (2)
file_read
Show Details
{"file_path": "src/Service/Ssma/SsmaNotificationService.php", "start_line": 270, "end_line": 400}
File: src/Service/Ssma/SsmaNotificationService.php (Total lines: 485)
IS_TRUNCATED: false
LINE_RANGE: 270-400
270|        $this->notify(
271|            $this->ssmaOccurrenceTypeConfig->getFlashReportApproverIds($company),
272|            $resubmitted
273|                ? sprintf('A ocorrência "%s" foi corrigida e aguarda nova validação.', $title)
274|                : sprintf('A ocorrência "%s" foi finalizada e aguarda sua validação.', $title),
275|            self::PENDING,
276|            $sender,
277|            $this->occurrenceViewUrl((int) $event->getId(), 'event')
278|        );
279|    }
280|
281|    /**
282|     * Fluxo natural da readequação: avisa só quem enviou o aprofundamento reprovado.
283|     * Não dispara para todos os especialistas do tipo (isso é o cadastro novo).
284|     * Destinatários extras ficam a cargo da automação ssma_on_occurrence_rejected.
285|     */
286|    public function notifyAprofundamentoAuthorOnReject(SsmaEvent $event, User $sender, string $note): void
287|    {
288|        $details = is_array($event->getDetails()) ? $event->getDetails() : [];
289|        $memberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
290|        $userId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
291|
292|        $recipients = [];
293|        if ($memberId > 0) {
294|            $recipients[] = $memberId;
295|        } elseif ($userId > 0) {
296|            $author = $this->entityManager->find(User::class, $userId);
297|            if ($author instanceof User) {
298|                $recipients[] = $author;
299|            }
300|        }
301|
302|        if ($recipients === []) {
303|            return;
304|        }
305|
306|        $content = sprintf(
307|            'A ocorrência "%s" foi reprovada na validação e voltou para readequação do aprofundamento.',
308|            $this->eventTitle($event)
309|        );
310|        if (trim($note) !== '') {
311|            $content .= ' Observação: ' . trim($note);
312|        }
313|
314|        $this->notify(
315|            $recipients,
316|            $content,
317|            self::PENDING,
318|            $sender,
319|            $this->occurrenceViewUrl((int) $event->getId(), 'event')
320|        );
321|    }
322|
323|    public function notifyMetaAbonoRequester(int $memberId, string $kind, ?User $sender): void
324|    {
325|        $this->notify(
326|            [$memberId],
327|            sprintf('Seu pedido de revisão de metas de %s foi respondido', $this->metaAbonoKindLabel($kind)),
328|            NotificationsCenter::TYPE_GENERAL,
329|            $sender,
330|            $this->urlGenerator->generate('ssma_prevencao_index') . '#tab_prev_meta_reviews'
331|        );
332|    }
333|
334|    /**
335|     * @param list<int> $memberIds
336|     */
337|    private function notifyActionPlan(array $memberIds, int $actionId, User $sender, string $content): void
338|    {
339|        if ($actionId <= 0) {
340|            return;
341|        }
342|
343|        $this->notify(
344|            $memberIds,
345|            $content,
346|            self::PENDING,
347|            $sender,
348|            $this->urlGenerator->generate('ssma_plano_acao_index', ['action' => $actionId])
349|        );
350|    }
351|
352|    /**
353|     * Aceita IDs de CompanyMembers ou instâncias de User.
354|     *
355|     * @param list<int|User> $recipients
356|     */
357|    private function notify(
358|        array $recipients,
359|        string $content,
360|        string $type,
361|        ?User $sender,
362|        string $buttonUrl,
363|        string $hub = self::HUB,
364|        string $product = self::PRODUCT,
365|        ?Company $restrictToCompany = null
366|    ): void {
367|        $senderId = $sender instanceof User ? (int) $sender->getId() : 0;
368|        $created = 0;
369|
370|        foreach ($recipients as $recipient) {
371|            if (!$recipient instanceof User) {
372|                $recipient = $this->resolveMemberUser((int) $recipient, $restrictToCompany);
373|            }
374|            if (!$recipient instanceof User || ($senderId > 0 && (int) $recipient->getId() === $senderId)) {
375|                continue;
376|            }
377|
378|            try {
379|                $this->notificationsCenterService->createNotification(
380|                    recipient: $recipient,
381|                    hub: $hub,
382|                    product: $product,
383|                    content: $content,
384|                    type: $type,
385|                    sender: $sender,
386|                    buttonUrl: $buttonUrl,
387|                    flush: false
388|                );
389|                $created++;
390|            } catch (\Throwable) {
391|            }
392|        }
393|
394|        if ($created === 0) {
395|            return;
396|        }
397|
398|        try {
399|            $this->entityManager->flush();
400|        } catch (\Throwable) {
code_search
Show Details
{"search_text": "getCreatedById", "file_patterns": ["src/Service/Ssma/SsmaNotificationService.php"]}
No matches found
Request #3 deepseek-flash P:14.81K C:1.35K CR:13.31K CW:0 6868ms
Reasoning
So `getCreatedById` is no longer referenced in notification service. The extractMethod window of 1800 chars from line 286 covers up to line ~330 (byte offset). The method is ~1100 chars. The next method notifyMetaAbonoRequester doesn't have getCreatedById. So the negative assertion is trivially true — it can't fail regardless. Actually the assertion is weak but not incorrect. It's a weak test but that's arguably a low-severity test quality issue. Now, more importantly, are there actual bugs in the test files? The review scope is only the test files. The main issues are test quality. Let me look for concrete defects. One concrete issue: The test `testRejectKeepsAuthorAndUsesReadequacaoCopy` asserts `'aprofundamento_finalized_by_*'` in the approval service. This literal only exists in the comment. So the test asserts on a comment. That's a false-positive-prone assertion: if the comment is edited (e.g., "aprofundamento_finalized_by_..."), test breaks. And it doesn't verify that the author keys are preserved. This is a valid maintainability/test finding. Another: `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', ...)` — asserts on a removed comment. Also weak, but primarily this couples to comments rather than behavior. Same category. Another consideration: the test class reads source files via `dirname(__DIR__, 4)`. Path: tests/Unit/Product/Ssma/file.php. dirname(__DIR__, 4) from tests/Unit/Product/Ssma → __DIR__ = tests/Unit/Product/Ssma. dirname(...,1)=tests/Unit/Product, 2=tests/Unit, 3=tests, 4=root. Correct. Now the key question from the review plan: tests don't test behavior. That's the main finding. But per instruction, "Teste que só instancia objeto de domínio sem passar pelo controller/service que decide acesso não substitui teste funcional do endpoint." Here tests just grep source text. The user rules say to signal missing functional test coverage as a pending item (Atenção; Crítico if auth/money/deletion/state). This PR changes authorization behavior (who can edit in readequação). So flagging missing functional test covering authorization is legitimate as high/critical? The rule: "Se a PR alterou comportamento e não trouxe teste novo/atualizado cobrindo esse comportamento, sinalize como pendência (Atenção; Crítico se o fluxo for autorização, dinheiro, exclusão ou estado)." This is authorization + state. So critical-ish. But careful: the review file itself is the test; suggesting tests in the test file is appropriate. However, I need to be careful about scope. Comments must address code within review files. The test file is in review. So I can comment on the test file that the test does not verify behavior. Let me also examine `assertStringContainsString('notifyAprofundamentoAuthorOnReject', $this->controllerSrc);` — the controller contains it. OK. Now, is there any actual failing assertion? Let's verify `'devolvida para readequação após reprovação'` exists — yes in approval service line. `'voltou para readequação'` — yes message. `'aprofundamento_finalized_by_*'` — comment line "Mantém aprofundamento_finalized_by_* — ...". Yes. Now let me check `assert_branch_ui_fixes.php` changes: it added `$notificationService` variable and checks. `fileContains($controller, 'if ($wantsFinalize) {')` — the controller has `if ($wantsFinalize) {`. Yes. `fileContains($controller, 'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)')` yes. `fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')` yes removed. `fileContains($controller, 'notifyAprofundamentoAuthorOnReject')` yes. `fileContains($notificationService, 'notifyAprofundamentoAuthorOnReject')` yes. `fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')` — hmm, does the notification service still contain that string anywhere? Search said only matches in tests. So fileNotContains passes. Wait, but `fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')` — the notification service no longer has any reference. Good. Now let me check the `$notificationService` variable definition placement in the smoke script to ensure it's not breaking. It's defined at line 347ish. Fine. Now let me check for potential issue: the new test class is `final class SsmaOccurrenceReadequacaoAuthorTest extends TestCase` in namespace `App\Tests\Unit\Product\Ssma`. Does the project autoload tests with that namespace? Let's check other tests in that dir. Let's check the existing test files' namespaces. Actually, more relevant: is the test suite run via PHPUnit, or is it run by assert_branch_ui_fixes.php smoke script (standalone assertions)? The new file is a PHPUnit test with `declare(strict_types=1)` and namespace. Let's check sibling test files to confirm the namespace convention. If other unit tests in tests/Unit/Product/Ssma use a different namespace, the new file may not be autoloaded/discovered. Let me check.
Tool Calls (2)
file_find
Show Details
{"query_name": "tests/Unit/Product/Ssma"}
tests/Unit/Product/Ssma/ActionOrigemEnumTest.php
tests/Unit/Product/Ssma/GlobalPermissionListenerOccurrenceCreateWriteTest.php
tests/Unit/Product/Ssma/GlobalPermissionListenerPreventionWriteTest.php
tests/Unit/Product/Ssma/HomeSsmaWeeklyGoalsServiceTest.php
tests/Unit/Product/Ssma/SafetyEnvironmentMemberSectionsServiceTest.php
tests/Unit/Product/Ssma/SecurityActionEffectivenessPresenterTest.php
tests/Unit/Product/Ssma/SecurityLeadershipEvaluationPresenterTest.php
tests/Unit/Product/Ssma/SsmaAbordagemAprofundamentoValidationTest.php
tests/Unit/Product/Ssma/SsmaAbordagemCoachingEvidenceTest.php
tests/Unit/Product/Ssma/SsmaActionDeadlineEditTest.php
tests/Unit/Product/Ssma/SsmaAdrianaConversationGuideTest.php
tests/Unit/Product/Ssma/SsmaAnalyticsAnonymizerTest.php
tests/Unit/Product/Ssma/SsmaBodyMapEntryRegressionTest.php
tests/Unit/Product/Ssma/SsmaBusinessHoursHelperTest.php
tests/Unit/Product/Ssma/SsmaCauseTreeAnalysisApprovalTest.php
tests/Unit/Product/Ssma/SsmaCauseTreeCommitteeCardUiTest.php
tests/Unit/Product/Ssma/SsmaCauseTreeCommitteeTest.php
tests/Unit/Product/Ssma/SsmaCauseTreeServiceTest.php
tests/Unit/Product/Ssma/SsmaCauseTreeSettingsAccessTest.php
tests/Unit/Product/Ssma/SsmaControllerPanelScopeTest.php
tests/Unit/Product/Ssma/SsmaEffectivenessProviderTest.php
tests/Unit/Product/Ssma/SsmaEventValidatorTest.php
tests/Unit/Product/Ssma/SsmaFeedImprovementFeedBridgeServiceTest.php
tests/Unit/Product/Ssma/SsmaFeedImprovementPendingStoreTest.php
tests/Unit/Product/Ssma/SsmaFlashReportApprovalGateTest.php
tests/Unit/Product/Ssma/SsmaFlashReportNotificationRegressionTest.php
tests/Unit/Product/Ssma/SsmaFrequencyRateCalculatorTest.php
tests/Unit/Product/Ssma/SsmaHorasTrabalhadasTimesheetSyncServiceTest.php
tests/Unit/Product/Ssma/SsmaInformativeQuestionGuardTest.php
tests/Unit/Product/Ssma/SsmaInjuredPersonCounterTest.php
tests/Unit/Product/Ssma/SsmaInspectionResponsibleInferenceTest.php
tests/Unit/Product/Ssma/SsmaLayerPreviewBridgeTest.php
tests/Unit/Product/Ssma/SsmaMetaAbonoMemberResolutionTest.php
tests/Unit/Product/Ssma/SsmaMetaAbonoServiceTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceAreaResponsibleTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceAutoFinalizeServiceTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceAutomationAccessResolverTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceConfigPillIsolationTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceCreatePermissionServiceTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceDashboardAggregatorTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceDisplayCodeFormatterTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceEntityTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceExportAccessResolverTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceExportFiltersTest.php
tests/Unit/Product/Ssma/SsmaOccurrencePanelSectionAnalyticsTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceProviderVoiceTrustTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceRosDeepeningSchemaTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceRosSuggestHeuristicTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceSstEvidenceServiceTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceTypeConfigServiceCategoriesTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceTypeConfigServiceTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceVoiceBaselineTest.php
tests/Unit/Product/Ssma/SsmaPanelAnalyticsChatRoutingTest.php
tests/Unit/Product/Ssma/SsmaPanelAnalyticsServiceTest.php
tests/Unit/Product/Ssma/SsmaPanelComparisonPeriodResolverTest.php
tests/Unit/Product/Ssma/SsmaPanelConversationContextStoreTest.php
tests/Unit/Product/Ssma/SsmaPanelFeedImprovementCommandServiceTest.php
tests/Unit/Product/Ssma/SsmaPanelFeedImprovementServiceTest.php
tests/Unit/Product/Ssma/SsmaPanelFreeTextIntentServiceTest.php
tests/Unit/Product/Ssma/SsmaPanelNetworkResolverTest.php
tests/Unit/Product/Ssma/SsmaPanelPeriodFilterTest.php
tests/Unit/Product/Ssma/SsmaPanelQuestionnaireAnalysisBridgeTest.php
tests/Unit/Product/Ssma/SsmaPanelSummaryDisplaySpecTest.php
tests/Unit/Product/Ssma/SsmaPanelSummaryFormatterTest.php
tests/Unit/Product/Ssma/SsmaPermissionServiceTest.php
tests/Unit/Product/Ssma/SsmaPermissionTagRepositoryTest.php
tests/Unit/Product/Ssma/SsmaPrevencaoMemberMetaSaveTest.php
tests/Unit/Product/Ssma/SsmaPrevencaoMetaPeriodoTest.php
tests/Unit/Product/Ssma/SsmaPreventionMutatePermissionServiceTest.php
tests/Unit/Product/Ssma/SsmaPreventionPanelViewAnalyticsTest.php
tests/Unit/Product/Ssma/SsmaPreviewVoicePolicyTest.php
tests/Unit/Product/Ssma/SsmaRefusalAutomationContractTest.php
tests/Unit/Product/Ssma/SsmaRefusalRightHubContractTest.php
tests/Unit/Product/Ssma/SsmaRefusalRightMutatePermissionServiceTest.php
tests/Unit/Product/Ssma/SsmaRegistrationIntentMatcherTest.php
tests/Unit/Product/Ssma/SsmaTenantAdminAprofundamentoAccessTest.php
tests/Unit/Product/Ssma/SsmaTestCase.php
tests/Unit/Product/Ssma/SsmaViewDataScopeTest.php
tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
tests/Unit/Product/Ssma/assert_flash_report_flow.php
tests/Unit/Product/Ssma/fixtures/ssma_occurrence_voice_golden.json
tests/Unit/Product/Ssma/ssma_automation_team_recipient_standalone.php
tests/Unit/Product/Ssma/ssma_permissions_policy_standalone.php
tests/Unit/Product/Ssma/verify_flash_approver_routing.php
code_search
Show Details
{"search_text": "^namespace ", "file_patterns": ["tests/Unit/Product/Ssma/*.php"], "use_perl_regexp": true}
File: tests/Unit/Product/Ssma/ActionOrigemEnumTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/GlobalPermissionListenerOccurrenceCreateWriteTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/GlobalPermissionListenerPreventionWriteTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/HomeSsmaWeeklyGoalsServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SafetyEnvironmentMemberSectionsServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SecurityActionEffectivenessPresenterTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SecurityLeadershipEvaluationPresenterTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaAbordagemAprofundamentoValidationTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaAbordagemCoachingEvidenceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaActionDeadlineEditTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaAdrianaConversationGuideTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaAnalyticsAnonymizerTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaBodyMapEntryRegressionTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaBusinessHoursHelperTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaCauseTreeAnalysisApprovalTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaCauseTreeCommitteeCardUiTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaCauseTreeCommitteeTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaCauseTreeServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaCauseTreeSettingsAccessTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaControllerPanelScopeTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaEffectivenessProviderTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaEventValidatorTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaFeedImprovementFeedBridgeServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaFeedImprovementPendingStoreTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaFlashReportApprovalGateTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaFlashReportNotificationRegressionTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaFrequencyRateCalculatorTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaHorasTrabalhadasTimesheetSyncServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaInformativeQuestionGuardTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaInjuredPersonCounterTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaInspectionResponsibleInferenceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaLayerPreviewBridgeTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaMetaAbonoMemberResolutionTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaMetaAbonoServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceAreaResponsibleTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceAutoFinalizeServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceAutomationAccessResolverTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceConfigPillIsolationTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceCreatePermissionServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceDashboardAggregatorTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceDisplayCodeFormatterTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceEntityTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceExportAccessResolverTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceExportFiltersTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrencePanelSectionAnalyticsTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceProviderVoiceTrustTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceRosDeepeningSchemaTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceRosSuggestHeuristicTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceSstEvidenceServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceTypeConfigServiceCategoriesTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceTypeConfigServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaOccurrenceVoiceBaselineTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelAnalyticsChatRoutingTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelAnalyticsServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelComparisonPeriodResolverTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelConversationContextStoreTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelFeedImprovementCommandServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelFeedImprovementServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelFreeTextIntentServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelNetworkResolverTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelPeriodFilterTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelQuestionnaireAnalysisBridgeTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelSummaryDisplaySpecTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPanelSummaryFormatterTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPermissionServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPermissionTagRepositoryTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPrevencaoMemberMetaSaveTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPrevencaoMetaPeriodoTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPreventionMutatePermissionServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPreventionPanelViewAnalyticsTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaPreviewVoicePolicyTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaRefusalAutomationContractTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaRefusalRightHubContractTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaRefusalRightMutatePermissionServiceTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaRegistrationIntentMatcherTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaTenantAdminAprofundamentoAccessTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaTestCase.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

File: tests/Unit/Product/Ssma/SsmaViewDataScopeTest.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

Request #4 deepseek-flash P:21.03K C:282 CR:16.13K CW:0 1742ms
Reasoning
Let me look at a sibling test that is also a source-grep test, e.g. SsmaTenantAdminAprofundamentoAccessTest.php and SsmaOccurrenceCreatePermissionServiceTest.php, to understand conventions and whether these source-grep patterns are the norm. This helps calibrate the severity of the "grep test" finding. Also, let me check whether there's a test that actually tests the readequação permission via service. Let's check SsmaTenantAdminAprofundamentoAccessTest, which might use SsmaTestCase (integration with kernel). Let me read SsmaTestCase. Also check if `dirname(__DIR__, 4)` is used elsewhere (test convention). Let's look at a grep-based test file.
Tool Calls (2)
file_read
Show Details
{"file_path": "tests/Unit/Product/Ssma/SsmaTenantAdminAprofundamentoAccessTest.php"}
File: tests/Unit/Product/Ssma/SsmaTenantAdminAprofundamentoAccessTest.php (Total lines: 76)
IS_TRUNCATED: false
LINE_RANGE: 1-76
1|<?php
2|
3|declare(strict_types=1);
4|
5|namespace App\Tests\Unit\Product\Ssma;
6|
7|use PHPUnit\Framework\TestCase;
8|
9|/**
10| * Regra 81 — Felipe (05/08): Tenant admin precisa ir ao aprofundamento
11| * sem se cadastrar como técnico; conta tenant não entra em select de membro.
12| *
13| * Contratos no SsmaController (métodos privados): se alguém reverter o bypass
14| * ou a exclusão da tenant, este teste quebra.
15| */
16|final class SsmaTenantAdminAprofundamentoAccessTest extends TestCase
17|{
18|    private string $controllerSrc;
19|
20|    protected function setUp(): void
21|    {
22|        $path = dirname(__DIR__, 4) . '/src/Controller/SsmaController.php';
23|        self::assertFileExists($path);
24|        $this->controllerSrc = (string) file_get_contents($path);
25|    }
26|
27|    public function testAprofundamentoAdminBypassesTenantAndCompanyAdminNotPlainMember(): void
28|    {
29|        self::assertStringContainsString(
30|            "isGranted('ROLE_TENANT')",
31|            $this->controllerSrc,
32|            'Tenant admin deve liberar aprofundamento.'
33|        );
34|        self::assertStringContainsString(
35|            "isGranted('ROLE_SUPER_ADMIN')",
36|            $this->controllerSrc
37|        );
38|        self::assertStringContainsString(
39|            'isPlainSsmaProductMember($member, $user)',
40|            $this->controllerSrc,
41|            'ROLE_MANAGER só libera se NÃO for plain member (Palloma).'
42|        );
43|        self::assertStringContainsString(
44|            "in_array('ROLE_MANAGER', \$user->getRoles(), true)",
45|            $this->controllerSrc
46|        );
47|    }
48|
49|    public function testTenantAdminExcludedFromMemberSelectsAndDeepDiveModal(): void
50|    {
51|        self::assertStringContainsString(
52|            "'ROLE_SUPER_ADMIN', 'ROLE_MANAGER', 'ROLE_TENANT', 'ROLE_ADMIN'",
53|            $this->controllerSrc,
54|            'Exclusão de tenant admin deve cobrir roles de plataforma.'
55|        );
56|        self::assertGreaterThanOrEqual(
57|            2,
58|            substr_count($this->controllerSrc, 'if ($this->isSsmaExcludedTenantAdminUser($memberUser))'),
59|            'Listas/modal de membros devem pular tenant admin em mais de um ponto.'
60|        );
61|        self::assertStringContainsString(
62|            'Tenant / admin da empresa nunca entra no select de gestor',
63|            $this->controllerSrc
64|        );
65|    }
66|
67|    public function testGestorAdministradorTagStillGrantsAprofundamento(): void
68|    {
69|        self::assertStringContainsString(
70|            "\$tag->getName() === 'Gestor Administrador'",
71|            $this->controllerSrc,
72|            'Tag Gestor Administrador deve continuar liberando aprofundamento.'
73|        );
74|    }
75|}
76|
code_search
Show Details
{"search_text": "dirname(__DIR__, 4)", "file_patterns": ["tests/Unit/Product/Ssma/*.php"]}
File: tests/Unit/Product/Ssma/SsmaBodyMapEntryRegressionTest.php
Match lines: 1
21|        $this->root = dirname(__DIR__, 4);

File: tests/Unit/Product/Ssma/SsmaCauseTreeCommitteeCardUiTest.php
Match lines: 6
103|        return (string) file_get_contents(dirname(__DIR__, 4) . '/' . $path);
137|            dirname(__DIR__, 4) . '/templates/ssma/cause_tree/tree_view/index.html.twig'
140|            dirname(__DIR__, 4) . '/templates/ssma/cause_tree/tree_view/tabs/_tab_tree.html.twig'
143|            dirname(__DIR__, 4) . '/public/js/ssma/tree_view.js'
146|            dirname(__DIR__, 4) . '/templates/ssma/cause_tree/tabs/_tab_cause_trees.html.twig'
149|            dirname(__DIR__, 4) . '/src/Controller/SsmaController.php'

File: tests/Unit/Product/Ssma/SsmaOccurrenceConfigPillIsolationTest.php
Match lines: 1
15|        $projectDir = dirname(__DIR__, 4);

File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 1
22|        $root = dirname(__DIR__, 4);

File: tests/Unit/Product/Ssma/SsmaRefusalAutomationContractTest.php
Match lines: 1
17|        $this->root = dirname(__DIR__, 4);

File: tests/Unit/Product/Ssma/SsmaRefusalRightHubContractTest.php
Match lines: 1
26|        $this->root = dirname(__DIR__, 4);

File: tests/Unit/Product/Ssma/SsmaTenantAdminAprofundamentoAccessTest.php
Match lines: 1
22|        $path = dirname(__DIR__, 4) . '/src/Controller/SsmaController.php';

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 1
12|$root = dirname(__DIR__, 4);

File: tests/Unit/Product/Ssma/assert_flash_report_flow.php
Match lines: 1
10|$root = dirname(__DIR__, 4);

File: tests/Unit/Product/Ssma/ssma_automation_team_recipient_standalone.php
Match lines: 1
11|$root = dirname(__DIR__, 4);

File: tests/Unit/Product/Ssma/verify_flash_approver_routing.php
Match lines: 2
23|require dirname(__DIR__, 4) . '/config/bootstrap.php';
25|$projectDir = dirname(__DIR__, 4);

Request #5 deepseek-flash P:22.82K C:1.06K CR:21.25K CW:0 5019ms
Reasoning
The grep-based test style is established convention in this repo (SsmaTenantAdminAprofundamentoAccessTest). So I shouldn't heavily penalize "grep test" style. The user rules do say tests should cover the real integration path; but this project clearly favors source-contract tests. Still, the user-provided review plan flags it. Given "favor precision over recall" and to avoid noise, I'd focus on concrete defects. Concrete defect candidates: 1. `assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc)` — This matches a comment, not code. If the comment changes, test fails. More importantly, this test claims to check "reject keeps author" but the assertion merely checks a comment mentioning `aprofundamento_finalized_by_*`. It provides no real guarantee. This is a genuine test-quality finding worth reporting (medium). Suggested fix: assert on the actual keys not being unset, or assert no `unset($details['aprofundamento_finalized_by` in the approval service. Actually wait — is that assertion even true about the real intent? The approval service code sets `$details['aprofundamento_status'] = 'draft'` and keeps the author keys. The test asserting the literal `aprofundamento_finalized_by_*` only exists in the newly added comment in the approval service. So this test is testing its own PR comment. That's a valid concern. 2. `extractMethod` fixed 1800-char window — the negative assertion `assertStringNotContainsString('getCreatedById()', ...)`. Since getCreatedById no longer exists anywhere in the file, this is trivially satisfied — a weak assertion. Also the window could include neighboring methods. Actually the method is ~1200 chars, so window covers only the target method plus part of the next method. But the next method (notifyMetaAbonoRequester) has no getCreatedById. So the assertion is trivially true. Low severity. I might mention the fragility but it's not a bug. 3. `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', $this->controllerSrc)` — tests absence of a comment. Also weak. Same category as #1. 4. Potentially the more significant: the new test file doesn't test the actual behavior (authorization enforcement / notification recipients). Given the PR changes authorization (who can edit the aprofundamento in readequação) and notification recipients, and per user rules this should be flagged. But given the codebase convention, this is "established style". Hmm. The user rules are mandatory: "Se a PR alterou comportamento e não trouxe teste novo/atualizado cobrindo esse comportamento, sinalize como pendência (Atenção; Crítico se o fluxo for autorização...)". The PR claims to add a new test but it doesn't cover the behavior. I'll report as a single finding on the test file: tests are text-based and don't cover the authorization/notification behavior. But I should be careful about redundancy and not over-report. Let me pick the strongest, most concrete findings. Let me consider whether there's an actual bug in the test that would make it fail. Let me check `assertStringContainsString('devolvida para readequação após reprovação', $this->approvalSrc)` — Yes present. `'voltou para readequação'` present. Good. Check `assertStringContainsString('notifyAprofundamentoAuthorOnReject', $this->controllerSrc)` — present in the private method in controller. Good. Now, one thing: this test file is a unit test in the PHPUnit suite. Is `assertStringNotContainsString` available in the PHPUnit version? Let's check composer.json for phpunit version. `assertStringNotContainsString` exists since PHPUnit 7.x? Actually assertStringNotContainsString was added in PHPUnit 8.0? Let me check. Hmm, `assertStringNotContainsString` added in PHPUnit 9.1? Let me recall: PHPUnit has `assertStringNotContainsString` since 7.5? Actually I think it was added in PHPUnit 8.4 or 9.0. Let me check the composer to see version, then verify other tests use it. Let me grep for assertStringNotContainsString in the tests dir to see if it's already used.
Tool Calls (2)
code_search
Show Details
{"search_text": "assertStringNotContainsString", "file_patterns": ["tests/"]}
Note: The results have been truncated. Only showing first 100 results.
File: tests/Controller/DecisionSystem/RiskIntelligence/SignalActionPlanControllerTest.php
Match lines: 1
139|            self::assertStringNotContainsString('/private/path', $exception->getMessage());

File: tests/Controller/DecisionSystemRiskIntelligenceControllerEvidenceTest.php
Match lines: 1
205|            self::assertStringNotContainsString('/private/path', $exception->getMessage());

File: tests/Controller/Finance/PayrollFinanceControllerWebTest.php
Match lines: 1
2054|        $this->assertStringNotContainsString('subfolhas', mb_strtolower((string) ($data['message'] ?? '')));

File: tests/Governance/Grc/GrcCaseHistoryPresenterTest.php
Match lines: 4
174|        self::assertStringNotContainsString('Decisão:', (string) $block['comment']);
190|        self::assertStringNotContainsString('Decisão:', (string) $unblock['comment']);
250|        self::assertStringNotContainsString('Exceção cancelada', (string) $event['comment']);
394|        self::assertStringNotContainsString('Caso encerrado', (string) $event['comment']);

File: tests/Service/Adriana/Command/AdrianaIntroCommandServiceTest.php
Match lines: 1
36|        self::assertStringNotContainsString('runtime cognitivo', $text);

File: tests/Service/Adriana/PrincipalLlmMessagePreparerTest.php
Match lines: 1
62|        self::assertStringNotContainsString('Total de tarefas ativas', $result);

File: tests/Service/Adriana/SsmaCommandServiceTest.php
Match lines: 1
234|        self::assertStringNotContainsString("\x00ssma:", $stripped);

File: tests/Service/Adriana/WorkflowAiPipelineTest.php
Match lines: 3
2054|        $this->assertStringNotContainsString('preciso saber', $withoutOptions);
3159|        $this->assertStringNotContainsString('Atualmente: #48CEB4.', $message);
4527|        $this->assertStringNotContainsString('compatível você quer usar', $existingQuestion);

File: tests/Service/Adriana/WorkflowLayerCallFailureTest.php
Match lines: 1
22|        self::assertStringNotContainsString('ADRIANA_COGNITIVE_LAYER_URL', $failure->userMessage());

File: tests/Service/Adriana/WorkflowLayerUnavailableDiagnosticsTest.php
Match lines: 2
58|        self::assertStringNotContainsString('ADRIANA_COGNITIVE_LAYER_URL', $result['response']);
73|        self::assertStringNotContainsString('ADRIANA_COGNITIVE_LAYER_URL', $result['response']);

File: tests/Service/AdrianaCognitiveLayer/AdrianaCognitiveReplySanitizerTest.php
Match lines: 20
22|        self::assertStringNotContainsString('working_memory', $out);
23|        self::assertStringNotContainsString('Referências:', $out);
33|        self::assertStringNotContainsString('metahuman_navigation_help', $out);
43|        self::assertStringNotContainsString('metahuman_member_research', $out);
44|        self::assertStringNotContainsString('"email"', $out);
45|        self::assertStringNotContainsString('👤', $out);
58|        self::assertStringNotContainsString('metahuman_entity_member_chain', $out);
59|        self::assertStringNotContainsString('"resolved"', $out);
70|        self::assertStringNotContainsString('MetaHuman respondeu', $out);
71|        self::assertStringNotContainsString('{', $out);
80|        self::assertStringNotContainsString('u00e3', $out);
90|        self::assertStringNotContainsString('base documental', $out);
91|        self::assertStringNotContainsString('#buscar', $out);
100|        self::assertStringNotContainsString('runtime cognitivo', $out);
102|        self::assertStringNotContainsString('#buscar', $out);
111|        self::assertStringNotContainsString('DeepSeek', $out);
140|        self::assertStringNotContainsString('evil.com', $out);
150|        self::assertStringNotContainsString('@@ADRIANA_ACTIONS@@', $out);
171|        self::assertStringNotContainsString('🔥', $out);
191|        self::assertStringNotContainsString('DeepSeek', $reply);

File: tests/Service/KnowledgeVault/KnowledgeVaultProxyServiceTest.php
Match lines: 1
134|            self::assertStringNotContainsString('/documents/', $url);

File: tests/Service/MetaHuman/LitigationCasePackPrefillAssemblerTest.php
Match lines: 1
392|        $this->assertStringNotContainsString('fluxo=#101', $byId['historico_disciplinar']['narrative']);

File: tests/Service/Ontology/OntologySignalBridgeServiceTest.php
Match lines: 1
238|        self::assertStringNotContainsString('análise integrada', mb_strtolower($narrative['interpretation']));

File: tests/Service/PeopleAnalytics/BehavioralIndicatorActionPlanApplicationServiceTest.php
Match lines: 3
411|        self::assertStringNotContainsString('can_evaluate', $javascript);
450|        self::assertStringNotContainsString('manager_note', $javascript);
451|        self::assertStringNotContainsString("action_origin !== 'manual'", $javascript);

File: tests/Service/PeopleAnalytics/NeuralAlertStepEvidenceStorageTest.php
Match lines: 4
103|        self::assertStringNotContainsString('.php.', $metadata['original_name']);
104|        self::assertStringNotContainsString('shell', $metadata['storage_path']);
105|        self::assertStringNotContainsString('php', $metadata['storage_path']);
106|        self::assertStringNotContainsString('..', $metadata['storage_path']);

File: tests/Service/ai_committee/ModelV3/HarassmentCasePackSanitizerTest.php
Match lines: 3
35|        self::assertStringNotContainsString('Maria Silva', $redacted['episodes'][0]['ato']);
36|        self::assertStringNotContainsString('João Santos', $redacted['episodes'][0]['ato']);
50|        self::assertStringNotContainsString('Ignore previous', $sanitized['episodes'][0]['ato']);

File: tests/Service/ai_committee/SpecializedCommitteeHcmRagKnowledgeFilterV1Test.php
Match lines: 1
18|        $this->assertStringNotContainsString('meteorologia', $out);

File: tests/Service/ai_committee/SpecializedCommitteePadronizadoDisplayV1Test.php
Match lines: 1
25|        self::assertStringNotContainsString('Dimensões (individual', (string) ($fr['limites_e_ressalvas'] ?? ''));

File: tests/Service/ai_committee/SpecializedCommitteeSessionEmployeeConflictDashAlignerTest.php
Match lines: 1
251|        self::assertStringNotContainsString('Decisão recomenda intervenção', $body);

File: tests/Service/ai_committee/SpecializedCommitteeSessionInternalInvestigationDashAlignerTest.php
Match lines: 1
127|        self::assertStringNotContainsString('Entrevistas iniciais', $body);

File: tests/Service/ai_committee/SpecializedCommitteeSessionPermanenceDashAlignerTest.php
Match lines: 2
234|        self::assertStringNotContainsString('Leitura executiva completa', $body);
235|        self::assertStringNotContainsString('Decisão recomenda manter', $body);

File: tests/Service/ai_committee/SpecializedCommitteeSessionPromotionDashAlignerTest.php
Match lines: 2
128|        self::assertStringNotContainsString('Leitura executiva completa', $body);
129|        self::assertStringNotContainsString('Decisão recomenda plano', $body);

File: tests/Service/ai_committee/SpecializedCommitteeUtf8DisplayV1Test.php
Match lines: 1
33|        self::assertStringNotContainsString('??', $fixed);

File: tests/Ssma/SpacesLocationRenameRegressionTest.php
Match lines: 2
30|        self::assertStringNotContainsString(
35|        self::assertStringNotContainsString(

File: tests/Ssma/SsmaChatFlowLogicTest.php
Match lines: 1
259|        self::assertStringNotContainsString('Entendido! Dados atualizados.', (string) ($result['response'] ?? ''));

File: tests/Ssma/SsmaPermissionsRegressionTest.php
Match lines: 1
556|        self::assertStringNotContainsString(

File: tests/Unit/Product/AuraLoginCpf/MemberAccessCredentialServiceTest.php
Match lines: 12
100|        self::assertStringNotContainsString('52998224725', $text);
101|        self::assertStringNotContainsString('Login:', $text);
122|        self::assertStringNotContainsString('bruno@example.com', $text);
123|        self::assertStringNotContainsString('Login:', $text);
124|        self::assertStringNotContainsString('da empresa', $text);
146|        self::assertStringNotContainsString('carla@example.com', $text);
147|        self::assertStringNotContainsString('Senha:', $text);
148|        self::assertStringNotContainsString('Login:', $text);
168|        self::assertStringNotContainsString('Tmp#', $preview);
169|        self::assertStringNotContainsString('52998224725', $preview);
170|        self::assertStringNotContainsString('Login:', $preview);
190|        self::assertStringNotContainsString('***', $applied);

File: tests/Unit/Product/AuraLoginCpf/MemberImportRowProcessorTest.php
Match lines: 1
236|        self::assertStringNotContainsString('must be of type', $result['message']);

File: tests/Unit/Product/CommunicationCenter/CommunicationCenterDemandListTest.php
Match lines: 1
23|        self::assertStringNotContainsString('allowedMemberIds', $sql);

File: tests/Unit/Product/Dimension/BehavioralEffectivenessProviderTest.php
Match lines: 1
179|        self::assertStringNotContainsString('Fórmula pendente', $view['tooltip_metadata']['help_text']);

File: tests/Unit/Product/DocumentTemplatesSignature/TimeManagementServiceSideEffectTest.php
Match lines: 1
86|        self::assertStringNotContainsString('hs.justification_type = :status', (string) $capturedDataSql);

File: tests/Unit/Product/Effectiveness/EffectivenessBusinessRulesProductTest.php
Match lines: 1
49|            self::assertStringNotContainsStringIgnoringCase('inefetiv', $label);

File: tests/Unit/Product/Effectiveness/EffectivenessDashboardActionComposerTest.php
Match lines: 13
38|        self::assertStringNotContainsString('Resolvido', (string) $row['result_label']);
328|        self::assertStringNotContainsString('lifecycle_status', json_encode($detail, JSON_THROW_ON_ERROR));
329|        self::assertStringNotContainsString('storage_path', json_encode($detail, JSON_THROW_ON_ERROR));
330|        self::assertStringNotContainsString('completed_at', json_encode($detail, JSON_THROW_ON_ERROR));
496|        self::assertStringNotContainsString('Array', $encoded);
521|        self::assertStringNotContainsString('Sem reabertura', json_encode($view['dashboard_action_rows'], JSON_THROW_ON_ERROR));
522|        self::assertStringNotContainsString('Problema similar', json_encode($view['dashboard_action_rows'], JSON_THROW_ON_ERROR));
523|        self::assertStringNotContainsString('Risco correlato', json_encode($view['dashboard_action_rows'], JSON_THROW_ON_ERROR));
524|        self::assertStringNotContainsString('Redução de severidade', json_encode($view['dashboard_action_rows'], JSON_THROW_ON_ERROR));
547|        self::assertStringNotContainsString('historical_state_unavailable', $encoded);
548|        self::assertStringNotContainsString('metadataJson', $encoded);
549|        self::assertStringNotContainsString('file_path', $encoded);
550|        self::assertStringNotContainsString('/var/', $encoded);

File: tests/Unit/Product/Effectiveness/EffectivenessDashboardMetricsAggregatorTest.php
Match lines: 11
298|            self::assertStringNotContainsString('Inclui:', (string) ($result['complementary_indicators'][$key]['hint'] ?? ''));
299|            self::assertStringNotContainsString('Exclui:', (string) ($result['complementary_indicators'][$key]['hint'] ?? ''));
365|        self::assertStringNotContainsString('Planos de ação SSMA', (string) $card['hint']);
366|        self::assertStringNotContainsString('após os filtros', (string) $card['subtitle']);
383|        self::assertStringNotContainsString('Sinais', $card['subtitle']);
400|        self::assertStringNotContainsString('SSMA', $card['subtitle']);
417|        self::assertStringNotContainsString('SSMA', $card['subtitle']);
434|        self::assertStringNotContainsString('Sinais', $card['subtitle']);
459|        self::assertStringNotContainsString('Sinais', $cardInsufficient['subtitle']);
576|            self::assertStringNotContainsString('Planos de ação SSMA', $blob);
783|        self::assertStringNotContainsString("\u{FFFD}", $json);

File: tests/Unit/Product/Effectiveness/EffectivenessDrawerContractTest.php
Match lines: 4
132|        self::assertStringNotContainsString('RISK_INDICATOR_CRITICAL', (string) $view['dashboard_action_rows'][1]['origin']);
183|        self::assertStringNotContainsString('file_path', $encoded);
184|        self::assertStringNotContainsString('metadataJson', $encoded);
185|        self::assertStringNotContainsString('/var/', $encoded);

File: tests/Unit/Product/Effectiveness/EffectivenessDrawerTemplateContractTest.php
Match lines: 4
42|            self::assertStringNotContainsString($selector, $contents, 'Legacy selector found: ' . $selector);
48|        self::assertStringNotContainsString('effectiveness-drawer-alert-badges', $contents);
49|        self::assertStringNotContainsString('effectiveness-alert-detail-dl', $contents);
59|            self::assertStringNotContainsString($selector, $contents, 'Legacy CSS selector found: ' . $selector);

File: tests/Unit/Product/Effectiveness/EffectivenessFrontendContractFixesTest.php
Match lines: 6
66|        self::assertStringNotContainsString('Sinais não faz parte do indicador geral', $participation['summary']);
213|        self::assertStringNotContainsString('sinal', mb_strtolower((string) $drawer['effectiveness']['explanation']));
235|        self::assertStringNotContainsString('sinal', mb_strtolower((string) $drawer['effectiveness']['explanation']));
277|        self::assertStringNotContainsString("dimension == 'behavioral' ? 'gray'", $badgeTwig);
278|        self::assertStringNotContainsString("dimension == 'grc' ? 'gray'", $badgeTwig);
287|        self::assertStringNotContainsString("dimensionKeyForBadge === 'alerts' ? 'yellow' : 'gray'", $js);

File: tests/Unit/Product/Effectiveness/EffectivenessOverallIndicatorCalculatorTest.php
Match lines: 2
168|        self::assertStringNotContainsString('Numerador', $help);
169|        self::assertStringNotContainsString('Denominador', $help);

File: tests/Unit/Product/Effectiveness/EffectivenessPresentationAndTooltipTest.php
Match lines: 15
203|        self::assertStringNotContainsString('Problema similar', $encoded);
204|        self::assertStringNotContainsString('Confiança alta', $encoded);
205|        self::assertStringNotContainsString('Confiança média', $encoded);
206|        self::assertStringNotContainsString('Confiança baixa', $encoded);
207|        self::assertStringNotContainsString('Sem reabertura', $encoded);
251|        self::assertStringNotContainsString('Numerador', $help);
252|        self::assertStringNotContainsString('Denominador', $help);
253|        self::assertStringNotContainsString('scorable_count', $help);
272|        self::assertStringNotContainsString('Numerador', $meta['help_text']);
273|        self::assertStringNotContainsString('Denominador', $meta['help_text']);
289|        self::assertStringNotContainsString('Numerador', $meta['help_text']);
290|        self::assertStringNotContainsString('Denominador', $meta['help_text']);
313|        self::assertStringNotContainsString('effectiveness-score-bar__fill--{{ row.classification_variant', $template);
329|        self::assertStringNotContainsString('Este score individual alimenta', $template);
330|        self::assertStringNotContainsString('A dimensão só entra no indicador geral', $template);

File: tests/Unit/Product/Effectiveness/EffectivenessVisualRowContractTest.php
Match lines: 1
81|        self::assertStringNotContainsString('RISK_INDICATOR_CRITICAL', $row['origin']);

File: tests/Unit/Product/Effectiveness/Leadership/LeadershipDimensionMatrixContractTest.php
Match lines: 2
141|        self::assertStringNotContainsString("style=\"--matrix-columns:\" + (columns.length + 1)", $js);
142|        self::assertStringNotContainsString('effectiveness-leadership-matrix-grid', $js);

File: tests/Unit/Product/Effectiveness/Leadership/LeadershipDistributionChartContractTest.php
Match lines: 4
150|        self::assertStringNotContainsString('formatter: () =>', $js);
151|        self::assertStringNotContainsString('this.point.options.description', $js);
152|        self::assertStringNotContainsString("pointFormat: '<b>{point.y}</b> ações", $js);
153|        self::assertStringNotContainsString('Nenhuma ação calculável para distribuição', $js);

File: tests/Unit/Product/Effectiveness/Leadership/LeadershipEffectivenessAnalyzerTest.php
Match lines: 19
380|        self::assertStringNotContainsString('intermediária', (string) $zeroCard['hint']);
381|        self::assertStringNotContainsString('estão atualmente', (string) $zeroCard['hint']);
385|        self::assertStringNotContainsString('Duas lideranças', (string) $zeroCard['help_text']);
390|        self::assertStringNotContainsString('intermediária', (string) $singularCard['hint']);
392|        self::assertStringNotContainsString('faixa intermediária', (string) $singularCard['help_text']);
397|        self::assertStringNotContainsString('intermediária', (string) $pluralCard['hint']);
414|        self::assertStringNotContainsString('intermediária', (string) $liveCard['hint']);
768|        self::assertStringNotContainsString('Mapa de efetividade contextual', $overview);
769|        self::assertStringNotContainsString('Perfil de resultado das ações por liderança', $overview);
787|        self::assertStringNotContainsString("id: 'leadershipPeriodSelect'", $header);
798|        self::assertStringNotContainsString('contrato não informou', $template);
799|        self::assertStringNotContainsString('ação(ões)', $template);
816|            self::assertStringNotContainsString($forbidden, $javascript);
852|        self::assertStringNotContainsString('Concluídas brutas', $javascript);
853|        self::assertStringNotContainsString('concluded_raw', $javascript);
854|        self::assertStringNotContainsString("title=\"' + escapeHtml(tooltip)", $javascript);
855|        self::assertStringNotContainsString(' · Qualificadas: ', $javascript);
856|        self::assertStringNotContainsString(' · Concluídas brutas: ', $javascript);
869|                self::assertStringNotContainsString($forbidden, $copy);

File: tests/Unit/Product/Effectiveness/Leadership/LeadershipImpactMapContractTest.php
Match lines: 4
120|        self::assertStringNotContainsString('numericOrNull(point.criticality) == null ? 50', $js);
121|        self::assertStringNotContainsString("title: { text: 'Criticidade normalizada' }", $js);
122|        self::assertStringNotContainsString("title: { text: 'Efetividade' }", $js);
127|        self::assertStringNotContainsString('Mapa de Eficiência e Impacto das Lideranças', $twig);

File: tests/Unit/Product/Effectiveness/Leadership/LeadershipTopComparisonContractTest.php
Match lines: 2
153|        self::assertStringNotContainsString('renderSignature', $js);
157|        self::assertStringNotContainsString('Assinatura de Eficiência da Liderança', $twig);

File: tests/Unit/Product/Effectiveness/Leadership/LeadershipTrendTrajectoryContractTest.php
Match lines: 3
73|        self::assertStringNotContainsString('Recorte atual', json_encode($trend['points'], JSON_UNESCAPED_UNICODE));
205|        self::assertStringNotContainsString("Amostra: {point.sample}", $js);
206|        self::assertStringNotContainsString('Recorte atual', $js);

File: tests/Unit/Product/Effectiveness/Leadership/LeadershipUpperFilterContractTest.php
Match lines: 2
126|        self::assertStringNotContainsString('navigateToFilters();', $js);
127|        self::assertStringNotContainsString('restoreFiltersFromUrl(', $js);

File: tests/Unit/Product/Effectiveness/Leadership/LeadershipUpperFilterHtmlContractTest.php
Match lines: 5
30|        self::assertStringNotContainsString('id="leadershipPeriodSelect"', $html);
157|        self::assertStringNotContainsString('restoreFiltersFromUrl(', $js);
158|        self::assertStringNotContainsString('forceCanonicalPeriodFromUrl(', $js);
159|        self::assertStringNotContainsString('navigateToFilters();', $js);
165|        self::assertStringNotContainsString('clearFilters($sheet);', $bottomSheet);

File: tests/Unit/Product/FolhaDePagamento/PayrollFlowDashboardResponseComposerTest.php
Match lines: 3
60|        self::assertStringNotContainsString('não há responsável identificado', mb_strtolower($answer));
112|        self::assertStringNotContainsString('**11** competência(s)', $answer);
126|        self::assertStringNotContainsString("\n  ", $line);

File: tests/Unit/Product/FolhaDePagamento/PayrollFlowDashboardUserCopyFormatterTest.php
Match lines: 3
22|        self::assertStringNotContainsString('stage_backlog_details', $sanitized);
23|        self::assertStringNotContainsString('blockingCauses', $sanitized);
24|        self::assertStringNotContainsString('approval_pending', $sanitized);

File: tests/Unit/Product/FreeTrialCaptcha/FreeTrialControllerCaptchaTest.php
Match lines: 2
130|        self::assertStringNotContainsString('TURNSTILE_SECRET_KEY', $template);
131|        self::assertStringNotContainsString('turnstile_secret', $template);

File: tests/Unit/Product/PayrollFinanceJavascriptContractTest.php
Match lines: 7
18|        self::assertStringNotContainsString('#payrollReopenBtn', $javascript);
19|        self::assertStringNotContainsString('/finance/payables/payroll/reopen', $javascript);
20|        self::assertStringNotContainsString('payrollReopenBtn', $template);
21|        self::assertStringNotContainsString('finance_payroll_reopen', $template);
31|        self::assertStringNotContainsString('/templates/payroll_form', $javascript);
32|        self::assertStringNotContainsString('finance_payroll_form', $indexTemplate);
33|        self::assertStringNotContainsString('finance_payroll_form', $competenceTemplate);

File: tests/Unit/Product/PesquisaIaV2/ConversationTreatmentServiceTest.php
Match lines: 6
494|        self::assertStringNotContainsString('instabilidade', $result->getAiMessage());
534|        self::assertStringNotContainsString('instabilidade', $result->getAiMessage());
594|        self::assertStringNotContainsString('inventada', $result->getAiMessage());
717|        self::assertStringNotContainsString('Sem problema', $result->getAiMessage());
718|        self::assertStringNotContainsString('observar a imagem', mb_strtolower($result->getAiMessage()));
747|        self::assertStringNotContainsString('observar a imagem', mb_strtolower($result->getAiMessage()));

File: tests/Unit/Product/PesquisaIaV2/SurveyPromptComposerTest.php
Match lines: 1
95|        self::assertStringNotContainsString('experience|skills|behavioral', (new OutputFormatPrompt())->build());

File: tests/Unit/Product/PesquisaIaV2/SurveyTemplatePersisterTest.php
Match lines: 1
144|        self::assertStringNotContainsString('orientacao', (string) $captured->getFilePath());

File: tests/Unit/Product/Projects/ProjectCollaboratorPermissionMigrationTest.php
Match lines: 2
34|        self::assertStringNotContainsString('ALTER TABLE project ADD', $source);
35|        self::assertStringNotContainsString('DROP collaborator_permissions', $source);

File: tests/Unit/Product/RiskIntelligenceIndicators/AdrianaPeopleAnalyticsResponseFormattingTest.php
Match lines: 2
49|        self::assertStringNotContainsString('Ótimo, Adriana', $sanitized);
50|        self::assertStringNotContainsString('|', $sanitized);

File: tests/Unit/Product/RiskIntelligenceIndicators/RiskIndicatorComponentLabelResolverTest.php
Match lines: 2
17|        self::assertStringNotContainsString('component_', $resolved['label']);
31|        self::assertStringNotContainsString('component_', $resolved['label']);

File: tests/Unit/Product/RiskIntelligenceIndicators/RiskIntelligenceIndicatorsTestCase.php
Match lines: 4
69|        self::assertStringNotContainsString('component_', $encoded);
70|        self::assertStringNotContainsString('component_0', $encoded);
71|        self::assertStringNotContainsString('component_1', $encoded);
72|        self::assertStringNotContainsString('component_2', $encoded);

File: tests/Unit/Product/Ssma/SsmaAdrianaConversationGuideTest.php
Match lines: 10
30|        self::assertStringNotContainsString('ainda preciso de', mb_strtolower($msg));
31|        self::assertStringNotContainsString('responda tudo', mb_strtolower($msg));
32|        self::assertStringNotContainsString('até aqui eu já anotei', mb_strtolower($msg));
33|        self::assertStringNotContainsString('o que já identifiquei', mb_strtolower($msg));
50|        self::assertStringNotContainsString('já pode ser registrada', mb_strtolower($msg));
51|        self::assertStringNotContainsString('posso complementar', mb_strtolower($msg));
113|        self::assertStringNotContainsString('Revise os dados', $msg);
186|        self::assertStringNotContainsString('sinto muito', mb_strtolower($ask));
216|        self::assertStringNotContainsString('responda tudo', mb_strtolower($msg));
217|        self::assertStringNotContainsString('o que já identifiquei', mb_strtolower($msg));

File: tests/Unit/Product/Ssma/SsmaBodyMapEntryRegressionTest.php
Match lines: 1
67|        self::assertStringNotContainsString(

File: tests/Unit/Product/Ssma/SsmaCauseTreeCommitteeCardUiTest.php
Match lines: 5
19|        self::assertStringNotContainsString('Como compor meu comitê?', $card);
64|        self::assertStringNotContainsString("sectionLabel: 'Membros da análise'", $treeView);
85|        self::assertStringNotContainsString('placeholder="nome do membro"', $tab);
88|        self::assertStringNotContainsString('ssma_cause_tree_approvers_get', $routes);
155|        self::assertStringNotContainsString('mhs-btn-secondary d-flex align-items-center js-cause-tree-origin-open', $view);

File: tests/Unit/Product/Ssma/SsmaEventValidatorTest.php
Match lines: 10
401|        self::assertStringNotContainsString('Categoria', $match[0]);
447|        self::assertStringNotContainsString('details.injury_type', $joined);
448|        self::assertStringNotContainsString('details.descaracterizado', $joined);
817|        self::assertStringNotContainsString('details.injury_type:', $joined);
818|        self::assertStringNotContainsString('details.injury_classification:', $joined);
819|        self::assertStringNotContainsString('details.descaracterizado:', $joined);
820|        self::assertStringNotContainsString('details.potential_consequence:', $joined);
821|        self::assertStringNotContainsString('consequence: obrigatório', $joined);
924|        self::assertStringNotContainsString('details.injury_type:', $joined);
949|        self::assertStringNotContainsString('details.person_id', $joined);

File: tests/Unit/Product/Ssma/SsmaOccurrencePanelSectionAnalyticsTest.php
Match lines: 1
154|        self::assertStringNotContainsString('relatos de risco potencial', $joined);

File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 3
33|        self::assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc);
34|        self::assertStringNotContainsString(
53|        self::assertStringNotContainsString(

File: tests/Unit/Product/Ssma/SsmaOccurrenceRosDeepeningSchemaTest.php
Match lines: 1
135|        self::assertStringNotContainsString(

File: tests/Unit/Product/Ssma/SsmaOccurrenceVoiceBaselineTest.php
Match lines: 1
95|            self::assertStringNotContainsString(

File: tests/Unit/Product/Ssma/SsmaPanelFeedImprovementServiceTest.php
Match lines: 6
26|        self::assertStringNotContainsString('Qual desses', $message);
82|        self::assertStringNotContainsString('onclick=', $message);
98|        self::assertStringNotContainsString('<strong>1. A</strong>', $message);
263|        self::assertStringNotContainsString('TRFR', $trfr['summary']);
332|        self::assertStringNotContainsString('TRFR', $draft['content']);
333|        self::assertStringNotContainsString('(redução de', $draft['content']);

File: tests/Unit/Product/Ssma/SsmaPanelSummaryFormatterTest.php
Match lines: 5
96|        self::assertStringNotContainsString('Resumo — Ocorrências', $text);
121|        self::assertStringNotContainsString('ACIDENTE_PESSOAL', $text);
125|        self::assertStringNotContainsString('Resumo — Ocorrências', $text);
149|        self::assertStringNotContainsString('↑100%', $text);
200|        self::assertStringNotContainsString('não é possível identificar', mb_strtolower($text));

File: tests/Unit/Product/Ssma/SsmaRefusalRightHubContractTest.php
Match lines: 10
44|        self::assertStringNotContainsString('realConsequenceOptions()', $controller);
76|        self::assertStringNotContainsString('Painel desativado por enquanto', $index);
96|        self::assertStringNotContainsString('Como deseja registrar', $modal);
97|        self::assertStringNotContainsString('Autoavaliação', $modal);
98|        self::assertStringNotContainsString('js-rr-check-opt', $modal);
99|        self::assertStringNotContainsString('>Como colaborador<', $modal);
100|        self::assertStringNotContainsString('>Como liderança<', $modal);
101|        self::assertStringNotContainsString('id="rrFlowChooser"', $modal);
164|        self::assertStringNotContainsString('function ensureSchema', $service);
165|        self::assertStringNotContainsString('CREATE TABLE IF NOT EXISTS ssma_refusal_right_config', $service);

File: tests/Unit/Product/TextToBpmn/WorkflowApprovedProcessoSeletivoEnricherTest.php
Match lines: 1
63|        self::assertStringNotContainsString('online', mb_strtolower((string) $stages[0]->getDescription()));

File: tests/Unit/Product/TextToBpmn/WorkflowLayerBridgeServiceTest.php
Match lines: 1
156|        self::assertStringNotContainsString('ADRIANA_COGNITIVE_LAYER_URL', $failure->userMessage());

code_search
Show Details
{"search_text": "phpunit/phpunit", "file_patterns": ["composer.json"]}
File: composer.json
Match lines: 1
86|    "phpunit/phpunit": "^9.5",

Request #6 deepseek-flash P:31.65K C:1.65K CR:23.81K CW:0 7349ms
Reasoning
PHPUnit ^9.5, so assertStringNotContainsString exists (added in 9.1? Actually assertStringNotContainsString was added in PHPUnit 8.4 / replaced assertNotContains). Fine. So the source-grep style is standard in this repo. The main finding would be about the assertion on `aprofundamento_finalized_by_*` matching only a comment. Also the negative assertion on `notifyOccurrenceRegistrantOnReject` — but that's fine since it doesn't exist elsewhere. Hmm, wait. Actually let me reconsider the `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc)` assertion. It's checking that the old method name is gone. Since the PR renamed it, this passes. But the concern in the review plan (item 4) is that if a new-registration flow needs that method, removing it would break. Let me verify: was `notifyOccurrenceRegistrantOnReject` used for new registrations elsewhere? Search showed no other references in src. So the method was only used in reject. So removing it is fine, and the assertion is not harmful. Actually wait — was it used to notify all specialists of the type? Let's check history: it notified only the registrant (`getCreatedById`). Hmm the PR description says "cadastro novo continua avisando todos os especialistas do tipo". That is a different notification (`notifyOccurrenceTypeSpecialists` or similar), not this one. So fine. Now let me think about whether the test file has actual bugs. Let's check the `extractMethod` more carefully: ```php $start = strpos($src, 'function ' . $method); self::assertNotFalse($start, $method . ' ausente'); $chunk = substr($src, $start, 1800); ``` `strpos` returns int|false. `assertNotFalse` — PHPUnit 9 has assertNotFalse. OK. If $start is false, assertNotFalse fails but PHPStan might complain about substr(false). Not a runtime issue. Now potential real issue: `assertStringNotContainsString('getCreatedById()', $this->extractMethod(...))`. The window is 1800 chars starting at `function notifyAprofundamentoAuthorOnReject`. The method spans ~1100 chars (lines 286-321 with indentation). Then next method `notifyMetaAbonoRequester` ~300 chars, then notifyActionPlan ~400 chars. 1800 chars would cover up to around notifyActionPlan which contains `notify(` calls... `getCreatedById()` isn't in any. So trivially passes. Weak but not a bug. Hmm. Let me now weigh: what's the most valuable feedback for this PR? The strongest is: Finding 1 (test): The test `testRejectKeepsAuthorAndUsesReadequacaoCopy` asserts the literal string `'aprofundamento_finalized_by_*'`, which only exists in a code comment in the approval service (not in executable code). So the test verifies a comment, not the behavior of preserving the author. If someone reformats those keys (e.g., drops the trailing `*` in the comment) the test turns red with no functional regression, and conversely the real risk — the author keys being cleared on reject — isn't checked. Suggest asserting on the absence of `unset($details['aprofundamento_finalized_by` in the reject branch, or better, a functional test. Finding 2 (test): Same test also asserts the absence of a code comment `'Readequação: reenvia tanto na edição completa'`. Comment-based assertion; it doesn't verify the queue doesn't reopen. This is part of the same class of issue. Finding 3 (test): The new test never exercises the behavior it names — authorization (only author/admin edits in readequação) and notification recipients. It just greps source for method names. Per mandatory test rules, behavior change in authorization/state should have a functional test. This is the biggest one. But is it a "code issue"? It's a legit "test" category finding per user rules. Hmm, but I must be careful not to be too noisy. The user rules explicitly demand flagging missing functional test coverage as a pending item. Given this is the test file under review, and the plan's issue #1, I'll report it. Let me also verify: is there an existing functional test that covers the readequação access? Let's search for tests referencing `isSsmaOccurrenceInReadequacao` or the approval service. There is `SsmaCauseTreeAnalysisApprovalTest`? No. Let's search for tests of `SsmacOccurrenceApprovalService` and `SsmaOccurrenceApprovalService`. Actually, more important: verify the test would actually run in the suite and pass. I trust it does. Let me also double-check the smoke script `assert_branch_ui_fixes.php` changes for correctness: the variable `$notificationService` is defined and used. Any chance `fileContains`/`fileNotContains` require the file to exist? Let's read the helper definitions near top of the smoke script to verify `fileNotContains` signature. Actually not needed. But wait — there's a subtle problem in the smoke script: `fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')`. This is fine. Let me check whether the smoke script's new check on controller `fileContains($controller, 'if ($wantsFinalize) {')` could be a false positive: the string `if ($wantsFinalize) {` — is it unique? It appears once. Fine. Now the review group also includes the diff of `assert_branch_ui_fixes.php` — a smoke script. Are there issues there? The added check uses `$notificationService` variable defined inline. Style: it defines `$notificationService` in the middle of the script after `$approvalService`; other variables defined at top. Not a bug. One concern: in the smoke script, the check block: ```php fileContains($controller, 'notifyAprofundamentoAuthorOnReject') && fileContains($notificationService, 'notifyAprofundamentoAuthorOnReject') && fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject') ``` This is fine. Hmm. Let me consider whether `fileNotContains($notificationService, ...)` actually works — need to confirm the helper handles the file path. Let me read the top of assert_branch_ui_fixes.php. Actually, let me look at whether the smoke script variables `$controller`, `$notificationService` are used in the same scope; there could be an issue if `$notificationService` is defined inside a conditional. Let me read lines around 340-385.
Tool Calls (2)
file_read
Show Details
{"file_path": "tests/Unit/Product/Ssma/assert_branch_ui_fixes.php", "start_line": 1, "end_line": 60}
File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php (Total lines: 545)
IS_TRUNCATED: false
LINE_RANGE: 1-60
1|<?php
2|
3|declare(strict_types=1);
4|
5|/**
6| * Smoke estático das correções da branch hotfix ambiental/material Brenda.
7| * Roda sem browser: valida presença/ausência de strings nos templates e enums.
8| *
9| * Uso: php tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
10| */
11|
12|$root = dirname(__DIR__, 4);
13|$fail = 0;
14|
15|function ok(string $msg): void
16|{
17|    echo "  ✓ $msg\n";
18|}
19|
20|function fail(string $msg): void
21|{
22|    global $fail;
23|    $fail++;
24|    echo "  ✗ $msg\n";
25|}
26|
27|function fileContains(string $path, string $needle): bool
28|{
29|    return is_file($path) && str_contains((string) file_get_contents($path), $needle);
30|}
31|
32|function fileNotContains(string $path, string $needle): bool
33|{
34|    return is_file($path) && !str_contains((string) file_get_contents($path), $needle);
35|}
36|
37|echo "=== Branch UI / config smoke ===\n";
38|
39|$twigYaml = $root . '/config/packages/twig.yaml';
40|$modal = $root . '/templates/ssma/occurrence/partials/_modal_event.html.twig';
41|$gmrPartial = $root . '/templates/ssma/partials/_ssma_gmr_options.html.twig';
42|$barrier = $root . '/templates/ssma/occurrence/partials/_ev_shared_barrier.html.twig';
43|$injured = $root . '/templates/ssma/occurrence/partials/_ev_injured_person_box.html.twig';
44|$consOpts = $root . '/templates/ssma/occurrence/partials/_ev_consequence_options.html.twig';
45|
46|fileContains($twigYaml, 'ssma_gmr_options:') ? ok('GMR global em twig.yaml') : fail('Falta ssma_gmr_options em twig.yaml');
47|fileContains($gmrPartial, 'for gmr in ssma_gmr_options') ? ok('Partial GMR itera ssma_gmr_options') : fail('Partial GMR sem loop');
48|fileNotContains($gmrPartial, "{% set ssma_gmr_options = [") ? ok('Partial GMR não seta lista local (evita 500)') : fail('Partial GMR ainda seta lista local');
49|
50|fileContains($modal, "evSyncAaIdentFields(type);") ? ok('applyTypeBlock chama evSyncAaIdentFields cedo') : fail('evSyncAaIdentFields ausente no applyTypeBlock');
51|fileContains($modal, 'id="ev_environmental_medium"') && fileContains($modal, 'class="form-control" id="ev_environmental_medium"')
52|    ? ok('AA: Meio afetado com select nativo form-control')
53|    : fail('AA: Meio afetado fora do padrão nativo');
54|fileContains($modal, 'id="ev_pollutant_type"') && fileContains($modal, 'class="form-control" id="ev_pollutant_type"')
55|    ? ok('AA: Tipo de poluente com select nativo form-control')
56|    : fail('AA: Tipo de poluente fora do padrão nativo');
57|fileNotContains($modal, 'ev_marcos_icon_select') ? ok('AA: sem macro de ícones (select nativo)') : fail('AA: ainda usa macro de ícones');
58|
59|fileContains($modal, 'height: calc(1.5em + 0.75rem + 2px);')
60|    && fileContains($modal, '.ev-containment-time-input')
file_read
Show Details
{"file_path": "tests/Unit/Product/Ssma/assert_branch_ui_fixes.php", "start_line": 330, "end_line": 390}
File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php (Total lines: 545)
IS_TRUNCATED: false
LINE_RANGE: 330-390
330|fileContains($occView, 'admin_ssma_occurrence_flash_report_approvers')
331|    && fileContains($occView, 'ssmaFlashReportApproverEmpty')
332|    ? ok('Flash report modal: fallback API + aviso aprovador vazio')
333|    : fail('Flash report modal: falta fallback API ou aviso de aprovador vazio');
334|
335|$tabConfig = $root . '/templates/ssma/occurrence/tabs/_tab_config.html.twig';
336|fileContains($tabConfig, 'FLASH_APPROVERS_TAG')
337|    && fileContains($tabConfig, '$tags.append(window.SsmaShared.buildSelectionTag')
338|    && fileContains($tabConfig, "removeClass: 'js-ssma-flash-approver'")
339|    && fileNotContains($tabConfig, 'tags.appendChild(window.SsmaShared.buildSelectionTag')
340|    ? ok('Flash report config: tags via jQuery (buildSelectionTag)')
341|    : fail('Flash report config: renderTags ainda usa appendChild ou removeClass errado');
342|fileContains($controller, "'approver_ids' => \$configIds")
343|    ? ok('Flash report GET approvers: approver_ids só da config SSMA')
344|    : fail('Flash report GET approvers: approver_ids mistura automação com config');
345|
346|$approvalService = $root . '/src/Service/Ssma/SsmaOccurrenceApprovalService.php';
347|$notificationService = $root . '/src/Service/Ssma/SsmaNotificationService.php';
348|fileContains($occView, '#ssmaOccurrenceApproveModal .js-occ-approve-reject')
349|    && fileContains($occView, '--company-theme1-800')
350|    ? ok('Validação: Reprovar usa cor da plataforma')
351|    : fail('Validação: Reprovar ainda sem override da cor da plataforma');
352|fileContains($occView, "_occ_approval != 'rejected'")
353|    && fileContains($occView, "_occ_can_open_validation")
354|    ? ok('Validação: botão oculto em Readequação (rejected)')
355|    : fail('Validação: botão ainda aparece em Readequação');
356|fileContains($approvalService, 'A ocorrência está em readequação')
357|    ? ok('Validação: service bloqueia decide() em rejected')
358|    : fail('Validação: service ainda permite validar Readequação');
359|fileContains($controller, 'A ocorrência está em readequação. Corrija e reenvie antes de validar de novo.')
360|    ? ok('Validação: controller bloqueia approve em Readequação')
361|    : fail('Validação: controller ainda permite approve em Readequação');
362|fileContains($controller, "if (\$status === 'draft')")
363|    && fileContains($controller, '!$fieldsIncomplete && $status !== \'draft\'')
364|    ? ok('Validação: gate aceita aprofundamento completo sem flag finalized')
365|    : fail('Validação: gate ainda exige finalized explícito');
366|fileNotContains($occView, 'Conclua o aprofundamento técnico (finalizado e com todos os campos obrigatórios) antes de validar a ocorrência.')
367|    && fileNotContains($occView, 'data-validation-ready')
368|    && fileNotContains($controller, 'Conclua o aprofundamento técnico (finalizado e com todos os campos obrigatórios) antes de validar a ocorrência.')
369|    ? ok('Validação: approve não exige aprofundamento no front nem no endpoint')
370|    : fail('Validação: approve ainda bloqueia por aprofundamento pendente');
371|fileContains($controller, 'if ($wantsFinalize) {')
372|    && fileContains($controller, 'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)')
373|    && fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')
374|    ? ok('Validação: reenvio após Readequação só ao reenviar aprofundamento')
375|    : fail('Validação: edição paralela ainda reenvia a fila');
376|fileContains($controller, 'notifyAprofundamentoAuthorOnReject')
377|    && fileContains($notificationService, 'notifyAprofundamentoAuthorOnReject')
378|    && fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')
379|    ? ok('Validação: reprovação avisa quem fez o aprofundamento')
380|    : fail('Validação: reprovação ainda avisa o registrante');
381|fileContains($modal, 'select.ssma-member-tag-native-select')
382|    && fileContains($modal, 'form-group:has(> .ssma-member-tag-search-wrap)')
383|    ? ok('Evento etapa 1: select nativo oculto quando há busca de membro')
384|    : fail('Evento etapa 1: select nativo ainda pode duplicar o campo de busca');
385|
386|!is_file($root . '/scripts/hooks/check-product-unit-tests.php')
387|    ? ok('CI: hook Regra 81 removido (pedido Gabriel)')
388|    : fail('CI: check-product-unit-tests.php ainda existe');
389|
390|$ssmaYaml = $root . '/config/automations/ssma.yaml';
Request #7 deepseek-flash P:35.6K C:699 CR:33.28K CW:0 4294ms
Reasoning
Now the key: the smoke script's line 371 `fileContains($controller, 'if ($wantsFinalize) {')`. Let's verify this literal exists in the controller — yes: `if ($wantsFinalize) {`. Now, are there any actual bugs in the tests? Let me think about false-positive risk in the smoke check at 366-368: `fileNotContains($occView, 'data-validation-ready')` etc.—unchanged. Now let me focus on producing meaningful comments. Considering the review group is only tests, and the user background asks to review regressions/security/isolation/integrity/migrations/tests. Let's identify genuine issues: Issue A (medium, test): `assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc)` — matches only the newly added comment in the approval service, not executable code. This is a false assurance: it passes because of a comment, and would break if the comment is reworded. It doesn't verify author preservation. This is concrete and verifiable. Worth reporting. Issue B (medium, test): The test file named `...ReadequacaoAuthorTest` doesn't exercise the authorization it documents; it only greps for method names like `isSsmaOccurrenceInReadequacao`/`stampSsmaAprofundamentoAuthor`. If the author check were inverted/removed, the tests stay green. Mandatory rule: behavior change in authorization should have functional coverage. Report as pendency. Issue C (low): `extractMethod` window 1800 chars — fragile; the negative assertion `getCreatedById()` is trivially true (string absent from the entire file now). Could mention but low value. Might include as low. Issue D (low): `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', ...)` asserts absence of a human comment — same class as A. Could fold into A. I'll combine A and D into one comment about comment-coupled assertions? They're at different lines. Let me report A separately (contains at line 38-ish) and D separately or as one. Actually the code_comment tool matches by existing_code lines. I can add two comments. Hmm, but should I report issue B? The user rules make it mandatory-ish. But the repo convention is source-contract tests. There's a functional test suite (tests/Ssma/SsmaPermissionsRegressionTest.php). Let me check whether there's an existing functional test for readequação access that this PR should have updated. Let me search tests for 'readequacao' / 'readequação' and 'aprofundamento_finalized_by'. Let me do a quick search.
Tool Calls (2)
code_search
Show Details
{"search_text": "aprofundamento_finalized_by"}
File: src/Controller/SsmaController.php
Match lines: 7
12768|            $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0
12769|                || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0;
12811|        $authorUserId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
12816|        $authorMemberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
12827|        $details['aprofundamento_finalized_by_user_id'] = (int) $user->getId();
12830|            $details['aprofundamento_finalized_by_member_id'] = $memberId;
12833|        $details['aprofundamento_finalized_by_name'] = $name !== '' ? $name : (string) ($user->getEmail() ?? '');

File: src/Service/Ssma/SsmaNotificationService.php
Match lines: 2
289|        $memberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
290|        $userId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);

File: src/Service/Ssma/SsmaOccurrenceApprovalService.php
Match lines: 1
110|            // Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento.

File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 2
32|        self::assertStringContainsString('aprofundamento_finalized_by_member_id', $this->notificationSrc);
42|        self::assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc);

code_search
Show Details
{"search_text": "isSsmaAprofundamentoAuthor|isSsmaOccurrenceInReadequacao|stampSsmaAprofundamentoAuthor", "use_perl_regexp": true}
File: src/Controller/SsmaController.php
Match lines: 6
12767|        if ($this->isSsmaOccurrenceInReadequacao($details)) {
12771|                return $this->isSsmaAprofundamentoAuthor($details, $user, $loggedMember);
12799|    private function isSsmaOccurrenceInReadequacao(array $details): bool
12809|    private function isSsmaAprofundamentoAuthor(array $details, User $user, ?CompanyMembers $member): bool
12825|    private function stampSsmaAprofundamentoAuthor(array &$details, Company $company, User $user): void
27908|                $this->stampSsmaAprofundamentoAuthor($detailsOut, $company, $user);

File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 2
57|        self::assertStringContainsString('stampSsmaAprofundamentoAuthor', $this->controllerSrc);
58|        self::assertStringContainsString('isSsmaOccurrenceInReadequacao', $this->controllerSrc);

Request #8 deepseek-flash P:37.01K C:256 CR:36.22K CW:0 1684ms
Reasoning
Confirmed: `aprofundamento_finalized_by_*` only exists in the approval service comment (line 110). So test line 42 asserts a comment. Now let's check whether there's an existing functional test for the readequação access control that should have been updated. Let's search tests for the access method name used in the controller (`hasSsmaAprofundamentoAccess` or whatever the method at 12761 is). Let me read around 12740-12800.
Tool Calls (2)
file_read
Show Details
{"end_line": 12800, "file_path": "src/Controller/SsmaController.php", "start_line": 12730}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 12730-12800
12730|
12731|        if ($this->memberIsSsmaGestorAdministrador($member)) {
12732|            return false;
12733|        }
12734|
12735|        try {
12736|            $tag = $this->resolveSsmaProductPermissionTagForMember($member);
12737|            $name = $tag instanceof \App\Entity\PermissionTag ? (string) $tag->getName() : '';
12738|
12739|            return in_array($name, ['Membro', 'Inspetor', 'Membro (default)'], true);
12740|        } catch (\Throwable) {
12741|            return false;
12742|        }
12743|    }
12744|
12745|    /**
12746|     * Quem pode preencher o Aprofundamento (2ª etapa) de ROS/Quase Acidente:
12747|     * Gestor Administrador SSMA / SUPER_ADMIN, técnico SSMA do tipo ou gestor direto do relator.
12748|     *
12749|     * Não usa canManageSsmaOccurrences()/ROLE_MANAGER — can_create de Membro não libera 2ª etapa.
12750|     *
12751|     * @param array<string, mixed> $details
12752|     */
12753|    private function canAccessSsmaEventAprofundamento(
12754|        Company $company,
12755|        User $user,
12756|        string $type,
12757|        array $details,
12758|        int $createdByUserId = 0
12759|    ): bool {
12760|        if ($this->isSsmaAprofundamentoAdmin($company, $user)) {
12761|            return true;
12762|        }
12763|
12764|        $loggedMember = $this->getCurrentCompanyMember($company, $user);
12765|
12766|        // Readequação: só quem enviou o aprofundamento (admin já passou acima).
12767|        if ($this->isSsmaOccurrenceInReadequacao($details)) {
12768|            $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0
12769|                || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0;
12770|            if ($hasAuthor) {
12771|                return $this->isSsmaAprofundamentoAuthor($details, $user, $loggedMember);
12772|            }
12773|        }
12774|
12775|        if (in_array($type, $this->resolveCurrentUserTechnicalTypes($company, $user), true)) {
12776|            return true;
12777|        }
12778|
12779|        if (!in_array($type, ['ROS', 'QUASE_ACIDENTE'], true)) {
12780|            return false;
12781|        }
12782|
12783|        if (!$loggedMember) {
12784|            return false;
12785|        }
12786|
12787|        return $this->isSsmaEventDirectManagerOfReporter(
12788|            $company,
12789|            $loggedMember,
12790|            $type,
12791|            $details,
12792|            $createdByUserId
12793|        );
12794|    }
12795|
12796|    /**
12797|     * @param array<string, mixed> $details
12798|     */
12799|    private function isSsmaOccurrenceInReadequacao(array $details): bool
12800|    {
code_search
Show Details
{"search_text": "canAccessSsmaAprofundamento|hasSsmaAprofundamentoAccess|AprofundamentoAccess", "use_perl_regexp": true}
File: docs/engineering/pr/homolog/PR_commits_homolog.txt
Match lines: 1
2499|503f1bcf40 fix(ssma): canAccessSsmaAprofundamento usa getCompanyFromRequest para consistencia de empresa

File: src/Twig/MemberPermissionExtension.php
Match lines: 2
445|            new TwigFunction('canAccessSsmaAprofundamento', [$this, 'canAccessSsmaAprofundamento']),
802|    public function canAccessSsmaAprofundamento(): bool

File: templates/layoutUser.html.twig
Match lines: 2
1116|                                {% set canAccessSsmaTechnical = canAccessSsmaAprofundamento() %}
2194|                                {% set canAccessSsmaTechnical = canAccessSsmaAprofundamento() %}

File: tests/Ssma/diag_member_ssma_sidebar.php
Match lines: 1
110|    'can_access_ssma_technical' => $permExt->canAccessSsmaAprofundamento(),

File: tests/Unit/Product/Ssma/SsmaTenantAdminAprofundamentoAccessTest.php
Match lines: 1
16|final class SsmaTenantAdminAprofundamentoAccessTest extends TestCase

Request #9 deepseek-flash P:38.41K C:2.75K CR:37.25K CW:0 12421ms
Reasoning
Now let me check if there's any functional test exercising `canAccessSsmaEventAprofundamento` (private, so via endpoint). Search tests for "canAccessSsmaEventAprofundamento" or "aprofundamento" endpoint tests. Actually the private method is only reachable through controller actions. The user rules: "Teste que só instancia objeto de domínio sem passar pelo controller/service que decide acesso não substitui teste funcional do endpoint." So finding B stands: the new test doesn't exercise the access decision. However, I need to be pragmatic. The repo's established pattern is exactly source-grep for private controller methods (SsmaTenantAdminAprofundamentoAccessTest). So criticizing the pattern broadly may be low-value/considered noise by maintainers. But the user's mandatory rules explicitly require flagging missing functional coverage for authorization changes. I'll report it once as a "test" category finding with medium/high severity, framed as: the test doesn't guarantee the behavior; no functional test covers it. Hmm, but I should be careful: the review plan's issue #1 says [high]. I'll report it but focus on the concrete: the assertion merely checks method-name presence; inverted/removed check would keep tests green. Let me also decide about issue #2 (grep style generally). I'll fold into the same comment to avoid noise. Now for the comment on line 42: concrete. Report as medium (test/maintainability). The suggestion: assert on behavior or at least on the absence of code that clears the keys. Also the "extractMethod 1800 chars" — low. Might skip to reduce noise. Actually it's a legit fragility concern but low value. I'll skip or mention briefly. Given "favor precision over recall", I'll skip it. Also `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc)` — the plan flagged issue #4. But since the method was removed and not used elsewhere, the assertion is harmless. Actually the assertion says the string shouldn't appear in the notification service — that also forbids the controller's private wrapper name? No, it's only the notification service file. The controller still has... no, controller renamed its private method too. So no false positive. Skip. Now `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', $this->controllerSrc)` — comment-based. I'll report this too as part of the comment-coupling comment? They're two distinct lines (line ~53 in test file, line 42). Let me create one comment for line 42 (the `aprofundamento_finalized_by_*` comment) since it's the most misleading — it claims to verify author preservation but only checks a comment. And maybe a second comment for line 53 comment-absence assertion. I think I'll report both, but the second is lower severity. Let me consider combining into one comment anchored at line 42... The tool anchors per existing_code. Two comments is fine. Actually, let me reconsider: is `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', $this->controllerSrc)` harmful? It just guards a removed comment. It's brittle: if someone re-adds a similar comment, the test fails. Low. I'll mention it as low severity, maybe combined with the smoke script equivalent line 373. Hmm. Since the smoke script (also in review group) has the same comment-based guard at line 373 `fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')`, I could comment there too. But to avoid duplication, maybe comment on both? The instructions say review each file. Let me put the comment-coupling comment on the PHPUnit test line 42 (strongest), and a lower one on the smoke line 373? Might be redundant. I'll do one comment on the test file line 42 and one on the smoke file line 371-373 for the comment-based guard. Hmm, let me limit to the most valuable. I'll produce: 1. test file line 42: comment-based assertion (`aprofundamento_finalized_by_*`) — medium. 2. test file testRejectNotifiesAprofundamentoAuthorNotRegistrant / testResubmitOnlyWhenAprofundamentoIsResent: tests only grep method names; no behavior/authorization coverage — medium/high (test pendency). 3. Maybe test file line 53: assertion on absence of human comment — low. Let me also double check: is there anything actually wrong functionally that the tests would falsely pass, leading to a real bug? E.g., the notification recipient logic: if `memberId > 0` it uses memberId and ignores userId. If the author's member id points to a different company member... but that's source code, not in review scope. Let's check whether the tests might silently pass while a real bug exists. Not needed for comment. Wait, one more potential concrete test bug: In `testRejectNotifiesAprofundamentoAuthorNotRegistrant`, `extractMethod` uses `assertNotFalse($start, ...)`, then `substr($src, $start, 1800)`. If `$start` is false, `substr` with false → PHP 8 TypeError? Actually assertNotFalse throws on failure, so no. Fine. Another: `assertStringNotContainsString('getCreatedById()', ...)` — since `getCreatedById()` no longer exists in the file at all, this is trivially satisfied even without the window; it adds near-zero coverage. Low. Could mention but I'll skip. Let me decide final comments. I'll produce 3 comments: C1 (test file, line 42): assertion checks a code comment, not behavior. C2 (test file, test method testRejectNotifies.../or overall class): tests grep source text; authorization change has no functional coverage; removing/inverting the author check keeps tests green. Anchor to the line `self::assertStringContainsString('isSsmaOccurrenceInReadequacao', $this->controllerSrc);` (line 58) since that's the access-rule assertion. C3 (smoke script, line 373): the `fileNotContains($controller, 'Readequação: reenvia...')` guards a comment, not behavior; the real guarantee (queue doesn't reopen on admin edit) isn't asserted. Low. Hmm, C3 might be considered noise given C1 similar. But it's a different file in the review group and the "review every file" instruction encourages it. I'll include it but low severity, concise. Actually, let me reconsider whether to include C2 at all with high severity. The user's mandatory rule says flag as pendency (Atenção; Crítico se autorização...). The change touches authorization (who can edit aprofundamento in readequação). So per rule it'd be Crítico. But the repo convention... I'll mark it "high" and category "test". That respects the user rule without being overly dramatic. Let me write comments in Portuguese, plain first sentence. Let me craft existing_code snippets exactly as in the diff. For C1: existing_code: ` self::assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc);` For C2: existing_code: ` self::assertStringContainsString('isSsmaOccurrenceInReadequacao', $this->controllerSrc);` For C3: existing_code: ``` && fileNotContains($controller, 'Readequação: reenvia tanto na edição completa') ``` Actually that line is in the added diff: ``` + && fileNotContains($controller, 'Readequação: reenvia tanto na edição completa') ``` Yes. Now write suggestion_code where applicable. For C1, suggest: replace with an assertion that the reject branch doesn't clear the keys, e.g. `self::assertStringNotContainsString("unset(\$details['aprofundamento_finalized_by", $this->approvalSrc);`. Hmm that's still textual but at least on executable code. Or better a functional assertion. I'll suggest `fileNotContains`-style check on real code. Provide suggestion_code. Actually to be safe, suggestion code should match style. I'll give a plausible replacement. Let me write.
Analisei os dois arquivos de teste em revisão e cruzei com o código-fonte alterado (`SsmaController`, `SsmaNotificationService`, `SsmaOccurrenceApprovalService`) para validar cada asserção.
Tool Calls (1)
code_comment
Show Details
{"comments": [{"path": "tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php", "content": "Esta asserção que deveria garantir que o autor do aprofundamento é preservado na reprovação verifica, na prática, apenas um comentário de código: a string `aprofundamento_finalized_by_*` só existe na linha de comentário adicionada em `SsmaOccurrenceApprovalService` (\"// Mantém aprofundamento_finalized_by_* — ...\"), não em código executável. Ou seja, o teste fica verde por causa de um comentário e quebra se alguém reescrever esse comentário, sem que haja qualquer regressão real — e, pior, não detecta o risco de verdade (algum `unset`/sobrescrita das chaves `aprofundamento_finalized_by_*` no ramo de reprovação). Troque por uma verificação sobre o código real (por exemplo, ausência de remoção dessas chaves) ou, preferencialmente, por um teste funcional que reprove uma ocorrência e confirme que as chaves permanecem em `details`.", "existing_code": "        self::assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc);", "category": "test", "severity": "medium", "suggestion_code": "        self::assertStringNotContainsString(\"unset(\\$details['aprofundamento_finalized_by\", $this->approvalSrc);"}, {"path": "tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php", "content": "O teste não cobre a regra de permissão que a PR introduz. Ele só confirma que o controller contém os nomes `isSsmaOccurrenceInReadequacao` e `stampSsmaAprofundamentoAuthor`; a mera presença do nome não prova que, em readequação, outro especialista do mesmo tipo é barrado nem que o autor continua autorizado. Como a mudança toca autorização (quem pode editar o aprofundamento) e estado, se a verificação de autor fosse removida ou invertida em `canAccessSsmaEventAprofundamento`, estes testes continuariam verdes. Vale adicionar/atualizar um teste funcional que passe pelo caminho real (endpoint/edição da ocorrência) exercitando: autor reenvia e volta para validação, outro especialista do tipo é negado, e gestor/tenant admin edita em paralelo sem reabrir a fila.", "existing_code": "        self::assertStringContainsString('isSsmaOccurrenceInReadequacao', $this->controllerSrc);", "category": "test", "severity": "high"}, {"path": "tests/Unit/Product/Ssma/assert_branch_ui_fixes.php", "content": "Este check valida a ausência de um comentário humano no controller (`'Readequação: reenvia tanto na edição completa'`), não o comportamento. Ele passa simplesmente porque o comentário foi apagado, e volta a falhar se alguém reintroduzir um texto parecido, sem que a fila de validação seja de fato reaberta. A garantia que importa — edição de gestor/tenant não reenvia para validação e só o reenvio do aprofundamento reabre a fila — deveria ser verificada por comportamento (teste do fluxo), e aqui a asserção poderia se apoiar em algo executável, não em texto de comentário.", "existing_code": "    && fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')", "category": "test", "severity": "low"}]}
(async)
Request #10 deepseek-flash P:41.17K C:379 CR:40.96K CW:0 2771ms
Reasoning
I've given each file a pass. Let me finalize. Actually, should I also mention the extractMethod 1800-char fragility? I decided to skip for precision. Fine. Let me call task_done.
Revisei os dois arquivos do grupo. Resumo dos achados: - **`tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php`** - A asserção `assertStringContainsString('aprofundamento_finalized_by_*', ...)` valida um **comentário** de código (única ocorrência da string no service), não a preservação do autor — falso positivo garantido e baixo valor de detecção. - O teste (a despeito do nome) não exercita a regra de autorização que a PR muda: só verifica a presença dos nomes de método no controller. Inverter/remover a checagem de autor mantém os testes verdes (pendência de cobertura funcional). - **`tests/Unit/Product/Ssma/assert_branch_ui_fixes.php`** - O check `fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')` guarda a **ausência de um comentário**, não o comportamento de não reabrir a fila. Verifiquei também que `notifyOccurrenceRegistrantOnReject` foi de fato removido do service (sem outras referências em `src`), então as asserções negativas sobre ele não geram falso positivo.
Tool Calls (1)
task_done
Show Details
{"state": "DONE"}
Request #11 deepseek-flash P:9.01K C:173 CR:8.32K CW:0 1656ms
Reasoning
Let me review the files in the review group. The confirmed findings already cover some issues. Let me look at the actual source files to verify other issues. Let me start by reading the test file and the source files.
Tool Calls (2)
file_read
Show Details
{"file_path": "tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php"}
File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php (Total lines: 70)
IS_TRUNCATED: false
LINE_RANGE: 1-70
1|<?php
2|
3|declare(strict_types=1);
4|
5|namespace App\Tests\Unit\Product\Ssma;
6|
7|use PHPUnit\Framework\TestCase;
8|
9|/**
10| * Readequação (Brenda): reprovação reabre aprofundamento para quem enviou,
11| * não para todos os especialistas do tipo. Gestor/tenant editam em paralelo
12| * sem reenviar a fila de validação.
13| */
14|final class SsmaOccurrenceReadequacaoAuthorTest extends TestCase
15|{
16|    private string $controllerSrc;
17|    private string $notificationSrc;
18|    private string $approvalSrc;
19|
20|    protected function setUp(): void
21|    {
22|        $root = dirname(__DIR__, 4);
23|        $this->controllerSrc = (string) file_get_contents($root . '/src/Controller/SsmaController.php');
24|        $this->notificationSrc = (string) file_get_contents($root . '/src/Service/Ssma/SsmaNotificationService.php');
25|        $this->approvalSrc = (string) file_get_contents($root . '/src/Service/Ssma/SsmaOccurrenceApprovalService.php');
26|    }
27|
28|    public function testRejectNotifiesAprofundamentoAuthorNotRegistrant(): void
29|    {
30|        self::assertStringContainsString('notifyAprofundamentoAuthorOnReject', $this->controllerSrc);
31|        self::assertStringContainsString('notifyAprofundamentoAuthorOnReject', $this->notificationSrc);
32|        self::assertStringContainsString('aprofundamento_finalized_by_member_id', $this->notificationSrc);
33|        self::assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc);
34|        self::assertStringNotContainsString(
35|            'getCreatedById()',
36|            $this->extractMethod($this->notificationSrc, 'notifyAprofundamentoAuthorOnReject')
37|        );
38|    }
39|
40|    public function testRejectKeepsAuthorAndUsesReadequacaoCopy(): void
41|    {
42|        self::assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc);
43|        self::assertStringContainsString('voltou para readequação', $this->approvalSrc);
44|        self::assertStringContainsString('devolvida para readequação após reprovação', $this->approvalSrc);
45|    }
46|
47|    public function testResubmitOnlyWhenAprofundamentoIsResent(): void
48|    {
49|        self::assertStringContainsString(
50|            'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)',
51|            $this->controllerSrc
52|        );
53|        self::assertStringNotContainsString(
54|            'Readequação: reenvia tanto na edição completa',
55|            $this->controllerSrc
56|        );
57|        self::assertStringContainsString('stampSsmaAprofundamentoAuthor', $this->controllerSrc);
58|        self::assertStringContainsString('isSsmaOccurrenceInReadequacao', $this->controllerSrc);
59|    }
60|
61|    private function extractMethod(string $src, string $method): string
62|    {
63|        $start = strpos($src, 'function ' . $method);
64|        self::assertNotFalse($start, $method . ' ausente');
65|        $chunk = substr($src, $start, 1800);
66|
67|        return $chunk;
68|    }
69|}
70|
file_read_diff
Show Details
{"path_array": ["src/Controller/SsmaController.php", "src/Service/Ssma/SsmaNotificationService.php", "src/Service/Ssma/SsmaOccurrenceApprovalService.php"]}
==== FILE: src/Controller/SsmaController.php ====
diff --git a/src/Controller/SsmaController.php b/src/Controller/SsmaController.php
--- a/src/Controller/SsmaController.php
+++ b/src/Controller/SsmaController.php
@@ -4131,7 +4131,7 @@ class SsmaController extends AbstractController
         }
 
         try {
-            $this->notifyOccurrenceRegistrantOnReject($event, $company, $user, $note);
+            $this->notifyAprofundamentoAuthorOnReject($event, $company, $user, $note);
         } catch (\Throwable $notifyError) {
             $this->ssmaLogger->warning('Ssma rejectOccurrence notify: ' . $notifyError->getMessage());
         }
@@ -12016,13 +12016,13 @@ class SsmaController extends AbstractController
         $this->ssmaNotificationService->notifyOccurrenceValidationApprovers($company, $event, $sender, false);
     }
 
-    private function notifyOccurrenceRegistrantOnReject(
+    private function notifyAprofundamentoAuthorOnReject(
         SsmaEvent $event,
         Company $company,
         User $sender,
         string $note
     ): void {
-        $this->ssmaNotificationService->notifyOccurrenceRegistrantOnReject($event, $sender, $note);
+        $this->ssmaNotificationService->notifyAprofundamentoAuthorOnReject($event, $sender, $note);
     }
 
     private function canManageSsmaOccurrences(): bool
@@ -12761,6 +12761,17 @@ SQL;
             return true;
         }
 
+        $loggedMember = $this->getCurrentCompanyMember($company, $user);
+
+        // Readequação: só quem enviou o aprofundamento (admin já passou acima).
+        if ($this->isSsmaOccurrenceInReadequacao($details)) {
+            $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0
+                || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0;
+            if ($hasAuthor) {
+                return $this->isSsmaAprofundamentoAuthor($details, $user, $loggedMember);
+            }
+        }
+
         if (in_array($type, $this->resolveCurrentUserTechnicalTypes($company, $user), true)) {
             return true;
         }
@@ -12769,7 +12780,6 @@ SQL;
             return false;
         }
 
-        $loggedMember = $this->getCurrentCompanyMember($company, $user);
         if (!$loggedMember) {
             return false;
         }
@@ -12783,6 +12793,46 @@ SQL;
         );
     }
 
+    /**
+     * @param array<string, mixed> $details
+     */
+    private function isSsmaOccurrenceInReadequacao(array $details): bool
+    {
+        $approval = is_array($details['occurrence_approval'] ?? null) ? $details['occurrence_approval'] : [];
+
+        return strtolower(trim((string) ($approval['status'] ?? ''))) === 'rejected';
+    }
+
+    /**
+     * @param array<string, mixed> $details
+     */
+    private function isSsmaAprofundamentoAuthor(array $details, User $user, ?CompanyMembers $member): bool
+    {
+        $authorUserId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
+        if ($authorUserId > 0 && (int) $user->getId() === $authorUserId) {
+            return true;
+        }
+
+        $authorMemberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
+
+        return $authorMemberId > 0 && $member instanceof CompanyMembers
+            && (int) $member->getId() === $authorMemberId;
+    }
+
+    /**
+     * @param array<string, mixed> $details
+     */
+    private function stampSsmaAprofundamentoAuthor(array &$details, Company $company, User $user): void
+    {
+        $details['aprofundamento_finalized_by_user_id'] = (int) $user->getId();
+        $memberId = $this->resolveCompanyMemberIdByUserId($company, (int) $user->getId());
+        if ($memberId) {
+            $details['aprofundamento_finalized_by_member_id'] = $memberId;
+        }
+        $name = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
+        $details['aprofundamento_finalized_by_name'] = $name !== '' ? $name : (string) ($user->getEmail() ?? '');
+    }
+
     /**
      * @param array<string, mixed> $details
      */
@@ -27855,6 +27905,7 @@ SQL;
                 $data['aprofundamento_complete'] = true;
                 $detailsOut['aprofundamento_complete'] = true;
                 $detailsOut['aprofundamento_status'] = 'finalized';
+                $this->stampSsmaAprofundamentoAuthor($detailsOut, $company, $user);
             } else {
                 $data['aprofundamento_complete'] = false;
                 $detailsOut['aprofundamento_complete'] = false;
@@ -27890,11 +27941,10 @@ SQL;
         $editorMeta = $this->resolveSsmaEditorMeta($company, $user);
         $event      = $service->updateFromPayload($event, $data, $user, $editorMeta);
 
-        if ($wasRejectedApproval) {
-            // Readequação: reenvia tanto na edição completa quanto ao finalizar só o aprofundamento.
-            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, true);
-        } elseif ($wantsFinalize) {
-            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, false);
+        // Readequação só volta à fila ao reenviar o aprofundamento. Editar (gestor/tenant)
+        // em paralelo não starta esse fluxo.
+        if ($wantsFinalize) {
+            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);
         }
 
         $registeredByName = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
==== FILE: src/Service/Ssma/SsmaNotificationService.php ====
diff --git a/src/Service/Ssma/SsmaNotificationService.php b/src/Service/Ssma/SsmaNotificationService.php
--- a/src/Service/Ssma/SsmaNotificationService.php
+++ b/src/Service/Ssma/SsmaNotificationService.php
@@ -278,15 +278,33 @@ class SsmaNotificationService
         );
     }
 
-    public function notifyOccurrenceRegistrantOnReject(SsmaEvent $event, User $sender, string $note): void
+    /**
+     * Fluxo natural da readequação: avisa só quem enviou o aprofundamento reprovado.
+     * Não dispara para todos os especialistas do tipo (isso é o cadastro novo).
+     * Destinatários extras ficam a cargo da automação ssma_on_occurrence_rejected.
+     */
+    public function notifyAprofundamentoAuthorOnReject(SsmaEvent $event, User $sender, string $note): void
     {
-        $recipient = $this->entityManager->find(User::class, $event->getCreatedById());
-        if (!$recipient instanceof User) {
+        $details = is_array($event->getDetails()) ? $event->getDetails() : [];
+        $memberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
+        $userId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
+
+        $recipients = [];
+        if ($memberId > 0) {
+            $recipients[] = $memberId;
+        } elseif ($userId > 0) {
+            $author = $this->entityManager->find(User::class, $userId);
+            if ($author instanceof User) {
+                $recipients[] = $author;
+            }
+        }
+
+        if ($recipients === []) {
             return;
         }
 
         $content = sprintf(
-            'A ocorrência "%s" foi reprovada na validação e voltou para rascunho.',
+            'A ocorrência "%s" foi reprovada na validação e voltou para readequação do aprofundamento.',
             $this->eventTitle($event)
         );
         if (trim($note) !== '') {
@@ -294,7 +312,7 @@ class SsmaNotificationService
         }
 
         $this->notify(
-            [$recipient],
+            $recipients,
             $content,
             self::PENDING,
             $sender,
==== FILE: src/Service/Ssma/SsmaOccurrenceApprovalService.php ====
diff --git a/src/Service/Ssma/SsmaOccurrenceApprovalService.php b/src/Service/Ssma/SsmaOccurrenceApprovalService.php
--- a/src/Service/Ssma/SsmaOccurrenceApprovalService.php
+++ b/src/Service/Ssma/SsmaOccurrenceApprovalService.php
@@ -107,9 +107,10 @@ final class SsmaOccurrenceApprovalService
             $event->setStatus(SsmaEvent::STATUS_ABERTO);
             $details['aprofundamento_status'] = 'draft';
             $details['aprofundamento_complete'] = false;
+            // Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento.
             $event->setDetails($details);
             if ($prevStatus !== SsmaEvent::STATUS_ABERTO) {
-                $event->appendHistory('Ocorrência devolvida para rascunho após reprovação.', [
+                $event->appendHistory('Ocorrência devolvida para readequação após reprovação.', [
                     'from' => $prevStatus,
                     'to' => SsmaEvent::STATUS_ABERTO,
                 ]);
@@ -122,7 +123,7 @@ final class SsmaOccurrenceApprovalService
             'success' => true,
             'message' => $decision === self::STATUS_APPROVED
                 ? 'Ocorrência aprovada. As automações (flash e/ou comitê) podem seguir.'
-                : 'Ocorrência reprovada. O registro voltou para rascunho para correção.',
+                : 'Ocorrência reprovada. O registro voltou para readequação.',
             'occurrence_approval' => $state,
         ];
     }
Request #12 deepseek-flash P:12.66K C:268 CR:9.09K CW:0 2372ms
Reasoning
Let me examine the notification service `notify` method and understand the recipients array. In the diff, `$recipients[] = $memberId;` — that's an int, and `$recipients[] = $author;` which is a User object. So the array contains mixed types. Let me check the `notify` method signature to see if it accepts CompanyMembers|User. Also there's a potential bug: the memberId is a CompanyMembers ID, but does `notify` find the member? Let's check. Let me look at `notify` method.
Tool Calls (2)
code_search
Show Details
{"search_text": "function notify(", "file_patterns": ["src/Service/Ssma/SsmaNotificationService.php"]}
File: src/Service/Ssma/SsmaNotificationService.php
Match lines: 1
357|    private function notify(

file_read
Show Details
{"file_path": "src/Service/Ssma/SsmaNotificationService.php", "start_line": 1, "end_line": 120}
File: src/Service/Ssma/SsmaNotificationService.php (Total lines: 485)
IS_TRUNCATED: false
LINE_RANGE: 1-120
1|<?php
2|
3|declare(strict_types=1);
4|
5|namespace App\Service\Ssma;
6|
7|use App\Entity\Company;
8|use App\Entity\CompanyMembers;
9|use App\Entity\NotificationsCenter;
10|use App\Entity\SsmaAbordagem;
11|use App\Entity\SsmaEvent;
12|use App\Entity\SsmaInspection;
13|use App\Entity\SsmaMetaAbonoRequest;
14|use App\Entity\User;
15|use App\Enum\Ssma\EventTypeEnum;
16|use App\Service\NotificationsCenterService;
17|use Doctrine\ORM\EntityManagerInterface;
18|use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
19|
20|class SsmaNotificationService
21|{
22|    private const HUB = 'Hub de Operações';
23|    private const PRODUCT = 'Módulo de Segurança';
24|    private const PENDING = NotificationsCenter::TYPE_PENDING_TASK;
25|
26|    public function __construct(
27|        private EntityManagerInterface $entityManager,
28|        private NotificationsCenterService $notificationsCenterService,
29|        private SsmaAutomationService $ssmaAutomationService,
30|        private SsmaOccurrenceTypeConfigService $ssmaOccurrenceTypeConfig,
31|        private UrlGeneratorInterface $urlGenerator
32|    ) {
33|    }
34|
35|    public function notifyImmediateRisk(SsmaEvent $event): void
36|    {
37|        $details = $event->getDetails();
38|        $title = trim((string) ($details['title'] ?? '')) ?: ('Evento #' . $event->getId());
39|
40|        $this->notify(
41|            [(int) ($details['manager_id'] ?? 0)],
42|            sprintf(
43|                'Risco imediato reportado em "%s" (id %d). Verifique e tome as providências necessárias.',
44|                $title,
45|                $event->getId()
46|            ),
47|            NotificationsCenter::TYPE_PROBLEM,
48|            null,
49|            $this->occurrenceViewUrl((int) $event->getId(), 'event'),
50|            'ssma',
51|            'ocorrencias'
52|        );
53|    }
54|
55|    public function notifyAbordagemCoach(SsmaAbordagem $abordagem): void
56|    {
57|        $coachMemberId = $abordagem->getCoachMemberId();
58|        if ($coachMemberId === null) {
59|            return;
60|        }
61|
62|        $abordagemId = (int) $abordagem->getId();
63|        $this->notify(
64|            [$coachMemberId],
65|            sprintf(
66|                'Você foi selecionado como coach na abordagem %s (%s). Registre a pendência de coaching.',
67|                $abordagemId > 0 ? 'I' . $abordagemId : '',
68|                $abordagem->getObservadorNome()
69|            ),
70|            self::PENDING,
71|            null,
72|            '/manager/ssma#tab_approaches',
73|            'ssma',
74|            'prevencao'
75|        );
76|    }
77|
78|    public function notifyInspectionParticipants(
79|        SsmaInspection $inspection,
80|        User $sender,
81|        array $previousMemberIds = []
82|    ): void {
83|        $inspectionId = (int) ($inspection->getId() ?? 0);
84|        if ($inspectionId <= 0) {
85|            return;
86|        }
87|
88|        $previous = array_fill_keys($this->uniquePositiveIds($previousMemberIds), true);
89|        $memberIds = array_values(array_filter(
90|            $this->resolveInspectionRecipientMemberIds($inspection),
91|            static fn (int $id): bool => !isset($previous[$id])
92|        ));
93|
94|        $this->notify(
95|            $memberIds,
96|            'Você foi incluído em uma nova inspeção. Colabore com o preenchimento',
97|            self::PENDING,
98|            $sender,
99|            $this->urlGenerator->generate('ssma_inspection_view', ['id' => $inspectionId])
100|        );
101|    }
102|
103|    /**
104|     * @return list<int>
105|     */
106|    public function resolveInspectionRecipientMemberIds(SsmaInspection $inspection): array
107|    {
108|        return $this->uniquePositiveIds([
109|            ...$inspection->getParticipantsIds(),
110|            (int) ($inspection->getSafetyResponsible()?->getId() ?? 0),
111|        ]);
112|    }
113|
114|    public function notifyOccurrenceResponsible(
115|        int $managerId,
116|        int $occurrenceId,
117|        string $viewKind,
118|        User $sender
119|    ): void {
120|        if ($occurrenceId <= 0) {
Request #13 deepseek-flash P:14.19K C:107 CR:12.8K CW:0 1634ms
Reasoning
Let me look at the `notify` method to understand the recipients array handling — specifically mixed int/User.
Tool Calls (1)
file_read
Show Details
{"file_path": "src/Service/Ssma/SsmaNotificationService.php", "start_line": 270, "end_line": 440}
File: src/Service/Ssma/SsmaNotificationService.php (Total lines: 485)
IS_TRUNCATED: false
LINE_RANGE: 270-440
270|        $this->notify(
271|            $this->ssmaOccurrenceTypeConfig->getFlashReportApproverIds($company),
272|            $resubmitted
273|                ? sprintf('A ocorrência "%s" foi corrigida e aguarda nova validação.', $title)
274|                : sprintf('A ocorrência "%s" foi finalizada e aguarda sua validação.', $title),
275|            self::PENDING,
276|            $sender,
277|            $this->occurrenceViewUrl((int) $event->getId(), 'event')
278|        );
279|    }
280|
281|    /**
282|     * Fluxo natural da readequação: avisa só quem enviou o aprofundamento reprovado.
283|     * Não dispara para todos os especialistas do tipo (isso é o cadastro novo).
284|     * Destinatários extras ficam a cargo da automação ssma_on_occurrence_rejected.
285|     */
286|    public function notifyAprofundamentoAuthorOnReject(SsmaEvent $event, User $sender, string $note): void
287|    {
288|        $details = is_array($event->getDetails()) ? $event->getDetails() : [];
289|        $memberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
290|        $userId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
291|
292|        $recipients = [];
293|        if ($memberId > 0) {
294|            $recipients[] = $memberId;
295|        } elseif ($userId > 0) {
296|            $author = $this->entityManager->find(User::class, $userId);
297|            if ($author instanceof User) {
298|                $recipients[] = $author;
299|            }
300|        }
301|
302|        if ($recipients === []) {
303|            return;
304|        }
305|
306|        $content = sprintf(
307|            'A ocorrência "%s" foi reprovada na validação e voltou para readequação do aprofundamento.',
308|            $this->eventTitle($event)
309|        );
310|        if (trim($note) !== '') {
311|            $content .= ' Observação: ' . trim($note);
312|        }
313|
314|        $this->notify(
315|            $recipients,
316|            $content,
317|            self::PENDING,
318|            $sender,
319|            $this->occurrenceViewUrl((int) $event->getId(), 'event')
320|        );
321|    }
322|
323|    public function notifyMetaAbonoRequester(int $memberId, string $kind, ?User $sender): void
324|    {
325|        $this->notify(
326|            [$memberId],
327|            sprintf('Seu pedido de revisão de metas de %s foi respondido', $this->metaAbonoKindLabel($kind)),
328|            NotificationsCenter::TYPE_GENERAL,
329|            $sender,
330|            $this->urlGenerator->generate('ssma_prevencao_index') . '#tab_prev_meta_reviews'
331|        );
332|    }
333|
334|    /**
335|     * @param list<int> $memberIds
336|     */
337|    private function notifyActionPlan(array $memberIds, int $actionId, User $sender, string $content): void
338|    {
339|        if ($actionId <= 0) {
340|            return;
341|        }
342|
343|        $this->notify(
344|            $memberIds,
345|            $content,
346|            self::PENDING,
347|            $sender,
348|            $this->urlGenerator->generate('ssma_plano_acao_index', ['action' => $actionId])
349|        );
350|    }
351|
352|    /**
353|     * Aceita IDs de CompanyMembers ou instâncias de User.
354|     *
355|     * @param list<int|User> $recipients
356|     */
357|    private function notify(
358|        array $recipients,
359|        string $content,
360|        string $type,
361|        ?User $sender,
362|        string $buttonUrl,
363|        string $hub = self::HUB,
364|        string $product = self::PRODUCT,
365|        ?Company $restrictToCompany = null
366|    ): void {
367|        $senderId = $sender instanceof User ? (int) $sender->getId() : 0;
368|        $created = 0;
369|
370|        foreach ($recipients as $recipient) {
371|            if (!$recipient instanceof User) {
372|                $recipient = $this->resolveMemberUser((int) $recipient, $restrictToCompany);
373|            }
374|            if (!$recipient instanceof User || ($senderId > 0 && (int) $recipient->getId() === $senderId)) {
375|                continue;
376|            }
377|
378|            try {
379|                $this->notificationsCenterService->createNotification(
380|                    recipient: $recipient,
381|                    hub: $hub,
382|                    product: $product,
383|                    content: $content,
384|                    type: $type,
385|                    sender: $sender,
386|                    buttonUrl: $buttonUrl,
387|                    flush: false
388|                );
389|                $created++;
390|            } catch (\Throwable) {
391|            }
392|        }
393|
394|        if ($created === 0) {
395|            return;
396|        }
397|
398|        try {
399|            $this->entityManager->flush();
400|        } catch (\Throwable) {
401|        }
402|    }
403|
404|    private function resolveMemberUser(int $memberId, ?Company $restrictToCompany = null): ?User
405|    {
406|        if ($memberId <= 0) {
407|            return null;
408|        }
409|
410|        $member = $this->entityManager->find(CompanyMembers::class, $memberId);
411|        if (!$member instanceof CompanyMembers || $member->getIsRemoved()) {
412|            return null;
413|        }
414|
415|        if ($restrictToCompany instanceof Company) {
416|            $memberCompany = $member->getCompany();
417|            if (
418|                !$memberCompany instanceof Company
419|                || (int) $memberCompany->getId() !== (int) $restrictToCompany->getId()
420|            ) {
421|                return null;
422|            }
423|        }
424|
425|        $user = $member->getUser();
426|
427|        return $user instanceof User ? $user : null;
428|    }
429|
430|    /**
431|     * @param iterable<mixed> $ids
432|     * @return list<int>
433|     */
434|    private function uniquePositiveIds(iterable $ids): array
435|    {
436|        $unique = [];
437|        foreach ($ids as $id) {
438|            $id = (int) $id;
439|            if ($id > 0) {
440|                $unique[$id] = $id;
Request #14 deepseek-flash P:16.01K C:496 CR:14.21K CW:0 3152ms
Reasoning
Important: `notify()` is called without `$restrictToCompany`. The previous code resolved via `find(User::class, $event->getCreatedById())` - no company restriction either. But now, when memberId > 0, it calls `resolveMemberUser($memberId, null)` — no company restriction. This means a member ID stored in details could theoretically belong to another company, but the ID is stamped by the system, so it's likely fine. However, this is a cross-tenant isolation concern: `resolveMemberUser` with `$restrictToCompany = null` allows notifications to a member of any company if the stored member ID is not validated against the event's company. Actually, let's think. `aprofundamento_finalized_by_member_id` is stamped by `stampSsmaAprofundamentoAuthor` using `resolveCompanyMemberIdByUserId($company, $user->getId())` — scoped to the company. So the stored member id belongs to the same company as the occurrence. So no cross-tenant leak in the natural flow. But wait — in the controller's `notifyAprofundamentoAuthorOnReject`, `$company` is available but not passed to the notification service. The service doesn't take company. It's a design decision but perhaps acceptable. Now, a more subtle issue: `$memberId` preferred over `$userId`. If `memberId` is set but that member was removed or belongs to another company... fine. Now more importantly: the fallback. `$recipients[] = $memberId;` — if the member is removed, `resolveMemberUser` returns null and no notification. Previously it also did no notification if user not found. OK. Now let's look at the controller's readequação logic. Let me read the relevant area of SsmaController around line 12740-12790 to see whether the ordering change broke the admin check. Let me read the controller context.
Tool Calls (1)
file_read
Show Details
{"file_path": "src/Controller/SsmaController.php", "start_line": 12720, "end_line": 12840}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 12720-12840
12720|    private function isSsmaPlainProductMember(?Company $company, ?User $user): bool
12721|    {
12722|        if (!$company || !$user) {
12723|            return false;
12724|        }
12725|
12726|        $member = $this->getCurrentCompanyMember($company, $user);
12727|        if (!$member) {
12728|            return false;
12729|        }
12730|
12731|        if ($this->memberIsSsmaGestorAdministrador($member)) {
12732|            return false;
12733|        }
12734|
12735|        try {
12736|            $tag = $this->resolveSsmaProductPermissionTagForMember($member);
12737|            $name = $tag instanceof \App\Entity\PermissionTag ? (string) $tag->getName() : '';
12738|
12739|            return in_array($name, ['Membro', 'Inspetor', 'Membro (default)'], true);
12740|        } catch (\Throwable) {
12741|            return false;
12742|        }
12743|    }
12744|
12745|    /**
12746|     * Quem pode preencher o Aprofundamento (2ª etapa) de ROS/Quase Acidente:
12747|     * Gestor Administrador SSMA / SUPER_ADMIN, técnico SSMA do tipo ou gestor direto do relator.
12748|     *
12749|     * Não usa canManageSsmaOccurrences()/ROLE_MANAGER — can_create de Membro não libera 2ª etapa.
12750|     *
12751|     * @param array<string, mixed> $details
12752|     */
12753|    private function canAccessSsmaEventAprofundamento(
12754|        Company $company,
12755|        User $user,
12756|        string $type,
12757|        array $details,
12758|        int $createdByUserId = 0
12759|    ): bool {
12760|        if ($this->isSsmaAprofundamentoAdmin($company, $user)) {
12761|            return true;
12762|        }
12763|
12764|        $loggedMember = $this->getCurrentCompanyMember($company, $user);
12765|
12766|        // Readequação: só quem enviou o aprofundamento (admin já passou acima).
12767|        if ($this->isSsmaOccurrenceInReadequacao($details)) {
12768|            $hasAuthor = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0) > 0
12769|                || (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0) > 0;
12770|            if ($hasAuthor) {
12771|                return $this->isSsmaAprofundamentoAuthor($details, $user, $loggedMember);
12772|            }
12773|        }
12774|
12775|        if (in_array($type, $this->resolveCurrentUserTechnicalTypes($company, $user), true)) {
12776|            return true;
12777|        }
12778|
12779|        if (!in_array($type, ['ROS', 'QUASE_ACIDENTE'], true)) {
12780|            return false;
12781|        }
12782|
12783|        if (!$loggedMember) {
12784|            return false;
12785|        }
12786|
12787|        return $this->isSsmaEventDirectManagerOfReporter(
12788|            $company,
12789|            $loggedMember,
12790|            $type,
12791|            $details,
12792|            $createdByUserId
12793|        );
12794|    }
12795|
12796|    /**
12797|     * @param array<string, mixed> $details
12798|     */
12799|    private function isSsmaOccurrenceInReadequacao(array $details): bool
12800|    {
12801|        $approval = is_array($details['occurrence_approval'] ?? null) ? $details['occurrence_approval'] : [];
12802|
12803|        return strtolower(trim((string) ($approval['status'] ?? ''))) === 'rejected';
12804|    }
12805|
12806|    /**
12807|     * @param array<string, mixed> $details
12808|     */
12809|    private function isSsmaAprofundamentoAuthor(array $details, User $user, ?CompanyMembers $member): bool
12810|    {
12811|        $authorUserId = (int) ($details['aprofundamento_finalized_by_user_id'] ?? 0);
12812|        if ($authorUserId > 0 && (int) $user->getId() === $authorUserId) {
12813|            return true;
12814|        }
12815|
12816|        $authorMemberId = (int) ($details['aprofundamento_finalized_by_member_id'] ?? 0);
12817|
12818|        return $authorMemberId > 0 && $member instanceof CompanyMembers
12819|            && (int) $member->getId() === $authorMemberId;
12820|    }
12821|
12822|    /**
12823|     * @param array<string, mixed> $details
12824|     */
12825|    private function stampSsmaAprofundamentoAuthor(array &$details, Company $company, User $user): void
12826|    {
12827|        $details['aprofundamento_finalized_by_user_id'] = (int) $user->getId();
12828|        $memberId = $this->resolveCompanyMemberIdByUserId($company, (int) $user->getId());
12829|        if ($memberId) {
12830|            $details['aprofundamento_finalized_by_member_id'] = $memberId;
12831|        }
12832|        $name = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
12833|        $details['aprofundamento_finalized_by_name'] = $name !== '' ? $name : (string) ($user->getEmail() ?? '');
12834|    }
12835|
12836|    /**
12837|     * @param array<string, mixed> $details
12838|     */
12839|    private function resolveSsmaEventReporterMemberId(
12840|        Company $company,
Request #15 deepseek-flash P:18K C:109 CR:16.38K CW:0 1697ms
Reasoning
Now let me examine the controller change around the versioning/finalize logic. Let's read around 27880-27960.
Tool Calls (1)
file_read
Show Details
{"end_line": 27960, "file_path": "src/Controller/SsmaController.php", "start_line": 27840}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 27840-27960
27840|            if ($aprofundamentoFinalized && !$this->isSsmaAprofundamentoAdmin($company, $user)) {
27841|                return new JsonResponse(['success' => false, 'message' => 'Aprofundamento finalizado. Somente um administrador ou gestor administrador pode alterar.'], 403);
27842|            }
27843|            $data = $this->mergeSsmaEventAprofundamentoOnlyPayload($event, $data);
27844|        } else {
27845|            $data = array_merge($this->eventToValidationArray($event), $data);
27846|            if ($aprofundamentoFinalized && !$this->isSsmaAprofundamentoAdmin($company, $user)) {
27847|                if ($aprofundamentoOnly || $isAprofundamentoUpdate) {
27848|                    return new JsonResponse(['success' => false, 'message' => 'Aprofundamento finalizado. Somente um administrador ou gestor administrador pode alterar.'], 403);
27849|                }
27850|                $data = $this->stripSsmaEventAprofundamentoFieldsFromPayload(
27851|                    $data,
27852|                    (string) ($data['type'] ?? $event->getType()),
27853|                    $existingDetails
27854|                );
27855|            }
27856|        }
27857|
27858|        $data = $this->normalizeSsmaEventPayload($data, $company);
27859|        $data = $this->normalizeSsmaEventManagerInDetails($data, $company);
27860|        if (!$aprofundamentoOnly) {
27861|            $data = $this->applySsmaEventHierarchyManagerForPlainMember($data, $company, $user, $existingDetails);
27862|            if (!empty($data['__ssma_event_hierarchy_blocked'])) {
27863|                return new JsonResponse([
27864|                    'success' => false,
27865|                    'message' => (string) ($data['__ssma_event_hierarchy_message'] ?? $this->ssmaEventHierarchyBlockMessage()),
27866|                ], 422);
27867|            }
27868|            if (!$this->isSsmaHierarchyLockedManagerProfile($company, $user)) {
27869|                $data = $this->applySsmaEventManagerAssignment($data, $company, $user, $existingDetails);
27870|            }
27871|        }
27872|        $data = $this->applySsmaEventAreaResponsibleFromLocation($data, $company);
27873|        $data = $this->applySsmaDescaracterPermissionGate(
27874|            $data,
27875|            $company,
27876|            $user,
27877|            $existingDetails
27878|        );
27879|        if (($data['__ssma_forbidden_reason'] ?? null) === 'descaracterizacao_sem_permissao') {
27880|            return new JsonResponse(['success' => false, 'message' => 'Você não tem permissão para descaracterizar/decidir sobre este acidente.'], 403);
27881|        }
27882|
27883|        $eventType = (string) ($data['type'] ?? $event->getType());
27884|        $eventDetails = is_array($data['details'] ?? null) ? $data['details'] : [];
27885|        // Em modo só-aprofundamento o especialista já está autorizado; não stripa o que acabou de enviar.
27886|        if (
27887|            !$aprofundamentoOnly
27888|            && !$this->canAccessSsmaEventAprofundamento($company, $user, $eventType, $eventDetails, $event->getCreatedById())
27889|            && in_array($eventType, ['ROS', 'QUASE_ACIDENTE', 'ACIDENTE_PESSOAL', 'ACIDENTE_MATERIAL', 'ACIDENTE_AMBIENTAL'], true)
27890|        ) {
27891|            $data = $this->stripSsmaEventAprofundamentoFieldsFromPayload(
27892|                $data,
27893|                $eventType,
27894|                $existingDetails
27895|            );
27896|        }
27897|
27898|        // Draft vs finalize do aprofundamento (especialista / admin).
27899|        $wantsFinalize = false;
27900|        if ($aprofundamentoOnly || !empty($data['aprofundamento_complete']) || array_key_exists('aprofundamento_status', $data)) {
27901|            $detailsOut = is_array($data['details'] ?? null) ? $data['details'] : [];
27902|            $wantsFinalize = !empty($data['aprofundamento_complete'])
27903|                || strtolower(trim((string) ($data['aprofundamento_status'] ?? ''))) === 'finalized';
27904|            if ($wantsFinalize) {
27905|                $data['aprofundamento_complete'] = true;
27906|                $detailsOut['aprofundamento_complete'] = true;
27907|                $detailsOut['aprofundamento_status'] = 'finalized';
27908|                $this->stampSsmaAprofundamentoAuthor($detailsOut, $company, $user);
27909|            } else {
27910|                $data['aprofundamento_complete'] = false;
27911|                $detailsOut['aprofundamento_complete'] = false;
27912|                $detailsOut['aprofundamento_status'] = 'draft';
27913|            }
27914|            $data['details'] = $detailsOut;
27915|        }
27916|
27917|        // Valida apenas se type ou details estiverem presentes no payload (atualização parcial ok)
27918|        if (!empty($data['type']) || !empty($data['details'])) {
27919|            $validator = new \App\Service\Ssma\SsmaEventValidator();
27920|            $errors    = $validator->validate($data, [
27921|                'allowed_type_keys' => $this->ssmaOccurrenceTypeConfig->getAllowedTypeKeys($company),
27922|                'soft_aprofundamento' => $aprofundamentoOnly && !$wantsFinalize,
27923|            ]);
27924|            if ($errors !== []) {
27925|                return new JsonResponse(['success' => false, 'errors' => $errors], 422);
27926|            }
27927|        }
27928|
27929|        $teamScopeErr = $this->validateSsmaEventPayloadAgainstTeamScope($data, $company, $user);
27930|        if ($teamScopeErr !== null) {
27931|            return new JsonResponse(['success' => false, 'message' => $teamScopeErr], 403);
27932|        }
27933|
27934|        $service = new \App\Service\Ssma\SsmaEventService($this->entityManager, $this->ssmaOccurrenceTypeConfig);
27935|        $prevType         = $event->getType();
27936|        $prevStatus       = $event->getStatus();
27937|        $prevImmediateRisk = !empty($event->getDetails()['immediate_risk']);
27938|        $prevManagerId = (int) ($event->getDetails()['manager_id'] ?? 0);
27939|        $wasRejectedApproval = $this->ssmaOccurrenceApprovalService->getState($event)['status']
27940|            === \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_REJECTED;
27941|        $editorMeta = $this->resolveSsmaEditorMeta($company, $user);
27942|        $event      = $service->updateFromPayload($event, $data, $user, $editorMeta);
27943|
27944|        // Readequação só volta à fila ao reenviar o aprofundamento. Editar (gestor/tenant)
27945|        // em paralelo não starta esse fluxo.
27946|        if ($wantsFinalize) {
27947|            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);
27948|        }
27949|
27950|        $registeredByName = trim(($user->getFirstName() ?? '') . ' ' . ($user->getLastName() ?? ''));
27951|        if ($registeredByName === '') {
27952|            $registeredByName = $user->getEmail() ?? 'Sistema';
27953|        }
27954|        $automationContext = [
27955|            'registered_by_name' => $registeredByName,
27956|            'type_raw'           => $event->getType(),
27957|            'new_type'           => $event->getType(),
27958|        ];
27959|        $flashApproval = null;
27960|
Request #16 deepseek-flash P:20.18K C:383 CR:18.05K CW:0 2294ms
Reasoning
Now, a key observation. The `stampSsmaAprofundamentoAuthor` is called only when `$wantsFinalize`. But note that the author is stamped on every finalize, overwriting the previous author. This matters for readequação: when the original author re-submits the aprofundamento (re-adjusts and finalizes), the author is stamped again with the same author — fine. But wait: when the author finalizes, `isSsmaOccurrenceInReadequacao` was true and only the author can edit. So the author remains. But there's a subtlety: consider a case where the occurrence gets rejected, then the author readequates and finalizes. At that moment, `stampSsmaAprofundamentoAuthor` stamps the same author. Fine. However, consider the case where an admin edits the aprofundamento (admin can pass `isSsmaAprofundamentoAdmin`). If an admin re-finalizes the aprofundamento, the author gets overwritten with the admin. But that's arguably fine/desired. Hmm, but the important issue: in the rejection flow, the `details` should keep the author. Let's check the approval service to ensure rejection doesn't remove the finalized_by fields. Indeed the diff shows the comment `// Mantém aprofundamento_finalized_by_*`. Let's read the full approval service method.
Tool Calls (1)
file_read
Show Details
{"file_path": "src/Service/Ssma/SsmaOccurrenceApprovalService.php", "start_line": 1, "end_line": 160}
File: src/Service/Ssma/SsmaOccurrenceApprovalService.php (Total lines: 158)
IS_TRUNCATED: false
LINE_RANGE: 1-158
1|<?php
2|
3|declare(strict_types=1);
4|
5|namespace App\Service\Ssma;
6|
7|use App\Entity\CompanyMembers;
8|use App\Entity\SsmaEvent;
9|use App\Entity\User;
10|use App\Util\Utf8MojibakeNormalizer;
11|use Doctrine\ORM\EntityManagerInterface;
12|
13|/**
14| * Aprovação da ocorrência (não do flash report).
15| * Depois de aprovada, o flash pode ser enviado.
16| */
17|final class SsmaOccurrenceApprovalService
18|{
19|    public const STATUS_PENDING = 'pending';
20|    public const STATUS_APPROVED = 'approved';
21|    public const STATUS_REJECTED = 'rejected';
22|
23|    public function __construct(private EntityManagerInterface $entityManager)
24|    {
25|    }
26|
27|    /**
28|     * @return array{status: string, approved_by_member_id: int|null, approved_by_name: string, approved_at: string, note: string}
29|     */
30|    public function getState(SsmaEvent $event): array
31|    {
32|        $details = $event->getDetails();
33|        $raw = is_array($details['occurrence_approval'] ?? null) ? $details['occurrence_approval'] : [];
34|
35|        return [
36|            'status' => (string) ($raw['status'] ?? ''),
37|            'approved_by_member_id' => isset($raw['approved_by_member_id']) ? (int) $raw['approved_by_member_id'] : null,
38|            'approved_by_name' => (string) ($raw['approved_by_name'] ?? ''),
39|            'approved_at' => (string) ($raw['approved_at'] ?? ''),
40|            'note' => (string) ($raw['note'] ?? ''),
41|        ];
42|    }
43|
44|    public function isApproved(SsmaEvent $event): bool
45|    {
46|        return $this->getState($event)['status'] === self::STATUS_APPROVED;
47|    }
48|
49|    public function decide(
50|        SsmaEvent $event,
51|        User $actor,
52|        ?CompanyMembers $member,
53|        string $decision,
54|        string $note = '',
55|    ): array {
56|        $decision = strtolower(trim($decision));
57|        if (!in_array($decision, [self::STATUS_APPROVED, self::STATUS_REJECTED], true)) {
58|            return ['success' => false, 'message' => 'Decisão inválida. Use approved ou rejected.'];
59|        }
60|
61|        if ($decision === self::STATUS_REJECTED && trim($note) === '') {
62|            return ['success' => false, 'message' => 'Informe a observação para reprovar a ocorrência.'];
63|        }
64|
65|        $currentStatus = $this->getState($event)['status'];
66|        if ($currentStatus === self::STATUS_REJECTED) {
67|            return [
68|                'success' => false,
69|                'message' => 'A ocorrência está em readequação. Corrija e reenvie antes de validar de novo.',
70|            ];
71|        }
72|        if ($currentStatus === self::STATUS_APPROVED) {
73|            return ['success' => false, 'message' => 'Esta ocorrência já foi validada.'];
74|        }
75|
76|        $details = $event->getDetails();
77|        if (!is_array($details)) {
78|            $details = [];
79|        }
80|        $normalizedDetails = Utf8MojibakeNormalizer::normalizeRecursive($details);
81|        $details = is_array($normalizedDetails) ? $normalizedDetails : [];
82|
83|        $actorName = trim((string) ($actor->getName() ?: $actor->getEmail() ?: 'Usuário'));
84|        $state = [
85|            'status' => $decision,
86|            'approved_by_member_id' => $member instanceof CompanyMembers ? (int) $member->getId() : null,
87|            'approved_by_name' => Utf8MojibakeNormalizer::normalize($actorName),
88|            'approved_at' => (new \DateTimeImmutable())->format('c'),
89|            'note' => Utf8MojibakeNormalizer::normalize(trim($note)),
90|        ];
91|        $details['occurrence_approval'] = $state;
92|
93|        $history = is_array($details['changelog'] ?? null) ? $details['changelog'] : [];
94|        $history[] = [
95|            'at' => $state['approved_at'],
96|            'message' => $decision === self::STATUS_APPROVED
97|                ? 'Ocorrência validada'
98|                : 'Ocorrência reprovada na validação',
99|            'source' => 'occurrence_approval',
100|        ];
101|        $details['changelog'] = $history;
102|
103|        $event->setDetails($details);
104|
105|        if ($decision === self::STATUS_REJECTED) {
106|            $prevStatus = $event->getStatus();
107|            $event->setStatus(SsmaEvent::STATUS_ABERTO);
108|            $details['aprofundamento_status'] = 'draft';
109|            $details['aprofundamento_complete'] = false;
110|            // Mantém aprofundamento_finalized_by_* — quem readequa é quem enviou o aprofundamento.
111|            $event->setDetails($details);
112|            if ($prevStatus !== SsmaEvent::STATUS_ABERTO) {
113|                $event->appendHistory('Ocorrência devolvida para readequação após reprovação.', [
114|                    'from' => $prevStatus,
115|                    'to' => SsmaEvent::STATUS_ABERTO,
116|                ]);
117|            }
118|        }
119|
120|        $this->entityManager->flush();
121|
122|        return [
123|            'success' => true,
124|            'message' => $decision === self::STATUS_APPROVED
125|                ? 'Ocorrência aprovada. As automações (flash e/ou comitê) podem seguir.'
126|                : 'Ocorrência reprovada. O registro voltou para readequação.',
127|            'occurrence_approval' => $state,
128|        ];
129|    }
130|
131|    public function markPending(SsmaEvent $event, bool $resubmitted = false): void
132|    {
133|        $details = $event->getDetails();
134|        if (!is_array($details)) {
135|            $details = [];
136|        }
137|
138|        $details['occurrence_approval'] = [
139|            'status' => self::STATUS_PENDING,
140|            'approved_by_member_id' => null,
141|            'approved_by_name' => '',
142|            'approved_at' => '',
143|            'note' => '',
144|        ];
145|        $history = is_array($details['changelog'] ?? null) ? $details['changelog'] : [];
146|        $history[] = [
147|            'at' => (new \DateTimeImmutable())->format('c'),
148|            'message' => $resubmitted
149|                ? 'Ocorrência reenviada para validação após correção'
150|                : 'Ocorrência enviada para validação',
151|            'source' => 'occurrence_approval',
152|        ];
153|        $details['changelog'] = $history;
154|        $event->setDetails($details);
155|        $this->entityManager->flush();
156|    }
157|}
158|
Request #17 deepseek-flash P:22.47K C:838 CR:20.48K CW:0 3858ms
Reasoning
Now, let me focus on the review scope: the two test files. Existing confirmed findings are about weak assertions. Let me see if there are other real issues in these test files. Potential issues in the new test file: 1. `extractMethod` grabs 1800 chars from `$start`. It uses `strpos($src, 'function ' . $method)` — this finds the first occurrence. But the test asserts `assertStringNotContainsString('getCreatedById()', ...)`. The chunk of 1800 chars may bleed into subsequent methods. `notifyAprofundamentoAuthorOnReject` is about 40 lines (~1600 chars). Hmm, let's count: the method spans lines 286-321 (36 lines), maybe ~1300 chars. 1800 chars may extend beyond into `notifyMetaAbonoRequester`. Does `notifyMetaAbonoRequester` contain `getCreatedById()`? No. So the assertion holds. But if `notifyAprofundamentoAuthorOnReject` is defined in both controller and notification service — the test asserts `assertStringContainsString('notifyAprofundamentoAuthorOnReject', $this->notificationSrc)` and then `extractMethod($this->notificationSrc, 'notifyAprofundamentoAuthorOnReject')`. `strpos` finds first occurrence in the notification service — the method is defined only once there. OK. Wait, but in the controller, the method `notifyAprofundamentoAuthorOnReject` is defined and calls `$this->ssmaNotificationService->notifyAprofundamentoAuthorOnReject(...)`. In the notification service, the method is defined at line 286. Good. 2. `dirname(__DIR__, 4)` — the test file is at `tests/Unit/Product/Ssma/`. `__DIR__` = tests/Unit/Product/Ssma. Up 4 = project root? tests/Unit/Product/Ssma → up1: tests/Unit/Product, up2: tests/Unit, up3: tests, up4: root. Yes, root. Good. 3. In `testRejectNotifiesAprofundamentoAuthorNotRegistrant`, `assertStringNotContainsString('getCreatedById()', $this->extractMethod(...))`. In `extractMethod`, `strpos($src, 'function ' . $method)`. Hmm, `notifyAprofundamentoAuthorOnReject` appears first at the docblock? No, docblock has "notifyAprofundamentoAuthorOnReject" only in the function declaration? The docblock text mentions "Fluxo natural da readequação..." and doesn't contain the method name. So `strpos` finds the `function notifyAprofundamentoAuthorOnReject`. Good. Now the more relevant: These are test files that assert on source code strings, which is fundamentally a fragile approach — "test" that only greps for method names. The confirmed findings already flagged some. Let me consider whether there are additional distinct issues worth reporting. Other potential issue: The test file is in `tests/Unit/Product/Ssma/` namespace `App\Tests\Unit\Product\Ssma`. Let's check whether other tests in that dir exist and follow the same pattern (e.g., does the project use PHPUnit via a custom runner for these "assert_*.php" scripts?). Let me search for existing similar test files. Actually the key question: does the test suite even run this file? Let me examine the directory.
Tool Calls (2)
file_find
Show Details
{"query_name": "tests/Unit/Product/Ssma"}
tests/Unit/Product/Ssma/ActionOrigemEnumTest.php
tests/Unit/Product/Ssma/GlobalPermissionListenerOccurrenceCreateWriteTest.php
tests/Unit/Product/Ssma/GlobalPermissionListenerPreventionWriteTest.php
tests/Unit/Product/Ssma/HomeSsmaWeeklyGoalsServiceTest.php
tests/Unit/Product/Ssma/SafetyEnvironmentMemberSectionsServiceTest.php
tests/Unit/Product/Ssma/SecurityActionEffectivenessPresenterTest.php
tests/Unit/Product/Ssma/SecurityLeadershipEvaluationPresenterTest.php
tests/Unit/Product/Ssma/SsmaAbordagemAprofundamentoValidationTest.php
tests/Unit/Product/Ssma/SsmaAbordagemCoachingEvidenceTest.php
tests/Unit/Product/Ssma/SsmaActionDeadlineEditTest.php
tests/Unit/Product/Ssma/SsmaAdrianaConversationGuideTest.php
tests/Unit/Product/Ssma/SsmaAnalyticsAnonymizerTest.php
tests/Unit/Product/Ssma/SsmaBodyMapEntryRegressionTest.php
tests/Unit/Product/Ssma/SsmaBusinessHoursHelperTest.php
tests/Unit/Product/Ssma/SsmaCauseTreeAnalysisApprovalTest.php
tests/Unit/Product/Ssma/SsmaCauseTreeCommitteeCardUiTest.php
tests/Unit/Product/Ssma/SsmaCauseTreeCommitteeTest.php
tests/Unit/Product/Ssma/SsmaCauseTreeServiceTest.php
tests/Unit/Product/Ssma/SsmaCauseTreeSettingsAccessTest.php
tests/Unit/Product/Ssma/SsmaControllerPanelScopeTest.php
tests/Unit/Product/Ssma/SsmaEffectivenessProviderTest.php
tests/Unit/Product/Ssma/SsmaEventValidatorTest.php
tests/Unit/Product/Ssma/SsmaFeedImprovementFeedBridgeServiceTest.php
tests/Unit/Product/Ssma/SsmaFeedImprovementPendingStoreTest.php
tests/Unit/Product/Ssma/SsmaFlashReportApprovalGateTest.php
tests/Unit/Product/Ssma/SsmaFlashReportNotificationRegressionTest.php
tests/Unit/Product/Ssma/SsmaFrequencyRateCalculatorTest.php
tests/Unit/Product/Ssma/SsmaHorasTrabalhadasTimesheetSyncServiceTest.php
tests/Unit/Product/Ssma/SsmaInformativeQuestionGuardTest.php
tests/Unit/Product/Ssma/SsmaInjuredPersonCounterTest.php
tests/Unit/Product/Ssma/SsmaInspectionResponsibleInferenceTest.php
tests/Unit/Product/Ssma/SsmaLayerPreviewBridgeTest.php
tests/Unit/Product/Ssma/SsmaMetaAbonoMemberResolutionTest.php
tests/Unit/Product/Ssma/SsmaMetaAbonoServiceTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceAreaResponsibleTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceAutoFinalizeServiceTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceAutomationAccessResolverTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceConfigPillIsolationTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceCreatePermissionServiceTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceDashboardAggregatorTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceDisplayCodeFormatterTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceEntityTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceExportAccessResolverTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceExportFiltersTest.php
tests/Unit/Product/Ssma/SsmaOccurrencePanelSectionAnalyticsTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceProviderVoiceTrustTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceRosDeepeningSchemaTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceRosSuggestHeuristicTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceSstEvidenceServiceTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceTypeConfigServiceCategoriesTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceTypeConfigServiceTest.php
tests/Unit/Product/Ssma/SsmaOccurrenceVoiceBaselineTest.php
tests/Unit/Product/Ssma/SsmaPanelAnalyticsChatRoutingTest.php
tests/Unit/Product/Ssma/SsmaPanelAnalyticsServiceTest.php
tests/Unit/Product/Ssma/SsmaPanelComparisonPeriodResolverTest.php
tests/Unit/Product/Ssma/SsmaPanelConversationContextStoreTest.php
tests/Unit/Product/Ssma/SsmaPanelFeedImprovementCommandServiceTest.php
tests/Unit/Product/Ssma/SsmaPanelFeedImprovementServiceTest.php
tests/Unit/Product/Ssma/SsmaPanelFreeTextIntentServiceTest.php
tests/Unit/Product/Ssma/SsmaPanelNetworkResolverTest.php
tests/Unit/Product/Ssma/SsmaPanelPeriodFilterTest.php
tests/Unit/Product/Ssma/SsmaPanelQuestionnaireAnalysisBridgeTest.php
tests/Unit/Product/Ssma/SsmaPanelSummaryDisplaySpecTest.php
tests/Unit/Product/Ssma/SsmaPanelSummaryFormatterTest.php
tests/Unit/Product/Ssma/SsmaPermissionServiceTest.php
tests/Unit/Product/Ssma/SsmaPermissionTagRepositoryTest.php
tests/Unit/Product/Ssma/SsmaPrevencaoMemberMetaSaveTest.php
tests/Unit/Product/Ssma/SsmaPrevencaoMetaPeriodoTest.php
tests/Unit/Product/Ssma/SsmaPreventionMutatePermissionServiceTest.php
tests/Unit/Product/Ssma/SsmaPreventionPanelViewAnalyticsTest.php
tests/Unit/Product/Ssma/SsmaPreviewVoicePolicyTest.php
tests/Unit/Product/Ssma/SsmaRefusalAutomationContractTest.php
tests/Unit/Product/Ssma/SsmaRefusalRightHubContractTest.php
tests/Unit/Product/Ssma/SsmaRefusalRightMutatePermissionServiceTest.php
tests/Unit/Product/Ssma/SsmaRegistrationIntentMatcherTest.php
tests/Unit/Product/Ssma/SsmaTenantAdminAprofundamentoAccessTest.php
tests/Unit/Product/Ssma/SsmaTestCase.php
tests/Unit/Product/Ssma/SsmaViewDataScopeTest.php
tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
tests/Unit/Product/Ssma/assert_flash_report_flow.php
tests/Unit/Product/Ssma/fixtures/ssma_occurrence_voice_golden.json
tests/Unit/Product/Ssma/ssma_automation_team_recipient_standalone.php
tests/Unit/Product/Ssma/ssma_permissions_policy_standalone.php
tests/Unit/Product/Ssma/verify_flash_approver_routing.php
code_search
Show Details
{"search_text": "assert_branch_ui_fixes"}
File: docs/engineering/pr/feature-ssma-automation-team-dropdown-new-production/PR_descricao_feature-ssma-automation-team-dropdown-new-production.md
Match lines: 2
98|php tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
157|- [x] `assert_branch_ui_fixes.php`

File: docs/engineering/pr/feature-ssma-ocorrencia-correcoes-new-production/PR_descricao_feature-ssma-ocorrencia-correcoes-new-production.md
Match lines: 3
46|- `tests/Unit/Product/Ssma/assert_branch_ui_fixes.php` — +6 asserts de validação/Readequação/médico/select duplicado
107|php tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
113|- `assert_branch_ui_fixes.php` — **6/6 asserts novos desta PR OK** (validação, Readequação, médico, select duplicado). O script reporta 5 falhas **pré-existentes da base** (flash report modal incompleto, gate de edição tenant, hook CI Regra 81) — não introduzidas por esta PR.

File: docs/engineering/pr/hotfix-ssma-ambiental-material-brenda-new-production/PR_descricao_hotfix-ssma-ambiental-material-brenda-new-production.md
Match lines: 2
64|| `tests/Ssma/assert_branch_ui_fixes.php` | Smoke estático UI + permissões + Prevenção + CI |
236|| `assert_branch_ui_fixes.php` | OK (smoke UI + permissões + Prevenção + CI) |

File: docs/engineering/pr/hotfix-ssma-occ-type-perms-datatables-new-production/PR_descricao_hotfix-ssma-occ-type-perms-datatables-new-production.md
Match lines: 2
201|php tests/Ssma/assert_branch_ui_fixes.php
279|| `assert_branch_ui_fixes.php` | OK |

File: docs/engineering/pr/hotfix-ssma-ros-barrier-type-422/PR_descricao_hotfix-ssma-ros-barrier-type-422.md
Match lines: 1
66|| `assert_branch_ui_fixes.php` | Smoke UI + permissões + Prevenção |

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 1
9| * Uso: php tests/Unit/Product/Ssma/assert_branch_ui_fixes.php

Request #18 deepseek-flash P:25.54K C:204 CR:23.3K CW:0 1489ms
Reasoning
Now, let me look at the assert_branch_ui_fixes.php file more thoroughly to understand its pattern, and check the change. Let me read around the modified region.
Tool Calls (2)
file_read
Show Details
{"end_line": 120, "file_path": "tests/Unit/Product/Ssma/assert_branch_ui_fixes.php", "start_line": 1}
File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php (Total lines: 545)
IS_TRUNCATED: false
LINE_RANGE: 1-120
1|<?php
2|
3|declare(strict_types=1);
4|
5|/**
6| * Smoke estático das correções da branch hotfix ambiental/material Brenda.
7| * Roda sem browser: valida presença/ausência de strings nos templates e enums.
8| *
9| * Uso: php tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
10| */
11|
12|$root = dirname(__DIR__, 4);
13|$fail = 0;
14|
15|function ok(string $msg): void
16|{
17|    echo "  ✓ $msg\n";
18|}
19|
20|function fail(string $msg): void
21|{
22|    global $fail;
23|    $fail++;
24|    echo "  ✗ $msg\n";
25|}
26|
27|function fileContains(string $path, string $needle): bool
28|{
29|    return is_file($path) && str_contains((string) file_get_contents($path), $needle);
30|}
31|
32|function fileNotContains(string $path, string $needle): bool
33|{
34|    return is_file($path) && !str_contains((string) file_get_contents($path), $needle);
35|}
36|
37|echo "=== Branch UI / config smoke ===\n";
38|
39|$twigYaml = $root . '/config/packages/twig.yaml';
40|$modal = $root . '/templates/ssma/occurrence/partials/_modal_event.html.twig';
41|$gmrPartial = $root . '/templates/ssma/partials/_ssma_gmr_options.html.twig';
42|$barrier = $root . '/templates/ssma/occurrence/partials/_ev_shared_barrier.html.twig';
43|$injured = $root . '/templates/ssma/occurrence/partials/_ev_injured_person_box.html.twig';
44|$consOpts = $root . '/templates/ssma/occurrence/partials/_ev_consequence_options.html.twig';
45|
46|fileContains($twigYaml, 'ssma_gmr_options:') ? ok('GMR global em twig.yaml') : fail('Falta ssma_gmr_options em twig.yaml');
47|fileContains($gmrPartial, 'for gmr in ssma_gmr_options') ? ok('Partial GMR itera ssma_gmr_options') : fail('Partial GMR sem loop');
48|fileNotContains($gmrPartial, "{% set ssma_gmr_options = [") ? ok('Partial GMR não seta lista local (evita 500)') : fail('Partial GMR ainda seta lista local');
49|
50|fileContains($modal, "evSyncAaIdentFields(type);") ? ok('applyTypeBlock chama evSyncAaIdentFields cedo') : fail('evSyncAaIdentFields ausente no applyTypeBlock');
51|fileContains($modal, 'id="ev_environmental_medium"') && fileContains($modal, 'class="form-control" id="ev_environmental_medium"')
52|    ? ok('AA: Meio afetado com select nativo form-control')
53|    : fail('AA: Meio afetado fora do padrão nativo');
54|fileContains($modal, 'id="ev_pollutant_type"') && fileContains($modal, 'class="form-control" id="ev_pollutant_type"')
55|    ? ok('AA: Tipo de poluente com select nativo form-control')
56|    : fail('AA: Tipo de poluente fora do padrão nativo');
57|fileNotContains($modal, 'ev_marcos_icon_select') ? ok('AA: sem macro de ícones (select nativo)') : fail('AA: ainda usa macro de ícones');
58|
59|fileContains($modal, 'height: calc(1.5em + 0.75rem + 2px);')
60|    && fileContains($modal, '.ev-containment-time-input')
61|    ? ok('Altura Contenção (h) alinhada ao form-control')
62|    : fail('CSS Contenção (h) ausente/errado');
63|
64|fileContains($modal, "_hide_failed_barrier: true") ? ok('ROS/QA/AM/AA usam Tipo de barreira (hide failed)') : fail('Falta _hide_failed_barrier');
65|fileContains($modal, "_barrier_suffix: '_ros'") ? ok('ROS usa shared barrier') : fail('ROS sem shared barrier');
66|fileContains($modal, "_barrier_suffix: '_qa'") ? ok('QA usa shared barrier') : fail('QA sem shared barrier');
67|fileContains($barrier, 'Tipo de barreira') ? ok('Label Tipo de barreira no partial') : fail('Label Tipo de barreira ausente');
68|fileContains($barrier, 'FISICA') && fileContains($barrier, 'NAO_EXISTIA_BARREIRA')
69|    ? ok('Lista Brenda de tipo de barreira no partial')
70|    : fail('Lista Brenda incompleta no partial');
71|
72|fileContains($consOpts, 'value="LEVE"')
73|    && fileContains($consOpts, 'value="MEDIO"')
74|    && fileContains($consOpts, 'value="SEVERO"')
75|    && !fileContains($consOpts, 'value="MODERADO"')
76|    && !fileContains($consOpts, 'value="CRITICO"')
77|    ? ok('Escala Leve…Severo (sem Moderado/Crítico) nas options')
78|    : fail('Options de consequência fora do padrão Brenda');
79|
80|fileContains($modal, "value: 'AA1'")
81|    && fileContains($modal, "value: 'AA2'")
82|    && fileContains($modal, "value: 'AA3'")
83|    && fileContains($modal, 'function evUsesAmbientalConsequenceScale')
84|    ? ok('Acidente Ambiental: select AA1/AA2/AA3')
85|    : fail('Acidente Ambiental sem opções AA1/AA2/AA3');
86|
87|fileContains($modal, 'EV_IS_ADMIN_APROFUNDAMENTO && window.OccurrenceModal')
88|    ? ok('Admin aprofundamento abre edição completa')
89|    : fail('Admin ainda trava no aprofundamento-only');
90|
91|$treeJs = $root . '/public/js/ssma/tree_view.js';
92|fileContains($treeJs, 'const actionCount = getNodeActionCount(item);')
93|    ? ok('Árvore: actionCount no escopo ao renderizar ícone de ação')
94|    : fail('Árvore: actionCount ainda fora de escopo');
95|
96|fileContains($injured, 'ev-inj-consequence-row') ? ok('Consequências dentro do card do acidentado') : fail('Row de consequência no card AP ausente');
97|fileContains($injured, 'Classificação de Ocorrência')
98|    && fileContains($injured, 'Tipo de CAT')
99|    ? ok('Campos Classificação e Tipo de CAT presentes no AP')
100|    : fail('Classificação/CAT ausentes');
101|
102|fileContains($modal, 'function evSyncPeopleWitnessExclusion') ? ok('Exclusão envolvido × testemunha presente') : fail('evSyncPeopleWitnessExclusion ausente');
103|fileContains($modal, 'function evSyncApGlobalConsequenceVisibility') ? ok('Hide consequência global no AP presente') : fail('evSyncApGlobalConsequenceVisibility ausente');
104|fileNotContains($modal, 'evSyncRosFailedBarrierOtherWrap') ? ok('JS morto de failed_barrier_ros removido') : fail('JS morto failed_barrier_ros ainda presente');
105|!is_file($root . '/templates/ssma/occurrence/partials/_ev_ros_barrier.html.twig')
106|    ? ok('Partial órfão _ev_ros_barrier removido')
107|    : fail('Partial órfão _ev_ros_barrier ainda existe');
108|
109|$spaces = $root . '/templates/spaces_control/buildings/tabs/_tab_spaces.html.twig';
110|fileContains($spaces, 'function renameLocationEverywhere') ? ok('Spaces: renameLocationEverywhere presente') : fail('Spaces: falta renameLocationEverywhere');
111|fileContains($spaces, '_ssmaEditingLocationIsMaster') ? ok('Spaces: guard de local herdado presente') : fail('Spaces: falta _ssmaEditingLocationIsMaster');
112|fileNotContains($spaces, "\$input.prop('readonly', true);")
113|    ? ok('Spaces: nome não fica readonly cego na edição')
114|    : fail('Spaces: regressão — nome readonly incondicional');
115|fileNotContains($spaces, "\$('#ssmaConfigLocationInput').prop('readonly', true);")
116|    ? ok('Spaces: openLocationDrawer não força readonly cego')
117|    : fail('Spaces: regressão — openLocationDrawer força readonly');
118|
119|require $root . '/vendor/autoload.php';
120|use App\Enum\Ssma\BarrierTypeEnum;
file_read
Show Details
{"end_line": 400, "file_path": "tests/Unit/Product/Ssma/assert_branch_ui_fixes.php", "start_line": 330}
File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php (Total lines: 545)
IS_TRUNCATED: false
LINE_RANGE: 330-400
330|fileContains($occView, 'admin_ssma_occurrence_flash_report_approvers')
331|    && fileContains($occView, 'ssmaFlashReportApproverEmpty')
332|    ? ok('Flash report modal: fallback API + aviso aprovador vazio')
333|    : fail('Flash report modal: falta fallback API ou aviso de aprovador vazio');
334|
335|$tabConfig = $root . '/templates/ssma/occurrence/tabs/_tab_config.html.twig';
336|fileContains($tabConfig, 'FLASH_APPROVERS_TAG')
337|    && fileContains($tabConfig, '$tags.append(window.SsmaShared.buildSelectionTag')
338|    && fileContains($tabConfig, "removeClass: 'js-ssma-flash-approver'")
339|    && fileNotContains($tabConfig, 'tags.appendChild(window.SsmaShared.buildSelectionTag')
340|    ? ok('Flash report config: tags via jQuery (buildSelectionTag)')
341|    : fail('Flash report config: renderTags ainda usa appendChild ou removeClass errado');
342|fileContains($controller, "'approver_ids' => \$configIds")
343|    ? ok('Flash report GET approvers: approver_ids só da config SSMA')
344|    : fail('Flash report GET approvers: approver_ids mistura automação com config');
345|
346|$approvalService = $root . '/src/Service/Ssma/SsmaOccurrenceApprovalService.php';
347|$notificationService = $root . '/src/Service/Ssma/SsmaNotificationService.php';
348|fileContains($occView, '#ssmaOccurrenceApproveModal .js-occ-approve-reject')
349|    && fileContains($occView, '--company-theme1-800')
350|    ? ok('Validação: Reprovar usa cor da plataforma')
351|    : fail('Validação: Reprovar ainda sem override da cor da plataforma');
352|fileContains($occView, "_occ_approval != 'rejected'")
353|    && fileContains($occView, "_occ_can_open_validation")
354|    ? ok('Validação: botão oculto em Readequação (rejected)')
355|    : fail('Validação: botão ainda aparece em Readequação');
356|fileContains($approvalService, 'A ocorrência está em readequação')
357|    ? ok('Validação: service bloqueia decide() em rejected')
358|    : fail('Validação: service ainda permite validar Readequação');
359|fileContains($controller, 'A ocorrência está em readequação. Corrija e reenvie antes de validar de novo.')
360|    ? ok('Validação: controller bloqueia approve em Readequação')
361|    : fail('Validação: controller ainda permite approve em Readequação');
362|fileContains($controller, "if (\$status === 'draft')")
363|    && fileContains($controller, '!$fieldsIncomplete && $status !== \'draft\'')
364|    ? ok('Validação: gate aceita aprofundamento completo sem flag finalized')
365|    : fail('Validação: gate ainda exige finalized explícito');
366|fileNotContains($occView, 'Conclua o aprofundamento técnico (finalizado e com todos os campos obrigatórios) antes de validar a ocorrência.')
367|    && fileNotContains($occView, 'data-validation-ready')
368|    && fileNotContains($controller, 'Conclua o aprofundamento técnico (finalizado e com todos os campos obrigatórios) antes de validar a ocorrência.')
369|    ? ok('Validação: approve não exige aprofundamento no front nem no endpoint')
370|    : fail('Validação: approve ainda bloqueia por aprofundamento pendente');
371|fileContains($controller, 'if ($wantsFinalize) {')
372|    && fileContains($controller, 'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)')
373|    && fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')
374|    ? ok('Validação: reenvio após Readequação só ao reenviar aprofundamento')
375|    : fail('Validação: edição paralela ainda reenvia a fila');
376|fileContains($controller, 'notifyAprofundamentoAuthorOnReject')
377|    && fileContains($notificationService, 'notifyAprofundamentoAuthorOnReject')
378|    && fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')
379|    ? ok('Validação: reprovação avisa quem fez o aprofundamento')
380|    : fail('Validação: reprovação ainda avisa o registrante');
381|fileContains($modal, 'select.ssma-member-tag-native-select')
382|    && fileContains($modal, 'form-group:has(> .ssma-member-tag-search-wrap)')
383|    ? ok('Evento etapa 1: select nativo oculto quando há busca de membro')
384|    : fail('Evento etapa 1: select nativo ainda pode duplicar o campo de busca');
385|
386|!is_file($root . '/scripts/hooks/check-product-unit-tests.php')
387|    ? ok('CI: hook Regra 81 removido (pedido Gabriel)')
388|    : fail('CI: check-product-unit-tests.php ainda existe');
389|
390|$ssmaYaml = $root . '/config/automations/ssma.yaml';
391|$autoTwig = $root . '/templates/decision_system/automations/new_automation.html.twig';
392|$autoCtrl = $root . '/src/Controller/DecisionSystem/FlowAutomationController.php';
393|fileContains($ssmaYaml, 'ssma_on_occurrence_rejected')
394|    && fileContains($ssmaYaml, 'ssma_condition_validation_status')
395|    && fileContains($ssmaYaml, 'organizational_structure')
396|    ? ok('Automação: gatilho não aprovado + filtro validação + destinatário por gerência')
397|    : fail('Automação YAML sem rejeição, filtro de validação ou estrutura organizacional');
398|fileContains($autoService, 'ssma_on_occurrence_rejected')
399|    && fileContains($autoService, 'ssma_condition_validation_status')
400|    && fileContains($autoService, 'notifyByOrganizationalStructure')
Request #19 deepseek-flash P:29.51K C:431 CR:25.73K CW:0 2806ms
Reasoning
Now let me consider the real issues in the review files. Review file 1: SsmaOccurrenceReadequacaoAuthorTest.php — confirmed findings already cover weak assertions. Let me look for other issues: - `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc)` — this checks the notification service no longer has the method. But the controller might still reference it. Actually the test also asserts the controller contains `notifyAprofundamentoAuthorOnReject`. Fine. - The test file `extractMethod` is fragile. But maybe not a "bug". - Test method `testRejectKeepsAuthorAndUsesReadequacaoCopy` asserts `voltou para readequação` and `devolvida para readequação após reprovação` exist in approval service. These are copy checks. Fine — weak but okay. Potential real issue: The test file only does static string checks, does not test behavior. Confirmed finding #2 covers the permission aspect. Is there another distinct issue? Perhaps the test asserts `assertStringNotContainsString('getCreatedById()', ...)` within 1800 chars — could produce false positives/negatives. But that's not a definite bug. Hmm, let me look for something more concrete. Consider `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc);` — the notification service might still reference `notifyOccurrenceRegistrantOnReject` elsewhere? Let me check current state. Let me search the notification service for notifyOccurrenceRegistrantOnReject.
Tool Calls (2)
code_search
Show Details
{"search_text": "notifyOccurrenceRegistrantOnReject"}
File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 1
33|        self::assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc);

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 1
378|    && fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')

code_search
Show Details
{"search_text": "maybeSubmitOccurrenceForValidation"}
File: cypress/e2e/ssma/README.md
Match lines: 1
168|`occurrence_approval` ser marcado `pending` automaticamente (`maybeSubmitOccurrenceForValidation`),

File: cypress/e2e/ssma/ssmaPermissionScenarios.spec.js
Match lines: 1
249|    // reacionar a submissão automática para validação (maybeSubmitOccurrenceForValidation),

File: docs/engineering/pr/feature-ssma-ocorrencia-correcoes-new-production/PR_descricao_feature-ssma-ocorrencia-correcoes-new-production.md
Match lines: 1
22|- `SsmaController.php` — `maybeSubmitOccurrenceForValidation()`, bloqueio de validação em Readequação/finalizada, endpoint `deleteCauseTreeActionPlanEntry`, helpers de status de ação

File: docs/ssma/CHAT-BASE-hotfix-permission-ocorrencia.md
Match lines: 1
26|`pending` **não** é gravado ao finalizar o aprofundamento. `maybeSubmitOccurrenceForValidation()` só marca pendente se o **status do evento** já é `CONCLUIDO` (Finalizada) **e** o aprofundamento está pronto.

File: docs/ssma/e2e-permission-scenarios.md
Match lines: 1
173|  /events/{id}/resolve`, que dispara `maybeSubmitOccurrenceForValidation` e marca

File: docs/ssma/engineering/ssma-permissoes-membro-analise-cypress.md
Match lines: 2
246|2. Algum save de aprofundamento em #148 dispara `maybeSubmitOccurrenceForValidation` e **tira** o `rejected` (o teste de reedição do especialista não pode contaminar o fixture se rodar antes).
249|**Correção de produto:** Validar só quando `occurrence_approval.status === 'pending'` (finalizado + fila). Hoje o Twig mostra o botão se o status **não** é `approved` nem `rejected` — string **vazia** também abre Validar. `viewOccurrence` ainda chama `maybeSubmitOccurrenceForValidation` ao abrir o detalhe (efeito colateral: pode promover `''` → `pending`).

File: src/Controller/SsmaController.php
Match lines: 9
3484|                    $this->maybeSubmitOccurrenceForValidation($eventEntity, $company, $user);
8382|                $this->maybeSubmitOccurrenceForValidation($parent, $company, $user);
8528|            $this->maybeSubmitOccurrenceForValidation($event, $event->getCompany(), $user);
9966|                        $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
10056|                    $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
11977|    private function maybeSubmitOccurrenceForValidation(
27715|                $this->maybeSubmitOccurrenceForValidation($event, $company, $user);
27947|            $this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);
30277|                    $this->maybeSubmitOccurrenceForValidation($event, $company, $user);

File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 1
50|            'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)',

File: tests/Unit/Product/Ssma/assert_branch_ui_fixes.php
Match lines: 1
372|    && fileContains($controller, 'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)')

Request #20 deepseek-flash P:30.94K C:167 CR:29.82K CW:0 1445ms
Reasoning
Now let's think about which issues to report in the test files (review scope). Confirmed findings already cover 3 issues. I need to find additional real issues. Let me look at `maybeSubmitOccurrenceForValidation` signature to see the default of the 4th param. `private function maybeSubmitOccurrenceForValidation($event, $company, $user, ...)`. Let me read.
Tool Calls (1)
file_read
Show Details
{"end_line": 12020, "file_path": "src/Controller/SsmaController.php", "start_line": 11977}
File: src/Controller/SsmaController.php (Total lines: 30389)
IS_TRUNCATED: false
LINE_RANGE: 11977-12020
11977|    private function maybeSubmitOccurrenceForValidation(
11978|        \App\Entity\SsmaEvent $event,
11979|        Company $company,
11980|        User $sender,
11981|        bool $resubmitted = false
11982|    ): void {
11983|        if ($this->ssmaOccurrenceApprovalService->isApproved($event)) {
11984|            return;
11985|        }
11986|
11987|        if (!$resubmitted && !$this->isSsmaEventStatusFinalized($event)) {
11988|            return;
11989|        }
11990|
11991|        if (!$this->isEventReadyForOccurrenceValidation($event)) {
11992|            return;
11993|        }
11994|
11995|        $status = $this->ssmaOccurrenceApprovalService->getState($event)['status'];
11996|
11997|        if ($resubmitted) {
11998|            if ($status !== \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_REJECTED) {
11999|                return;
12000|            }
12001|            $this->ssmaOccurrenceApprovalService->markPending($event, true);
12002|            $this->ssmaNotificationService->notifyOccurrenceValidationApprovers($company, $event, $sender, true);
12003|
12004|            return;
12005|        }
12006|
12007|        if ($status === \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_PENDING) {
12008|            return;
12009|        }
12010|
12011|        if ($status === \App\Service\Ssma\SsmaOccurrenceApprovalService::STATUS_REJECTED) {
12012|            return;
12013|        }
12014|
12015|        $this->ssmaOccurrenceApprovalService->markPending($event, false);
12016|        $this->ssmaNotificationService->notifyOccurrenceValidationApprovers($company, $event, $sender, false);
12017|    }
12018|
12019|    private function notifyAprofundamentoAuthorOnReject(
12020|        SsmaEvent $event,
Request #21 deepseek-flash P:31.66K C:1.74K CR:31.1K CW:0 7698ms
Reasoning
Wait — this is interesting. `maybeSubmitOccurrenceForValidation` when `$resubmitted = true` requires the status to be `rejected`; otherwise returns. So in readequação, the status is rejected, so resubmit works. Good. But here's a potential functional issue in the controller change: previously `elseif ($wantsFinalize)` was executed when not `$wasRejectedApproval`. Now the code only calls `maybeSubmitOccurrenceForValidation` when `$wantsFinalize`. Previously, when `$wasRejectedApproval` was true (readequação), it always resubmitted (even without finalize). Now, it resubmits only if `$wantsFinalize`. That's the intent. But wait: what about the original flow where `$wasRejectedApproval` is false and `$wantsFinalize` is false? Previously the `elseif` wouldn't execute either... Actually previously: `if ($wasRejectedApproval) {...} elseif ($wantsFinalize) {...}`. So when not rejected and not finalize, nothing. Now: `if ($wantsFinalize)`. Same behavior for the non-rejected case. OK. Now, there's a subtle issue: previously, in readequação, editing the full record (not only aprofundamento) would resubmit (old behavior). The new behavior only resubmits when `$wantsFinalize`. The requirement states "Editar (gestor/tenant) não reenvia para validação. A fila só reabre ao reenviar o aprofundamento." So `$wantsFinalize` = the aprofundamento status is finalized. Is "reenviar o aprofundamento" equivalent to `$wantsFinalize`? `$wantsFinalize` is true when `aprofundamento_complete` not empty or `aprofundamento_status` = 'finalized'. Hmm — but note this branch executes when `$aprofundamentoOnly || !empty($data['aprofundamento_complete']) || array_key_exists('aprofundamento_status', $data)`. When editing the full record with admin, `$data` is merged with `eventToValidationArray($event)`. Does `eventToValidationArray` include `aprofundamento_complete`? If the event is finalized (aprofundamento_complete true stored in details), then... Actually `aprofundamento_complete` is stored both in details and top-level? Let me check. The controller sets `$data['aprofundamento_complete'] = true;` and `$detailsOut['aprofundamento_complete'] = true;`. Hmm, so it's stored in details at least. But `eventToValidationArray` might map details to top-level `aprofundamento_complete`. This is critical: if an admin edits the full record while the occurrence is in readequação (aprofundamento_status draft), `eventToValidationArray` might set `aprofundamento_complete` = false and `aprofundamento_status` = 'draft'. Then `$data['aprofundamento_complete']` empty and `array_key_exists('aprofundamento_status', $data)` true → branch entered → `$wantsFinalize` false → sets draft → no resubmit. Good. But what if the admin edit doesn't touch the aprofundamento and the existing details still have `aprofundamento_status = 'finalized'`? On rejection, the approval service sets `aprofundamento_status = 'draft'`. So it's draft. So `$wantsFinalize` false. Good. However — potential issue: consider the readequação flow where the author re-finalizes. `$wantsFinalize` true and `$wasRejectedApproval` true → resubmit. Good. Now let me think about a real bug: In `stampSsmaAprofundamentoAuthor`, the author is stamped whenever the aprofundamento is finalized. Consider a case: the occurrence was rejected (readequação), the original author A re-submits... fine. Now consider: the occurrence is approved already? `aprofundamentoFinalized` guard etc. Hmm, but there's another subtle scenario: The admin can edit the aprofundamento even after it's finalized (guard allows admin). If admin re-finalizes, the author changes to admin. Then a subsequent rejection notifies the admin. That's a behavior nuance but arguably fine. Let's now focus on whether there's a cross-tenant/isolation issue in the notification service: `notify()` is called without `$restrictToCompany`. When `$memberId > 0`, `resolveMemberUser($memberId, null)` — no company restriction. The stored member ID is from the event's company, so it's fine. However, `$userId` fallback path finds a User by ID with no company check. Also fine because stored by system. So the only real concerns in the review files... Let me re-examine the test file for any logic bug. `extractMethod`: `$chunk = substr($src, $start, 1800)`. If `strpos` returns 0 (method at start of file) → `assertNotFalse(0)` passes (0 !== false). OK. Hmm, potential issue: `testRejectNotifiesAprofundamentoAuthorNotRegistrant` asserts `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc)`. This is fine currently. Now what about the assertion in `testResubmitOnlyWhenAprofundamentoIsResent`: ``` self::assertStringContainsString('maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)', $this->controllerSrc); ``` Note there are other calls to `maybeSubmitOccurrenceForValidation($event, $company, $user)` — but this exact 4-arg call exists. Fine. Now, do these test files actually get executed by the test suite? The namespace `App\Tests\Unit\Product\Ssma` and `PHPUnit\Framework\TestCase`. Let me check phpunit config for testsuites. Actually, more important for the review: are there real defects? The confirmed findings already cover most. Let me consider the `assert_branch_ui_fixes.php` change more. Line 376-380: ``` fileContains($controller, 'notifyAprofundamentoAuthorOnReject') && fileContains($notificationService, 'notifyAprofundamentoAuthorOnReject') && fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject') ? ok(...) : fail(...); ``` This is fine as a smoke test. Line 371-375: ``` fileContains($controller, 'if ($wantsFinalize) {') && fileContains($controller, 'maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)') && fileNotContains($controller, 'Readequação: reenvia tanto na edição completa') ``` Confirmed finding #3 covers the comment check. Hmm — but there's a subtle bug: `fileContains($controller, 'if ($wantsFinalize) {')`. Is there any other occurrence of `if ($wantsFinalize) {`? If yes, the check doesn't validate the correct location. Let me search. Actually there's only one probably. Now let me check whether the tests would actually run — is there a phpunit testsuite covering tests/Unit/Product? Let me check phpunit.xml.
Tool Calls (2)
code_search
Show Details
{"search_text": "tests/Unit", "file_patterns": ["phpunit*.xml*", "*.dist", "composer.json"]}
No matches found
file_find
Show Details
{"query_name": "phpunit"}
bin/.phpunit.result.cache
phpunit.xml.dist
Request #22 deepseek-flash P:33.44K C:52 CR:33.28K CW:0 1190ms
Reasoning
Let me read phpunit.xml.dist.
Tool Calls (1)
file_read
Show Details
{"file_path": "phpunit.xml.dist"}
File: phpunit.xml.dist (Total lines: 87)
IS_TRUNCATED: false
LINE_RANGE: 1-87
1|<?xml version="1.0" encoding="UTF-8"?>
2|
3|<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
4|<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5|         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
6|         backupGlobals="false"
7|         colors="true"
8|         bootstrap="tests/bootstrap.php"
9|         convertDeprecationsToExceptions="false"
10|>
11|    <php>
12|        <ini name="memory_limit" value="1024M" />
13|        <ini name="display_errors" value="1" />
14|        <ini name="error_reporting" value="-1" />
15|        <server name="APP_ENV" value="test" force="true" />
16|        <server name="SHELL_VERBOSITY" value="-1" />
17|        <server name="SYMFONY_PHPUNIT_REMOVE" value="" />
18|        <server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
19|        <env name="KERNEL_CLASS" value="App\Kernel"/>
20|
21|        <!-- ###+ symfony/messenger ### -->
22|        <!-- Choose one of the transports below -->
23|        <!-- MESSENGER_TRANSPORT_DSN=doctrine://default -->
24|        <!-- MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages -->
25|        <!-- MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages -->
26|        <!-- ###- symfony/messenger ### -->
27|
28|        <!-- ###+ knplabs/knp-snappy-bundle ### -->
29|        <env name="WKHTMLTOPDF_PATH" value="/usr/local/bin/wkhtmltopdf"/>
30|        <env name="WKHTMLTOIMAGE_PATH" value="/usr/local/bin/wkhtmltoimage"/>
31|        <!-- ###- knplabs/knp-snappy-bundle ### -->
32|
33|        <!-- ###+ google/apiclient ### -->
34|        <env name="GOOGLE_API_KEY" value=""/>
35|        <env name="GOOGLE_CLIENT_ID" value=""/>
36|        <env name="GOOGLE_CLIENT_SECRET" value=""/>
37|        <env name="GOOGLE_AUTH_CONFIG" value="%kernel.project_dir%/path/to/file.json"/>
38|        <!-- ###- google/apiclient ### -->
39|
40|        <!-- ###+ pusher/pusher-php-server ### -->
41|        <env name="PUSHER_APP_ID" value=""/>
42|        <env name="PUSHER_KEY" value=""/>
43|        <env name="PUSHER_SECRET" value=""/>
44|        <!-- ###- pusher/pusher-php-server ### -->
45|
46|        <!-- ###+ symfony/lock ### -->
47|        <!-- Choose one of the stores below -->
48|        <!-- postgresql+advisory://db_user:db_password@localhost/db_name -->
49|        <env name="LOCK_DSN" value="flock"/>
50|        <!-- ###- symfony/lock ### -->
51|    </php>
52|
53|    <testsuites>
54|        <testsuite name="Project Test Suite">
55|            <directory>tests</directory>
56|        </testsuite>
57|        <!-- Explicit suite for CI and local DB integration runs. -->
58|        <testsuite name="financial-flow-db">
59|            <directory>tests/Integration/Products</directory>
60|        </testsuite>
61|    </testsuites>
62|
63|    <coverage processUncoveredFiles="true">
64|        <include>
65|            <directory suffix=".php">src</directory>
66|        </include>
67|    </coverage>
68|
69|    <listeners>
70|        <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
71|    </listeners>
72|
73|    <!-- Run `composer require symfony/panther` before enabling this extension -->
74|    <!--
75|    <extensions>
76|        <extension class="Symfony\Component\Panther\ServerExtension" />
77|    </extensions>
78|    -->
79|
80|    <!-- DAMA: descomente se composer require dama/doctrine-test-bundle estiver instalado -->
81|    <!--
82|    <extensions>
83|        <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
84|    </extensions>
85|    -->
86|</phpunit>
87|
Request #23 deepseek-flash P:34.61K C:1.73K CR:33.41K CW:0 8204ms
Reasoning
The testsuite includes `tests` directory, so the new test runs. PHPUnit 9.5. Tests extending `PHPUnit\Framework\TestCase` with `setUp(): void` and `final class`. Fine. Now, an important consideration: `SsmaOccurrenceReadequacaoAuthorTest` extends PHPUnit TestCase and reads files via `file_get_contents`. This mirrors the pattern of other tests in the dir? Let me check e.g. `SsmaOccurrenceConfigPillIsolationTest.php` to see the pattern. Not necessary. Now, are there any other issues I should report? Let me re-examine the test for a potential fatal error: `extractMethod` is called in `testRejectNotifiesAprofundamentoAuthorNotRegistrant` on `$this->notificationSrc`. If the method were absent, `assertNotFalse` fails. Fine. Potential issue: In the new test, the assertion `assertStringContainsString('aprofundamento_finalized_by_member_id', $this->notificationSrc)` — this passes because the method references it. OK. Now, let's think about whether the test file provides value or is misleading — the confirmed findings already flagged. I should look for additional distinct issues. Let me consider: `self::assertStringNotContainsString('getCreatedById()', $this->extractMethod(...))`. Hmm, the controller method `notifyAprofundamentoAuthorOnReject` is extracted... no wait, this is notification service. In notification service, the method body doesn't reference getCreatedById. But `substr(..., 1800)` might extend past the method into `notifyMetaAbonoRequester` (lines 323-332) which doesn't have getCreatedById. Fine. Now let me consider whether the test's use of `dirname(__DIR__, 4)` is correct relative to the other tests. In assert_branch_ui_fixes.php, `$root = dirname(__DIR__, 4);` with __DIR__ = tests/Unit/Product/Ssma → up 4 = root. Same. Good. So what other issues exist? Perhaps a genuine finding: `testRejectKeepsAuthorAndUsesReadequacaoCopy` asserts `'voltou para readequação'` in approval service. Let me check the approval service contains that string. Line 126: `'Ocorrência reprovada. O registro voltou para readequação.'` — yes. OK. Let me consider a potential bug that the confirmed findings didn't mention: the notification to the author is only sent in the controller's `rejectOccurrenceAction`. But is `notifyAprofundamentoAuthorOnReject` also used elsewhere? Let me check the controller for other reject paths (e.g., `SsmAccident`). Search for `notifyOccurrenceRegistrantOnReject` showed no matches in src. So only one reject path. OK. But wait: is there another reject call site that previously notified registrant and now doesn't? The service method was renamed; if some other code called `notifyOccurrenceRegistrantOnReject` on the service, it would now be a fatal error. Search showed no matches in src, so fine. Now, is there a risk that `SsmAccident` or flash report reject used it? Search whole repo for "RegistrantOnReject" returned only test files. Good. Let me consider documentation: `.opencodereview/rules/ssma/occurrence-approve.md` mentioned in PR. Not in review scope. Now, the review scope files are only the two test files. The confirmed findings concern the test files. I need to find any additional real issues. Let me re-read the test file carefully for a subtle bug: ```php self::assertStringNotContainsString( 'Readequação: reenvia tanto na edição completa', $this->controllerSrc ); ``` This is a comment-based check (confirmed finding #3 mentions this in assert_branch_ui_fixes.php but the same pattern exists in the test file at line 53-56). Hmm — the confirmed finding #3 was for assert_branch_ui_fixes.php line 373. The test file has the same issue at lines 53-56. Is that a "repeat"? The confirmed finding specifically targets `tests/Unit/Product/Ssma/assert_branch_ui_fixes.php` with code `&& fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')`. The same anti-pattern in the unit test file is a distinct location. But it's arguably the same finding. Hmm. The instruction says don't repeat confirmed findings. I'd avoid repeating the same concept. But it is a different file and different code. It could be reported as a separate comment since it's in a different file. However, being cautious about duplication... The confirmed findings list mentions "1. tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php ... 'aprofundamento_finalized_by_*'" and "2. ... 'isSsmaOccurrenceInReadequacao'". It did NOT mention the comment-absence check in the unit test file. But finding #3 mentions the analogous check in assert_branch_ui_fixes.php. So arguably reporting the same anti-pattern in the test file is a new location. Hmm, it might be considered duplicative. I'll consider whether to include it. Let me look for something else more concrete. Consider the test doubles as "byte-level" tests. A significant issue: these tests are essentially "change detectors" / tautological. There's a broader concern that a test asserting on source code strings will pass even if behavior is broken and fail on refactors. The user rules say: "Teste que só instancia objeto de domínio sem passar pelo controller/service que decide acesso não substitui teste funcional do endpoint." and "Se a PR alterou comportamento e não trouxe teste novo/atualizado cobrindo esse comportamento, sinalize como pendência (Atenção; Crítico se o fluxo for autorização...)". The new test doesn't exercise the endpoint; it greps source. And the PR changed authorization behavior (readequação restricts editing to author + admin/tenant). That's an authorization flow → Critical per rules. This overlaps with confirmed finding #2 but the finding #2 is specific to the `isSsmaOccurrenceInReadequacao` assertion. Hmm. Actually, confirmed finding #2 says: "O teste não cobre a regra de permissão que a PR introduz. Ele só confirma que o controller contém os nomes..." So the "no functional test for authorization" point is covered. Let me look for additional concrete issues in the test that aren't covered: 1. `assertStringNotContainsString('getCreatedById()', $this->extractMethod(...))` — the extract window of 1800 chars could include other methods, making the assertion unreliable. But it's not currently failing. Low value. 2. Is the test namespace/autoload correct? `App\Tests\Unit\Product\Ssma` maps to `tests/Unit/Product/Ssma`? Let me check composer autoload-dev PSR-4 mapping. Actually the other tests in the same directory — let me check their namespace. E.g., SsmaOccurrenceEntityTest.php.
Tool Calls (1)
code_search
Show Details
{"search_text": "^namespace|^final class|^class ", "file_patterns": ["tests/Unit/Product/Ssma/SsmaOccurrenceEntityTest.php", "tests/Unit/Product/Ssma/SsmaOccurrenceConfigPillIsolationTest.php", "tests/Unit/Product/Ssma/SsmaTestCase.php"], "use_perl_regexp": true}
File: tests/Unit/Product/Ssma/SsmaOccurrenceConfigPillIsolationTest.php
Match lines: 2
5|namespace App\Tests\Unit\Product\Ssma;
11|final class SsmaOccurrenceConfigPillIsolationTest extends TestCase

File: tests/Unit/Product/Ssma/SsmaOccurrenceEntityTest.php
Match lines: 2
5|namespace App\Tests\Unit\Product\Ssma;
9|final class SsmaOccurrenceEntityTest extends SsmaTestCase

File: tests/Unit/Product/Ssma/SsmaTestCase.php
Match lines: 1
5|namespace App\Tests\Unit\Product\Ssma;

Request #24 deepseek-flash P:36.52K C:4.94K CR:36.22K CW:0 21868ms
Reasoning
Namespace is fine. Now, one thing: the test file uses `PHPUnit\Framework\TestCase` directly (like SsmaOccurrenceConfigPillIsolationTest). Fine. Let me check whether there's a hook or CI check enforcing that test files must be in `tests/Unit/Product` and match a specific naming. Not needed. Let me reconsider hidden issues: The PR description says "Testes: smoke assert_branch_ui_fixes, SsmaOccurrenceReadequacaoAuthorTest, comentário Cypress." So the intent is static smoke tests. So the review may just note that these static tests don't validate the behavior. Given the confirmed findings already cover the main points, maybe I should check whether there's a real *bug* in the review files. Since review files are only tests, real bugs would be test bugs. Let me check the `extractMethod` helper: `substr($src, $start, 1800)`. If the file uses CRLF, no issue. Fine. Potential test bug: `testResubmitOnlyWhenAprofundamentoIsResent` asserts the controller does NOT contain 'Readequação: reenvia tanto na edição completa'. This is a comment check. But more importantly, this test doesn't fail if the old buggy code is reintroduced *with a different comment*. That's confirmed finding #3's point but for assert_branch_ui_fixes. Hmm. Wait — actually the assertion `assertStringContainsString('maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)', ...)` — does that actually prove the readequação-only-on-finalize behavior? Not exactly; it just proves the call exists. Combined with `if ($wantsFinalize)`. Hmm. Let me think about whether there's a genuine second-order issue: the test asserts `assertStringContainsString('if ($wantsFinalize) {', ...)`? No, that's in assert_branch_ui_fixes. The unit test doesn't check the `if ($wantsFinalize)` guard. So the unit test would pass even if the code called `maybeSubmitOccurrenceForValidation(..., $wasRejectedApproval)` unconditionally. That's a weakness similar to confirmed findings. I think the most defensible additional findings: (a) In the unit test file, `testResubmitOnlyWhenAprofundamentoIsResent` asserts the absence of a code *comment* (`'Readequação: reenvia tanto na edição completa'`) as proof that parallel admin edits no longer reopen the validation queue. This is a false assurance: the test passes because a comment was deleted, and it would still pass even if the resubmission logic regressed. This is essentially the same as confirmed finding #3 but in the unit test file. Given the instruction to not repeat confirmed findings, and the finding #3 is about the same string in the other file... Duplicative? The finding code differs (`fileNotContains($controller, ...)` vs `assertStringNotContainsString(...)`). It's a distinct file/line. I think it's acceptable to report, but risk of being seen as duplicate. (b) The test file uses `extractMethod` with a hardcoded 1800-char window, which can leak into adjacent methods and both cause false negatives and false positives. For example, the assertion `assertStringNotContainsString('getCreatedById()', ...)` could pass or fail depending on unrelated code within the window. Low-medium severity maintainability. Hmm, is there anything about the test not asserting the notification recipient resolution? Not needed. Let me look at whether the tests are even added to the CI smoke list. Not needed. Let me take a broader look: maybe there's an issue in `assert_branch_ui_fixes.php` where `$notificationService` variable was added but the check uses `fileContains($controller, 'notifyAprofundamentoAuthorOnReject')`. Fine. Wait, there's one thing: line 371 check uses `fileContains($controller, 'if ($wantsFinalize) {')`. If the controller has multiple `if ($wantsFinalize) {` occurrences... let me search. Only one. Fine. Let me reconsider the possibility that the test file's `extractMethod` for `notifyAprofundamentoAuthorOnReject` in the notification service actually picks up the first occurrence which might be in a comment? No. OK, given the difficulty, let me look at whether there's a "no test for staleness" issue: since these are grep tests, if someone renames things, tests break — fine. Now let me reconsider whether there is an actual functional issue in the *review files* related to the notification recipient: The test asserts `assertStringNotContainsString('getCreatedById()', $this->extractMethod(...))`. But the notification service's `notifyAprofundamentoAuthorOnReject` doesn't call getCreatedById, correct. So the test passes. Hmm, but there's a subtle: The PR claims "não o registrante". The test verifies the notification service method doesn't call getCreatedById. But the controller's `rejectOccurrenceAction` — does it still notify the registrant somewhere else (e.g., via automation or another call)? Not in scope. Given the confirmed findings, I think the appropriate additional findings are limited. Let me search for other possible test-quality issues: Does the new test cover the security/authorization rule that another specialist of the same type is blocked? That's confirmed finding #2. Maybe I should check whether the test file is registered such that it would be picked up by the CI "hook" that was removed... The smoke test checks `!is_file($root . '/scripts/hooks/check-product-unit-tests.php')`. Fine. Let me now consider: does `SsmaOccurrenceReadequacaoAuthorTest` duplicate the assertions in `assert_branch_ui_fixes.php`? Yes largely. Not a bug. Alternatively, maybe I should report that the test asserts the wrong thing about the notification: `assertStringContainsString('aprofundamento_finalized_by_member_id', $this->notificationSrc)` — that's just the string. It doesn't verify that only the author receives it. Covered by confirmed #2 partially. Hmm, let me look at whether there's a mismatch between test expectation and implementation: `testRejectKeepsAuthorAndUsesReadequacaoCopy` asserts `devolvida para readequação após reprovação` in approvalSrc. Present at line 113. Good. And `voltou para readequação` at line 126. Good. And `aprofundamento_finalized_by_*` at line 110 comment. Confirmed. So the only remaining thing: the `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', $this->controllerSrc)` in the unit test. And the `extractMethod` 1800-char window fragility. Actually wait. Let me reconsider a potentially real defect: In `testRejectNotifiesAprofundamentoAuthorNotRegistrant`, the assertion `assertStringNotContainsString('getCreatedById()', ...)`. Note the notification service file overall still may use `getCreatedById()`. But the extract confines to the method. OK. Hmm, `extractMethod` searches `'function ' . $method`. In the notification service, is there a preceding occurrence of the method name in the docblock? The docblock at 281-285 mentions "notifyAprofundamentoAuthorOnReject"? Let me re-read: "Fluxo natural da readequação: avisa só quem enviou..." No method name. So `strpos` finds the function declaration. Good. Hold on — `strpos($src, 'function ' . $method)`. Actually in the controller, `notifyAprofundamentoAuthorOnReject` is called as `$this->ssmaNotificationService->notifyAprofundamentoAuthorOnReject(...)`, but that's not `function `. In the notification service, the declaration is `public function notifyAprofundamentoAuthorOnReject(`. `strpos` will find it. Good. Now let me think about the 1800 window in the *notification service*: the method spans from line 286 to 321. Let me count characters roughly: line 288 ~75, 289 ~90, 290 ~78, 292 ~28, 293 ~30, 294 ~35, 295 ~30, 296 ~78, 297 ~45, 298 ~35, 299 ~10, 300 ~5, 302 ~30, 303 ~10, 304 ~10, 306-309 ~200, 310 ~45, 311 ~55, 314-320 ~180. Total ~1100 chars. So 1800 extends ~700 chars into `notifyMetaAbonoRequester` (lines 323-332, ~330 chars) and into its docblock. `notifyMetaAbonoRequester` doesn't call getCreatedById. So the assertion holds. But if someone adds getCreatedById to that next method... low risk. I think I should report at least one additional distinct finding to be useful. The strongest one is the comment-based absence assertion in the unit test file, but it duplicates confirmed finding #3's concept. Hmm. Alternatively, maybe there's a genuinely real issue I'm missing. Let me reconsider the notification service `notify()` recipients: It's `list<int|User>`. The test asserts the method doesn't use getCreatedById — meaning if `aprofundamento_finalized_by_*` is missing (legacy occurrences rejected before this PR), NOBODY gets notified. Previously, the registrant got notified. This is a behavioral regression for legacy occurrences without the stamped author: the reject notification silently disappears. The PR acknowledges legacy occurrences "seguem a regra antiga de acesso (legado)". But notification-wise, legacy occurrences rejected after deploy would notify nobody (the author fields absent → `$recipients === []` → return). Is that acceptable? The PR says "Aviso in-app só para quem enviou o aprofundamento (não o registrante...)". For legacy, author unknown → no notification. That's a real behavior regression but maybe intended. Hmm, but is it in the review files? The review files are tests. The notification service is NOT in the review group. I must only comment on files within <review_files>. So I cannot comment on the notification service. Right — strict focus rules: comments must address code within review_files. So my findings must be about the two test files. So: what issues exist in the test files? Given the confirmed findings, remaining: - The comment-absence assertion in the unit test (line 53-56) — same class as confirmed #3 but different file/line. - The `extractMethod` 1800-char window fragility. - Tests are static source-greps and don't exercise behavior (covered by #2 partly, but the general point about legacy/behavioral regression not covered). Hmm. Also: The test file asserts `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc)`. If the whole notification method is removed (e.g., reverted), the test fails. Fine. Another: `testRejectNotifiesAprofundamentoAuthorNotRegistrant` name suggests it tests that the registrant isn't notified, but it only checks the service file doesn't contain `getCreatedById()` within a 1800-char window; the controller's call site could still notify the registrant through another path. Weak. Let me also verify: does `SsmaTestCase` exist and should the new test extend it instead of raw TestCase? Not required for static tests. I wonder if there's a real bug: `self::assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc);` — but what if the notification service is referenced elsewhere with the old name? No. Let me broaden: maybe there is an issue where the new test file will FAIL because of the assertion `assertStringNotContainsString('getCreatedById()', $this->extractMethod(...))`. Let me double-check the current notification service method doesn't contain `getCreatedById()`. Confirmed lines 286-321 - no. So passes. Hmm, but wait: could `extractMethod` for the notification service pick up the `notifyOccurrenceValidationApprovers`... no. What if `strpos` finds `notifyAprofundamentoAuthorOnReject` inside the controller's call `$this->ssmaNotificationService->notifyAprofundamentoAuthorOnReject`? For notificationSrc it's the notification service file only. OK. Let me check `SsmaOccurrenceReadequacaoAuthorTest` line 30-31: asserts the method name exists in both. Fine. Let me think about whether the new unit test is redundant with assert_branch_ui_fixes and thus duplicated test logic (maintainability). Both assert on the same source strings. That's duplication which the PR checklist claims "Não existe código duplicado conhecido". Might be worth a low-severity note. Given the instruction to keep responses concise and avoid repeating confirmed findings, maybe I should report: 1. The unit test's comment-based negative assertion (line 53-56) is a false assurance — but this is basically finding #3. I'll skip to avoid duplication? Hmm, the task explicitly says "Continue reviewing all files in <review_files> and report any other real issues you find." Since #3 is in a different file, reporting the analogous issue in the unit test file is not a repeat of the same finding location. I think it's legitimate and useful. Actually, let me reconsider: maybe there is a more concrete issue with the unit test that I can report: The test claims to verify "reenvio após Readequação só ao reenviar aprofundamento" but never asserts the guard `if ($wantsFinalize)`, so it would still pass if the controller called `maybeSubmitOccurrenceForValidation` unconditionally... Actually no: the assertion `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', ...)` is a comment check, and the positive assertion of the 4-arg call. If regression reintroduced the `if ($wasRejectedApproval) { ... } elseif ($wantsFinalize)`, the 4-arg call might be split into two 3-arg/4-arg calls: `maybeSubmitOccurrenceForValidation($event, $company, $user, true)` and `... , false)`. Then the exact string with `$wasRejectedApproval` would be absent → test fails. So it does catch that specific regression. OK so it's somewhat effective. Let me also examine: the test's `testResubmitOnlyWhenAprofundamentoIsResent` doesn't verify the negative for the old behavior of "edition resubmits". The current negative is a comment. So the test's named guarantee is unproven. That is essentially what I'd report. Given the analysis, I'll report two findings on the unit test file: Finding A (medium): `testRejectNotifiesAprofundamentoAuthorNotRegistrant` verifies the recipient change only by checking the absence of `getCreatedById()` inside a fixed 1800-char window of the notification service; it doesn't exercise the endpoint/service, so a regression in who actually receives the notification (e.g., the registrant still being notified through another path, or nobody being notified for legacy occurrences without the stamped author) wouldn't be caught. — Hmm, this overlaps confirmed #2 conceptually (test doesn't cover behavior). Confirmed #2 is about controller permission though. Hmm. Finding B (low): `extractMethod`'s fixed 1800-char slice makes the "not contains getCreatedById()" assertion depend on unrelated adjacent methods; if a nearby method later uses getCreatedById, the assertion breaks for the wrong reason (false failure), and conversely other method content can mask a real presence. Suggest parsing until the next `function` or using token_get_all. Hmm, finding B is a legit maintainability/test-robustness issue and distinct from confirmed findings. Finding C (medium): the comment-based negative assertion in testResubmitOnlyWhenAprofundamentoIsResent. Let me consider whether the review should include a finding that the new test file duplicates assert_branch_ui_fixes.php. That's low severity maintainability. Maybe skip. Let me also double-check the `assert_branch_ui_fixes.php` diff for a possible bug: the new check at 376-380 references `$notificationService` which is defined at 347. Good. But is `$notificationService` used before definition? No, defined earlier. Fine. One more: In `assert_branch_ui_fixes.php`, line 371: `fileContains($controller, 'if ($wantsFinalize) {')`. Hmm, is this string present in the controller? Yes at 27904. Fine. I'm a bit worried that reporting too many overlapping findings is not great. Let me focus and pick the most concrete, non-duplicative ones. Actually, let me reconsider — maybe there is a genuine test bug: `extractMethod` returns 1800 chars starting at `function`. For the notification service, the method `notifyAprofundamentoAuthorOnReject` is ~1100 chars, so the window includes part of `notifyMetaAbonoRequester` and its docblock. Does `notifyMetaAbonoRequester` contain getCreatedById? No. So the assertion passes. Fine. But actually there's a more subtle real issue: `assertStringNotContainsString('getCreatedById()', ...)` will FAIL if the notification service's *next* method uses getCreatedById. But more importantly, it's a false-negative risk. Low. Let me also verify there isn't an issue with `testRejectKeepsAuthorAndUsesReadequacaoCopy` asserting `'aprofundamento_finalized_by_*'` which is the confirmed #1. Skip. Let me reconsider whether to also flag that the tests pass regardless of actual regression for the notification recipient (i.e., the tests are change detectors). The user's rule: "Se a PR alterou comportamento e não trouxe teste novo/atualizado cobrindo esse comportamento, sinalize como pendência (Atenção; Crítico se o fluxo for autorização...)". This PR changed authorization behavior and notification recipients. The new "tests" don't cover the behavior functionally. This is a strong point to raise as a pendency. But confirmed #2 already says the test doesn't cover the permission rule. I could phrase a broader pendency: no functional/integration test covers the new authorization + notification behavior; the added tests are static source-greps. But that risks duplicating #2. I'll instead focus on distinct technical defects. Hmm. Let me reconsider the review scope: maybe the most valuable distinct finding is the `extractMethod` window bug, because it's a concrete defect that can cause a test to pass/fail for the wrong reason. I'll report it. Additionally, I could report the comment-based assertion in the unit test (distinct file from #3). Let me decide: I'll provide 2 comments on the new unit test file: 1. `extractMethod` fixed 1800-char window (bug/maintainability). 2. The `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', $this->controllerSrc)` comment-based false assurance. Hmm, but is #2 too close to confirmed #3? Confirmed #3 is about `assert_branch_ui_fixes.php`'s `fileNotContains`. The unit test file is different. I think it's fine, but I could frame it as part of the same test file's weakness. Actually to be safe and add value, maybe I combine: the unit test relies on greps including a human comment; suggest asserting the guard `if ($wantsFinalize)` and the absence of an unconditional resubmit path. Let me finalize. Also should I comment on assert_branch_ui_fixes.php for anything not covered? Confirmed #3 covers the comment check there. Is there anything else in the diff? The lines 376-380 new check. Any issue? `fileContains($controller, 'notifyAprofundamentoAuthorOnReject')` — trivially true since the method is defined there; it doesn't validate the call site uses it instead of the old notifier. Actually the diff renamed the controller method, so the controller has `notifyAprofundamentoAuthorOnReject` both as definition and call. The check would pass even if the controller still called the old method for the actual notification... Actually there's no old method in the controller now. Hmm, the check `fileNotContains($notificationService, 'notifyOccurrenceRegistrantOnReject')` verifies the service no longer has the old method. And `fileContains($controller, 'notifyAprofundamentoAuthorOnReject')` verifies presence. This is okay. Is there an issue that the check doesn't verify the reject flow calls `notifyAprofundamentoAuthorOnReject` inside the reject action? Minimal. I think adding one comment on assert_branch_ui_fixes.php about the new check being tautological is redundant with confirmed #3's theme. Skip. Let me reconsider whether to add a comment about the unit test duplicating assert_branch_ui_fixes (maintainability, low). Probably low value; skip. Now, before finalizing, let me double check the `extractMethod` claim by reading it precisely: it takes `substr($src, $start, 1800)`. Since the method declaration for `notifyAprofundamentoAuthorOnReject` starts at some offset, 1800 chars later — will it reach `notifyMetaAbonoRequester`? Yes. Good, my claim is verifiable. Actually, wait. Let me reconsider whether the window might even include `notifyOccurrenceValidationApprovers` or other methods with `getCreatedById()`. Let's check: does the notification service use getCreatedById anywhere?
Tool Calls (2)
code_search
Show Details
{"file_patterns": ["src/Service/Ssma/SsmaNotificationService.php"], "search_text": "getCreatedById"}
No matches found
code_search
Show Details
{"file_patterns": ["tests/Unit/Product/Ssma/"], "search_text": "extractMethod"}
File: tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php
Match lines: 2
36|            $this->extractMethod($this->notificationSrc, 'notifyAprofundamentoAuthorOnReject')
61|    private function extractMethod(string $src, string $method): string

Request #25 deepseek-flash P:41.56K C:3.12K CR:41.34K CW:0 14606ms
Reasoning
Good — `getCreatedById` is nowhere in the notification service now, so the specific assertion `assertStringNotContainsString('getCreatedById()', extract(...))` is trivially satisfied for the whole file; the extract window is irrelevant. This reinforces that the assertion is meaningless (the whole file has zero occurrences). Hmm, this is a stronger point: the assertion doesn't isolate the method at all — the string doesn't exist anywhere in the file. So the "author not registrant" check is a tautology. That is a concrete, distinct issue from confirmed #1/#2? Confirmed #2 was about controller permission; this is about the notification recipient check. Hmm the confirmed list didn't mention this specific assertion. I think it's a valid distinct finding: the assertion is tautological because `getCreatedById()` no longer appears anywhere in the file (given the method was renamed/replaced), so even a regression that reintroduces the registrant notification via another variable wouldn't be caught. Wait, but the assertion's purpose is exactly to ensure the new method doesn't fall back to the registrant. Since getCreatedById isn't in the file at all, it passes trivially. But if someone re-adds it in the method, the test would catch it... unless they re-add it in a different method within the file — then it'd still catch it only if within the window... Actually the whole file has zero, and the assertion only checks the window. So if getCreatedById were reintroduced *outside* the window, the test wouldn't catch. Eh. I think I can report: the assertion `assertStringNotContainsString('getCreatedById()', ...)` is trivially satisfied and doesn't prove the recipient is the author — because the whole notification service file no longer contains `getCreatedById()`, and the extract window only covers part of the file. More importantly, this test cannot detect if the notification goes to the creator through a different code path. Hmm, this is getting into "weak test" territory similar to confirmed findings. But it's a distinct assertion. Let me step back and decide the final set of comments. I want to be useful and not noise. I'll likely report: 1. Unit test: the negative assertion about `Readequação: reenvia tanto na edição completa` — a comment, not behavior (distinct file, same anti-pattern as confirmed #3). Severity medium. Hmm, but risk of being flagged as duplicate. The instruction: "Do not repeat them." Reporting the same anti-pattern on a different file/line is technically not repeating the same finding, but a reviewer might see it as duplicate. I'll include it but keep it concise. 2. `extractMethod` 1800-char window + the getCreatedById assertion being tautological — combine into one comment about the recipient test being a false assurance. Actually, I realize the confirmed findings already say the tests are static-grep-based and don't prove behavior. Adding more of the same might be noise. Let me think about which is the single most defensible *new* finding. The most concrete new finding that isn't in the confirmed list: `extractMethod` uses a fixed 1800-character slice, which can (a) leak into neighboring methods and (b) with the current layout already includes `notifyMetaAbonoRequester`. And the assertion `getCreatedById()` is satisfied trivially because the file contains no occurrence at all. So the test doesn't verify that the author method uses the new fields exclusively. Hmm. Alternatively, is there any *bug* in the test that would cause a false pass on an actual regression? Consider the main behavior the test claims: "reprovação avisa o autor, não o registrante". The only real check is `str_contains('aprofundamento_finalized_by_member_id')` in the file. That's already confirmed-ish? No, confirmed #1 is about approval service `aprofundamento_finalized_by_*`. Hmm, close. Let me just go with: - Comment A (medium, test): `extractMethod` window makes the negative assertion unreliable / trivially true; suggest asserting on the method body properly (e.g., regex boundaries or token parsing) and verifying the recipient resolution logic (memberId → author, no fallback to creator). - Comment B (medium, test): the `assertStringNotContainsString('Readequação: reenvia ...')` is a comment check that doesn't prove parallel edits no longer reopen the queue; suggest asserting the actual guard behavior. Hmm, B is very similar to confirmed #3. I'll risk it but keep it distinct in wording, or maybe skip it. Let me consider the reviewer's perspective: They explicitly gave confirmed findings and asked to find "other real issues". If I report B, it's a different location, arguably fine. Let me also consider whether to comment on `assert_branch_ui_fixes.php` at all (since the confirmed #3 covers its new check). Perhaps there's another issue: line 376 check `fileContains($controller, 'notifyAprofundamentoAuthorOnReject')` is tautological (method is defined). Not worth. Alright, let me also reconsider: is the new unit test file's `assertStringNotContainsString('notifyOccurrenceRegistrantOnReject', $this->notificationSrc)` redundant with assert_branch_ui_fixes? Yes. Given the balance, I'll submit two comments on the new unit test file. Let me draft them in pt-BR with the required structure. Wait, actually I want to double-check one more potential *real* bug: In the unit test `testResubmitOnlyWhenAprofundamentoIsResent`, the assertion: ``` self::assertStringContainsString('maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval)', $this->controllerSrc); ``` This string exists exactly? Let me verify line 27947: yes `$this->maybeSubmitOccurrenceForValidation($event, $company, $user, $wasRejectedApproval);`. The assertion substring matches. Good. And `assertStringContainsString('stampSsmaAprofundamentoAuthor', $this->controllerSrc)` — yes. And `isSsmaOccurrenceInReadequacao` — yes. OK, no test failure. Let me draft the comments. Comment A location: in the test file, the line `$chunk = substr($src, $start, 1800);` — this is new added code. Good. Content: "O helper pega sempre 1800 caracteres a partir do `function`, o que pode invadir o método seguinte. Hoje, ao checar 'não contém getCreatedById()', a asserção passa de forma trivial: o arquivo inteiro do serviço não contém mais `getCreatedById()`, e a janela ainda inclui trechos de `notifyMetaAbonoRequester`. Ou seja, o teste não prova que `notifyAprofundamentoAuthorOnReject` resolve o destinatário pelo autor do aprofundamento — se a notificação voltar a ser para o registrante por outro caminho, o teste continua verde. Sugestão: delimitar o corpo até o próximo `function ` (ou usar token_get_all) e testar o vínculo real destinatário↔autor." Hmm, "se a notificação voltar a ser para o registrante por outro caminho, o teste continua verde" — that's true because the test only checks the specific method. OK, that's more of a "test doesn't cover behavior" point. Combined. Comment B location: `'Readequação: reenvia tanto na edição completa',`. Content: "Esta asserção checa a ausência de um comentário humano no controller, não o comportamento. Ela passa porque o comentário foi removido e voltaria a falhar/passar independentemente de a fila de validação ser reaberta na edição paralela. Para dar garantia real, valide a guarda `if ($wantsFinalize)` em torno da chamada de reenvio e a ausência de qualquer chamada de `maybeSubmitOccurrenceForValidation(..., true)` fora do caminho de reenvio do aprofundamento." Hmm, this is essentially confirmed #3 restated for the unit test. I'll include it but maybe mark severity low/medium. Actually, re-reading confirmed #3: it's for `assert_branch_ui_fixes.php` code `&& fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')`. The unit test has the identical anti-pattern. I'm fairly convinced a reviewer would consider it a duplicate concept. To be safe, I'll merge them? No, I can't comment on a different file. I'll include the unit test one as a separate comment since it's a different file — the tool mounts per file. Hmm, the guidance "Do not repeat them" — I'll include only the extractMethod finding, which is clearly distinct and concrete, plus perhaps mention the comment-check weakly. Actually, let me reconsider whether there's yet another distinct issue I've missed. Let me look at the full new test file once more... It's short. The `setUp` reads three files with `(string) file_get_contents` — if a file is missing, it throws a warning (PHP 8) converting to... Actually `file_get_contents` on a missing file emits a warning and returns false; the `(string)` cast gives ''. Then assertions fail. That's OK behavior. But in PHP 8, warnings aren't exceptions by default unless an error handler. Fine. `dirname(__DIR__, 4)` — could use `__DIR__ . '/../../../../'`. Fine. One more: The class docblock mentions "Gestor/tenant editam em paralelo sem reenviar a fila de validação" but the tests never assert the admin/tenant path. Weak coverage. Could combine into Comment A/B. I'll finalize with two comments. Let me pick severities: A = medium (test), B = low (test) or medium. The user rules say authorization/state test gaps are Critical. But since these are "test quality" issues already partly confirmed, I'll use medium. Let me write final comments. Ensure `existing_code` matches exactly lines in the diff (added lines only). For A: `+ $chunk = substr($src, $start, 1800);` → existing_code: " $chunk = substr($src, $start, 1800);" For B: `+ 'Readequação: reenvia tanto na edição completa',` → existing_code: " 'Readequação: reenvia tanto na edição completa'," Wait, in the diff, line 54 shows: ``` + self::assertStringNotContainsString( + 'Readequação: reenvia tanto na edição completa', + $this->controllerSrc + ); ``` Yes. Let me write both. Actually, hold on. Let me reconsider whether the extractMethod comment is warranted given the confirmed finding #1 (aprofundamento_finalized_by_* comment) and #2 already flag weak assertions. My A is about a different assertion and a concrete helper defect. I think it's fine. Let me also reconsider: should I report anything for assert_branch_ui_fixes.php beyond the confirmed #3? The diff added `$notificationService = $root . '/src/Service/Ssma/SsmaNotificationService.php';` and the check. Nothing new besides. So no. Final: two comments. Let me make the wording follow the format: first sentence plain, then why it matters, then what to do.
Analisei os dois arquivos de teste do grupo e o contexto de implementação para confirmar o comportamento referenciado. Os achados já confirmados (asserções atreladas a comentários e ausência de teste da regra de permissão) não serão repetidos. Seguem os pontos adicionais que encontrei:
Tool Calls (1)
code_comment
Show Details
{"comments": [{"path": "tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php", "content": "O helper de extração pega sempre 1800 caracteres a partir do `function`, então ele invade o método seguinte — hoje ele já engloba trecho de `notifyMetaAbonoRequester`. Com isso, a checagem de que a notificação não vai para quem cadastrou (`assertStringNotContainsString('getCreatedById()', ...)`) passa de forma trivial: o arquivo inteiro do serviço não contém mais `getCreatedById()`, e a janela ainda pode variar conforme métodos vizinhos. Na prática, o teste não prova que `notifyAprofundamentoAuthorOnReject` resolve o destinatário pelo autor do aprofundamento; se a notificação voltar a atingir o cadastrante por outro caminho (ou passar a usar outro campo), ele continua verde. Sugestão: delimitar o corpo até o próximo `function ` (ou usar `token_get_all`) e verificar de fato o vínculo destinatário↔autor (memberId autor presente, sem fallback para o criador).", "category": "test", "severity": "medium", "existing_code": "        $chunk = substr($src, $start, 1800);"}, {"path": "tests/Unit/Product/Ssma/SsmaOccurrenceReadequacaoAuthorTest.php", "content": "Esta asserção valida a ausência de um comentário humano no controller, não o comportamento. Ela passa apenas porque o comentário foi apagado e passaria/ falharia independentemente de a edição paralela voltar a reabrir a fila de validação — ou seja, não garante a regra que o nome do teste promete. Vale reforçar a cobertura verificando o guard real (`if ($wantsFinalize)` envolvendo a chamada de reenvio) e a inexistência de reenvio fora do caminho de reenvio do aprofundamento.", "category": "test", "severity": "medium", "existing_code": "            'Readequação: reenvia tanto na edição completa',"}]}
(async)
Request #26 deepseek-flash P:44.69K C:425 CR:44.54K CW:0 3208ms
Reasoning
I have given both files their pass. Summing up.
Revisão concluída. Resumo do que foi verificado nos arquivos do grupo: - **`SsmaOccurrenceReadequacaoAuthorTest.php`**: teste novo baseado em leitura estática dos fontes (`file_get_contents` + `str_contains`). Confirmei que ele é executado pela suíte (`phpunit.xml.dist` inclui `tests`) e que as strings referenciadas existem hoje nos fontes, então não há falha imediata. Além dos achados já confirmados, apontei (1) o helper `extractMethod` que fatia 1800 caracteres fixos e invade métodos vizinhos, tornando a checagem de destinatário da notificação uma garantia falsa, e (2) a asserção negativa baseada em comentário humano, que não prova o comportamento de reenvio. - **`assert_branch_ui_fixes.php`**: o diff apenas adiciona a variável `$notificationService` e um novo bloco de smoke (`notifyAprofundamentoAuthorOnReject` presente no controller/serviço, `notifyOccurrenceRegistrantOnReject` ausente). Os novos checks são coerentes com o restante do script; a fragilidade do check baseado em comentário nesse arquivo já consta nos achados confirmados, então não a repeti. Nenhum defeito funcional, de segurança ou de isolamento por empresa pôde ser atribuído ao código dentro de `<review_files>` — as observações relevantes de comportamento (destinatário da notificação, permissão de readequação) estão fora do grupo e já foram cobertas pelos achados confirmados.
Tool Calls (1)
task_done
Show Details
{"state": "DONE"}

review_filter_task

Request #1 deepseek-flash P:4.53K C:434 CR:1.79K CW:0 2653ms
Reasoning
Let me analyze. Comments c-0, c-1, c-2 target test files. They're about test quality — assertions relying on comments, etc. Are these Ground A or Ground B violations? c-0: claims the string `aprofundamento_finalized_by_*` only exists in a comment line in SsmaOccurrenceApprovalService. But that file is not in the diff! The diff only contains the two test files. The subject file is the test file, which does contain the assertion `self::assertStringContainsString('aprofundamento_finalized_by_*', $this->approvalSrc);`. So the comment's claim about the service file content is unverifiable from the diff. Also, the diff of the test file... In assert_branch_ui_fixes.php there's a check but not that string. Hmm. Actually the claim is about content of a file not in the diff. Not provable wrong. Also not a removal ground — the comment describes the assertion which IS present in the diff. Approve. Actually could it be Ground A? No — the assertion is in the subject file's diff. Approve. c-1: similar, about test coverage. Approve. c-2: about assert_branch_ui_fixes.php — the check `fileNotContains($controller, 'Readequação: reenvia tanto na edição completa')` is literally present in the diff (as added line). The comment says the check validates the absence of a human comment. That's plausibly true. Is that Ground B wrong? We can't see the controller. The comment's claim is not contradicted. Approve. So approve all. Wait, one nuance: comments are about test quality, low-value maybe, but not incorrect. Also there's a "value veto" — style? No, these are about test design. Approve all. None of these reach Ground A or B. Approve all.
Tool Calls (1)
approve_all_comments
Show Details
{}
Request #2 deepseek-flash P:4.19K C:480 CR:3.46K CW:0 2837ms
Reasoning
Let me analyze the comments. Comment c-0: About the extraction helper taking 1800 chars from `function`, so it invades the next method. It claims the check `assertStringNotContainsString('getCreatedById()', ...)` passes trivially because the whole service file no longer contains `getCreatedById()`. Is this contradicted by the diff? The diff doesn't show the service file content. We can't verify. The comment is about test methodology. Is it a protected subject? No. Is it style/readability? It's about test rigor. Ground A? The comment targets code present in the diff (the `substr($src, $start, 1800)` line). Actually it cites existing_code line `$chunk = substr($src, $start, 1800);` which is present. So Ground A doesn't apply. Ground B? We need a diff line that literally contradicts the central claim. The claim: the helper takes 1800 chars, invading the next method; and the whole service file no longer contains `getCreatedById()`. We can't see the service file, so we can't verify/contradict. So approve. Comment c-1: This asserts about `assertStringNotContainsString('Readequação: reenvia tanto na edição completa', $this->controllerSrc)`. The comment says this assertion validates the absence of a human comment in the controller, not behavior. Is this contradicted? The diff shows the test file contains that assertion. The comment's claim is that it's checking for the absence of a comment string. That seems true — 'Readequação: reenvia tanto na edição completa' looks like a code comment. So the comment's central claim appears accurate. Is it Ground A or B? The code is present in the diff. The claim doesn't contradict a diff line. So approve. Both comments look like valid critiques (test quality). Neither meets Ground A or Ground B. Approve all. Wait, let me double-check whether the comment c-1 is style/readability, which would trigger Step 2 approval anyway. Either way, approve. So answer: approve_all_comments.
Tool Calls (1)
approve_all_comments
Show Details
{}