# Research: Contracts Module (Contratos)

This is not a greenfield feature. `sistema/new/pages/contratos/contratos.php` and its full `includes/ajax/contrato/*` triad already implement a substantial working draft of the wizard, and `sistema/new/pages/produto/` already has the Classe/Categoria plumbing the spec assumes is new. Every section below states what was found in the live code/DB (this branch's local Docker MySQL, database `yebcrm_sistema`) and the decision that follows, not a hypothetical choice among frameworks.

## 1. Baseline: how much of the spec is already built

Confirmed via `sistema/new/includes/ajax/contrato/{create,update,cancel,read,form,formproduto,formvalores,divisao,periodicidade,tipo,entregavel,pacote,download}.php` and `includes/js/contratos.js`:

- **Already correct, no change needed**: the 4-page wizard shell (Produtos → Prazo → Pagamento → Assinatura), `Valores` calculation logic for Uniforme/Personalizado (FR-021–023), NF-issuance "Período" wording already correct (FR-024) with 1–30/1–22 ranges, reajuste "Personalizado" already reveals no extra fields (FR-025), `editar`/`editaradmin` modes already exist for Story 3's view→edit flow, Estender and Cancelar already perform true in-place `UPDATE`s, the "Contrato Assinado" → "Regularizado?" conditional reveal (FR-027) already works today (`includes/js/contratos.js:1050-1065`, toggling `.hidden`/`inputRequired` on the `assinado`/`regularizado` change handlers), and `CONTRATO_ALLOWED_FIELDS_RENOVAR` (`contratos.js:784`) already excludes `segmento-` from the client-side Renovar field allowlist — the frontend field-restriction for FR-033 is already half-built, only the backend (`update.php`'s `renovar` mode) needs to catch up.
- **Already built but with a real gap**: Renovar/Ampliar/Reduzir (see §2), Cancelar's justification requirement (see §3), no tenant/profile access control at all (see §4, §8), `segmento`/`insumo` are hardcoded mock arrays (see §5), no PDF/size validation on the contract upload (see §7), no Pacote Padrão → insumo pre-marking cascade exists (see §10 — the only trace is a comment at `contratos.js:169` referencing "the old pacotes→insumos cascade," past tense; no such cascade is currently wired to anything), no strategy exists yet for old contracts' segmento/insumo data surviving the SO5 cutover (see §11), and `create.php`/`update.php`'s catch blocks leak stack traces to the client on error (see §12).
- **Net new**: SO5 outbound integration (§5), Produto "Marca SO" link (§6), Classe combination-rule enforcement + rename + migration (§6), Admin/Gestão access rule (§8 — reuses existing data, not a new permission tier), structured change-history capture (§9), Pacote Padrão pre-marking cascade (§10), legacy segmento/insumo handling (§11).

## 2. Renovar/Ampliar/Reduzir: duplicate-row pattern → in-place UPDATE

**Finding**: `update.php`'s `renovar`/`ampliar`/`reduzir` modes call `duplicate($contrato, $sql)`, which `INSERT`s a new `Contrato新` row copying the old one's scalar columns, then deactivates the old row (`ativo=0`) and writes produtos/valores onto the **new** id. `estender` and `cancelar`, by contrast, `UPDATE` the same row. A real, deliberate guard already exists for ampliar/reduzir: `getContratoTotals($sql, $contratoId)` sums `ContratoProdutoClasse新` count and `ContratoValores新` (`nome='rstotal'`) value for a contract, and the code blocks the request if `ampliar` would decrease either total or `reduzir` would increase either (lines ~1196, ~1447).

**Conflict**: FR-031/CF-28 and User Stories 4/6 require the *same* contract record to be updated, never duplicated — confirmed as a deliberate, repeated business requirement, not an oversight, across all three source scope docs.

**Decision (reversed this session — supersedes the "switch to in-place UPDATE" decision made earlier during planning)**: Keep the existing `duplicate()`-based design as-is. `renovar`/`ampliar`/`reduzir` continue to `INSERT` a new `Contrato新` row, copy/overwrite it, and deactivate the old row (`ativo=0`); `getContratoTotals()` keeps comparing old-row vs newly-duplicated-row exactly as it does today. No code change to this mechanism as part of this feature.

**Alternatives considered**: Switching to in-place `UPDATE` (matching FR-031/CF-28's original wording literally) — this was the prior decision for this exact point, made and confirmed with the user during planning; superseded by this reversal. See version control history for that fuller design if it's needed again.

**Spec reconciled**: `spec.md` (FR-031, FR-033, FR-035, User Stories 4/6 and their Acceptance Scenarios, Edge Cases, Assumptions) was updated this session to describe the duplicate-row/deactivate-old-row design as the real behavior — including that a contract's "Número do Contrato" now changes on every Renovar/Ampliar/Reduzir. `data-model.md`, `contracts/contrato-crud.md`, `tasks.md` (T022/T027/T028), and `quickstart.md`'s Story 4/6 steps were all updated to match.

## 3. Cancelar: keep the required "motivo" field

**Finding**: `cancel.php` requires `motivo` (`if (empty($motivo)) inputError(...)` client-side in `contratos.js:1264-1267`, and the column `Contrato新.motivocancelamento` is written from it).

**Decision (reversed this session — supersedes the "drop the requirement" decision made earlier during planning)**: Keep `motivo` required, both client-side (`contratos.js:1264-1267`) and server-side (`cancel.php`'s `empty($motivo)` guard). No code change to this validation as part of this feature.

**Spec reconciled**: `spec.md` (FR-036, FR-037, User Story 7 and its Acceptance Scenarios, Edge Cases, Assumptions) was updated this session to require a justification for Cancelar, distinguishing it from an approval/alçada workflow (still not required, FR-037 unchanged). `data-model.md`, `contracts/contrato-crud.md`, `tasks.md` (T030), and `quickstart.md`'s Story 7 step were all updated to match.

## 4. Tenant isolation gaps in files this feature already has to touch

**Finding**: `Contrato新` has **no `grupo` column at all** — tenancy is indirect via `Contrato新.empresa → Empresa新.grupo`. `read.php` assigns `$grupo = $_SESSION["grupo"]` but never uses it in any query (list-mode and detail-mode both trust the caller's `empresa`/`id` param with no tenant check). `download.php` has **no tenant or auth-beyond-login check whatsoever** — any authenticated user can stream any contract's signed PDF by iterating `?contrato=N`.

**Decision**: Since this feature substantially rewrites `read.php` anyway (SO5-sourced fields, Classe rules, new listing columns) and `download.php`'s file gets touched for the PDF/size validation work (§7) and the new Admin/Gestão gate (§8), both get a `JOIN Empresa新 ON Empresa新.id = Contrato新.empresa AND Empresa新.grupo = ?` tenant check added as part of this same change, per Constitution Principle I (hard gate, no exceptions) and the "contributors fix anti-patterns in code they touch" workflow rule — not deferred as unrelated cleanup.

## 5. SO5 integration: no outbound-HTTP precedent exists

**Finding**: Searched the whole `sistema/new` tree for a shared outbound HTTP client — none exists. Every outbound call in this codebase is a one-off inline `curl_init()`/`curl_setopt_array()`/`curl_exec()` block (e.g. `includes/apis/get_cnpj.php`). `api/so.php` and `includes/ajax/so4/*.php` looked like promising precedent by name but are actually **inbound** — this CRM is the server, answering requests from an external "SO4" system (contract/company/user data lookups); they establish no outbound-call pattern at all. `segmento.php` and `insumo.php` today just `echo json_encode()` a hardcoded PHP array — no DB, no HTTP call.

**Decision**: Build one small outbound-call helper (`includes/funcoes/so5.php`, mirroring `get_cnpj.php`'s curl shape since that's the only existing precedent in this codebase, no Guzzle/composer dependency introduced) with two functions the two mock files get rewritten around: fetch segmentos-by-marca and fetch produto_insumo-by-marca, called with the produto's stored SO5 integration code (§6). Per the clarification already recorded in spec.md, a failed/unreachable call surfaces a scoped inline error and blocks only that produto_marca section, not the whole form (FR-029a).

**Confirmed against the live SO5 API during implementation** (SO5's backend is available locally — `so5-back-end-web-1`, `SO5_URL=http://localhost:8080` — and its source at `Documents/SO5/SO5-Back-End`, `routes/api.php`, was readable for verification): the endpoints are `api/brands`, `api/segments`, `api/products` (all POST-only). Auth is the `X-Internal-Secret` header checked against the existing `INTERNAL_API_SECRET` env var (already present in `sistema/funcoes/.env` — no new secret to provision, supersedes the originally-planned separate `SO5_TOKEN`). Each endpoint reads its request body as **raw JSON** (`json_decode(file_get_contents('php://input'))`), not form-encoded `$_POST` — `so5.php` must send `Content-Type: application/json` with a `json_encode()`'d body, confirmed via live testing (a form-urlencoded body silently fails to populate the filter and falls through to the endpoint's unfiltered branch). `api/segments`/`api/products` accept an optional `brand` key (brand id or name-slug) in that JSON body and filter server-side — confirmed live (18→8 segments, 842→146 products when filtering by brand id 1, with cross-brand items correctly absent).

Response shape is **not** a bare array — it's wrapped: `{"message": "...", "<brands|segments|products>": [...], "execution_time": "..."}`. Item fields are `id`/`name` (not `codigo`/`nome` as originally guessed); `segments`/`products` items additionally carry `description` and their own `brands: [{id, slug, name}]` sub-array. `so5.php` unwraps the envelope and normalizes each item to `{id, nome}` before returning, so downstream code (`formproduto.php` etc.) sees the same shape the old mock array already returned — no ripple past `so5.php` itself.

SO5 identifies brands/segments/produtos by plain integer id (confirmed live, not just assumed) — matches the existing `int` columns on `ContratoProdutoSegmento新.segmento` / `ContratoProdutoInsumoSegmento新.insumo` and `Produto新.so_brand`, no column type change needed. The `VARCHAR` widening contingency is no longer relevant.

## 6. Classe: already-real infrastructure, needs rules + a rename + cleanup, not a new table

**Finding (from live DB, grupo 1)**: `Classe新` is a **shared, grupo-scoped, Categoria-filtered** lookup table (`CategoriaClasse新` maps which `Classe新` rows are even selectable per `Categoria新`), not a table dedicated to this feature. Rows 1–3 (`Plataforma`, `Serviço Padrão`, `VHP`) are already exactly the three commercial classes the spec describes, already scoped to Categoria 1 ("Plataforma de Inteligência de Mercado") and Categoria 2 ("Soluções") — 12 produtos total. Rows 4–13 (`Top Banner`, `Banner lateral`, …, `Prata`) are an unrelated advertising-placement classification scoped to Categoria 3/4 ("Publicidade") — must not be touched by this feature's rename/rules. Categoria 5 ("Outros": Consultoria, Unykair, AbekoAir) has no `CategoriaClasse新` mapping at all — no classe concept applies there.

Within the 12 classe-eligible produtos, `ProdutoClasse新` today already has real, messy data (re-confirmed live during T009's implementation, correcting an earlier miscount): 4 produtos (`GlobalFert Agro`, `GlobalFert Industria`, `GlobalCropProtection`, `Price Index`) were linked to **all three** classes simultaneously, 1 produto (`GCP EUA`) was linked to just `Plataforma`+`VHP`, and 7 had **no** commercial classe link at all (5 with none, plus 2 — `Steel Economics`, `GlobalMed` — linked only to an unrelated Publicidade classe id, 13/12 respectively, correctly left untouched). `Produto新.classe` assignment has zero validation (`produto/create.php`/`update.php` insert whatever `classe[]` array is posted) — **and correctly so, see below**.

**Corrected understanding (this session, after live testing surfaced the mistake)**: `ProdutoClasse新` (Cadastro > Produto) configures which Classe values are **available options** for a produto_marca — a produto can legitimately have all three linked at once, since that just means a salesperson can choose any of them per contract. The mutual-exclusivity/dependency rule (FR-004) applies to which of those options get **selected within a specific contract's produto_marca section** (`ContratoProdutoClasse新`, via `classe-{produto}` on `contrato/create.php`/`update.php`), not to how the produto itself is configured. An earlier pass of this implementation got this backwards — added the FR-004 check to `produto/create.php`/`update.php` and had T009's migration "resolve" the 5 conflicting produtos by deleting their `VHP` link — both reverted: the validation moved to `contrato/create.php`/`update.php` (scoped per produto, when that produto's submitted `classe-{produto}` includes any of ids 1/2/3), and a follow-up correction migration (`migrations/sql/produto_classe_vhp_correction.sql`) re-added `VHP` to the 5 produtos it was wrongly removed from. FR-003/FR-004/FR-016 in spec.md were reworded to match.

**Decisions**:
- **Rename** (FR-005): `UPDATE Classe新 SET nome = 'Plataforma + Serviço Padrão' WHERE id = 2 AND grupo = 1` — targeted by id+grupo, not a name match, so it can never touch an unrelated grupo's data. **Reverted post-launch** (`migrations/sql/produto_classe_nome_reversao.sql`, same id+grupo targeting): the classification is displayed as "Serviço Padrão" again; FR-005 is withdrawn. FR-004's dependency-on-Plataforma rule was never tied to the label — it's keyed off the classe ids in `contrato/create.php`/`contrato/update.php` — so it's unaffected by either the rename or the revert.
- **Combination-rule validation** (FR-004): add the check to `contrato/create.php` and `contrato/update.php` (`editar`'s classe fields are locked/unchanged so it doesn't need it; `editaradmin` does, as will `renovar`/`ampliar`/`reduzir` once those phases add their own classe handling) — reject a produto's submitted `classe-{produto}` if it contains both `Plataforma`(1)/`VHP`(3), or `Serviço Padrão`(2) without `Plataforma`(1). No corresponding check on the produto config side (`produto/create.php`/`update.php`) — a produto can hold any subset of the three as available options.
- **Migration scope** (FR-038, SC-006): "every previously existing produto_marca" is scoped to the 12 produtos in Categoria 1/2 — the only ones structurally capable of holding these three classes today (Publicidade/Outros produtos have no `CategoriaClasse新` entry for ids 1–3 and are correctly out of this migration's scope). The originally-planned "resolve conflicts by dropping VHP" step was removed per the correction above — produtos keep whatever classes they already had, conflicts included, since those aren't conflicts at the produto-configuration level. For the 7 unclassified produtos, default to `VHP` as an available option — the simplest single-tag baseline, reviewable and correctable later per-product without a second migration (kept as-is; still makes sense under the corrected model, since an unclassified produto has zero selectable options in a contract otherwise).

## 7. PDF/10MB upload validation: copy the existing pattern, it's not new

**Finding**: `includes/ajax/interacoes/create.php` already validates `$allowedTypes=['application/pdf']` and `$maxSize = 10*1024*1024` against `$_FILES['arquivo']`. `contrato/create.php`/`update.php` accept `arquivo` today with only an `UPLOAD_ERR_OK` check — no type/size validation at all.

**Decision**: Copy the same check into `contrato/create.php` and every `update.php` mode that accepts `arquivo` (`editar`, `editaradmin`, `renovar`). No new dependency, no schema change — `Contrato新.arquivo` stays a `mediumblob`, matching every other file-upload column in this codebase today (the drafted-but-unapplied S3 migration in `local/S3_FILE_STORAGE_PLAN.md`/`contratos_s3_schema.sql` is a separate, not-yet-implemented initiative on another branch; this feature does not depend on it and does not block it — `arquivo_s3_key`/`name_arquivo` already exist as unused nullable columns on the live schema and are left untouched).

## 8. Access control: "Gestão" is an existing Área, not a new Perfil tier

**Correction (caught during `/speckit-analyze` + user clarification)**: An earlier draft of this research proposed adding "Gestão" as a new `Perfil新` row. That was wrong on two counts: (a) `Perfil新` has **no `grupo` column at all** (confirmed schema: `id, nome` only — the earlier draft's "grupo-scoped insert" instruction would have failed with a SQL error), and (b) more fundamentally, per the user: **"Gestão" refers to a user with Perfil `Padrão` and área `Gestão`**, not a new Perfil value. The real mechanism already exists in the data.

**Finding**: `sistema/new`'s access-control convention is `$_SESSION['perfil']`, an int FK into `Perfil新` (`1 = Padrão`, `2 = Administrador`), already compared directly (`$_SESSION['perfil'] == 2`) in `cadastro/load.php`, `pipeline/read.php`, `pipeline.php`, `indicadores.php`. Separately, `AreaComercial新` (`id, grupo, nome`) already has a row `id=3, grupo=1, nome='Gestão'` — alongside `1 Vendas`, `2 Comunicação`, `4 Atendimento` — and `Cargo新` (the job-title catalog `Usuarios.cargo` points into) already has an `area` column FK'd to `AreaComercial新.id`. So a user's área is reached transitively: `Usuarios.cargo → Cargo新.id → Cargo新.area → AreaComercial新.id`. No `área` value is cached in session — only `perfil`, `acesso`, `grupo`, `user_id` are (`ajaxheader.php`). Confirmed against live data: user `teste3` (`cargo=2` → `Cargo新` "Diretor Financeiro" → `area=3`) already resolves to the Gestão área under this path.

**Decision**: No schema change and no new `Perfil新` row — "Gestão" already exists as `AreaComercial新` id 3 (grupo 1). Add one small helper, `usuario_pode_gerir_contratos($sql)`, to `includes/funcoes/funcoes.php` (already included by every ajax file via `ajaxheader.php`, so no new include wiring needed) implementing exactly: `$_SESSION['perfil'] == 2` (Administrador) **OR** (`$_SESSION['perfil'] == 1` AND the logged-in user's `Cargo新.area == 3`, resolved via a small `JOIN Usuarios → Cargo新` query using `$_SESSION['user_id']`). A single shared helper is used here — rather than this codebase's usual bare-literal-per-file convention — because the check requires an actual JOIN query, not a one-line comparison; duplicating that query across 6+ files would be the real premature-avoidance-of-abstraction mistake. Gate every contracts-module page/ajax entry point with `if (!usuario_pode_gerir_contratos($sql)) { http_response_code(403); die(); }`. No migration needed — Gestão-área users who should get contract access are assigned by an Admin via the existing Cargo/Usuario management screens, same as any other área assignment.

## 9. History (FR-032/CF-29): extend the existing `Logs新` mechanism, no new table

**Finding**: `Logs新` (`data, tipo, objeto, alvo, responsavel, observacao`) is already the standing, entity-agnostic audit mechanism (Constitution-adjacent convention already used for Produto and every current Contrato action). Today's `logging()` calls for Renovar/Ampliar/Reduzir pass only a lineage string (`"Renovado de {$id}"`) in `observacao`; Editar/Editar Admin/Estender pass an empty string — none capture *which fields changed*, which FR-032/CF-29 require.

**Decision**: Keep `Logs新` as the mechanism (no new `ContratoHistorico新` table — would duplicate an existing, already-wired generic mechanism for no added capability). For Estender and Cancelar (same-row `UPDATE`, per §2/§3), diff the row's values before and after applying the change. For Renovar/Ampliar/Reduzir (still a `duplicate()`-based new row, per §2's reversal), diff the old row's values against the new row's values right after it's created and the old one deactivated. Either way, pass a JSON-encoded `{field: {old, new}}` map for the changed fields as `observacao`. `objeto="Contrato"`, `alvo=$id` stay as-is; `tipo` keeps the existing per-action strings (`"Renovar"`, `"Estender"`, `"Ampliar"`, `"Reduzir"`, `"Cancelar"`). The contract's "view history" UI (new, since none exists today) reads `Logs新 WHERE objeto='Contrato' AND alvo=? ORDER BY data`, parses `observacao` as JSON when present, and falls back to displaying it as plain text for pre-existing log rows written before this change (no backfill needed — old entries just show less detail, they don't break).

## 10. Pacote Padrão → insumo pre-marking cascade: not built (caught during `/speckit-analyze`)

**Finding**: FR-014/CF-11/CF-12 (selecting a Pacote Padrão pre-marks its insumos under the first segmento shown; changing the pacote resets those marks) has no live implementation. The only trace in `contratos.js` is a comment at line 169 referencing "the old pacotes→insumos cascade" in past tense, guarding an unrelated restore-from-existing-contract race condition — there is no function that reacts to a pacote selection by marking `insumoMatrix` entries. `pacote.php` itself (hardcoded, manually-curated data per produto) is unaffected and needs no change.

**Decision**: Add a pacote-selection handler in `contratos.js` that, on a `pacotes-{produto}` change event, looks up the newly-selected pacote(s)' insumo lists (from the same `pacote.php` payload already loaded for the dropdown) and marks the corresponding entries in `insumoMatrix[produto]` under the first currently-selected segmento, mirroring the existing `insumoMatrix`/`selectedSegmentos` bookkeeping pattern already used for the segmento-add cascade (lines ~150-204). On pacote change (not just initial selection), clear the previous pacote's marks before applying the new one's, per FR-014's "changing the pacote MUST reset those markings" wording.

## 11. Old contracts' segmento/insumo after the SO5 cutover (caught during `/speckit-analyze`)

**Finding**: FR-040/SC-006 ("contracts registered before the migration remain viewable and editable... without breaking the record") had no owner anywhere in this plan. Once `segmento.php`/`insumo.php` (§5) stop serving the hardcoded mock arrays and instead call live SO5, an existing `Contrato新`'s `ContratoProdutoSegmento新.segmento`/`ContratoProdutoInsumoSegmento新.insumo` values — stored as the old mock's arbitrary sequential ids (1–17 for segmento, 1–110 for insumo) — have no guaranteed correspondence to SO5's real codes. There is no reliable automatic mapping between "the old mock's id 3 happened to mean 'Floresta'" and whatever SO5 actually calls marca X's third segmento.

**Decision**: Per the spec's own edge case, "mapped automatically" and "flagged for manual review" are both acceptable — automatic mapping isn't trustworthy here (no real correspondence exists), so this uses the flag-for-review path, reusing the same degrade-gracefully pattern already decided for "SO5 temporarily unavailable" (Edge Cases, FR-029a) rather than inventing a new UX: when rendering an existing contract's produto_marca section (`formproduto.php`), for each stored segmento/insumo id, check whether it appears in that produto's *current* live SO5 list. Any that don't are rendered with a "dado legado — não reconhecido pela integração atual" flag instead of a resolved name — visible, editable/removable, not silently hidden and not blocking the rest of the page. This is a distinct failure mode from "SO5 unreachable" (SO5 answers fine, it just doesn't recognize this specific old id) and needs its own inline treatment, separate from FR-029a's handling.

## 12. `create.php`/`update.php` catch-block leaks: reuse the `fix/general-backlog` pattern (caught during `/speckit-analyze`)

**Finding**: `create.php`/`update.php`'s outer `catch (Throwable $e)` blocks currently `echo $e->getMessage(); echo '<pre>'; print_r($e->getTrace()); echo '</pre>';` on failure — a live Constitution Principle VII violation, and one this feature's own new code (T016, T017, T022, T025, T027, T028 all add logic inside that same try/catch scope) would trigger just as easily as any pre-existing code. Per the user: the `fix/general-backlog` branch already has work in flight on this exact problem (commit `68f1ad6`, "fix: error handling padronization part 1") — it adds a global `set_exception_handler()`/`register_shutdown_function()` pair to `ajaxheader.php` that logs via `error_log()` and responds with `{"success": false, "message": "Erro inesperado. Tente novamente.", "data": null}`. That global handler only catches *uncaught* exceptions, though — it doesn't touch `create.php`/`update.php`'s own local `catch`, since a locally-caught exception never reaches it.

**Decision**: Change `create.php`/`update.php`'s own catch blocks (touched anyway by this feature) to emit the exact same shape `fix/general-backlog` uses for the global handler — `error_log($e); http_response_code(500); echo json_encode(['success' => false, 'message' => 'Erro inesperado. Tente novamente.', 'data' => null]);` — instead of `echo`/`print_r`. Matching the shape now (rather than inventing a different safe format) means this feature and `fix/general-backlog` converge on the same pattern instead of producing two different "safe" error shapes that would conflict at merge time.

## 13. "Número do Contrato" — confirmed as the existing `id`, no new column

**Finding**: No `numero`-style column exists on `Contrato新`, and the current create form has no such input field. `read.php`'s list mode already returns `id` as the row identity.

**Decision**: "Número do Contrato" in the list (FR-006) displays `Contrato新.id` directly — no schema change, no generator logic needed.
