Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'

git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
This commit is contained in:
gti
2026-09-29 14:43:23 -03:00
868 changed files with 566394 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
# Hub + local dev-only Firebird settings. Copy to .env locally; never commit
# credentials or database URLs (this file is a template, .env is gitignored).
#
# For a real client Agent installation, use agent/.env.example instead — this
# file's FIREBIRD_* values are for the disposable dev/test database only.
FIREBIRD_DATABASE_PATH=
# Default MUST be 0. Only ever set to 1 against the authorized disposable
# test database (see CLAUDE.md "Firebird write policy"); the agent fails
# closed if the configured path is not exactly that file.
FIREBIRD_WRITES=0
# --- Hub / PostgreSQL ---
# DATABASE_URL takes precedence when set. If you override POSTGRES_DB/POSTGRES_USER
# below, also set DATABASE_URL explicitly to match — the fallback URL built from
# POSTGRES_PASSWORD alone assumes the gti/gti_fiscal defaults.
DATABASE_URL=postgresql+psycopg://gti:development-only@localhost:5432/gti_fiscal
POSTGRES_DB=gti_fiscal
POSTGRES_USER=gti
POSTGRES_PASSWORD=
HUB_PORT=8000
BASE_URL=http://localhost:8000
APP_ENV=development
LOG_LEVEL=INFO
AGENT_ONLINE_THRESHOLD_SECONDS=120
# Local-dev-only agent bootstrap. Leave unset in any shared/pilot environment —
# real Agents are created with central/scripts/register_agent.py.
AGENT_DEV_TOKEN=
NCM_DATASET_URL=https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json
CEST_DATASET_URL=https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18
DATASET_STORAGE_PATH=./data/datasets
HTTP_TIMEOUT_SECONDS=30
# Company context (Agent). Leave empty when C000004 has one row and CRT sources agree.
# COMPANY_CODE= # C000004.CODIGO of the active company (when C000004 has several rows and EMISSOR cannot disambiguate)
# COMPANY_CNPJ= # alternative selector (digits only); a stable, verifiable identifier
# COMPANY_CRT_SOURCE= # EMISSOR | C000004 — required when the two CRT values disagree (emission programs read EMISSOR)
# Z.ai (OpenAI-compatible). NEVER commit the real key. Key lives only in .env
AI_API_KEY=
AI_BASE_URL=https://api.z.ai/api/paas/v4
AI_MODEL=glm-5.3
# Optional prices (USD per 1M tokens) - only set when confirmed with the provider
AI_PRICE_IN_PER_M=
AI_PRICE_OUT_PER_M=
+6
View File
@@ -0,0 +1,6 @@
# Shell scripts run on the Linux Hub host. With core.autocrlf=true on the
# Windows release machine, `git archive`/checkout would otherwise ship them
# with CRLF and `sh` rejects them ("set: Illegal option -").
*.sh text eol=lf
# systemd units are parsed on the same host; a trailing \r corrupts ExecStart.
*.service text eol=lf
+59
View File
@@ -0,0 +1,59 @@
*.FDB
*.FDB-journal
*.FDB.lck
.env
__pycache__/
.pytest_cache/
.superpowers/
agent/*.sqlite
agent/logs/
agent/*.log
dist/
build/
# --- release closure: secrets, runtime state, local configs, temporary artifacts ---
.env.*
!.env.example
*.env
!*.env.example
*.secret
secrets/
credentials*
*.pem
*.key
*.pfx
*.log
logs/
*.sqlite
*.sqlite3
*.db
*.dump
*.zip
Banco/
bancos_teste/
.playwright-mcp/
.superpowers/
deploy/hub/backups/
deploy/hub/.env
docs/runs/multi_db/_*
scripts/_*
.worktrees/
*.tmp
*.bak
*.gz
*.pkl
.pytmp/
sources_cache/legal/
scratch_rtc_e2e/
scratch_sandbox/
# Missão 5: raw official dataset downloads for the Reference Data Registry stay off git -
# only the manifest (URL, date, hash, size) is committed. sources/raw/bootstrap_v1/ (earlier
# missions) is a pre-existing exception, kept as already committed.
sources/raw/reference_registry/
# Missão 9: per-installation Agent env files hold real AGENT_TOKEN secrets, plus local SQLite
# state and logs - never commit any of it. Only the *_original.sha256.txt manifests under
# work_copies/ (proof the real-client originals were never touched) are tracked.
work_copies/agent_envs/
work_copies/*.log
scratch_enrich/
@@ -0,0 +1,139 @@
# Task 1 Report
## Status
Implemented the read-only discovery foundation. The FDB database was not opened,
inspected, or modified.
## Files Changed
- `src/gti_discovery/__init__.py`: exports the read-only public interface.
- `src/gti_discovery/read_only.py`: validates one `SELECT`/`WITH` statement and
exposes `ReadOnlyConnection.execute` without connection write methods.
- `tests/test_read_only.py`: covers accepted queries, normalization, prohibited
statements, comments, multiple statements, quoted literals, and delegation.
- `pyproject.toml`: defines the package and `src`-layout pytest configuration.
- `.env.example`: documents an empty database-path placeholder without secrets.
- `CLAUDE.md`: records the strict read-only project constraints and test command.
## Tests
Command:
```text
python -m pytest tests/test_read_only.py -q
```
Result: `18 passed in 0.04s`.
`git diff --check` also completed without whitespace errors.
## Concerns
- Commit was not created because Git has no configured author identity (`Author
identity unknown`). Git configuration was not changed.
- Pytest emits an existing `pytest-asyncio` deprecation warning because its event
loop fixture scope is not configured; the focused tests still pass.
- The validator intentionally rejects all SQL comments and any prohibited keyword
outside quoted values. It does not attempt to parse the full Firebird grammar.
- The wrapper expects the injected driver connection to provide `execute(sql,
params)`; no Firebird driver is installed or required for this offline task.
## Reviewer Fixes
- Replaced the retained raw driver connection with a narrow execute-only
delegate and added coverage proving `_connection`, `commit`, and `rollback`
are unavailable on `ReadOnlyConnection`.
- Added rejection for selectable procedure calls, `GEN_ID`, generator value
expressions, `WITH LOCK`, and `FOR UPDATE`. The wrapper cannot enforce the
driver's transaction/read-only mode, so deployment must use a read-only
database account or driver-level setting.
- Replaced global `split`/`join` normalization with whitespace collapsing only
outside quoted literals; quoted contents are preserved byte-for-byte.
- Added regression tests for side-effecting SELECT constructs and literal
preservation.
## Fix Verification
Exact command:
```text
python -m pytest tests -q
```
Exact output:
```text
C:\Users\Gustavo\AppData\Local\Programs\Python\Python312\Lib\site-packages\pytest_asyncio\plugin.py:299: PytestDeprecationWarning: The configuration option "asyncio_default_fixture_loop_scope" is unset.
The event loop scope for asynchronous fixtures will default to the "fixture" caching scope. Future versions of pytest-asyncio will default the fixture scope to "function". Set the default fixture loop scope explicitly to avoid unexpected behavior.
warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET))
....................... [100%]
23 passed in 0.06s
```
## Re-review Fixes
- Replaced the instance-bound driver delegate with an opaque-token lookup in a
module-private registry. `ReadOnlyConnection` stores only the token; its
bound `execute` method has the wrapper as `__self__` and no closure.
- Added a regression test that checks `__self__`, `__closure__`, wrapper slots,
and reachable slot values cannot expose the raw driver or `commit`.
- Added conservative rejection of unknown and qualified function calls, while
allowing a small deterministic-function allowlist. This covers selectable
procedures, `GEN_ID`, UDF/external-function style calls, generator values,
locking, and update forms. True read-only enforcement still requires a
database read-only account or read-only transaction/driver configuration.
## Re-review Verification
Exact command:
```text
python -m pytest tests -q
```
Exact output:
```text
C:\Users\Gustavo\AppData\Local\Programs\Python\Python312\Lib\site-packages\pytest_asyncio\plugin.py:299: PytestDeprecationWarning: The configuration option "asyncio_default_fixture_loop_scope" is unset.
The event loop scope for asynchronous fixtures will default to the "fixture" caching scope. Future versions of pytest-asyncio will default the fixture scope to "function". Set the default fixture loop scope explicitly to avoid unexpected behavior.
warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET))
.......................... [100%]
26 passed in 0.07s
```
## Third Fix Round
- Removed the module-level bound-method registry. `ReadOnlyConnection` now
accepts only a narrow adapter-supplied execute callable and exposes no driver
or connection attributes. Python process-level introspection is explicitly
outside this guarantee.
- Rejected all function-like SQL calls except a small deterministic-function
allowlist, including qualified names with arbitrary whitespace around dots.
Selectable procedures in `FROM`, generator calls, `GEN_ID`, `EXECUTE`, UDF,
and external-function-style calls are rejected conservatively.
- Documented that actual read-only enforcement requires a Firebird read-only
account or read-only transaction/driver configuration.
- Added regression coverage for spaced qualified calls and the practical public
API boundary.
## Third Fix Verification
Exact command:
```text
python -m pytest tests -q
```
Exact output:
```text
C:\Users\Gustavo\AppData\Local\Programs\Python\Python312\Lib\site-packages\pytest_asyncio\plugin.py:299: PytestDeprecationWarning: The configuration option "asyncio_default_fixture_loop_scope" is unset.
The event loop scope for asynchronous fixtures will default to the "fixture" caching scope. Future versions of pytest-asyncio will default the fixture scope to "function". Set the default fixture loop scope explicitly to avoid unexpected behavior.
warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET))
................................... [100%]
35 passed in 0.09s
```
@@ -0,0 +1,21 @@
# Final Fix Report — Control Panel Operational UX V1
Base: `c237820`
## Changes
- Legacy blockers without an exact installation binding remain fail-closed. Their detail page explains in Portuguese why resolution is unavailable and omits the resolution form. Direct HTML and JSON attempts return HTTP 409; missing blockers remain 404. Legacy details do not load context by guessing a company identity.
- Resolution rejects derived `company_key` values longer than the database limit of 64 characters before creating context, job, or audit rows. No truncation or identity aliasing is performed.
- Validation-error re-renders preserve the submitted key, value, scope, scope key, and reason, and reload current persisted context for a bound blocker.
- Added regression coverage in `central/tests/test_blocker_resolution_panel.py` and `central/tests/test_control_panel_pages.py`.
## Verification
- TDD RED: focused tests initially failed on the missing company-key validation, unavailable-blocker response/UI, and form/context recovery behavior.
- Focused: `python -m pytest central/tests/test_blocker_resolution_panel.py central/tests/test_control_panel_pages.py -q` — **26 passed**.
- Full central suite: `python -m pytest central/tests -q` — **284 passed, 1 warning** (existing `openpyxl` print-area warning in `test_bootstrap_v1_extra.py`).
- `git diff --check` — passed.
## Concerns
- Test output includes the existing `pytest-asyncio` configuration deprecation notice and one unrelated `openpyxl` warning; neither caused test failures.
@@ -0,0 +1,101 @@
# Task 1 Report: Presentation Helpers And Password Policy
## Status
Implemented Task 1 only. The existing server-rendered architecture and fiscal behavior were not changed.
## Changed Files
- `central/app/panel_presentation.py`: added timezone-aware UI datetime, age, duration, operational priority, and health-label helpers.
- `central/app/config.py`: added `ui_timezone` (default `America/Bahia`) and `allow_weak_dev_password` settings.
- `central/app/human_auth.py`: restored the 12-character minimum unless the process is non-production and `ALLOW_WEAK_DEV_PASSWORD=1`; the flag is read at call time.
- `central/tests/test_panel_presentation.py`: added helper contract tests.
- `central/tests/test_control_plane.py`: added explicit opt-in and production password-policy tests.
- `docker-compose.yml`: explicitly enables weak development passwords for the local `fiscal-hub` service.
## RED Evidence
Command:
```text
python -m pytest central/tests/test_panel_presentation.py central/tests/test_control_plane.py -q
```
Result: collection failed with `ModuleNotFoundError: No module named 'central.app.panel_presentation'`, proving the new helper module was absent.
The control-plane tests were then run independently:
```text
python -m pytest central/tests/test_control_plane.py -q
```
Result: `2 failed, 3 passed`. Both new password-policy tests failed because the pre-change implementation accepted short passwords without the required environment gate.
## GREEN Evidence
Focused command:
```text
python -m pytest central/tests/test_panel_presentation.py central/tests/test_control_plane.py -q
```
Result: `10 passed`.
Focused command including configuration coverage:
```text
python -m pytest central/tests/test_panel_presentation.py central/tests/test_control_plane.py central/tests/test_config.py -q
```
Result: `11 passed`.
Full central regression:
```text
python -m pytest central/tests -q
```
Result: `241 passed, 1 warning`. The warning is the existing `openpyxl` print-area warning from `central/tests/test_bootstrap_v1_extra.py`.
`git diff --check` and the staged diff check completed without whitespace errors.
## Commit
Implementation commit: `0846b9d1b076c60c57ad2a30a3071480a680bccf` (`feat: add operational panel presentation helpers`).
## Concerns
- The existing pytest environment emits one non-failing `pytest-asyncio` deprecation warning; the full central suite also emits the pre-existing `openpyxl` warning noted above.
- `operational_priority` intentionally uses only operational blocker type, field, and reason signals; it does not infer fiscal severity.
## Reviewed Finding Fix
Finding: `create_admin` used the import-time `settings.allow_weak_dev_password` value as the default for the call-time environment lookup. If the flag was enabled during import and later removed, short passwords remained accepted.
Fix: `ALLOW_WEAK_DEV_PASSWORD` now defaults to `"0"` at every call. The existing `APP_ENV != production` check remains enforced, and Docker Compose still explicitly sets `ALLOW_WEAK_DEV_PASSWORD: "1"` for the local `fiscal-hub` service.
Regression RED command:
```text
python -m pytest central/tests/test_control_plane.py::test_removed_weak_password_flag_is_fail_closed_after_import -q
```
Result before the fix: `1 failed`; after reloading configuration/auth with the flag enabled, removing the flag did not raise `ValueError` for a short password.
Regression GREEN command:
```text
python -m pytest central/tests/test_panel_presentation.py central/tests/test_control_plane.py -q
```
Result: `11 passed`.
Full central regression:
```text
python -m pytest central/tests -q
```
Result: `242 passed, 1 warning`; the warning is the existing `openpyxl` print-area warning.
Fix commit: `de571e3ac2ce214677e1f9baa673181350b7d411` (`fix: fail closed weak password opt-in`).
@@ -0,0 +1,193 @@
# Task 2 Report: Bounded Operational Query View-Models
## Status
Implemented Task 2 only. The control-plane query layer now supplies bounded operational view-models while preserving the existing JSON keys and technical health derivation. No fiscal logic, database schema, Firebird access, or remote write capability was changed.
## RED Evidence
Added `central/tests/test_operational_view_models.py` before changing production code.
Command:
```text
python -m pytest central/tests/test_operational_view_models.py -q
```
Result before implementation: `6 failed`.
The failures were the expected missing-contract failures: `dashboard_summary` did not accept `now`, `list_installations` did not accept `sort`, `list_jobs` did not accept `client`, `list_blockers` did not accept `client`, and `blocker_detail` did not exist. This confirmed the tests exercised the new behavior rather than existing behavior.
## GREEN Evidence
Focused Task 2 tests:
```text
python -m pytest central/tests/test_operational_view_models.py -q
```
Result: `6 passed`.
Focused compatibility tests:
```text
python -m pytest central/tests/test_operational_view_models.py central/tests/test_control_plane.py central/tests/test_jobs.py -q
```
Result: `21 passed`.
Central regression suite:
```text
python -m pytest central/tests -q
```
Result: `248 passed, 1 warning`.
The warning is the pre-existing `openpyxl` print-area warning from `central/tests/test_bootstrap_v1_extra.py`. The pytest environment also emits the existing `pytest-asyncio` fixture-loop-scope deprecation warning.
Additional verification:
```text
git diff --check
```
Result: no whitespace errors. Git reported only its existing LF/CRLF normalization notice for the edited Python files.
## Changed Files
- `central/app/control_plane.py`: added bounded dashboard aggregates and attention items; enriched installation, job, and blocker view-models; added filters, stable pagination, worst-coverage sorting, presentation fields, and bounded blocker detail relations.
- `central/tests/test_operational_view_models.py`: added focused TDD coverage for health reconciliation, distinct safe-blocked products, attention ordering/cap, fiscal/technical separation, job identity/duration, blocker filters/priorities, and detail relations.
## Contract Notes
- Dashboard health counts preserve lowercase compatibility keys and add uppercase `health_counts`.
- `safe_blocked_products` counts distinct active `(tenant_id, product_code)` pairs; `open_blockers` counts active rows.
- Safe fiscal blockers do not alter `technical_health`.
- API timestamps remain ISO UTC values; presentation fields use Task 1 helpers.
- Collection queries use joins/aggregates. Blocker detail related events, jobs, and audit rows are bounded and bulk-loaded.
- Existing `resolved` behavior remains supported while `status` adds explicit `OPEN`, `RESOLVED`, and `ALL` handling.
## Commit
Implementation commit: `d729710` (`feat: add bounded operational view models`).
## Concerns
- `BlockedProduct` has no persisted installation foreign key. Blocker list/detail identity therefore uses the lowest-ID installation for the tenant as the representative context; agent and installation filters check tenant installation membership without duplicating blocker rows.
- The existing non-failing pytest environment warnings remain as noted above.
## Task 2 Re-Review Fix Report
### Findings Fixed
- Unified fiscal completion rounding with deterministic one-decimal `ROUND_HALF_UP` behavior in Python and SQL. The SQL ratio is explicitly numeric before two-argument `ROUND`, preserving SQLite/PostgreSQL compatibility.
- Extended `blocker_fingerprint` with an optional installation identifier. Legacy calls without an installation retain the original hash; authenticated upserts scope both generated and supplied fingerprints to the exact installation, so identical reports from two installations remain distinct.
- Normalized blocker attention candidate fields with SQL `UPPER`, matching the normalized breakdown behavior for lowercase `cfop`, `st`, and other required fields.
### Re-Review RED Evidence
Command after adding the three regression tests:
```text
python -m pytest central/tests/test_operational_view_models.py::test_worst_coverage_sort_cannot_disagree_with_one_decimal_display central/tests/test_operational_view_models.py::test_lowercase_required_blocker_field_reaches_attention_candidates central/tests/test_blocked_products.py::test_authenticated_installations_get_distinct_fingerprints_for_identical_blockers -q
```
Result before implementation: `3 failed`.
The failures reproduced the SQL/Python 6.3 versus 6.2 ordering mismatch, omitted lowercase blocker attention, and deduplication of identical blocker reports across installations.
### Re-Review GREEN Evidence
Focused Task 2/control/blocker tests:
```text
python -m pytest central/tests/test_operational_view_models.py central/tests/test_control_plane.py central/tests/test_jobs.py central/tests/test_blocked_products.py central/tests/test_operational_state.py -q
```
Result: `37 passed`.
Full central suite:
```text
python -m pytest central/tests -q
```
Result: `256 passed, 1 warning`.
The warning remains the pre-existing `openpyxl` print-area warning; the environment also emits the existing `pytest-asyncio` fixture-loop-scope deprecation warning.
PostgreSQL SQL compatibility check:
```text
python -c "from sqlalchemy import select; from sqlalchemy.dialects.postgresql import dialect; from central.app.control_plane import _fiscal_completion_expression; print(select(_fiscal_completion_expression()).compile(dialect=dialect()))"
```
Result: the expression compiles as numeric SQL using two-argument `ROUND`.
### Re-Review Fix Commit
Implementation commit: `2ee271c` (`fix: close remaining task 2 review findings`).
### Re-Review Concerns
- Legacy no-installation payloads intentionally retain their original fingerprint and tenant-level behavior; authenticated installation-scoped payloads use the new namespace.
- The existing non-failing pytest environment warnings remain as noted above.
## Task 2 Review Fix Report
### Findings Fixed
- Added nullable `blocked_products.installation_id` with a foreign key to `installations.id` and index in Alembic revision `0018_blocker_installation_identity`; updated the SQLAlchemy model.
- Updated blocker upsert and authenticated job completion to persist the authenticated Agent installation. Agent-facing blocker list/reprocess/export paths and control-panel agent detail routes now use exact installation ownership. Legacy null-owned rows remain tenant-level and render without fabricated server context.
- Replaced dashboard installation/blocker materialization with SQL health counts, snapshot totals, open-row count, distinct tenant/product count, and blocker-field breakdown. Health, blocker, and version attention candidates are individually bounded before Python merge/truncation.
- Replaced Python health filtering/slicing with a portable SQL `CASE` expression using the supplied `now`, failure/job fields, heartbeat cutoff, SQL count, order, offset, and limit.
- Worst-coverage ordering now uses the same derived complete/total percentage, clamped and rounded consistently with presentation.
### Review-Fix RED Evidence
Command after adding the review regressions:
```text
python -m pytest central/tests/test_operational_view_models.py central/tests/test_jobs.py -q
```
Result: the new regressions failed as expected: exact ownership was unsupported, legacy blockers received the fabricated representative installation, dashboard SQL `DISTINCT` aggregation was absent, health-filtered SQL pagination was absent, and stored coverage ordering disagreed with derived coverage. The API assertion initially also exposed a test setup omission; the test was corrected to heartbeat/register the installation before implementation.
### Review-Fix GREEN Evidence
Focused review and compatibility tests:
```text
python -m pytest central/tests/test_operational_view_models.py central/tests/test_control_plane.py central/tests/test_jobs.py central/tests/test_blocked_products.py -q
```
Result: `30 passed`.
Full central suite:
```text
python -m pytest central/tests -q
```
Result: `253 passed, 1 warning`.
The warning remains the pre-existing `openpyxl` print-area warning. The test environment also emits the existing `pytest-asyncio` fixture-loop-scope deprecation warning.
Additional verification:
```text
python -m compileall -q central/app central/scripts
git diff --check
```
Both completed without errors; Git's LF/CRLF normalization notices are environmental only.
### Review-Fix Commit
Implementation commit: `e35417f` (`fix: close task 2 operational review findings`).
### Review-Fix Concerns
- Legacy blocker rows intentionally remain tenant-level because their installation ownership is unavailable; they render `-`/null server context and are never assigned a representative installation.
- The existing non-failing pytest environment warnings remain as noted above.
@@ -0,0 +1,172 @@
# Task 3 Report: Safe Blocker Resolution Workflow
## Status
Implemented Task 3 only. The old hidden-context blocker action was replaced by a structured, server-rendered, CSRF-protected resolution workflow. The JSON control-panel route now uses the same validated transaction service and rejects arbitrary context text.
## RED
Created `central/tests/test_blocker_resolution_panel.py` before production implementation.
Initial focused command:
```text
python -m pytest central/tests/test_blocker_resolution_panel.py -q
```
Expected RED result:
```text
ModuleNotFoundError: No module named 'central.app.blocker_resolution'
```
The failure occurred during collection because the required production module did not yet exist.
## Implementation
- Added `central/app/blocker_resolution.py` with structured input validation through `validate_entry`, workflow-scope restrictions, exact PRODUCT scope-key enforcement, persisted installation/company context derivation, context upsert, bounded `REPROCESS_PRODUCTS` job creation, sanitized audit recording, and one commit with rollback on failure.
- Added `GET /blockers/{blocker_id}` with installation-aware blocker detail and context form.
- Added `POST /blockers/{blocker_id}/resolve-context` with human authentication and CSRF enforcement.
- Updated `/v1/blockers/{blocker_id}/resolve-human` and added `/v1/blockers/{blocker_id}/resolve-context` to call the same service; neither accepts arbitrary context text.
- Removed the blocker-list hidden fixed-context form and `data-confirm` action.
- Added focused coverage for enum validation, unknown keys, unsupported NCM scope, empty reason, missing PRODUCT scope key, authentication, CSRF, context/job/audit persistence, exact product scoping, and secret redaction.
## GREEN
Focused command:
```text
python -m pytest central/tests/test_blocker_resolution_panel.py -q
```
Result:
```text
5 passed, 1 warning
```
Central suite:
```text
python -m pytest central/tests -q
```
Result:
```text
261 passed, 1 warning
```
The warnings are existing third-party test-environment warnings from `pytest_asyncio` configuration and `openpyxl` print-area parsing.
## Safety Review
- No Firebird, AI, SQL execution, shell, credential, or remote-write path was added.
- The workflow accepts only `scope`, `scope_key`, `key`, `value`, and `reason`; context keys and enum values are validated against existing shared rules.
- Company context is derived from persisted blocker/installation data, not arbitrary client `company_key` input.
- Jobs contain exactly the blocked product code.
- Audit context passes through existing redaction before persistence.
## Concerns
- Live PostgreSQL and agent execution were not exercised; verification used the repository's offline SQLite test fixtures.
- The blocker remains open until the agent's normal reprocess completion path reports resolution; this prevents marking it resolved before the bounded job succeeds.
## Review Fixes
### RED
Added regression tests before changing production code for the four review findings:
- Legacy blockers with `installation_id=NULL` must fail closed without selecting an Agent or creating work.
- Cross-tenant installation and Agent pointers must fail before context, job, or audit mutation.
- Oversized values/reasons and non-string fields must be rejected cleanly.
- Successful web resolution must redirect to the blocker detail page.
Focused command before the fixes:
```text
python -m pytest central/tests/test_blocker_resolution_panel.py -q
```
RED result:
```text
4 failed, 5 passed
```
The failures reproduced the legacy Agent fallback, cross-tenant acceptance, unbounded/malformed input, and `/blockers` redirect behavior.
### GREEN
- Resolution now requires a non-null exact installation and verifies both installation and Agent tenant ownership before mutation.
- `value` is required to be a string of at most 512 characters; `reason` is required to be a string of at most 2000 characters.
- Boundary-sized audit data retains all required audit fields without whole-context truncation.
- Web success redirects to `/blockers/{blocker_id}`.
Focused command:
```text
python -m pytest central/tests/test_blocker_resolution_panel.py -q
```
Result:
```text
9 passed, 1 warning
```
Full central command:
```text
python -m pytest central/tests -q
```
Result:
```text
265 passed, 1 warning
```
The existing `pytest_asyncio` configuration and `openpyxl` print-area warnings remain unchanged.
## UTF-8 Byte-Length Re-Review Fix
### RED
Added `test_resolution_input_rejects_multibyte_utf8_at_ascii_character_limits` before changing production code. It submits `é` repeated 512 times as `value` and 2000 times as `reason`; both are within the existing character counts but exceed the corresponding UTF-8 byte budgets.
Focused command:
```text
python -m pytest central/tests/test_blocker_resolution_panel.py -q
```
RED result:
```text
1 failed, 9 passed
```
The failure confirmed character-count validation accepted the multibyte inputs.
### GREEN
- `value` and `reason` limits now use `len(value.encode("utf-8"))` and `len(reason.encode("utf-8"))`.
- Existing ASCII boundary inputs remain accepted.
- Multibyte inputs fail closed with explicit UTF-8 byte-limit validation errors, before persistence or audit sanitization.
- Redaction and remote-write boundaries were not changed.
Focused result:
```text
10 passed, 1 warning
```
Full central result:
```text
266 passed, 1 warning
```
The existing `pytest_asyncio` configuration and `openpyxl` print-area warnings remain unchanged.
@@ -0,0 +1,44 @@
# Task 4 Report: Server-Rendered Operational Pages
STATUS: COMPLETE
## Implemented
- Added authenticated page tests covering dashboard, agents, jobs, blockers, agent detail, blocker detail, filters, pagination state, human dates, and safe-form constraints.
- Added server-side route filters for agents, blockers, jobs, and agent events while preserving bounded view-model calls.
- Reworked the Jinja pages with Portuguese operational labels, separate technical/fiscal statuses, empty states, detail links, accessible controls, semantic table captions/headers, and secondary technical IDs.
- Added dashboard attention, failed-job, outdated-agent, and blocker summary sections.
- Preserved the Task 3 blocker detail and CSRF-protected resolution form, with evidence and related operational records.
- Replaced raw primary timestamp rendering with humanized presentation values.
- Replaced the old confirmation JavaScript with harmless progressive enhancement; no `window.confirm` or `data-confirm` remains in the panel pages.
- Added responsive table overflow, mobile breakpoints, visible focus states, and text-bearing status badges.
## Verification
- RED: `python -m pytest central/tests/test_control_panel_pages.py -q` failed 5 tests against the pre-change templates/routes.
- Focused: `python -m pytest central/tests/test_control_panel_pages.py -q` passed 5 tests.
- Central suite: `python -m pytest central/tests -q` passed 271 tests with 1 existing openpyxl warning.
- `git diff --check` passed.
- No credentials, tokens, cookies, or secrets were added.
## Concerns
- The blocker page's affected-client count is derived from the bounded filtered result page; the canonical global blocker/product metrics continue to come from `dashboard_summary`.
- Browser viewport screenshots were not run in this offline task; responsive behavior is covered by CSS breakpoints and horizontal table scrolling.
Commit message: `feat: refine operational control panel pages`
## Task 4 review fixes
- Added a dashboard list of at most 10 recent failed jobs with client, installation display name, hostname, job ID/kind, short error, created/finished display and age, and a job-ID-filtered link. The failed-job total and empty state remain visible.
- Replaced page-local/global blocker metric mixing with SQL-backed aggregates over the complete relation selected by the current filters. Counts use distinct `(tenant_id, product_code)` identity and distinct tenant IDs; OPEN, RESOLVED, and ALL behavior is covered. Breakdown now follows the filtered relation.
- Agent detail now loads its installation, agent, tenant, and snapshot by agent ID, without scanning the first 100 installations. Agent blocker breakdown and its filtered link include only blockers whose installation belongs to that agent; tenant-only legacy records are excluded.
- Added event since/until filters and page-size+1 lookahead, preserving filters in pagination links and omitting Next on the final page. Job references link to jobs filtered by job ID; product references link to blockers filtered by agent/product.
- Reprocess-all now opens an explicit server-rendered confirmation page with a required confirmation checkbox, CSRF token, and cancel link. Missing confirmation is rejected; valid confirmation retains the guarded enqueue path.
- Added route regressions for these cases, including 55+ blockers, repeated product code across tenants, resolved/all status, agent ID beyond 100, exact blocker attribution, date filtering/pagination, links, and confirmation/CSRF behavior.
- RED evidence: the new dashboard-row and date-filter cases failed before implementation because those views were absent; after correcting test setup, the new assertions detected the unimplemented behaviors. Focused regressions are green after implementation.
- Verification: `python -m pytest central/tests/test_control_panel_pages.py -q` — 9 passed; `python -m pytest central/tests -q` — 275 passed, 1 existing openpyxl warning; `git diff --check` — passed.
- Route smoke coverage is provided by authenticated TestClient requests. CSS breakpoint review confirmed the existing 1100px, 768px, and 520px responsive rules and table overflow remain intact.
- The earlier concern that blocker metrics were page-local is resolved by the complete filtered SQL aggregates above. No writes to Firebird databases were performed.
Commit message: `fix: close operational panel page review findings`
@@ -0,0 +1,79 @@
# Task 5 Report: Fixture Coverage, Documentation, And Regression Tests
## Result
Completed the fixture regression coverage and operational UX documentation.
The fixture retains its explicit non-production command gate and the default
100-Agent/500-event/100-job shape. Its deterministic blocker count is now 51:
50 distinct open tenant/product pairs, including two open blocker rows for the
same first product. Safe-blocked snapshot counts match the fixture's open
product pairs. Failed jobs have duration timestamps, and linked events point to
their job and, where present, the correct blocker/product and installation.
## TDD evidence
1. Added assertions for the additional same-product blocker, unique fingerprints,
exact UUID5 installation identities, ownership, failed-job durations, and
job/blocker/product event links. The first focused run was RED as expected:
fixture returned 50 blockers while the requirement expected 51.
2. After implementing the blocker/job/event fixtures, added an assertion that
each snapshot's `safe_blocked` value matched its open product pairs. The
focused run was RED as expected because the old sample formula reported
blocked products for installations without an open blocker.
3. Updated the deterministic snapshot values to match the generated open
product pairs. The focused test then passed.
## Verification commands
- RED: `python -m pytest central/tests/test_control_panel_fixture.py -q` —
failed on the expected blocker count gap (`50 != 51`).
- RED: `python -m pytest central/tests/test_control_panel_fixture.py -q` —
failed on the expected safe-blocked snapshot mismatch (`1 != 0`).
- GREEN: `python -m pytest central/tests/test_control_panel_fixture.py -q` —
passed (`1 passed`).
- Full central suite: `python -m pytest central/tests -q` — passed
(`275 passed`). Pytest emitted its existing `pytest-asyncio` fixture-loop
deprecation warning and one existing `openpyxl` print-area warning.
- `git diff --cached --check` — passed with no whitespace errors.
## Documentation review
Updated `docs/CONTROL_PANEL_V1.md` to link to the new operational UX contract
and distinguish the installation-wide confirmation step from the validated
blocker context workflow. Added `docs/CONTROL_PANEL_OPERATIONAL_UX_V1.md`
covering the health invariant, technical/fiscal distinction, blocker/product
counts, dashboard attention and empty states, identity-first views, timestamp
and timezone presentation, blocker filters and summaries, operational-only
priority tiers, safe resolution/auth/CSRF/audit fields, reprocess confirmation,
password policy, performance limits, responsiveness, fixture command, and
write/deployment boundaries.
## Commit
- Implementation and product documentation: `4ba0fa8`
(`test: complete control panel fixture UX coverage`)
## Concerns
- Resolved in the timestamp tooltip follow-up below: templates now use raw ISO
UTC timestamps in `title` and retain localized human-readable visible text.
- Full central tests retain the two pre-existing dependency warnings described
above; they did not fail the suite.
## Timestamp tooltip follow-up
Resolved the full technical timestamp concern: agent, job, blocker, and
populated dashboard failure timestamps now keep their localized human-readable
text visible and expose the complete ISO UTC timestamp (including seconds and
`+00:00`) in the `title` attribute. Blocker detail includes first- and
last-seen timestamps. The existing raw ISO serializer now explicitly normalizes
naive SQLite timestamps as UTC, matching the presentation layer's existing
interpretation without changing the view-model shape.
### TDD and verification
- RED: `python -m pytest central/tests/test_control_panel_pages.py -q -k "localized_last_seen or localized_created or localized_seen_times or bounded_failed_job_rows"` — 4 expected failures because the tooltips did not contain the required raw ISO values.
- GREEN: `python -m pytest central/tests/test_control_panel_pages.py -q` — passed (`12 passed`).
- Full suite: `python -m pytest central/tests -q` — passed (`278 passed`, one existing `openpyxl` warning; pytest-asyncio also emits its existing fixture-loop deprecation warning).
- `git diff --check` — passed.
- Commit message: `fix: show technical timestamps in tooltips`.
+130
View File
@@ -0,0 +1,130 @@
# GTI Discovery
**Primeira leitura obrigatória, antes de qualquer missão fiscal nova**: `docs/PROJECT_STATE.md`
(versões Hub/Agent, migration head de cada branch, status do Reference Data Registry, decisões
ADMIN pendentes, o que já foi feito e o que falta, riscos abertos). Mantê-lo atualizado ao final
de cada missão que mude esse estado.
This project is strictly read-only. Discovery code must use a database-level
read-only account or transaction/driver mode; lexical SQL validation is only
defense-in-depth. Discovery code must not perform DML or DDL,
modify database objects or permissions, or store credentials and other secrets.
Run the offline tests with:
```text
# Suíte completa canônica (1160+ testes: raiz + Hub + Agent):
python -m pytest tests central/tests agent/tests -q
# Apenas testes unitários offline da raiz:
python -m pytest tests -q
```
## Firebird write policy
Default policy:
- All client/production Firebird databases are STRICTLY READ-ONLY.
- Never perform INSERT, UPDATE, DELETE, DDL, permission changes, or destructive operations
against any client/production ERP database unless explicitly authorized in a future policy change.
Explicit development exception:
The following database is a disposable TEST DATABASE and is explicitly authorized for writes:
```
D:\PROJETOS\GTISISTEMAS\Projeto\TESTESOFICIAIS.FDB
```
For this exact file only, the agent MAY perform:
- SELECT
- INSERT
- UPDATE
- DELETE
- DDL
- transactional tests
- rollback/recovery tests
- destructive development experiments when necessary
This database has external intact backups and can be restored.
Safety requirement:
Before ANY Firebird write, the code must verify that the canonical absolute database path is exactly:
```
D:\PROJETOS\GTISISTEMAS\Projeto\TESTESOFICIAIS.FDB
```
If the path differs in any way, writes must fail closed.
Never infer write authorization from filename alone.
Never enable writes to other Firebird databases.
Secrets:
- credentials remain local in ignored environment files;
- never log, commit, or expose passwords.
Development priority:
- TESTESOFICIAIS.FDB may be modified freely as needed to validate the MVP.
- Do not over-engineer rollback protections for this disposable test database unless they are part
of the product behavior being validated.
## GTI Fiscal write-target rule
The SGV keeps its fiscal defaults in the canonical product record (`C000025`); every other routine consumes them.
GTI Fiscal therefore writes ONLY to the canonical product record, and only fields whose mapping is proven
(`docs/SGV_PRODUCT_FISCAL_UI_MAP_V1.json`, `write_target=true`). It never writes to NFCE, NFCE_ITENS, item tables,
issued documents or any other transactional/historical table; those are read-only evidence for validation.
Company context (UF, CRT) is read from `C000004` at run time — never hardcoded.
## Local completion mission — exact write allowlist (operator-authorized)
For the "GTI FISCAL LOCAL COMPLETION V1" mission the operator explicitly authorized writes to the disposable COPIES listed, by exact canonical path, in
`docs/LOCAL_WRITE_ALLOWLIST_V1.json` (CONSTRUCAO, MMMODAS, RINCAO, UNIAO under `Projeto\Banco\`, plus TESTESOFICIAIS.FDB). Authorization is by exact
path only — never by directory or `.FDB` extension. Any other Firebird under `D:\PROJETOS\GTISISTEMAS` stays strictly READ-ONLY. Writes go through the
guarded executor (path allowlist, company-context guard, schema/type/length guard, compare-before-write, rowcount, transaction, audit) and only to
`C000025` fields whose contract in `docs/FISCAL_FIELD_CONTRACT_V1.json` has `WRITE_ALLOWED=true`. This authorization does not extend to production or client databases.
## Production deploy rule (Hub)
Normal deploy = `powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 [-PublishAgent -Version X]` (docs/DEPLOY_HUB.md). Do not recreate ad-hoc deploy scripts (deploy115.py etc.) and do not improvise SSH/docker against 10.0.10.117. The script handles VPN/SSH retry, backup, upload, migrations, rebuild, health, release publish, download verification, and automatic rollback; the real error is in `logs/last_deploy_result.json` and the log.
## Versioning (adopted standard, see docs/VERSIONING.md)
Hub and Agent are versioned and released independently — never assume they share a version
number, and never make the Hub's `GTI_FISCAL_VERSION` bump touch the Agent's own version files
(`agent/pyproject.toml`, the `AGENT_VERSION` defaults) or vice versa. The panel's "Atualização
disponível" already compares an Agent's reported version against the highest **PUBLISHED**
Windows `AgentRelease`, never against the Hub's version — see `docs/VERSIONING.md` for the exact
mechanism and the current (informal) compatibility matrix.
## Permanent mission rules (fiscal completion missions, recorded 2026-09-28)
- No AI/LLM/embeddings, no paid API, no scraping — every rule/decision is derived from real ERP
evidence or an explicit, audited operator/ADMIN decision (`FiscalContextEntry`), never inferred
or guessed.
- No Firebird triggers or schema changes, ever, for any discovery/completion mission. All queries
against any Firebird database (client, lab copy, or disposable test DB) are read-only unless the
exact path is on an explicit, mission-scoped write allowlist (see above) — writes still go only
through the guarded executor.
- Deploy only via `scripts\deploy_hub.ps1` (see "Production deploy rule" above) — never ad-hoc
SSH/docker/psql against 10.0.10.117.
- If the Agent (agent/) changes in a way that affects its runtime behavior: bump the single-source
version, build a new installer, validate it in a fresh Windows Sandbox (install, upgrade in-place,
identity preserved), and never overwrite an already-published release. A pure version-label sync
bump (release/VERSION, pyproject.toml files, the `AGENT_VERSION` default string) that changes no
Agent behavior is not itself an "Agent change" requiring a new installer.
- Never reenroll an existing installation (never mint a new `installation_id`/token for a machine
that already has one) — an in-place upgrade always preserves identity and local SQLite state.
- No remote-execution channel to any client's Agent host, ever (by design — see
`docs/rtc_auto_apply.md`). An Agent version upgrade on a real client machine is always performed
by a human operator on-site; the assistant never attempts RDP/WinRM/SSH/PowerShell-remoting into
a client host, and never asks for or accepts a production `AGENT_TOKEN`/admin session credential
pasted in chat to work around that — live validation of a real installation, when needed, comes
from the operator (a panel screenshot, or a value they read and paste themselves).
- No real client/production data (CNPJ, razão social, product names/descriptions, per-product NCM)
is ever published in a location a non-admin can reach (e.g. `central/static/`). Any evidence
artifact referenced from a UI card must either be served through an authenticated ADMIN-only
route, or be anonymized (generic client labels, no names/documents/descriptions) before being
published as a static file.
+41
View File
@@ -0,0 +1,41 @@
# One of these per Agent installation (one per client machine). Copy to
# agent/.env on that machine; never commit it. Get AGENT_ID/AGENT_TOKEN/
# COMPANY_ID from central/scripts/register_agent.py, run once per installation.
GTI_HUB_URL=https://hub.example.com
AGENT_ID=
AGENT_TOKEN=
COMPANY_ID=
COMPANY_UF=
# The client's Firebird ERP database.
FIREBIRD_DATABASE_PATH=
FB_USER=
FB_PASSWORD=
FIREBIRD_CLIENT_LIBRARY=
# Default MUST be 0. Writes only ever happen against exactly the database
# named by AUTHORIZED_WRITE_DATABASE_PATH below (never inferred from
# FIREBIRD_DATABASE_PATH alone, never from the ".FDB" extension).
FIREBIRD_WRITES=0
# Required to enable writes for THIS installation. Must be set to the exact
# same absolute path as FIREBIRD_DATABASE_PATH above — this is the operator's
# explicit, per-installation authorization. Leaving it unset means
# FIREBIRD_WRITES=1 has no effect against a real client database (it falls
# back to the developer-only disposable test database path and therefore
# fails closed for anything else).
AUTHORIZED_WRITE_DATABASE_PATH=
# Required to enable writes for THIS installation. Must equal the schema
# fingerprint this installation was verified/registered against; if the live
# schema fingerprint (reported in heartbeats) ever differs, writes are
# refused until an operator re-verifies and updates this value.
EXPECTED_SCHEMA_FINGERPRINT=
POLL_INTERVAL_SECONDS=10
AGENT_VERSION=0.1.0
# Optional overrides:
# AGENT_STATE_DB=C:\GTI\Agent\agent_state.sqlite
# AGENT_LOG_PATH=C:\GTI\Agent\logs\agent.log
+8
View File
@@ -0,0 +1,8 @@
[project]
name = "gti-fiscal-agent"
version = "1.3.9"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27,<1"]
[project.optional-dependencies]
windows-service = ["pywin32>=306; sys_platform == 'win32'"]
+46
View File
@@ -0,0 +1,46 @@
"""GTI Fiscal Agent installation preflight (READ-ONLY): python agent/scripts/preflight.py [--env-file FILE] [--json]
Prints PREFLIGHT=PASS|FAIL and the blockers; exit code 0 on PASS, 2 on FAIL. Secrets are never printed.
"""
import argparse
import json
import os
import sys
from pathlib import Path
AGENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(AGENT_ROOT / "src"))
from gti_fiscal_agent.preflight import run_preflight # noqa: E402
def load_env(path):
if path and Path(path).exists():
for line in Path(path).read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--env-file")
ap.add_argument("--json", action="store_true")
a = ap.parse_args(argv)
load_env(a.env_file or os.environ.get("GTI_AGENT_ENV_FILE") or AGENT_ROOT.parent / ".env")
os.environ.setdefault("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
os.environ.setdefault("AGENT_LOG_PATH", str(AGENT_ROOT / "logs" / "agent.log"))
res = run_preflight(os.environ)
if a.json:
print(json.dumps(res, ensure_ascii=False, indent=1, default=str))
else:
for c in res["checks"]:
print(f" [{c['status']:<4}] {c['name']:<24} {c['detail']}")
print(f"PREFLIGHT={res['PREFLIGHT']}")
for b in res["blockers"]:
print(f"BLOCKER: {b}")
raise SystemExit(0 if res["PREFLIGHT"] == "PASS" else 2)
if __name__ == "__main__":
main()
@@ -0,0 +1,40 @@
"""Agent preflight: COMPANY_CONTEXT=PASS/FAIL for one Firebird database (READ-ONLY).
python agent/scripts/preflight_company_context.py [--database <FDB>]
Configuration (environment or the local ignored .env): COMPANY_CODE / COMPANY_CNPJ (company selection when C000004 is ambiguous),
COMPANY_CRT_SOURCE = EMISSOR | C000004 (explicit choice when the two CRT sources disagree). The full CNPJ is never printed.
"""
import argparse
import json
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "agent" / "src"))
from gti_fiscal_agent.company_context import preflight_company_context, preflight_report # noqa: E402
def load_dotenv(path):
if path.exists():
for line in path.read_text(encoding="utf-8").splitlines():
if "=" in line and not line.startswith("#"):
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--database")
a = ap.parse_args(argv)
load_dotenv(ROOT / ".env")
if a.database:
os.environ["FIREBIRD_DATABASE_PATH"] = a.database
report = preflight_report(preflight_company_context(os.environ))
print(json.dumps(report, ensure_ascii=False, indent=1))
raise SystemExit(0 if report["COMPANY_CONTEXT"] == "PASS" else 2)
if __name__ == "__main__":
main()
+34
View File
@@ -0,0 +1,34 @@
"""Run the agent against a sanitized sample only after an explicit fingerprint gate."""
import argparse, json, os, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from gti_fiscal_agent.client import HubClient
from gti_fiscal_agent.runner import run_sample
from gti_fiscal_agent.state import AgentState
FIELDS = "CODIGO,PRODUTO,NCM,CLASSIFICACAO_FISCAL,CFOP,CFOP_ENTRADA,CST,CST_ENTRADA,ALIQUOTA,CEST,CSOSN,COD_ENTRADA_COFINS,COD_SAIDA_COFINS,ALIQ_COFINS,COD_ENTRADA_PIS,COD_SAIDA_PIS,ALIQ_PIS,ALIQ_IPI,CST_IPI,ORIGEM,IAT,INCIDENCIA_PISCOFINS,IPPT,ULTIMA_ALTERACAO"
def read_firebird_products(codes):
import fdb
connection = fdb.connect(database=os.environ["FIREBIRD_DATABASE_PATH"], user=os.environ["FB_USER"], password=os.environ["FB_PASSWORD"], fb_library_name=os.environ.get("FIREBIRD_CLIENT_LIBRARY"), isolation_level=fdb.ISOLATION_LEVEL_READ_COMMITED_RO)
try:
cursor = connection.cursor(); cursor.execute(f"SELECT {FIELDS} FROM C000025 WHERE CODIGO IN ({','.join('?' for _ in codes)}) ORDER BY CODIGO", tuple(codes))
names = [column[0].strip() for column in cursor.description]
return [dict(zip(names, row)) for row in cursor.fetchall()]
finally:
connection.close()
def main():
parser = argparse.ArgumentParser(); parser.add_argument("--sample", required=True); parser.add_argument("--output", required=True); parser.add_argument("--schema-fingerprint", default="")
args = parser.parse_args(); sample = json.loads(Path(args.sample).read_text(encoding="utf-8"))
codes = [item["CODIGO"] for item in sample.get("sample_without_ncm", [])[:20]]
products = read_firebird_products(codes)
result = run_sample(products, HubClient(os.getenv("HUB_URL", "http://localhost:8000"), os.getenv("AGENT_DEV_TOKEN", "")), AgentState(Path(args.output).with_suffix(".sqlite")), args.schema_fingerprint)
result["products_requested"] = len(products); result["firebird_products_read"] = len(products); result["firebird_access"] = "READ_ONLY_SELECT"
output = Path(args.output)
if result["results"]:
output.write_text(json.dumps(result, indent=2, default=str), encoding="utf-8")
elif not output.exists():
output.write_text(json.dumps(result, indent=2, default=str), encoding="utf-8")
print(json.dumps(result, indent=2))
if __name__ == "__main__": main()
@@ -0,0 +1,104 @@
"""Integrated Agent flow: Firebird read -> Hub /v1/classify/batch -> IPI dry-run
-> guarded auto-apply write.
FIREBIRD_WRITES=0 (default): classifies every product and logs the decision
(and what write *would* happen) to the local SQLite audit log; never writes.
FIREBIRD_WRITES=1: also auto-applies every write_eligible C000025.IPI proposal
where rate_kind=NUMERIC, reusing the same schema/path/allowlist/compare-before-write
guards as the manual executor. Fails closed before touching the connection unless
the configured database is exactly the authorized disposable test database.
Auto-apply is scoped to table=C000025, field=IPI, rate_kind=NUMERIC only. NT/
non-numeric markers, blank rates, ambiguous EX candidates, legacy/invalid NCM,
CEST and EX_TIPI are never auto-applied here.
"""
import argparse
import importlib.util
import json
import os
import sys
from pathlib import Path
AGENT_ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = AGENT_ROOT.parent
sys.path.insert(0, str(AGENT_ROOT / "src"))
from gti_fiscal_agent.client import HubClient # noqa: E402
from gti_fiscal_agent.firebird_reader import read_products # noqa: E402
from gti_fiscal_agent.pipeline import run_pipeline # noqa: E402
from gti_fiscal_agent.runner import REFERENCE_SCHEMA_V1, to_hub_product # noqa: E402
from gti_fiscal_agent.state import AgentState # noqa: E402
def load_dotenv(path: Path) -> None:
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
def load_executor():
spec = importlib.util.spec_from_file_location("firebird_write_executor", PROJECT_ROOT / "scripts" / "firebird_write_executor.py")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--hub-url", default=os.getenv("HUB_URL", "http://localhost:8000"))
parser.add_argument("--token", default=os.getenv("AGENT_DEV_TOKEN", ""))
parser.add_argument("--state-db", default=str(AGENT_ROOT / "agent_state.sqlite"))
parser.add_argument("--schema-fingerprint", default=REFERENCE_SCHEMA_V1)
args = parser.parse_args()
load_dotenv(PROJECT_ROOT / ".env")
if not args.token:
args.token = os.environ.get("AGENT_DEV_TOKEN", "")
firebird_writes = os.environ.get("FIREBIRD_WRITES", "0") == "1"
executor = load_executor()
database_path = os.environ["FIREBIRD_DATABASE_PATH"]
if firebird_writes:
# Fail closed before any product is read or any connection is opened for writing.
executor.assert_authorized_write_path(database_path)
if args.schema_fingerprint != REFERENCE_SCHEMA_V1:
print(json.dumps({"status": "SCHEMA_UNKNOWN", "processed": 0}))
return
products = read_products()
client = HubClient(args.hub_url, args.token)
decisions = client.classify_batch([to_hub_product(p) for p in products])
state = AgentState(args.state_db)
write_connection = None
if firebird_writes:
import fdb
write_connection = fdb.connect(
database=database_path,
user=os.environ["FB_USER"],
password=os.environ["FB_PASSWORD"],
fb_library_name=os.environ.get("FIREBIRD_CLIENT_LIBRARY"),
)
try:
summary = run_pipeline(products, decisions, state, firebird_writes, write_connection, executor, database_path)
finally:
if write_connection is not None:
write_connection.close()
summary = {"status": "OK", "firebird_writes_enabled": firebird_writes, **summary}
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()
+582
View File
@@ -0,0 +1,582 @@
"""Long-running Agent service: heartbeat + poll Hub jobs + execute pipeline.
startup -> load config -> validate Hub -> validate Firebird -> heartbeat
-> poll job -> claim -> execute -> report result -> back to polling.
No job available: sleep POLL_INTERVAL_SECONDS.
Hub unavailable: retry with simple capped backoff; the Agent stays alive.
Job fails internally (bad Firebird read, classify error, write refused):
the job is reported FAILED with a sanitized error and the Agent keeps polling.
SIGINT/SIGTERM: finish the current job (if any) and stop the loop cleanly.
Written to be runnable as-is today, and easy to later wrap as a Windows
Service (a service wrapper would just call main()); no installer here.
"""
import argparse
import importlib.util
import json
import logging
import logging.handlers
import os
import signal
import sys
import threading
import time
from pathlib import Path
AGENT_ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = AGENT_ROOT.parent
sys.path.insert(0, str(AGENT_ROOT / "src"))
sys.path.insert(0, str(PROJECT_ROOT)) # ships central/app engine modules read-only (central.app.*)
from gti_fiscal_agent.client import HubClient # noqa: E402
from gti_fiscal_agent import firebird_reader as _firebird_reader, rtc_auto_apply as _rtc_apply, unified_fiscal as _unified # noqa: E402
from gti_fiscal_agent.firebird_reader import check_connection, read_products # noqa: E402
from gti_fiscal_agent.pipeline import run_pipeline # noqa: E402
from gti_fiscal_agent.runner import REFERENCE_SCHEMA_V1, product_fingerprint, to_hub_product # noqa: E402
from central.app.operational_state import classify_product_state, summarize_operational_states # noqa: E402
from gti_fiscal_agent.state import AgentState # noqa: E402
from gti_fiscal_agent import enrichment as _enrichment # noqa: E402
_MAX_BACKOFF_SECONDS = 60
SYNC_JOB_KINDS = ("INITIAL_SYNC", "SYNC_INCREMENTAL", "REPROCESS_ALL") # read-only ERP jobs that refresh coverage
logger = logging.getLogger("gti_fiscal_agent")
def setup_logging(log_path):
"""Rotating local log file. Never logs FB_PASSWORD or AGENT_TOKEN — every
log call in this module only references non-secret identifiers/counts."""
logger.setLevel(logging.INFO)
if logger.handlers:
return logger
Path(log_path).parent.mkdir(parents=True, exist_ok=True)
handler = logging.handlers.RotatingFileHandler(log_path, maxBytes=5_000_000, backupCount=5, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger.addHandler(handler)
logger.addHandler(logging.StreamHandler())
return logger
def load_dotenv(path: Path) -> None:
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
def load_executor():
# The frozen service host has no repo tree; the installer writes
# GTI_WRITE_EXECUTOR_PATH pointing at the extracted executor next to
# agent.env. Falls back to the dev/repo path for source runs.
executor_path = os.environ.get("GTI_WRITE_EXECUTOR_PATH") or (PROJECT_ROOT / "scripts" / "firebird_write_executor.py")
spec = importlib.util.spec_from_file_location("firebird_write_executor", executor_path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class Config:
def __init__(self, env):
self.hub_url = env.get("GTI_HUB_URL") or env.get("HUB_URL") or "http://localhost:8000"
self.agent_id = env.get("AGENT_ID", "")
self.credential_file = env.get("GTI_CREDENTIAL_FILE") or str(Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "GTI" / "FiscalAgent" / "credentials.bin")
# The permanent token NEVER lives in agent.env: it is decrypted from
# credentials.bin (DPAPI LocalMachine + SYSTEM/Administrators ACL). An
# explicit AGENT_TOKEN/AGENT_DEV_TOKEN in env wins (dev override only).
self.agent_token = self._resolve_agent_token(env)
self.company_id = env.get("COMPANY_ID", "")
self.company_uf = env.get("COMPANY_UF", "")
_resolve_firebird_credentials(env)
self.database_path = env["FIREBIRD_DATABASE_PATH"]
# FISCAL_MODE gates writes technically: DRY_RUN can never write, whatever FIREBIRD_WRITES says. Unset = legacy
# behaviour (FIREBIRD_WRITES decides). `firebird_writes` below is the EFFECTIVE value used by every write path.
requested = env.get("FIREBIRD_WRITES", "0") == "1"
self.fiscal_mode = (env.get("FISCAL_MODE") or ("AUTO_APPLY" if requested else "DRY_RUN")).strip().upper()
self.requested_writes = requested
self.firebird_writes = requested and self.fiscal_mode == "AUTO_APPLY"
# Job execution is controlled CENTRALLY (Hub heartbeat: processing_enabled) — there is no
# local toggle, so no installation ever needs its agent.env edited to start/stop work.
# (A legacy AGENT_JOBS_ENABLED line in an old agent.env is deliberately ignored.)
self.agent_jobs_enabled = True
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
# RTC auto-apply is a SEPARATE switch (default OFF): FIREBIRD_WRITES alone never lets the Agent fill cClassTrib.
self.rtc_auto_apply_max_per_job = int(env.get("RTC_AUTO_APPLY_MAX_PER_JOB", str(_rtc_apply.DEFAULT_MAX_PER_JOB)))
self.agent_version = env.get("AGENT_VERSION", "1.3.9")
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
# Pilot write authorization: both must be explicitly set by the installer for
# a real client database. Left unset, both default to the dev-only values,
# so a real client path/schema is never silently authorized (see
# scripts/firebird_write_executor.py's assert_authorized_write_path).
self.authorized_write_database_path = env.get("AUTHORIZED_WRITE_DATABASE_PATH")
self.expected_schema_fingerprint = env.get("EXPECTED_SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
self.log_path = env.get("AGENT_LOG_PATH", str(AGENT_ROOT / "logs" / "agent.log"))
@staticmethod
def _resolve_agent_token(env):
"""DPAPI credential from credentials.bin; explicit AGENT_TOKEN env wins
(dev override). Fail closed: never log or fabricate a token."""
from gti_fiscal_agent.credential_store import install_env_from_credentials
explicit = env.get("AGENT_TOKEN") or env.get("AGENT_DEV_TOKEN") or ""
if explicit:
return explicit
try:
return install_env_from_credentials(env.get("GTI_CREDENTIAL_FILE") or str(
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "GTI" / "FiscalAgent" / "credentials.bin"), env)
except Exception as exc: # noqa: BLE001 — fail closed: no token, no heartbeat
logger.warning("no usable agent credential: %s", exc)
return ""
def _resolve_firebird_credentials(env):
"""FB_USER/FB_PASSWORD come from the DPAPI-protected firebird.bin written by Setup
(never from a hand-edited agent.env, never sent to the Hub). An explicit FB_PASSWORD in
the environment still wins (dev override)."""
if env.get("FB_PASSWORD"):
return
from gti_fiscal_agent.credential_store import read_credential
path = env.get("GTI_FIREBIRD_CREDENTIAL_FILE") or str(Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "GTI" / "FiscalAgent" / "firebird.bin")
try:
data = json.loads(read_credential(path, env=env))
except Exception as exc: # noqa: BLE001 — absent/unreadable: preflight will fail closed with a clear blocker
logger.info("no Firebird credential file usable: %s", type(exc).__name__)
return
env["FB_USER"], env["FB_PASSWORD"] = data.get("user", ""), data.get("password", "")
class GracefulStop:
def __init__(self):
self.stop = False
signal.signal(signal.SIGINT, self._handle)
signal.signal(signal.SIGTERM, self._handle)
def _handle(self, signum, frame):
self.stop = True
def build_job_coverage(env, client, products, decisions):
"""Company-context-aware coverage over the job's products (read-only). Fails closed when the company context is unresolved."""
from gti_fiscal_agent.company_context import mask_cnpj, preflight_company_context
from gti_fiscal_agent.coverage import build_refs, compute_coverage
from gti_fiscal_agent.firebird_reader import read_evidence
ctx = preflight_company_context(env)
if ctx["status"] != "PASS":
raise RuntimeError("COMPANY_CONTEXT=FAIL: " + "; ".join(ctx.get("reasons") or ["unresolved"]))
evidence = read_evidence(env, ctx["company_code"])
refs = build_refs(client.get_json, products, ctx["company_uf"])
report = compute_coverage(products, decisions, {"uf": ctx["company_uf"], "crt": ctx["company_crt"]}, evidence.get("company_default_csosn"), refs, evidence)
report["company_context"] = {"COMPANY_CONTEXT_STATUS": "PASS", "classification": ctx["classification"], "company_code": ctx["company_code"], "company_cnpj": ctx["company_cnpj"], "company_uf": ctx["company_uf"],
"company_crt": ctx["company_crt"], "source_of_truth": ctx["source_of_truth"], "confidence": ctx["confidence"], "warnings": ctx["warnings"]}
return report
def open_write_connection(env, database_path, firebird_writes=True):
if not firebird_writes: # technical guarantee: no write-capable connection exists in DRY_RUN / FIREBIRD_WRITES=0
raise RuntimeError("write connection refused: writes are disabled (FISCAL_MODE=DRY_RUN or FIREBIRD_WRITES=0)")
import fdb
return fdb.connect(
database=database_path,
user=env["FB_USER"],
password=env["FB_PASSWORD"],
fb_library_name=env.get("FIREBIRD_CLIENT_LIBRARY"),
)
def _unified_fiscal(env, company, ncm_valid=None):
"""PIS/COFINS applicability + RTC (only when the ERP has the reform on), read-only (SELECT only). Fail-closed: if it cannot be assessed the job fails
instead of reporting a coverage that ignores these fields."""
connection = _firebird_reader._connect(env)
try:
return _unified.assess_from_connection(connection, company, ncm_valid=ncm_valid)
finally:
connection.close()
def _fiscal_blocked_products(products, findings):
"""One queue entry per (product, blocker). Same shape as every other blocker; `field` is the dashboard category (PIS/COFINS/RTC)."""
out = []
for product in products:
code = str(product.get("CODIGO") or "")
found = findings.get(code) or {}
for blocker in found.get("blockers") or []:
out.append({"company": "", "product_code": code, "gtin": product.get("GTIN") or product.get("EAN_GTIN"), "description": product.get("PRODUTO"), "blocker_type": "BLOCKED_FISCAL_DATA",
"field": blocker["category"], "reason_code": blocker["type"], "evidence_summary": {"reason": blocker["reason"], "applicability": found.get("applicability"), **({"human": blocker["human"]} if blocker.get("human") else {})},
"input_fingerprint": found.get("input_fingerprint", "")})
return out
def _fetch_authorizations(config, job):
"""Current central authorization for this installation, straight from the Hub (authenticated). Tests replace this; production never trusts the job's copy for the write itself."""
return HubClient(config.hub_url, config.agent_token).authorizations()
def _make_authorizer(config, job, mode, approved_at_claim):
"""Re-checks the Hub right before writing (cached for a few seconds): if the mode changed, the product lost its approval, or the Hub cannot be reached, nothing is written (fail closed)."""
state = {"at": None, "auth": None}
def authorize(code):
now = time.monotonic()
if state["auth"] is None or now - state["at"] > 3.0:
try:
state["auth"], state["at"] = _fetch_authorizations(config, job) or {}, now
except Exception: # noqa: BLE001 - unverifiable authorization is not authorization
return "AUTHORIZATION_UNVERIFIABLE"
fresh = state["auth"]
if fresh.get("rtc_write_mode") != mode:
return "AUTHORIZATION_CHANGED"
if mode == "APPROVAL_REQUIRED" and str(code) not in {str(c) for c in fresh.get("rtc_approved_codes") or []}:
return "AUTHORIZATION_CHANGED"
return None
return authorize
def _rtc_auto_apply(env, config, job, products, fiscal, company):
"""Preview (always) and, only when the Hub-delivered mode permits it, fill CCLASSTRIB for products whose class is PROVEN by an AUTO_APPLY_SAFE rule; also executes
Hub-requested rollbacks. Authorization arrives ONLY inside the authenticated job (`authorizations`); there is no local switch. Never raises for a single product."""
from datetime import datetime, timezone
auth = job.get("authorizations") or {}
mode = auth.get("rtc_write_mode") if auth.get("rtc_write_mode") in _rtc_apply.MODES else "DISABLED"
approved = {str(c) for c in (auth.get("rtc_approved_codes") or [])}
limit = int(getattr(config, "rtc_auto_apply_max_per_job", _rtc_apply.DEFAULT_MAX_PER_JOB))
rtc_on = bool(fiscal["context"].get("rtc_enabled"))
all_candidates, ready_total = _rtc_apply.select_candidates(fiscal["findings"], products, limit=_rtc_apply.MAX_PREVIEW_CANDIDATES)
gate = _rtc_apply.write_gate(mode=mode, capability=True, approved=bool(approved))
summary = {"gate": gate, "mode": mode if mode != "DISABLED" else "REPORT_ONLY", "hub_mode": mode, "capability": _rtc_apply.CAPABILITY, "ready_total": ready_total, "max_per_job": limit, "write_attempts": 0, "write_successes": 0,
"applied": [], "aborted": [], "aborted_reasons": {}, "rollbacks": [], "allowed_fields": list(_rtc_apply.ALLOWED_RTC_WRITE_FIELDS),
"preview": [{k: c[k] for k in ("product_code", "description", "ncm", "current", "proposed", "cst", "rule_id", "basis")} for c in all_candidates] if mode != "DISABLED" and rtc_on else [],
"preview_truncated": ready_total > len(all_candidates)}
if not rtc_on:
return summary
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
rollbacks = [r for r in (auth.get("rtc_rollbacks") or []) if isinstance(r, dict)]
if mode == "ENABLED":
wanted = all_candidates[:limit]
elif mode == "APPROVAL_REQUIRED":
wanted = [c for c in all_candidates if c["product_code"] in approved][:limit]
else:
wanted = []
if not (rollbacks or (gate["allowed"] and wanted)):
return summary
connection = open_write_connection(env, config.database_path)
try:
if rollbacks:
summary["rollbacks"] = _rtc_apply.rollback_records(connection, rollbacks, now=now, job_id=job.get("job_id"))["rollbacks"]
if gate["allowed"] and wanted:
outcome = _rtc_apply.apply_candidates(connection, wanted, company=company or {}, decision={"local_catalog_hash": fiscal["context"].get("local_catalog_hash"), "origin": f"HUB_MODE:{mode}"}, now=now, job_id=job.get("job_id"), authorize=_make_authorizer(config, job, mode, approved))
summary.update({k: outcome[k] for k in ("applied", "aborted", "aborted_reasons", "write_attempts", "write_successes")})
finally:
connection.close()
return summary
def execute_job(job, config, executor, state, env):
"""Run the pipeline for one claimed job. Never raises for job-domain
failures (bad Firebird read, classify error) — returns (result_summary, error)."""
kind = job["kind"]
payload = job.get("payload") or {}
try:
if config.firebird_writes:
# Layered, per-installation authorization: the path must match this
# installation's own explicitly configured authorized_write_database_path
# (defaults to the dev-only constant, so a real client path is refused
# unless the installer deliberately set it), AND the live schema
# fingerprint must match what this installation was configured to expect.
executor.assert_authorized_write_path(config.database_path, authorized_path=config.authorized_write_database_path)
executor.assert_schema_fingerprint_authorized(config.schema_fingerprint, config.expected_schema_fingerprint)
codigos = payload.get("codigos") if kind == "REPROCESS_PRODUCTS" else None
products = read_products(env, codigos=codigos)
logger.info("job %s: read %d product(s) from Firebird", job.get("job_id"), len(products))
client = HubClient(config.hub_url, config.agent_token)
sync_kind = kind in SYNC_JOB_KINDS
cache = sync_kind and hasattr(state, "remember_decision")
# SYNC_INCREMENTAL = delta: only products whose source fingerprint changed (or are new) are
# re-classified by the Hub; INITIAL_SYNC / REPROCESS_ALL / REPROCESS_PRODUCTS process everything they read.
if kind == "SYNC_INCREMENTAL" and cache:
to_process = [p for p in products if state.cached_fingerprint(p["CODIGO"]) != product_fingerprint(p)]
else:
to_process = products
decisions = client.classify_batch([to_hub_product(p) for p in to_process])
write_connection = None
if config.firebird_writes:
write_connection = open_write_connection(env, config.database_path)
try:
summary = run_pipeline(to_process, decisions, state, config.firebird_writes, write_connection, executor, config.database_path, authorized_path=config.authorized_write_database_path)
finally:
if write_connection is not None:
write_connection.close()
operational = coverage = fiscal_context = rtc_apply = None
fiscal = None
if sync_kind:
if cache:
for product, decision in zip(to_process, decisions):
state.remember_decision(product["CODIGO"], product_fingerprint(product), decision)
state.forget_missing([p["CODIGO"] for p in products])
all_decisions = [state.cached_decision(p["CODIGO"]) or {} for p in products]
else:
all_decisions = decisions
coverage = build_job_coverage(env, client, products, all_decisions)
company = {"crt": ((coverage or {}).get("company_context") or {}).get("company_crt")}
ncm_ok = {str(p["CODIGO"]): (d or {}).get("status") == "EXACT_NCM" for p, d in zip(products, all_decisions)}
fiscal = _unified_fiscal(env, company, ncm_ok)
rtc_apply = _rtc_auto_apply(env, config, job, products, fiscal, company)
if rtc_apply["write_successes"] or any(r.get("outcome") == "ROLLED_BACK" for r in rtc_apply["rollbacks"]):
fiscal = _unified_fiscal(env, company, ncm_ok) # coverage must reflect the fields that were just filled
findings = fiscal["findings"]
product_states = [classify_product_state(p, d, fiscal=findings.get(str(p["CODIGO"]))) for p, d in zip(products, all_decisions)]
operational = summarize_operational_states(product_states)
fiscal_context = {**fiscal["context"], "evaluated_all": kind != "REPROCESS_PRODUCTS"}
else:
# REPROCESS_PRODUCTS (and any other non-sync kind) used to hardcode company=None here - not a missing feature, a real gap: the SAME
# deterministic, fail-closed company_context.resolve() every sync job already uses is cheap (no full-table scan) and applies just as
# well to a one-off reprocess job. `company` stays None only when the company genuinely cannot be resolved yet - never a silent guess.
try:
company = {"crt": _firebird_reader.read_company_context(env).get("crt")}
except Exception:
company = None
fiscal = _unified_fiscal(env, company, {str(p["CODIGO"]): (d or {}).get("status") == "EXACT_NCM" for p, d in zip(to_process, decisions)})
fiscal_blocked = _fiscal_blocked_products(products, fiscal["findings"])
fiscal_codes = {b["product_code"] for b in fiscal_blocked}
summary["blocked_products"] = summary["blocked_products"] + fiscal_blocked
summary["resolved_products"] = [c for c in summary["resolved_products"] if str(c) not in fiscal_codes]
result_summary = {
"coverage": coverage,
"operational": operational,
"fiscal_context": fiscal_context,
"rtc_auto_apply": rtc_apply,
"fiscal_mode": getattr(config, "fiscal_mode", None),
"release_version": env.get("AGENT_VERSION") or "1.3.9",
"products_total": len(products),
"products_changed": len(to_process),
"products_processed": summary["processed"],
"blocked_products": summary["blocked_products"],
"resolved_products": summary["resolved_products"],
"writes_attempted": summary["writes_attempted"],
"writes_succeeded": summary["writes_succeeded"],
"writes_failed": summary["writes_failed"],
"no_ops": summary["no_ops"],
"firebird_writes_enabled": config.firebird_writes,
"agent_id": job["agent_id"],
"schema_fingerprint": config.schema_fingerprint,
}
logger.info(
"job %s finished: processed=%d writes_attempted=%d writes_succeeded=%d writes_failed=%d no_ops=%d",
job.get("job_id"), summary["processed"], summary["writes_attempted"], summary["writes_succeeded"], summary["writes_failed"], summary["no_ops"],
)
if summary["writes_failed"] > 0:
return result_summary, f"{summary['writes_failed']} write(s) failed guard checks"
return result_summary, None
except executor.WriteRefused as error:
logger.warning("job %s refused: %s", job.get("job_id"), error)
return None, f"write refused: {error}"
except Exception as error: # noqa: BLE001 — job-domain isolation boundary
logger.warning("job %s failed internally: %s: %s", job.get("job_id"), type(error).__name__, error)
return None, f"{type(error).__name__}: {error}"
BUSY_HEARTBEAT_SECONDS = 20
def _busy_heartbeats(client, installation_id, config, job_id, done):
while not done.wait(BUSY_HEARTBEAT_SECONDS):
try:
client.heartbeat_metadata({"installation_id": installation_id, "status": "BUSY", "schema_fingerprint": config.schema_fingerprint, "agent_version": config.agent_version, "last_job_id": job_id})
except Exception: # noqa: BLE001 — Hub hiccup mid-job must not affect the job
pass
def resolve_installation_id(config, env, state, client):
"""Recover and persist the Hub-bound identity; never invent one for an Agent."""
persisted = state.stored_installation_id()
configured = (env.get("INSTALLATION_ID") or "").strip() or None
if persisted is not None:
return state.installation_id(configured)
if configured is not None:
return state.installation_id(configured)
configured_agent_id = str(env.get("AGENT_ID") or config.agent_id or "").strip()
if not configured_agent_id:
raise RuntimeError("AGENT_ID is required to recover installation identity")
identity = client.identity()
if str(identity.get("agent_id") or "") != configured_agent_id:
raise RuntimeError("authenticated identity does not match configured Agent")
installation_id = identity.get("installation_id")
if not installation_id:
raise RuntimeError("authenticated identity response has no installation_id")
return state.installation_id(str(installation_id))
def _company_context_for_heartbeat(env, cached):
"""Resolves company UF/CRT via the SAME deterministic, fail-closed resolver already used for coverage/RTC (`company_context.resolve`,
called through `preflight_company_context`) - never a second, separate guess. Previously the heartbeat only ever sent `COMPANY_UF` from an
operator-set env var and never sent CRT at all, so `Installation.company_crt` stayed permanently empty in the Hub even though the Agent
already proves CRT from C000004/EMISSOR for every coverage job. Resolves once and caches: never re-queries Firebird once solid, retries only
while still unresolved (e.g. the database was not yet reachable when the service started)."""
if cached is not None:
return cached, None
try:
from gti_fiscal_agent.company_context import preflight_company_context
result = preflight_company_context(env)
except Exception as error:
return None, f"company context not resolved yet: {error}"
if result.get("status") != "PASS":
return None, f"company context not resolved yet: {result.get('classification')} ({'; '.join(result.get('reasons', []))})"
return result, None
def _start_enrichment(config, env, client, stopper):
"""New-product enrichment worker (own thread, own SQLite file). It gates ITSELF on the Hub's per-installation features: DISABLED / not configured = zero Firebird activity."""
if not hasattr(client, "resolve_product"):
return None
store = _enrichment.EnrichmentStore(Path(config.state_db).with_name("enrichment_state.db"))
worker = _enrichment.EnrichmentWorker(hub=client, store=store, read_conn=lambda: _firebird_reader._connect(env, True), write_conn=lambda: open_write_connection(env, config.database_path),
create_reprocess=lambda codes: client.create_job("REPROCESS_PRODUCTS", {"codigos": list(codes), "reason": "ENRICHMENT"}))
threading.Thread(target=worker.run_forever, args=(stopper,), name="product-enrichment", daemon=True).start()
return worker
def run(config, env, *, max_iterations=None, stopper=None):
"""`stopper` defaults to a signal-based GracefulStop (SIGINT/SIGTERM), which
only works from the main thread. A host that manages its own lifecycle
(e.g. the Windows Service wrapper, which stops the loop via the Service
Control Manager instead of OS signals) should pass its own stop object —
anything with a `.stop` boolean attribute — and flip it from any thread."""
executor = load_executor()
state = AgentState(config.state_db)
client = HubClient(config.hub_url, config.agent_token)
installation_id = resolve_installation_id(config, env, state, client)
stopper = stopper if stopper is not None else GracefulStop()
if max_iterations is None:
_start_enrichment(config, env, client, stopper)
backoff = 1
was_down = False
iterations = 0
last_job_id = None
company_ctx = None
while not stopper.stop:
if max_iterations is not None and iterations >= max_iterations:
break
iterations += 1
try:
company_ctx, reason = _company_context_for_heartbeat(env, company_ctx)
if reason:
logger.debug(reason)
heartbeat_payload = {
"installation_id": installation_id, "status": "IDLE", "schema_fingerprint": config.schema_fingerprint,
"agent_version": config.agent_version, "last_job_id": last_job_id, "display_name": env.get("AGENT_DISPLAY_NAME", ""),
"customer_code": env.get("CUSTOMER_CODE"), "hostname": env.get("AGENT_HOSTNAME") or os.environ.get("COMPUTERNAME"),
"windows_version": env.get("WINDOWS_VERSION"), "company_code": config.company_id, "company_uf": (company_ctx or {}).get("company_uf") or config.company_uf,
"company_crt": (company_ctx or {}).get("company_crt"), "company_contribuinte_ipi": (company_ctx or {}).get("company_contribuinte_ipi"),
"database_path": config.database_path, "capabilities": {"heartbeat": True, "jobs": True, "local_logs": True, "RTC_CCLASSTRIB_WRITE_V1": True, "PRODUCT_ENRICHMENT_WRITE_V1": True},
"fiscal_engine_version": env.get("FISCAL_ENGINE_VERSION", config.agent_version), "dataset_versions": {},
}
if hasattr(client, "heartbeat_metadata"):
hb_response = client.heartbeat_metadata(heartbeat_payload)
else:
hb_response = client.heartbeat("IDLE", schema_fingerprint=config.schema_fingerprint, agent_version=config.agent_version, last_job_id=last_job_id)
processing_enabled = not (isinstance(hb_response, dict) and hb_response.get("processing_enabled") is False)
if was_down:
logger.info("reconnected to Hub after %ss backoff", backoff)
was_down = False
backoff = 1
except Exception as error:
logger.warning("heartbeat failed, Hub unreachable: %s (retrying in %ss)", error, backoff)
was_down = True
time.sleep(backoff)
backoff = min(backoff * 2, _MAX_BACKOFF_SECONDS)
continue
if not processing_enabled: # central pause: heartbeat keeps flowing, no job is claimed or executed
time.sleep(config.poll_interval_seconds)
continue
try:
job = client.next_job()
except Exception as error:
logger.warning("poll failed, Hub unreachable: %s (retrying in %ss)", error, backoff)
was_down = True
time.sleep(backoff)
backoff = min(backoff * 2, _MAX_BACKOFF_SECONDS)
continue
if job is None:
time.sleep(config.poll_interval_seconds)
continue
last_job_id = job.get("job_id")
logger.info("job %s received: kind=%s", job.get("job_id"), job["kind"])
try:
if hasattr(client, "heartbeat_metadata"):
client.heartbeat_metadata({"installation_id": installation_id, "status": "BUSY", "schema_fingerprint": config.schema_fingerprint, "agent_version": config.agent_version, "last_job_id": last_job_id})
else:
client.heartbeat("BUSY", schema_fingerprint=config.schema_fingerprint, agent_version=config.agent_version, last_job_id=last_job_id)
except Exception:
pass # best-effort; the job itself still proceeds
# A full sync can take minutes: keep the heartbeat alive (BUSY) from a side thread so the
# panel never shows a working Agent as OFFLINE. The side thread only talks to the Hub
# (no SQLite/Firebird), so it cannot interfere with the job.
done = threading.Event()
beat = threading.Thread(target=_busy_heartbeats, args=(client, installation_id, config, last_job_id, done), daemon=True)
beat.start()
try:
result_summary, error = execute_job(job, config, executor, state, env)
finally:
done.set()
try:
if error is None:
client.complete_job(job.get("job_id"), result_summary)
logger.info("job %s reported COMPLETED", job.get("job_id"))
else:
client.fail_job(job.get("job_id"), error)
logger.warning("job %s reported FAILED: %s", job.get("job_id"), error)
except Exception as report_error:
logger.warning("could not report job %s result to Hub: %s", job.get("job_id"), report_error)
def enforce_startup_preflight(env):
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
from gti_fiscal_agent.preflight import run_preflight
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.3.9")
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
if report["PREFLIGHT"] != "PASS":
logger.error("startup aborted: preflight failed (fail closed): %s", report["blockers"])
raise SystemExit(2)
return report
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--max-iterations", type=int, default=None, help="stop after N poll cycles (used by tests); omit to run forever")
args = parser.parse_args()
load_dotenv(Path(os.environ.get("GTI_AGENT_ENV_FILE") or PROJECT_ROOT / ".env"))
config = Config(os.environ)
setup_logging(config.log_path)
logger.info("startup: agent_id=%s company_id=%s agent_version=%s firebird_writes=%s", config.agent_id, config.company_id, config.agent_version, config.firebird_writes)
hub_ok = HubClient(config.hub_url, config.agent_token).health()
firebird_ok = check_connection(os.environ)
logger.info("startup checks: hub_ok=%s firebird_ok=%s", hub_ok, firebird_ok)
enforce_startup_preflight(os.environ)
run(config, os.environ, max_iterations=args.max_iterations)
logger.info("shutdown: stopped cleanly")
if __name__ == "__main__":
main()
@@ -0,0 +1,109 @@
"""Windows Service wrapper around run_agent_service.run() — reuses it as-is,
does not reimplement any polling/pipeline logic.
Requires pywin32 (`pip install pywin32`), Windows-only.
Commands (run from an elevated/administrator terminal):
python gti_agent_windows_service.py install # register the service, Startup=Automatic
python gti_agent_windows_service.py start
python gti_agent_windows_service.py stop
python gti_agent_windows_service.py restart
python gti_agent_windows_service.py remove # uninstall
python gti_agent_windows_service.py --startup=auto --username . --password . install
Recovery (restart on failure) is not set by pywin32's `install` alone; run once
after installing:
sc.exe failure "GTIFiscalAgent" reset= 86400 actions= restart/60000/restart/60000/restart/60000
This module only wraps agent/scripts/run_agent_service.py's `run()` — the
actual polling/heartbeat/pipeline logic lives there, unchanged.
"""
import sys
import threading
from pathlib import Path
AGENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(AGENT_ROOT / "scripts"))
sys.path.insert(0, str(AGENT_ROOT / "src"))
import run_agent_service as agent_service # noqa: E402
try:
import servicemanager
import win32event
import win32service
import win32serviceutil
except ImportError: # pragma: no cover — only available on Windows with pywin32 installed
servicemanager = win32event = win32service = win32serviceutil = None
class _ServiceStopFlag:
"""Plain stop flag (no signal.signal registration) — safe to flip from any
thread. The Windows Service Control Manager stops the service, not a
console SIGINT/SIGTERM, so this replaces GracefulStop for this host."""
def __init__(self):
self.stop = False
if win32serviceutil is not None:
class GTIFiscalAgentService(win32serviceutil.ServiceFramework):
_svc_name_ = "GTIFiscalAgent"
_svc_display_name_ = "GTI Fiscal Agent"
_svc_description_ = "Reads the local Firebird ERP, polls the GTI Fiscal Hub for jobs, and applies guarded fiscal updates."
def __init__(self, args):
win32serviceutil.ServiceFramework.__init__(self, args)
self.stop_event = win32event.CreateEvent(None, 0, 0, None)
self.stop_flag = _ServiceStopFlag()
self._thread = None
def SvcStop(self):
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
self.stop_flag.stop = True
win32event.SetEvent(self.stop_event)
def SvcDoRun(self):
servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE, servicemanager.PYS_SERVICE_STARTED, (self._svc_name_, ""))
self._thread = threading.Thread(target=self._run_agent, daemon=True)
self._thread.start()
win32event.WaitForSingleObject(self.stop_event, win32event.INFINITE)
def _run_agent(self):
import os
agent_service.load_dotenv(Path(os.environ.get("GTI_AGENT_ENV_FILE") or agent_service.PROJECT_ROOT / ".env"))
config = agent_service.Config(os.environ)
agent_service.setup_logging(config.log_path)
try:
agent_service.enforce_startup_preflight(os.environ) # fail closed: a failing preflight stops the service
except SystemExit:
self.stop_flag.stop = True
servicemanager.LogErrorMsg("GTIFiscalAgent: startup preflight failed; see the agent log")
win32event.SetEvent(self.stop_event)
return
try:
agent_service.run(config, os.environ, stopper=self.stop_flag)
except BaseException as error: # noqa: BLE001 - a dead loop must never look like a healthy "Running" service
import logging
logging.getLogger("gti_fiscal_agent").exception("fatal error in the Agent loop; stopping the service")
servicemanager.LogErrorMsg(f"GTIFiscalAgent: fatal error, stopping: {type(error).__name__}: {error}")
self.stop_flag.stop = True
win32event.SetEvent(self.stop_event)
def main():
if win32serviceutil is None:
raise SystemExit("pywin32 is required: pip install pywin32")
if len(sys.argv) == 1:
# SCM launches a service executable without CLI arguments. HandleCommandLine
# requires a command (install/start/debug) and exits with usage when none
# is present, which makes SCM report error 1053 for a frozen service host.
servicemanager.Initialize()
servicemanager.PrepareToHostSingle(GTIFiscalAgentService)
servicemanager.StartServiceCtrlDispatcher()
else:
win32serviceutil.HandleCommandLine(GTIFiscalAgentService)
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
"""Read-only ERP to Fiscal Hub agent."""
@@ -0,0 +1,96 @@
"""Pure capability detection / schema classification for SGV (C000025/C000004) databases. No I/O, no writes."""
WRITE_TARGETS = ["CLASSIFICACAO_FISCAL", "CFOP_ENTRADA", "CST_ENTRADA", "CFOP", "CST", "ALIQUOTA", "CEST", "SITUACAO_TRIBUTARIA", "COD_ENTRADA_COFINS",
"COD_SAIDA_COFINS", "ALIQ_COFINS", "COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "CSOSN", "IPI", "CST_IPI", "IAT", "INCIDENCIA_PISCOFINS",
"PISCOFINS", "IPPT"]
ORIGIN_FIELD = "COD_ORIGEM_MERCADORIA"
# Firebird RDB$FIELD_TYPE -> family
FAMILY = {7: "int", 8: "int", 16: "int", 10: "float", 11: "float", 27: "float", 12: "date", 13: "time", 35: "timestamp", 14: "text", 37: "text", 261: "blob"}
# accepted families per field (UI edit controls take text or numbers; numeric columns may be int/float)
ACCEPT = {"ALIQUOTA": {"float", "int"}, "IPI": {"float", "int"}, "COD_ORIGEM_MERCADORIA": {"int"}}
DEFAULT_ACCEPT = {"text", "int"} # text columns; some ERPs keep codes such as CST_ENTRADA in SMALLINT
CAPABILITIES = {
"supports_ncm": ["CLASSIFICACAO_FISCAL"],
"supports_cfop_entry": ["CFOP_ENTRADA"],
"supports_cfop_exit": ["CFOP"],
"supports_cst_entry": ["CST_ENTRADA"],
"supports_cst_exit": ["CST"],
"supports_icms_rate": ["ALIQUOTA"],
"supports_cest": ["CEST"],
"supports_csosn": ["CSOSN"],
"supports_ipi": ["IPI", "CST_IPI"],
"supports_pis": ["COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS"],
"supports_cofins": ["COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS"],
"supports_origin": [ORIGIN_FIELD],
"supports_iat": ["IAT"],
"supports_incidence": ["INCIDENCIA_PISCOFINS"],
"supports_ippt": ["IPPT"],
}
def type_ok(field, type_code):
return FAMILY.get(type_code, "other") in ACCEPT.get(field, DEFAULT_ACCEPT)
def detect_capabilities(columns):
"""columns: {FIELD: rdb_type_code} of C000025. capability=true only if every required column exists with a usable type."""
out = {}
for cap, fields in CAPABILITIES.items():
out[cap] = all(f in columns and type_ok(f, columns[f]) for f in fields)
return out
def field_compatibility(columns, reference_types=None):
"""Per write target (+ origin): PRESENT_OK / PRESENT_TYPE_VARIATION / MISSING."""
res = {}
for f in WRITE_TARGETS + [ORIGIN_FIELD]:
if f not in columns:
res[f] = "MISSING"
elif not type_ok(f, columns[f]):
res[f] = "PRESENT_TYPE_INCOMPATIBLE"
elif reference_types and reference_types.get(f) is not None and reference_types[f] != columns[f]:
res[f] = "PRESENT_TYPE_VARIATION"
else:
res[f] = "PRESENT_OK"
return res
def classify_schema(has_c000004, has_c000025, fingerprint, reference_fingerprint, compat):
"""REFERENCE_SCHEMA_MATCH | COMPATIBLE_WITH_VARIATIONS | INCOMPATIBLE. A different hash is not a failure
when every required capability exists (origin is optional: it is only PROBABLE in the map)."""
if not (has_c000004 and has_c000025):
return "INCOMPATIBLE"
required = {f: v for f, v in compat.items() if f in WRITE_TARGETS}
if any(v in ("MISSING", "PRESENT_TYPE_INCOMPATIBLE") for v in required.values()):
return "INCOMPATIBLE"
if fingerprint == reference_fingerprint:
return "REFERENCE_SCHEMA_MATCH"
return "COMPATIBLE_WITH_VARIATIONS"
NAME_KEYWORDS = {"RESTAURANT": ("RESTAURANTE", "PIZZARIA", "LANCHONETE", "CHURRASC"), "RETAIL_MARKET": ("MERCADO", "SUPERMERC", "MERCEARIA", "ATACAD"),
"APPAREL": ("MODAS", "CONFEC", "BOUTIQUE", "ROUPAS"), "CONSTRUCTION": ("CONSTRU", "MATERIAIS"), "AUTO_REPAIR": ("OFICINA", "MECANICA", "AUTO PECAS", "AUTOPECAS")}
def infer_segment(signals, company_name=""):
"""Evidence-based segment guess from populated tables/flags (never from the file name).
signals: {"restaurant_tables_rows": int, "kitchen_products": int, "recipes": int, "service_orders": int, "vehicle_products": int, "grade_items": int, "nfce_items": int}"""
ev = []
score = {"RESTAURANT": 0, "AUTO_REPAIR": 0, "APPAREL": 0, "RETAIL_MARKET": 0, "CONSTRUCTION": 0}
import unicodedata
up = unicodedata.normalize("NFKD", company_name or "").encode("ascii", "ignore").decode().upper()
for seg_name, kws in NAME_KEYWORDS.items():
hit = [k for k in kws if k in up]
if hit:
score[seg_name] += 1; ev.append(f"company name in C000004 contains {hit[0]} (weak, name is data, not the file name)")
if signals.get("restaurant_tables_rows", 0) > 50: score["RESTAURANT"] += 2; ev.append(f"mesas/comandas rows={signals['restaurant_tables_rows']}")
if signals.get("kitchen_products", 0) > 0: score["RESTAURANT"] += 1; ev.append(f"PROD_COZINHA products={signals['kitchen_products']}")
if signals.get("recipes", 0) > 0: score["RESTAURANT"] += 1; ev.append(f"recipes={signals['recipes']}")
if signals.get("service_orders", 0) > 20: score["AUTO_REPAIR"] += 2; ev.append(f"ordens de serviço={signals['service_orders']}")
if signals.get("vehicle_products", 0) > 0: score["AUTO_REPAIR"] += 1; ev.append(f"veículos={signals['vehicle_products']}")
if signals.get("grade_items", 0) > 50: score["APPAREL"] += 2; ev.append(f"grade (tamanho/cor) rows={signals['grade_items']}")
if signals.get("nfce_items", 0) > 1000 and max(score.values()) == 0: score["RETAIL_MARKET"] += 1; ev.append("high-volume NFC-e retail without restaurant/OS/grade signals")
best = max(score, key=score.get)
return (best if score[best] > 0 else "UNKNOWN"), score, ev
@@ -0,0 +1,185 @@
"""CFOP/IPPT auto-apply: fills or corrects ONLY C000025.CFOP and C000025.IPPT, and nothing else,
when central.app.field_contract.resolve_product already classified the cell AUTO_FILL_SAFE
(state=CHANGE, write_eligible=True) for this product.
Safety model (defense in depth, default OFF), mirrors gti_fiscal_agent.rtc_auto_apply: the Hub sets
a per-installation, per-capability mode DISABLED / DRY_RUN / APPROVAL_REQUIRED / ENABLED and delivers
it inside an authenticated job (central.app.cfop_ippt_audit.job_authorizations). The Agent only
exposes the restricted capability CFOP_IPPT_WRITE_V1; there is no local .env switch. Write allowlist:
ALLOWED_FIELDS = ("CFOP", "IPPT") on C000025 - no free SQL, one short transaction per cell.
Compare-before-write ("human override sempre vence"): every candidate carries the exact value the
field_contract resolver saw when it was selected (`expected_before`). Immediately before the UPDATE
the cell is re-read; if it no longer equals `expected_before` - a human (or any other process) changed
it since - the write is ABORTED with STALE_VALUE_CHANGED_SINCE_DECISION and nothing is written; if it
already equals the proposed value the write is skipped as NO_WRITE_REQUIRED (idempotent - a second run
over the same candidates always writes 0). Every applied change is read back and verified; a mismatch
never returns APPLIED. `rollback_one` restores the previous value only if the current value still
equals what was written (a later human edit blocks the rollback too).
"""
from __future__ import annotations
CAPABILITY = "CFOP_IPPT_WRITE_V1"
ALLOWED_FIELDS = ("CFOP", "IPPT")
MODES = ("DISABLED", "DRY_RUN", "APPROVAL_REQUIRED", "ENABLED")
DEFAULT_MAX_PER_JOB = 500
class CfopIpptApplyAborted(Exception):
"""The cell is left untouched; `reason` is reported in the audit."""
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
def _text(value) -> str:
return "" if value is None else str(value).strip()
def write_gate(*, mode: str, capability: bool = True, approved: bool = True) -> dict:
"""Same shape/semantics as rtc_auto_apply.write_gate: a real write needs the capability AND an
authenticated Hub mode that permits writing (ENABLED, or APPROVAL_REQUIRED for approved cells)."""
mode = mode if mode in MODES else "DISABLED"
missing = []
if not capability:
missing.append("CAPABILITY")
if mode not in ("ENABLED", "APPROVAL_REQUIRED"):
missing.append("HUB_MODE_" + mode)
elif mode == "APPROVAL_REQUIRED" and not approved:
missing.append("HUB_APPROVAL")
return {"allowed": not missing, "missing": missing, "mode": mode, "capability": bool(capability)}
def candidate_fingerprint(code: str, field: str, expected_before, proposed) -> str:
import hashlib
return hashlib.sha256("|".join((_text(code), field, _text(expected_before), _text(proposed))).encode("utf-8")).hexdigest()[:24]
def select_candidates(results: dict, *, limit: int = DEFAULT_MAX_PER_JOB) -> tuple[list[dict], int]:
"""`results` is {product_code: {field: {"state": ..., "current": ..., "proposed": ..., "write_eligible": ..., "reason": ...}}} -
the SAME per-field dict central.app.field_contract.resolve_product already returns for every product in the job's dry-run pass.
Only CFOP/IPPT cells with state=CHANGE and write_eligible=True are candidates - never a second, independent rule."""
from central.app.field_contract import CHANGE
ready = []
for code, fields in results.items():
for field in ALLOWED_FIELDS:
r = (fields or {}).get(field) or {}
if r.get("state") != CHANGE or not r.get("write_eligible"):
continue
expected_before, proposed = r.get("current"), r.get("proposed")
ready.append({"product_code": code, "field": field, "expected_before": expected_before, "proposed": proposed, "reason": r.get("reason"),
"fingerprint": candidate_fingerprint(code, field, expected_before, proposed)})
return ready[:limit], len(ready)
def apply_candidate(conn, candidate: dict, *, now: str, job_id=None) -> dict:
"""One cell, one short transaction. Raises CfopIpptApplyAborted (nothing written)."""
field = candidate["field"]
assert field in ALLOWED_FIELDS
code, expected_before, proposed = candidate["product_code"], candidate["expected_before"], candidate["proposed"]
base = {"product_code": code, "field": field, "job_id": job_id, "timestamp": now, "action": "APPLY", "reason_code": candidate.get("reason")}
cur = conn.cursor()
try:
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
rows = cur.fetchall()
if len(rows) != 1:
raise CfopIpptApplyAborted("PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND")
current = _text(rows[0][0]) or None
norm_expected = _text(expected_before) or None
if current == (_text(proposed) or None):
raise CfopIpptApplyAborted("NO_WRITE_REQUIRED")
if current != norm_expected:
raise CfopIpptApplyAborted("STALE_VALUE_CHANGED_SINCE_DECISION")
cur.execute(f"UPDATE C000025 SET {field} = ? WHERE CODIGO = ? AND (({field} IS NULL) OR (TRIM({field}) = ?))", (proposed, code, norm_expected or ""))
if cur.rowcount != 1:
conn.rollback()
raise CfopIpptApplyAborted("UPDATE_ROWCOUNT_NOT_ONE")
conn.commit()
cur = conn.cursor()
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
after = _text(cur.fetchone()[0]) or None
if after != (_text(proposed) or None):
raise RuntimeError(f"post-write verification failed for {code}.{field}: {after!r} != {proposed!r}")
return {**base, "outcome": "APPLIED", "old_value": current, "new_value": proposed, "rowcount": 1, "verified_after": True}
except CfopIpptApplyAborted:
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
raise
except Exception:
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
raise
def apply_candidates(conn, candidates: list[dict], *, now: str, job_id=None, authorize=None) -> dict:
"""Applies each candidate independently; a failure or an abort never stops the rest and never
leaves a partial change. `authorize(product_code, field)` (optional) re-asks the Hub, right before
each UPDATE, whether the central authorization still permits it."""
applied, aborted, reasons = [], [], {}
for candidate in candidates:
code, field = candidate["product_code"], candidate["field"]
if authorize is not None:
denied = authorize(code, field)
if denied:
aborted.append({"outcome": "ABORTED", "product_code": code, "field": field, "reason": denied, "job_id": job_id, "timestamp": now, "action": "APPLY"})
reasons[denied] = reasons.get(denied, 0) + 1
continue
try:
applied.append(apply_candidate(conn, candidate, now=now, job_id=job_id))
except CfopIpptApplyAborted as stop:
aborted.append({"outcome": "ABORTED", "product_code": code, "field": field, "reason": stop.reason, "job_id": job_id, "timestamp": now, "action": "APPLY"})
reasons[stop.reason] = reasons.get(stop.reason, 0) + 1
except Exception: # noqa: BLE001 - a technical failure of ONE cell must not abort the batch
aborted.append({"outcome": "FAILED", "product_code": code, "field": field, "reason": "TECHNICAL_FAILURE", "job_id": job_id, "timestamp": now})
reasons["TECHNICAL_FAILURE"] = reasons.get("TECHNICAL_FAILURE", 0) + 1
return {"applied": applied, "aborted": aborted, "aborted_reasons": reasons, "write_attempts": len(candidates), "write_successes": len(applied)}
def rollback_one(conn, item: dict, *, now: str, job_id=None) -> dict:
"""Restores C000025.<field> to `old_value` for ONE previously applied audit record, only if the
current value still equals what was written (`new_value`) - a later human edit blocks the rollback."""
field = item.get("field")
if field not in ALLOWED_FIELDS:
return {"outcome": "ABORTED", "reason": "UNKNOWN_FIELD", "field": field, "job_id": job_id, "timestamp": now}
code, written, restore_to = _text(item.get("product_code")), _text(item.get("new_value")) or None, item.get("old_value")
base = {"product_code": code, "field": field, "job_id": job_id, "timestamp": now, "action": "ROLLBACK", "rollback_of": item.get("audit_id"), "old_value": written, "new_value": restore_to}
if not code:
return {**base, "outcome": "ABORTED", "reason": "ROLLBACK_REQUEST_INVALID"}
cur = conn.cursor()
try:
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
rows = cur.fetchall()
if len(rows) != 1:
conn.rollback()
return {**base, "outcome": "ABORTED", "reason": "PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND"}
current = _text(rows[0][0]) or None
if current != written:
conn.rollback()
return {**base, "outcome": "ABORTED", "reason": "ROLLBACK_CONFLICT_VALUE_CHANGED"}
cur.execute(f"UPDATE C000025 SET {field} = ? WHERE CODIGO = ? AND (({field} IS NULL) OR (TRIM({field}) = ?))", (restore_to, code, written or ""))
if cur.rowcount != 1:
conn.rollback()
return {**base, "outcome": "ABORTED", "reason": "UPDATE_ROWCOUNT_NOT_ONE"}
conn.commit()
cur = conn.cursor()
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
after = _text(cur.fetchone()[0]) or None
if after != (_text(restore_to) or None):
raise RuntimeError("post-rollback verification failed")
return {**base, "outcome": "ROLLED_BACK", "rowcount": 1, "verified_after": True}
except Exception as error: # noqa: BLE001
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
return {**base, "outcome": "FAILED", "reason": type(error).__name__}
def rollback_records(conn, items: list[dict], *, now: str, job_id=None) -> dict:
results = [rollback_one(conn, item, now=now, job_id=job_id) for item in items]
return {"rollbacks": results, "rolled_back": sum(1 for r in results if r["outcome"] == "ROLLED_BACK")}
@@ -0,0 +1,85 @@
import time
import httpx
TRANSIENT_STATUS = (502, 503, 504) # reverse proxy / Hub restarting
BACKOFF_SECONDS = (2, 5, 10, 20, 30, 30, 30, 30, 30) # 10 attempts, ~3 minutes of outage tolerated mid-job
def _resilient(send):
"""Runs one idempotent HTTP call, retrying transport failures (connect/TLS/read/protocol errors) and proxy 502/503/504 with backoff, so a network blip in
the middle of a long job does not throw the whole job (and minutes of classification) away. Non-transient errors (4xx) are returned untouched."""
last = None
for attempt in range(len(BACKOFF_SECONDS) + 1):
try:
response = send()
if response.status_code not in TRANSIENT_STATUS or attempt == len(BACKOFF_SECONDS):
return response
last = None
except httpx.TransportError as error:
last = error
if attempt == len(BACKOFF_SECONDS):
raise
time.sleep(BACKOFF_SECONDS[attempt])
raise last # pragma: no cover
class HubClient:
def __init__(self, url, token, chunk_size=25): self.url, self.token, self.chunk_size = url.rstrip("/"), token, chunk_size
def _headers(self): return {"Authorization": f"Bearer {self.token}"}
def classify_batch(self, products):
results = []
for start in range(0, len(products), self.chunk_size):
chunk = products[start:start + self.chunk_size]
# Small chunks + generous timeout + retry with backoff on any transport error or proxy 5xx: a large first sync must survive
# a slow Hub, a reverse-proxy read timeout or a TLS/network blip instead of failing the whole job.
response = _resilient(lambda: httpx.post(f"{self.url}/v1/classify/batch", json={"products": chunk}, headers=self._headers(), timeout=120))
response.raise_for_status(); results.extend(response.json()["results"])
return results
def get_json(self, path):
response = httpx.get(f"{self.url}{path}", headers=self._headers(), timeout=30)
response.raise_for_status(); return response.json()
def identity(self): return self.get_json("/v1/agents/me/identity")
def authorizations(self): return self.get_json("/v1/agents/me/authorizations")
def resolve_product(self, payload):
"""Asks the Hub to resolve a NEW product (the Agent never contacts external providers). Bounded: the Hub answers within its own time budget."""
response = httpx.post(f"{self.url}/v1/products/resolve", json=payload, headers=self._headers(), timeout=20)
response.raise_for_status(); return response.json()
def pending_enrichment(self):
"""Codes the Hub flagged for a new evaluation (panel button, Reprocessar/Sincronizar, KB decision)."""
response = httpx.get(f"{self.url}/v1/products/enrichment/pending", headers=self._headers(), timeout=15)
response.raise_for_status(); return response.json().get("codes") or []
def report_enrichment(self, payload):
response = _resilient(lambda: httpx.post(f"{self.url}/v1/products/enrichment/applied", json=payload, headers=self._headers(), timeout=30))
response.raise_for_status(); return response.json()
def health(self):
response = httpx.get(f"{self.url}/health", timeout=10)
return response.status_code == 200
def heartbeat(self, status, schema_fingerprint=None, agent_version=None, last_job_id=None):
payload = {"status": status, "schema_fingerprint": schema_fingerprint, "agent_version": agent_version, "last_job_id": last_job_id}
response = httpx.post(f"{self.url}/v1/agents/heartbeat", json=payload, headers=self._headers(), timeout=15)
response.raise_for_status(); return response.json()
def register(self, payload):
response = httpx.post(f"{self.url}/v1/agents/register", json=payload, headers=self._headers(), timeout=15)
response.raise_for_status(); return response.json()
def heartbeat_metadata(self, payload):
response = httpx.post(f"{self.url}/v1/agents/heartbeat", json=payload, headers=self._headers(), timeout=15)
response.raise_for_status(); return response.json()
def event(self, payload):
response = httpx.post(f"{self.url}/v1/agents/events", json=payload, headers=self._headers(), timeout=15)
response.raise_for_status(); return response.json()
def next_job(self):
response = httpx.get(f"{self.url}/v1/jobs/next", headers=self._headers(), timeout=15)
response.raise_for_status(); return response.json()
def create_job(self, kind, payload=None):
response = httpx.post(f"{self.url}/v1/jobs", json={"kind": kind, "payload": payload or {}}, headers=self._headers(), timeout=15)
response.raise_for_status(); return response.json()
def get_job(self, job_id):
response = httpx.get(f"{self.url}/v1/jobs/{job_id}", headers=self._headers(), timeout=15)
response.raise_for_status(); return response.json()
def complete_job(self, job_id, result_summary):
response = _resilient(lambda: httpx.post(f"{self.url}/v1/jobs/{job_id}/complete", json={"result_summary": result_summary}, headers=self._headers(), timeout=30))
response.raise_for_status(); return response.json()
def fail_job(self, job_id, error):
response = _resilient(lambda: httpx.post(f"{self.url}/v1/jobs/{job_id}/fail", json={"error": str(error)}, headers=self._headers(), timeout=30))
response.raise_for_status(); return response.json()
@@ -0,0 +1,222 @@
"""Deterministic, auditable company-context resolution for an Agent installation (READ-ONLY).
Evidence (proved on 4 SGV databases, see docs/COMPANY_CONTEXT_RESOLUTION_V1.json):
* EMISSOR.EMITENTE_CNPJ/UF/CRT is the emitter block read by the emission programs (GtiVendas_NFCe.exe and Envio NF-e.exe both bind
QrEmissor.EMITENTE_*; neither reads C000004.CRT/OPTANTE_SIMPLES). 100% of emitted NFC-e access keys carry the CNPJ of the C000004 row
whose CNPJ equals EMISSOR.EMITENTE_CNPJ, and cUF=29.
* C000004.CRT / OPTANTE_SIMPLES belong to the ERP filial registry (SPED, filial screen) and can disagree with EMISSOR.
Never guesses: first row / lowest code / latest row are NOT used. Ambiguity -> FAIL with the reason, unless the operator configures
COMPANY_CODE or COMPANY_CNPJ (company) and COMPANY_CRT_SOURCE = EMISSOR | C000004 (explicit choice when the two CRT sources conflict).
"""
import hashlib
import os
IBGE_UF = {"11": "RO", "12": "AC", "13": "AM", "14": "RR", "15": "PA", "16": "AP", "17": "TO", "21": "MA", "22": "PI", "23": "CE", "24": "RN", "25": "PB", "26": "PE",
"27": "AL", "28": "SE", "29": "BA", "31": "MG", "32": "ES", "33": "RJ", "35": "SP", "41": "PR", "42": "SC", "43": "RS", "50": "MS", "51": "MT", "52": "GO",
"53": "DF"}
VALID_CRT = {"1", "2", "3"}
class CompanyContextError(Exception):
def __init__(self, result):
super().__init__(f"COMPANY_CONTEXT=FAIL ({result.get('classification')}): {'; '.join(result.get('reasons', []))}")
self.result = result
def digits(value):
return "".join(c for c in str(value or "") if c.isdigit())
def mask_cnpj(value):
d = digits(value)
return f"{d[:2]}**********{d[-2:]}" if len(d) >= 4 else None
def cnpj_fingerprint(value):
d = digits(value)
return hashlib.sha256(d.encode()).hexdigest()[:8] if d else None
def _s(v):
return None if v is None else str(v).strip()
def config_from_env(env):
crt_source = (env.get("COMPANY_CRT_SOURCE") or "").strip().upper() or None
if crt_source == "AUTO": # AUTO = resolve automatically, only when there is no conflict
crt_source = None
invalid = False
else:
invalid = bool(crt_source and crt_source not in ("EMISSOR", "C000004"))
uf = (env.get("COMPANY_UF") or "").strip().upper() or None
return {"company_code": (env.get("COMPANY_CODE") or "").strip() or None, "company_cnpj": digits(env.get("COMPANY_CNPJ")) or None,
"crt_source": crt_source if crt_source in ("EMISSOR", "C000004") else None, "crt_source_invalid": invalid,
"uf_override": uf}
def resolve(evidence, config=None):
"""evidence: {"c000004": [{code, cnpj, ie, uf, crt, optante, name}], "emissor": [{cnpj, uf, crt}],
"nfce_keys": [{"cuf", "cnpj", "n"}], "nf_by_filial": {code: n}}. Pure function."""
cfg = config or {}
out = {"status": "FAIL", "classification": "UNRESOLVED", "company_code": None, "company_cnpj": None, "company_cnpj_fingerprint": None, "company_uf": None, "company_crt": None,
"company_contribuinte_ipi": None, "source_of_truth": {}, "confidence": "NONE", "evidence": [], "warnings": [], "reasons": [], "configured": False}
def fail(cls, reason):
out["classification"] = cls; out["reasons"].append(reason); return out
if cfg.get("crt_source_invalid"):
return fail("CONFIG_REQUIRED", "COMPANY_CRT_SOURCE must be EMISSOR or C000004")
companies = [{**c, "cnpj": digits(c.get("cnpj"))} for c in evidence.get("c000004", [])]
emissors = [{**e, "cnpj": digits(e.get("cnpj"))} for e in evidence.get("emissor", [])]
docs = evidence.get("nfce_keys", [])
if not companies:
return fail("UNRESOLVED", "C000004 has no company rows")
# ---- company selection -------------------------------------------------------------------
selected = None; basis = None
by_code, by_cnpj = cfg.get("company_code"), cfg.get("company_cnpj")
if by_code or by_cnpj:
out["configured"] = True
cand = [c for c in companies if (not by_code or c["code"] == by_code) and (not by_cnpj or c["cnpj"] == by_cnpj)]
if len(cand) != 1:
return fail("CONFIG_REQUIRED", "configured company (COMPANY_CODE/COMPANY_CNPJ) does not identify exactly one C000004 row")
selected, basis = cand[0], "CONFIGURED"
elif len(companies) == 1:
selected, basis = companies[0], "SINGLE_C000004_ROW"
elif len(emissors) == 1 and emissors[0]["cnpj"]:
match = [c for c in companies if c["cnpj"] == emissors[0]["cnpj"]]
if len(match) != 1:
return fail("CONFIG_REQUIRED", f"{len(companies)} C000004 rows and the EMISSOR CNPJ matches {len(match)} of them: configure COMPANY_CODE or COMPANY_CNPJ")
selected, basis = match[0], "EMISSOR_CNPJ_MATCH"
else:
return fail("CONFIG_REQUIRED", f"{len(companies)} C000004 rows and no unique EMISSOR row to disambiguate: configure COMPANY_CODE or COMPANY_CNPJ")
out["evidence"].append(f"company selected by {basis}")
out.update(company_code=selected["code"], company_cnpj=mask_cnpj(selected["cnpj"]), company_cnpj_fingerprint=cnpj_fingerprint(selected["cnpj"]))
out["source_of_truth"]["company"] = basis
contribuinte_ipi = _s(selected.get("contribuinte_ipi"))
if contribuinte_ipi and contribuinte_ipi.upper() in ("S", "N"): # same row already proven to be THE company; no extra query, no extra trust needed
out["company_contribuinte_ipi"] = contribuinte_ipi.upper()
out["source_of_truth"]["contribuinte_ipi"] = "C000004"
# ---- emitter (EMISSOR) must describe the same company --------------------------------------
emissor = None
if emissors:
same = [e for e in emissors if e["cnpj"] and e["cnpj"] == selected["cnpj"]]
if same:
emissor = same[0]; out["evidence"].append("EMISSOR.EMITENTE_CNPJ equals the selected company CNPJ")
else:
return fail("CONFLICT", "EMISSOR emitter CNPJ differs from the selected company (unexplained): refusing to guess")
else:
out["warnings"].append("NO_EMISSOR_ROW")
# ---- emitted documents must belong to the same CNPJ ------------------------------------------
doc_ufs = set()
if docs:
total = sum(d["n"] for d in docs); own = sum(d["n"] for d in docs if digits(d["cnpj"]) == selected["cnpj"])
out["evidence"].append(f"emitted NFC-e keys: {own}/{total} carry the selected CNPJ")
if own == 0:
return fail("CONFLICT", f"none of the {total} emitted NFC-e keys carries the selected company CNPJ")
if own != total: # e.g. a database copy/migration that kept another emitter's history: evidence, not a reason to guess
out["warnings"].append(f"HISTORICAL_NFCE_KEYS_FROM_OTHER_CNPJ {total - own}/{total}")
doc_ufs = {IBGE_UF.get(d["cuf"]) for d in docs if digits(d["cnpj"]) == selected["cnpj"]} - {None}
# ---- UF ------------------------------------------------------------------------------------------
uf_sources = {}
if emissor and _s(emissor.get("uf")): uf_sources["EMISSOR"] = _s(emissor["uf"]).upper()
if _s(selected.get("uf")): uf_sources["C000004"] = _s(selected["uf"]).upper()
if len(doc_ufs) == 1: uf_sources["DOCUMENT_KEYS"] = next(iter(doc_ufs))
if len(doc_ufs) > 1: return fail("CONFLICT", "emitted NFC-e keys carry several UFs")
values = set(uf_sources.values())
override = cfg.get("uf_override")
if len(values) > 1:
return fail("CONFLICT", f"UF_SOURCE_CONFLICT {uf_sources}")
if override and values and override not in values:
return fail("CONFLICT", f"COMPANY_UF={override} differs from the UF proved by the database {sorted(values)}: an override never replaces the resolver silently")
if not values and override: # explicit override only when the database proves nothing
uf_sources["CONFIG_OVERRIDE"] = override; values = {override}; out["warnings"].append("UF_FROM_EXPLICIT_OVERRIDE")
if not values:
return fail("UNRESOLVED", "UF_SOURCE_UNKNOWN")
out["company_uf"] = next(iter(values)); out["source_of_truth"]["uf"] = "+".join(uf_sources)
out["uf_status"] = "UF_SOURCE_CONFIRMED"
# ---- CRT ------------------------------------------------------------------------------------------
e_crt = _s(emissor.get("crt")) if emissor else None
c_crt = _s(selected.get("crt"))
e_ok, c_ok = e_crt in VALID_CRT, c_crt in VALID_CRT
if e_ok and c_ok and e_crt == c_crt:
crt, src, status = e_crt, "EMISSOR+C000004", "CRT_SOURCE_CONFIRMED"
elif e_ok and c_ok:
choice = cfg.get("crt_source")
if not choice:
out["crt_candidates"] = {"EMISSOR": e_crt, "C000004": c_crt}
return fail("CONFLICT", f"CRT_SOURCE_CONFLICT EMISSOR={e_crt} C000004={c_crt}: configure COMPANY_CRT_SOURCE (emission programs read EMISSOR)")
crt, src, status = (e_crt if choice == "EMISSOR" else c_crt), f"{choice} (configured; conflict acknowledged)", "CRT_SOURCE_CONFLICT_RESOLVED_BY_CONFIG"
out["warnings"].append(f"CRT_CONFLICT EMISSOR={e_crt} C000004={c_crt}")
elif e_ok or c_ok:
crt, src, status = (e_crt, "EMISSOR", "CRT_SOURCE_SINGLE") if e_ok else (c_crt, "C000004", "CRT_SOURCE_SINGLE")
out["warnings"].append("CRT_HAS_A_SINGLE_SOURCE")
else:
return fail("UNRESOLVED", "CRT_SOURCE_UNKNOWN (no valid CRT in EMISSOR or C000004)")
out.update(company_crt=crt, crt_status=status); out["source_of_truth"]["crt"] = src
opt = _s(selected.get("optante"))
if opt and ((crt in ("1", "2") and opt.upper() != "S") or (crt == "3" and opt.upper() == "S")):
out["warnings"].append(f"OPTANTE_SIMPLES={opt} inconsistent with CRT={crt}")
agree = len(uf_sources) + (2 if status == "CRT_SOURCE_CONFIRMED" else 1)
out["confidence"] = "HIGH" if (status == "CRT_SOURCE_CONFIRMED" and len(uf_sources) >= 2) else "MEDIUM"
out["status"] = "PASS"
out["classification"] = "CONFIGURED" if out["configured"] or status == "CRT_SOURCE_CONFLICT_RESOLVED_BY_CONFIG" else "AUTO_RESOLVED"
out["reasons"] = []
out["evidence_source_count"] = agree
return out
def collect_evidence(cursor):
"""Read-only SELECTs. Missing optional tables/columns simply yield less evidence."""
cursor.execute("SELECT TRIM(RDB$RELATION_NAME), TRIM(RDB$FIELD_NAME) FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME IN ('C000004','EMISSOR','NFCE','C000061')")
cols = {}
for t, f in cursor.fetchall():
cols.setdefault(t, set()).add(f)
ev = {"c000004": [], "emissor": [], "nfce_keys": [], "nf_by_filial": {}}
c4 = cols.get("C000004", set())
want = [("CODIGO", "code"), ("CNPJ", "cnpj"), ("IE", "ie"), ("UF", "uf"), ("CRT", "crt"), ("OPTANTE_SIMPLES", "optante"), ("FILIAL", "name"), ("CONTRIBUINTE_IPI", "contribuinte_ipi")]
use = [(c, k) for c, k in want if c in c4]
if use:
cursor.execute(f"SELECT {','.join(c for c, _ in use)} FROM C000004")
for row in cursor.fetchall():
ev["c000004"].append({k: _s(v) for (_, k), v in zip(use, row)})
em = cols.get("EMISSOR", set())
want = [("EMITENTE_CNPJ", "cnpj"), ("EMITENTE_UF", "uf"), ("EMITENTE_CRT", "crt")]
use = [(c, k) for c, k in want if c in em]
if use:
cursor.execute(f"SELECT {','.join(c for c, _ in use)} FROM EMISSOR")
for row in cursor.fetchall():
ev["emissor"].append({k: _s(v) for (_, k), v in zip(use, row)})
if "CHAVE" in cols.get("NFCE", set()):
cursor.execute("SELECT SUBSTRING(CHAVE FROM 1 FOR 2), SUBSTRING(CHAVE FROM 7 FOR 14), COUNT(*) FROM NFCE WHERE CHAVE IS NOT NULL AND CHAR_LENGTH(TRIM(CHAVE)) = 44 "
"GROUP BY SUBSTRING(CHAVE FROM 1 FOR 2), SUBSTRING(CHAVE FROM 7 FOR 14)")
ev["nfce_keys"] = [{"cuf": _s(a), "cnpj": _s(b), "n": n} for a, b, n in cursor.fetchall()]
if "CODFILIAL" in cols.get("C000061", set()):
cursor.execute("SELECT CODFILIAL, COUNT(*) FROM C000061 GROUP BY CODFILIAL")
ev["nf_by_filial"] = {_s(a): n for a, n in cursor.fetchall()}
return ev
def resolve_from_cursor(cursor, env=None):
return resolve(collect_evidence(cursor), config_from_env(env or os.environ))
def preflight_company_context(env=None):
"""Opens the configured Firebird database read-only and resolves the company. Never prints the full CNPJ."""
from .firebird_reader import _connect
env = env or os.environ
conn = _connect(env)
try:
return resolve_from_cursor(conn.cursor(), env)
finally:
conn.close()
def preflight_report(result):
keys = ("company_code", "company_cnpj", "company_uf", "company_crt", "company_contribuinte_ipi", "source_of_truth", "confidence", "classification", "warnings", "reasons")
return {"COMPANY_CONTEXT": "PASS" if result["status"] == "PASS" else "FAIL", **{k: result.get(k) for k in keys}}
@@ -0,0 +1,92 @@
"""Read-only fiscal coverage report for a set of products (used by the Agent's REPROCESS_ALL result and by the multi-database tooling).
Pure computation over Hub decisions + product rows + local evidence. It reuses the shipped pure engines in central.app
(fiscal_decision, product_defaults, icms_context) — no fiscal rule lives here, only guards for the company context:
the BA Anexo 1 ST/MVA dataset is applied to UF=BA only and CSOSN logic only under CRT=1.
"""
from collections import Counter
PRODUCT_COLUMNS = ("CODIGO", "PRODUTO", "CLASSIFICACAO_FISCAL", "CFOP", "CSOSN", "CST", "CEST", "IPI", "EX_TIPI", "TIPO", "PROD_COZINHA", "PRODUTO_SERVICO", "SITUACAO_TRIBUTARIA")
def _s(v):
return None if v is None else str(v).strip()
def compute_coverage(products, decisions, company, company_default, refs, evidence):
"""products/decisions aligned lists. company: {"uf","crt"} (resolved). evidence: {"recipes","composed","purchases","stock_entry"}."""
from central.app.fiscal_decision import decide_product
from central.app.icms_context import csosn_v2, product_role, st_status, withheld_status
from central.app.product_defaults import cfop_default
uf_ba = company.get("uf") == "BA"
crt1 = company.get("crt") == "1"
recipes, composed = evidence.get("recipes", set()), evidence.get("composed", set())
purchases, stock_entry = evidence.get("purchases", {}), evidence.get("stock_entry", {})
m, cfop_values, situ = Counter(), Counter(), Counter()
for p, d in zip(products, decisions):
p = {c: p.get(c) for c in PRODUCT_COLUMNS}
code = _s(p["CODIGO"]); p["CODIGO"] = code
cls = d.get("classification") or {}
rec = decide_product(p, d, {"uf": company.get("uf") or "", "crt": company.get("crt") or ""}, refs)
ncm_status = d.get("status"); raw_ncm = _s(p["CLASSIFICACAO_FISCAL"])
m["NCM_MISSING" if not raw_ncm else ("NCM_VALID" if ncm_status == "EXACT_NCM" else ("NCM_LEGACY" if ncm_status == "LEGACY_NCM" else "NCM_INVALID"))] += 1
ipi = rec["IPI"]; rk = (cls.get("ipi") or {}).get("rate_kind"); ist = d.get("ipi_status")
if ist == "IPI_RESOLVED":
m[{"NUMERIC": "IPI_NUMERIC", "NON_NUMERIC": "IPI_NON_NUMERIC", "BLANK": "IPI_SOURCE_BLANK"}.get(rk, "IPI_NO_MATCH")] += 1
elif ist == "IPI_CANDIDATES":
m["IPI_AMBIGUOUS"] += 1
elif ist != "LEGACY_NCM" and raw_ncm:
m["IPI_NO_MATCH"] += 1
if ipi["action_class"] == "ALREADY_CORRECT":
m["IPI_ALREADY_CORRECT"] += 1
if ipi["write_eligible"]:
m["IPI_CHANGE_REQUIRED"] += 1; m["IPI_WRITE_ELIGIBLE"] += 1
cs_ = d.get("cest_status")
if cs_ == "CEST_CONFIRMED":
m["CEST_CONFIRMED"] += 1
elif cs_ == "CEST_CANDIDATES":
m["CEST_CANDIDATES"] += 1
cf = cfop_default(p, ncm_status, d.get("ba_st_mva_status") if uf_ba else None, refs["cfop_valid"])
cfop_values[_s(p["CFOP"])] += 1
m["CFOP_RESOLVED" if cf["status"] == "CFOP_DEFAULT_RESOLVED" else "CFOP_CONTEXT_REQUIRED"] += 1
ba = cls.get("ba_st_mva") or {}
if uf_ba:
st, _ = st_status(ncm_status, d.get("ba_st_mva_status"), cs_, p["CEST"], ba.get("cest"), None)
else:
st = "ST_UNKNOWN"
m[st] += 1
wh, _ = withheld_status(purchases.get(code, []))
m[{"ST_ALREADY_WITHHELD_CONFIRMED": "ST_WITHHOLDING_CONFIRMED", "ST_ALREADY_WITHHELD_NOT_CONFIRMED": "ST_WITHHOLDING_NOT_CONFIRMED"}.get(wh, "ST_WITHHOLDING_UNKNOWN")] += 1
role, _ = product_role(p["TIPO"], p["PROD_COZINHA"] == 1, code in recipes, code in composed, bool(_s(p["PRODUTO_SERVICO"])), bool(purchases.get(code)) or stock_entry.get(code, False))
m["PRODUCT_ROLE_KNOWN" if role != "UNKNOWN" else "PRODUCT_ROLE_UNKNOWN"] += 1
m["ROLE_" + role] += 1
state = csosn_v2(p["CSOSN"], company_default, ncm_status, st if uf_ba else "ST_UNKNOWN", wh, role, refs["csosn"])["status"] if crt1 else "CSOSN_CONTEXT_REQUIRED"
m["CSOSN_RESOLVED" if state == "CSOSN_RESOLVED" else ("CSOSN_CANDIDATES" if state == "CSOSN_CANDIDATE" else "CSOSN_CONTEXT_REQUIRED")] += 1
situ[_s(p["SITUACAO_TRIBUTARIA"])] += 1
n = len(products)
fill = lambda f: sum(1 for p in products if _s(p.get(f)) not in (None, "")) # noqa: E731
return {"TOTAL_PRODUCTS": n, "metrics": dict(m),
"IPI_RESOLVED": m["IPI_NUMERIC"] + m["IPI_NON_NUMERIC"] + m["IPI_SOURCE_BLANK"],
"quality": {"ncm_valid_share": round(m["NCM_VALID"] / n, 4) if n else None, "cest_filled": fill("CEST"), "csosn_filled": fill("CSOSN"), "cfop_filled": fill("CFOP"), "ipi_filled": fill("IPI"),
"cfop_distinct": len(cfop_values), "cfop_top": cfop_values.most_common(6), "situacao_tributaria": dict(situ.most_common(6)),
"purchase_items_C000088": sum(len(v) for v in purchases.values()), "products_with_purchase_evidence": len(purchases), "recipes": len(recipes), "composed_products": len(composed)},
"guards_applied": {"st_dataset_ba_only": not uf_ba, "csosn_requires_crt1": not crt1}}
def build_refs(get, products, company_uf):
"""Reference-data snapshot from the Hub (`get(path) -> json`). Used by the engines for domain validity only."""
refs = {}
for key, domain in (("csosn", "CSOSN"), ("cst_icms", "CST_ICMS"), ("cst_pis", "CST_PIS"), ("cst_cofins", "CST_COFINS"), ("origem", "ORIGEM_MERCADORIA")):
refs[key] = set(get(f"/v1/reference/fiscal_codes/{domain}")["codes"])
valid = set()
for code in {_s(p.get(f)) for p in products for f in ("CFOP", "CFOP_ENTRADA") if _s(p.get(f))}:
if get(f"/v1/reference/cfop/{code}")["found"]:
valid.add(code)
refs["cfop_valid"] = valid
refs["fcp_uf"] = {}
fcp = get(f"/v1/reference/fcp/{company_uf}")
if fcp.get("found"):
refs["fcp_uf"][company_uf] = fcp
refs["cbs_rate_2026"] = get("/v1/reference/rtc_cbs_rate/2026").get("rate")
refs["cclasstrib_count"] = sum(d["record_count"] for d in get("/v1/datasets/status") if d["kind"] == "RTC_CCLASSTRIB" and d["status"] == "ACTIVE")
return refs
@@ -0,0 +1,137 @@
"""Windows DPAPI credential store for the GTI Fiscal Agent.
The permanent Agent credential (AGENT_TOKEN) must NEVER live in `agent.env`
(which is world-readable config). It is stored in a dedicated
`credentials.bin` file, encrypted with DPAPI in LocalMachine scope
(`CRYPTPROTECT_LOCAL_MACHINE`), so any local Windows account on the host can
decrypt it — and the file ACL is tightened to SYSTEM/Administrators only via
`icacls`. The token is decrypted in-process at runtime by the service host and
never logged.
`write_credential`/`read_credential` are shared by Setup.exe (writes at
install time) and the Service host (reads at every start).
Fallback: a plaintext token file is accepted ONLY in development, guarded by
`GTI_ALLOW_PLAINTEXT_CREDENTIAL=1`. Production Setup.exe never enables it.
"""
import os
import subprocess
import sys
from pathlib import Path
_PROGRAM_DIR = Path("C:/Program Files/GTI/FiscalAgent")
_MAGIC = b"GTIDPAPI1"
_DEFAULT_FLAGS = 0x1 | 0x4 # CRYPTPROTECT_UI_FORBIDDEN | CRYPTPROTECT_LOCAL_MACHINE
class CredentialStoreError(Exception):
pass
class _DpapiLazy:
"""Lazy wrapper around win32crypt so importing this module (and the whole
agent package) works on non-Windows hosts without pywin32. Functions accept
the same signatures pywin32 exposes on this machine's build, tolerating both
the tuple and the bytes return shapes seen across pywin32 versions."""
def __init__(self):
self._crypt = None
def _api(self):
if self._crypt is None:
try:
import win32crypt
except Exception as exc: # noqa: BLE001
raise CredentialStoreError(f"win32crypt unavailable: {exc}") from exc
self._crypt = win32crypt
return self._crypt
def protect(self, data):
crypt = self._api()
returned = crypt.CryptProtectData(data, "gti-fiscal-agent-credential", None, None, None, _DEFAULT_FLAGS)
if isinstance(returned, tuple):
returned, _ = returned
return bytes(returned)
def unprotect(self, blob):
crypt = self._api()
returned = crypt.CryptUnprotectData(bytes(blob), None, None, None, 0)
if isinstance(returned, tuple):
_, returned = returned
return bytes(returned)
_dpapi = _DpapiLazy()
def _dpapi_available() -> bool:
try:
_dpapi._api()
return True
except CredentialStoreError:
return False
def default_credential_path() -> Path:
return _PROGRAM_DIR / "credentials.bin"
def _apply_acl(path: Path) -> None:
"""Tighten ACL so only SYSTEM and Administrators can read/modify the blob.
Well-known SIDs are used (`*S-1-5-18` = SYSTEM, `*S-1-5-32-544` = BUILTIN\
Administrators) because account-name mapping is locale-dependent and fails
on non-English Windows. Setup.exe runs elevated (`--uac-admin`), so icacls
succeeds (the owner — the installing administrator — can change the DACL)."""
cmd = ["icacls", str(path), "/inheritance:r", "/grant:r", "*S-1-5-18:(F)", "*S-1-5-32-544:(F)"]
try:
result = subprocess.run(cmd, capture_output=True, text=True)
except FileNotFoundError as exc: # non-Windows host without icacls
raise CredentialStoreError(f"icacls unavailable: {exc}") from exc
if result.returncode != 0:
raise CredentialStoreError(f"icacls failed: {result.stdout} {result.stderr}")
def write_credential(path, payload: str) -> None:
"""Encrypt `payload` with DPAPI LocalMachine and write `path` with a
SYSTEM/Administrators-only ACL. Fails closed: no plaintext file is ever
created on this path."""
if isinstance(payload, str):
payload = payload.encode("utf-8")
if not _dpapi_available():
raise CredentialStoreError("DPAPI unavailable; refusing to store the credential as plaintext (fail closed)")
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
blob = _MAGIC + _dpapi.protect(payload)
path.write_bytes(blob)
try:
_apply_acl(path)
except CredentialStoreError:
path.unlink(missing_ok=True) # do not leave a credential file without its ACL
raise
def read_credential(path, *, env=None) -> str:
"""Decrypt `path`. A plaintext file is honored ONLY when
`GTI_ALLOW_PLAINTEXT_CREDENTIAL=1` is set in `env`/environment (development)."""
env = env if env is not None else os.environ
path = Path(path)
if not path.exists():
raise CredentialStoreError(f"credential file missing: {path}")
raw = path.read_bytes()
if raw.startswith(_MAGIC):
return _dpapi.unprotect(raw[len(_MAGIC):]).decode("utf-8")
if (env.get("GTI_ALLOW_PLAINTEXT_CREDENTIAL") or "").strip() != "1":
raise CredentialStoreError("credential file is plaintext and GTI_ALLOW_PLAINTEXT_CREDENTIAL is not set; refusing (fail closed)")
return raw.decode("utf-8").strip()
def install_env_from_credentials(path, env) -> str:
"""Fill `env["AGENT_TOKEN"]` from `credentials.bin` unless the env already
carries an explicit token (which wins). Returns the effective token —
WITHOUT writing it into `agent.env` — and never logs it."""
existing = env.get("AGENT_TOKEN") or env.get("AGENT_DEV_TOKEN") or ""
if existing:
return existing
token = read_credential(path, env=env)
env["AGENT_TOKEN"] = token
return token
@@ -0,0 +1,530 @@
"""New-product enrichment on the Agent: detect NEW rows of C000025, ask the Hub, write ONLY safe empty fields.
Safety model (defense in depth; nothing here is switchable locally - the Hub owns the per-installation feature flags):
- Feature gate: nothing touches Firebird unless the Hub says features_configured AND enrichment_mode != DISABLED. DRY_RUN never opens a write connection.
- Scope: only products that appear AFTER the baseline (taken when the function is first active). The historical catalog is never sent anywhere.
- No trigger, no table, no procedure in the SGV database: detection is one `SELECT COUNT(*)` per poll (+ a code diff only when the count moved, and every 60 s as a safety net).
- Typed capability PRODUCT_ENRICHMENT_WRITE_V1: ALLOWED_FIELDS is an explicit allowlist on C000025; the only statement is a fixed UPDATE ... WHERE CODIGO=? AND (field IS NULL OR TRIM(field)=''),
one short transaction per field, followed by a re-read. COD_ORIGEM_MERCADORIA and cClassTrib are NOT in the list (cClassTrib stays with the existing RTC gates).
- Right before EVERY field write: re-read the row, re-check the Hub authorization (fail closed), skip when a human already touched the field.
- Human overrides: after writing, the Agent watches the value; anything different from what it wrote (including a deleted value) marks the field HUMAN_OVERRIDDEN forever, is reported to the Hub
and is never written again by the enrichment (nor re-suggested by the Hub).
"""
from __future__ import annotations
import json
import logging
import sqlite3
import time
from datetime import datetime, timezone
logger = logging.getLogger("gti_fiscal_agent.enrichment")
CAPABILITY = "PRODUCT_ENRICHMENT_WRITE_V1"
ALLOWED_FIELDS = ("CLASSIFICACAO_FISCAL", "CEST", "IPI")
# TECHNICAL_PLACEHOLDER: the SGV refuses a product without NCM, so operators type a filler. It means "not classified yet" (not a human decision) and may be replaced.
# Explicit per-field allowlist, identical to central/app/enrichment/placeholders.py (parity test). Nothing else is ever overwritten.
PLACEHOLDERS = {"CLASSIFICACAO_FISCAL": frozenset({"00000000"})} # explicit allowlist; anything else raises before touching SQL
# READ-ONLY reporting to the Hub for field_contract.resolve_product's post-NCM fiscal-completion decisions (CFOP/CST/CSOSN/PIS-COFINS/...).
# This is NOT a write allowlist - PRODUCT_ENRICHMENT_WRITE_V1 still only ever writes ALLOWED_FIELDS above. Sending the CURRENT value of these
# fields (and the classification columns below) just lets the Hub's field_contract engine see what's already filled and never guess.
FISCAL_COMPLETION_FIELDS = ("SITUACAO_TRIBUTARIA", "CFOP", "CFOP_ENTRADA", "CST", "CST_ENTRADA", "CSOSN", "ALIQUOTA", "CST_IPI",
"COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS",
"INCIDENCIA_PISCOFINS", "PISCOFINS", "IAT", "IPPT")
# Classification metadata field_contract.infer_role reads to auto-discover product_role (RESALE/OWN_PRODUCTION/INPUT/SERVICE) from real ERP
# facts instead of asking the operator. Read-only here too.
CLASSIFICATION_CONTEXT_FIELDS = ("CODGRUPO", "TIPO", "PROD_COZINHA", "PRODUTO_SERVICO")
CURRENT_REPORT_FIELDS = ALLOWED_FIELDS + FISCAL_COMPLETION_FIELDS + CLASSIFICATION_CONTEXT_FIELDS
READ_FIELDS = ("CODIGO", "PRODUTO", "GTIN", "CODBARRA") + CURRENT_REPORT_FIELDS # the SGV operator types the EAN in CODBARRA; GTIN (fiscal) is mostly empty
POLL_SECONDS = 2.0
SAFETY_DIFF_SECONDS = 60.0
OVERRIDE_CHECK_SECONDS = 20.0
PENDING_POLL_SECONDS = 10.0 # cheap GET: products the Hub flagged for a new evaluation
WATCH_WINDOW_SECONDS = 120.0 # a fresh product is re-read for a while: the operator may add the EAN seconds after the first save
RETRY_BACKOFF = (30, 60, 120, 300, 600, 1800)
def _now() -> float:
return time.time()
def _text(value) -> str:
return "" if value is None else str(value).strip()
class EnrichmentStore:
"""Own SQLite connection (the worker runs in its own thread)."""
def __init__(self, path):
self.db = sqlite3.connect(str(path), timeout=10, check_same_thread=False)
self.db.row_factory = sqlite3.Row
self.db.executescript("""
CREATE TABLE IF NOT EXISTS enrich_meta(k TEXT PRIMARY KEY, v TEXT);
CREATE TABLE IF NOT EXISTS enrich_known(code TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS enrich_products(
code TEXT PRIMARY KEY, state TEXT NOT NULL, detected_at REAL NOT NULL, next_attempt_at REAL, attempts INTEGER NOT NULL DEFAULT 0,
snapshot TEXT, written TEXT NOT NULL DEFAULT '{}', overridden TEXT NOT NULL DEFAULT '{}', last_state TEXT);
""")
self.db.commit()
def meta(self, key, default=None):
row = self.db.execute("SELECT v FROM enrich_meta WHERE k=?", (key,)).fetchone()
return row["v"] if row else default
def set_meta(self, key, value):
self.db.execute("INSERT INTO enrich_meta(k,v) VALUES(?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v", (key, str(value)))
self.db.commit()
def known(self) -> set[str]:
return {r["code"] for r in self.db.execute("SELECT code FROM enrich_known")}
def add_known(self, codes):
self.db.executemany("INSERT OR IGNORE INTO enrich_known(code) VALUES(?)", [(c,) for c in codes])
self.db.commit()
def enqueue(self, code, now=None):
now = _now() if now is None else now
self.db.execute("INSERT OR IGNORE INTO enrich_products(code,state,detected_at,next_attempt_at) VALUES(?,?,?,?)", (code, "PENDING", now, now))
self.db.commit()
def get(self, code):
row = self.db.execute("SELECT * FROM enrich_products WHERE code=?", (code,)).fetchone()
return dict(row) if row else None
def due(self, now):
return [dict(r) for r in self.db.execute("SELECT * FROM enrich_products WHERE state IN ('PENDING','RETRY') AND next_attempt_at<=? ORDER BY detected_at LIMIT 20", (now,))]
def watching(self, now):
return [dict(r) for r in self.db.execute("SELECT * FROM enrich_products WHERE state IN ('DONE','DRY_RUN_DONE') AND detected_at>=?", (now - WATCH_WINDOW_SECONDS,))]
def tracked_written(self):
return [dict(r) for r in self.db.execute("SELECT * FROM enrich_products WHERE written != '{}'")]
def update(self, code, **values):
for key in ("written", "overridden"):
if key in values and not isinstance(values[key], str):
values[key] = json.dumps(values[key])
sets = ",".join(f"{k}=?" for k in values)
self.db.execute(f"UPDATE enrich_products SET {sets} WHERE code=?", (*values.values(), code))
self.db.commit()
def is_overridden(self, code, field) -> bool:
row = self.get(code)
return bool(row and json.loads(row["overridden"] or "{}").get(field))
def is_human_overridden(store_path, code, field) -> bool:
"""Used by every automatic writer of the Agent (the Fiscal Corrector included) before touching a field the enrichment tracks."""
try:
return EnrichmentStore(store_path).is_overridden(str(code), field)
except Exception: # noqa: BLE001 - no state means nothing is protected yet
return False
# ---- Firebird access (injected: `read_conn()` returns a READ-ONLY connection, `write_conn()` a write-capable one, only ever opened by ENABLED mode) ----------------------------------------
def is_placeholder(field, value) -> bool:
text = _text(value)
text = "".join(ch for ch in text if ch.isdigit()) if field == "CLASSIFICACAO_FISCAL" else text
return text in PLACEHOLDERS.get(field, frozenset())
def writable_now(field, value) -> bool:
return not _text(value) or is_placeholder(field, value)
def _digits(value) -> str:
return "".join(ch for ch in _text(value) if ch.isdigit())
def gtin_check_ok(value) -> bool:
"""Same acceptance as central.app.enrichment.gtin (8/12/13/14 digits + check digit); parity is asserted by a test. Only used to pick WHICH column to send: the Hub decides."""
d = _digits(value)
if len(d) not in (8, 12, 13, 14) or len(set(d)) == 1:
return False
total = sum(int(x) * (3 if i % 2 == 0 else 1) for i, x in enumerate(reversed(d[:-1])))
return (10 - total % 10) % 10 == int(d[-1])
def pick_gtin(row) -> str:
"""First VALID of GTIN / CODBARRA; otherwise whatever was typed (the Hub classifies it INVALID_GTIN/MISSING_GTIN without calling any provider)."""
candidates = [_text(row.get("GTIN")), _text(row.get("CODBARRA"))]
return next((c for c in candidates if gtin_check_ok(c)), next((c for c in candidates if c), ""))
def read_codes(conn) -> set[str]:
cur = conn.cursor()
cur.execute("SELECT CODIGO FROM C000025")
codes = {_text(r[0]) for r in cur.fetchall()}
conn.commit()
return codes
def read_count(conn) -> int:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM C000025")
n = int(cur.fetchone()[0])
conn.commit()
return n
def read_rows(conn, codes) -> dict[str, dict]:
out = {}
codes = list(codes)
for start in range(0, len(codes), 100):
chunk = codes[start:start + 100]
cur = conn.cursor()
cur.execute(f"SELECT {','.join(READ_FIELDS)} FROM C000025 WHERE CODIGO IN ({','.join('?' for _ in chunk)})", tuple(chunk))
for row in cur.fetchall():
rec = dict(zip(READ_FIELDS, row))
out[_text(rec["CODIGO"])] = rec
conn.commit()
return out
def write_field(conn, code: str, field: str, value, observed=None, guard_observed: bool = False) -> dict:
"""PRODUCT_ENRICHMENT_WRITE_V1: one field, one short transaction, only if the field is still empty. Returns {outcome, reason?}."""
if field not in ALLOWED_FIELDS:
raise ValueError(f"field {field!r} is outside the PRODUCT_ENRICHMENT_WRITE_V1 allowlist")
if field == "IPI":
value = float(value)
else:
value = _digits(value)
if not value or (field == "CLASSIFICACAO_FISCAL" and len(value) != 8) or (field == "CEST" and len(value) != 7):
return {"outcome": "ABORTED", "reason": "VALUE_SHAPE_INVALID"}
cur = conn.cursor()
try:
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
rows = cur.fetchall()
if len(rows) != 1:
conn.rollback()
return {"outcome": "ABORTED", "reason": "PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND"}
raw = rows[0][0]
if guard_observed and _text(raw) != _text(observed): # STALE_CONFLICT: the value changed between the Hub decision and now (e.g. a person typed something) -> zero write
conn.rollback()
return {"outcome": "ABORTED", "reason": "STALE_CONFLICT"}
if not writable_now(field, raw):
conn.rollback()
return {"outcome": "ABORTED", "reason": "VALUE_ALREADY_SET_NEVER_OVERWRITTEN"}
if _text(raw): # technical placeholder: compare-and-swap on the exact value just read
cur.execute(f"UPDATE C000025 SET {field} = ? WHERE CODIGO = ? AND TRIM({field}) = ?", (value, code, _text(raw)))
else:
cur.execute(f"UPDATE C000025 SET {field} = ? WHERE CODIGO = ? AND ({field} IS NULL OR TRIM({field}) = '')", (value, code))
if cur.rowcount != 1:
conn.rollback()
return {"outcome": "ABORTED", "reason": "UPDATE_ROWCOUNT_NOT_ONE"}
conn.commit()
cur = conn.cursor()
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
after = cur.fetchone()[0]
conn.commit()
same = (float(after) == value) if field == "IPI" and after is not None else _text(after) == value
if not same:
return {"outcome": "FAILED", "reason": "POST_WRITE_VERIFICATION_FAILED"}
return {"outcome": "APPLIED", "value": value, "rowcount": 1, "replaced_placeholder": _text(raw) if _text(raw) else None}
except Exception as error: # noqa: BLE001
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
return {"outcome": "FAILED", "reason": type(error).__name__}
def _same_value(field, current, written) -> bool:
if field == "IPI":
try:
return current is not None and _text(current) != "" and abs(float(current) - float(written)) < 1e-9
except (TypeError, ValueError):
return False
return _text(current) == _text(written) and _text(current) != ""
class EnrichmentWorker:
def __init__(self, *, hub, store: EnrichmentStore, read_conn, write_conn, create_reprocess=None, clock=_now, sleep=time.sleep):
self.hub, self.store, self.read_conn, self.write_conn = hub, store, read_conn, write_conn
self.create_reprocess, self.clock, self.sleep = create_reprocess, clock, sleep
self._features = None
self._features_at = 0.0
self._last_count = None
self._last_diff = 0.0
self._last_override = 0.0
self._auth_cache = (None, 0.0)
self._count_ms = 0.0
self._last_pending = 0.0
self._wconn = None
self.metrics = {"detected": 0, "resolved": 0, "fields_written": 0, "detect_latency_ms": []}
def _open(self, factory, attempts=3):
"""Firebird can drop a connection under load ('connection lost'): retry briefly instead of aborting the product."""
for attempt in range(attempts):
try:
return factory()
except Exception: # noqa: BLE001
if attempt == attempts - 1:
raise
self.sleep(0.3 * (attempt + 1))
# ---- feature gate ------------------------------------------------------------------------------------------------------------------------------
def features(self, max_age=10.0) -> dict:
if self._features is None or self.clock() - self._features_at > max_age:
try:
auth = self.hub.authorizations()
self._features = (auth or {}).get("features") or {}
except Exception: # noqa: BLE001 - Hub offline: keep the last known gate, never invent one
self._features = self._features or {}
self._features_at = self.clock()
return self._features
def mode(self) -> str:
f = self.features()
if not f.get("features_configured"):
return "WAITING_FEATURE_CONFIGURATION"
return f.get("enrichment_mode") or "DISABLED"
def _fresh_enabled(self) -> bool:
"""Fail-closed authorization read right before a write (cached ~3 s)."""
auth, at = self._auth_cache
if auth is None or self.clock() - at > 3.0:
try:
auth = self.hub.authorizations().get("features") or {}
except Exception: # noqa: BLE001
return False
self._auth_cache = (auth, self.clock())
return bool(auth.get("features_configured")) and auth.get("enrichment_mode") == "ENABLED"
# ---- detection ---------------------------------------------------------------------------------------------------------------------------------
def detect(self) -> list[str]:
conn = self._open(self.read_conn)
try:
if self.store.meta("baseline_done") != "1":
codes = read_codes(conn)
self.store.add_known(codes) # historical catalog: known, never sent
self.store.set_meta("baseline_done", "1")
self.store.set_meta("baseline_at", datetime.now(timezone.utc).isoformat())
self.store.set_meta("baseline_count", len(codes))
self._last_count, self._last_diff = len(codes), self.clock()
return []
if self.store.meta("rebaseline") == "1":
# the function was inactive (DISABLED / WAITING_FEATURE_CONFIGURATION) for a while: whatever appeared meanwhile is NOT "new" for the enrichment
# (a long-disabled installation must never send a burst of products to the providers when it is switched on again)
codes = read_codes(conn)
self.store.add_known(codes)
self.store.set_meta("rebaseline", "0")
self._last_count, self._last_diff = len(codes), self.clock()
return []
started = self.clock()
t0 = time.monotonic()
count = read_count(conn)
self._count_ms = (time.monotonic() - t0) * 1000
moved = self._last_count is None or count != self._last_count
if not moved and self.clock() - self._last_diff < SAFETY_DIFF_SECONDS:
return []
codes = read_codes(conn)
self._last_count, self._last_diff = len(codes), self.clock()
new = sorted(codes - self.store.known())
self.store.add_known(new)
for code in new:
self.store.enqueue(code, self.clock())
if new:
self.metrics["detected"] += len(new)
self.metrics["detect_latency_ms"].append(int((self.clock() - started) * 1000))
return new
finally:
conn.close()
# ---- one product ---------------------------------------------------------------------------------------------------------------------------------
def process(self, item: dict):
code, mode = item["code"], self.mode()
conn = self._open(self.read_conn)
try:
rows = read_rows(conn, [code])
row = rows.get(code)
if row is None or mode not in ("DRY_RUN", "ENABLED"):
ev = None
else:
from .firebird_reader import read_product_evidence # targeted, indexed by product_code - never a full-table scan (see docstring)
ev = read_product_evidence(conn, code)
finally:
conn.close()
if row is None:
self.store.update(code, state="GONE")
return
snapshot = f"{pick_gtin(row)}|{_text(row['PRODUTO'])}"
if mode not in ("DRY_RUN", "ENABLED"):
self.store.update(code, state="SKIPPED_FEATURE", snapshot=snapshot)
return
attempts = int(item["attempts"] or 0) + 1
current = {f: row[f] for f in CURRENT_REPORT_FIELDS}
try:
result = self.hub.resolve_product({"product_code": code, "gtin": pick_gtin(row), "description": _text(row["PRODUTO"]), "ev": {k: (sorted(v) if isinstance(v, set) else v) for k, v in ev.items()},
"current": {k: (None if v is None else str(v)) for k, v in current.items()}})
except Exception as error: # noqa: BLE001 - Hub offline / slow: the product stays pending, the SGV is unaffected
delay = RETRY_BACKOFF[min(attempts - 1, len(RETRY_BACKOFF) - 1)]
self.store.update(code, state="RETRY", attempts=attempts, next_attempt_at=self.clock() + delay, last_state=f"HUB_ERROR:{type(error).__name__}")
return
self.metrics["resolved"] += 1
state = result.get("state")
if state == "FEATURE_DISABLED":
self.store.update(code, state="SKIPPED_FEATURE", snapshot=snapshot, last_state=state)
return
if state in ("PENDING_PROVIDER_QUOTA", "PENDING_PROVIDERS_OFFLINE"):
retry_at = result.get("retry_at")
due = self.clock() + RETRY_BACKOFF[min(attempts - 1, len(RETRY_BACKOFF) - 1)]
if retry_at:
try:
due = max(due, datetime.fromisoformat(retry_at).timestamp())
except ValueError:
pass
self.store.update(code, state="RETRY", attempts=attempts, next_attempt_at=due, last_state=state, snapshot=snapshot)
return
fields = result.get("fields") or {}
reports = []
written = json.loads(item.get("written") or "{}")
overridden = json.loads(item.get("overridden") or "{}")
for field in ALLOWED_FIELDS:
decision = fields.get(field) or {}
status, value = decision.get("status"), decision.get("value")
placeholder = _text(current.get(field)) if is_placeholder(field, current.get(field)) else None
base = {"field": field, "rule": decision.get("rule"), "kb_status": decision.get("kb_status"), "evidence": decision.get("evidence") or [], "old": placeholder, "new": value}
if status == "HUMAN_OVERRIDDEN" or overridden.get(field):
reports.append({**base, "outcome": "HUMAN_OVERRIDDEN", "reason": "HUMAN_OVERRIDDEN", "new": None})
continue
if status != "AUTO_FILL_SAFE" or value in (None, ""):
continue
if mode == "DRY_RUN":
reports.append({**base, "outcome": "DRY_RUN", "reason": "DRY_RUN_NO_WRITE"})
continue
outcome = self._write_safely(code, field, value, observed=current.get(field))
reports.append({**base, "outcome": outcome["outcome"], "reason": outcome.get("reason")})
if outcome["outcome"] == "APPLIED":
written[field] = value
self.metrics["fields_written"] += 1
self.store.update(code, written=written) # remembered at once: a crash right after a write still leaves it protected/tracked
self._close_write()
self.store.update(code, state="DRY_RUN_DONE" if mode == "DRY_RUN" else "DONE", attempts=attempts, snapshot=snapshot, written=written, last_state=state, next_attempt_at=None)
if reports:
try:
self.hub.report_enrichment({"product_code": code, "fields": reports, "dataset_versions": result.get("dataset_versions") or {}})
except Exception: # noqa: BLE001 - audit delivery is retried on the next cycle via last_state
self.store.update(code, last_state="AUDIT_PENDING")
if written and self.create_reprocess is not None and mode == "ENABLED":
try:
self.create_reprocess([code]) # cClassTrib is decided by the existing RTC gates, never here
except Exception: # noqa: BLE001
logger.warning("reprocess request for %s failed", code)
def _write_safely(self, code, field, value, observed=None) -> dict:
"""Per field: fresh central authorization (fail closed) + guarded typed UPDATE. write_field re-reads the field INSIDE its own transaction and refuses when anything is there."""
if not self._fresh_enabled():
return {"outcome": "ABORTED", "reason": "AUTHORIZATION_CHANGED"}
try:
if self._wconn is None:
self._wconn = self._open(self.write_conn)
return write_field(self._wconn, code, field, value, observed=observed, guard_observed=True)
except Exception as error: # noqa: BLE001 - one field failing never breaks the others or the SGV
self._close_write()
return {"outcome": "FAILED", "reason": type(error).__name__}
def _close_write(self):
conn, self._wconn = self._wconn, None
if conn is not None:
try:
conn.close()
except Exception: # noqa: BLE001
pass
def reevaluate_flagged(self) -> list[str]:
"""'Reavaliar' in the panel, Reprocessar/Sincronizar and KB decisions: re-run the normal resolve for tracked, not-yet-enriched products (KB/cache first, the Hub decides about providers)."""
try:
codes = self.hub.pending_enrichment()
except Exception: # noqa: BLE001 - Hub offline: try again next cycle
return []
done = []
for code in codes[:25]:
item = self.store.get(code)
if item is None:
self.store.enqueue(code, self.clock())
item = self.store.get(code)
try:
self.process(item)
except Exception as error: # noqa: BLE001
self._close_write()
logger.warning("re-evaluation of %s failed: %s", code, type(error).__name__)
done.append(code)
return done
# ---- human override watch --------------------------------------------------------------------------------------------------------------------------
def check_overrides(self):
tracked = self.store.tracked_written()
if not tracked:
return
conn = self._open(self.read_conn)
try:
rows = read_rows(conn, [t["code"] for t in tracked])
finally:
conn.close()
for item in tracked:
row = rows.get(item["code"])
written, overridden = json.loads(item["written"]), json.loads(item["overridden"] or "{}")
new = []
for field, value in written.items():
if overridden.get(field) or row is None:
continue
if not _same_value(field, row[field], value): # different value OR blanked/deleted: a person decided, forever
overridden[field] = True
new.append({"field": field, "outcome": "HUMAN_OVERRIDDEN", "reason": "VALUE_CHANGED_AFTER_WRITE", "old": value, "new": None})
if new:
self.store.update(item["code"], overridden=overridden)
try:
self.hub.report_enrichment({"product_code": item["code"], "fields": new})
except Exception: # noqa: BLE001 - local protection is already in place; the Hub copy follows on the next cycle
logger.warning("override report for %s failed", item["code"])
# ---- loop ----------------------------------------------------------------------------------------------------------------------------------------
def step(self):
"""One iteration; returns the list of processed product codes."""
mode = self.mode()
if mode not in ("DRY_RUN", "ENABLED"):
if self.store.meta("baseline_done") == "1":
self.store.set_meta("rebaseline", "1")
return [] # WAITING_FEATURE_CONFIGURATION / DISABLED: zero Firebird activity
self.detect()
done = []
for item in self.store.due(self.clock()):
try:
self.process(item)
except Exception as error: # noqa: BLE001 - keep the product retryable, never lose the batch
self._close_write()
attempts = int(item["attempts"] or 0) + 1
self.store.update(item["code"], state="RETRY", attempts=attempts, next_attempt_at=self.clock() + RETRY_BACKOFF[min(attempts - 1, len(RETRY_BACKOFF) - 1)], last_state=f"ERROR:{type(error).__name__}")
logger.warning("enrichment of %s failed: %s", item["code"], type(error).__name__)
done.append(item["code"])
for item in self.store.watching(self.clock()): # operator completes the record (EAN typed a moment later): re-resolve once per change
rows = None
conn = self._open(self.read_conn)
try:
rows = read_rows(conn, [item["code"]])
finally:
conn.close()
row = (rows or {}).get(item["code"])
if row and f"{pick_gtin(row)}|{_text(row['PRODUTO'])}" != item.get("snapshot"):
self.process(item)
done.append(item["code"])
if self.clock() - self._last_pending >= PENDING_POLL_SECONDS:
self._last_pending = self.clock()
done += self.reevaluate_flagged()
if self.clock() - self._last_override >= OVERRIDE_CHECK_SECONDS:
self._last_override = self.clock()
self.check_overrides()
return done
def poll_seconds(self) -> float:
"""Adaptive: the SGV is never loaded by more than ~2.5% of one query-time. 3 s on small catalogs, longer when COUNT(*) is slow (huge catalogs); never above 30 s."""
return min(30.0, max(POLL_SECONDS, 40 * self._count_ms / 1000))
def run_forever(self, stopper):
while not stopper.stop:
try:
self.step()
except Exception: # noqa: BLE001 - the enrichment thread must never die or affect the SGV
logger.exception("enrichment step failed")
self.sleep(self.poll_seconds())
@@ -0,0 +1,182 @@
"""Shared read-only Firebird access for the Agent: full table or a CODIGO subset."""
import os
FIELDS = "CODIGO,PRODUTO,GTIN AS EAN_GTIN,NCM,CLASSIFICACAO_FISCAL,CFOP,CST,ALIQUOTA,CEST,CSOSN,IPI,EX_TIPI"
# Present in every audited SGV database but selected only when the column exists (capability-based, never assumed).
OPTIONAL_FIELDS = ("TIPO", "PROD_COZINHA", "PRODUTO_SERVICO", "SITUACAO_TRIBUTARIA")
def _select_fields(cursor):
cursor.execute("SELECT TRIM(RDB$FIELD_NAME) FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = 'C000025'")
have = {r[0] for r in cursor.fetchall()}
return FIELDS + "".join(f",{c}" for c in OPTIONAL_FIELDS if c in have)
def _connect(env, read_only=True):
import fdb
kwargs = dict(
database=env["FIREBIRD_DATABASE_PATH"],
user=env["FB_USER"],
password=env["FB_PASSWORD"],
fb_library_name=env.get("FIREBIRD_CLIENT_LIBRARY"),
)
if read_only:
kwargs["isolation_level"] = fdb.ISOLATION_LEVEL_READ_COMMITED_RO
return fdb.connect(**kwargs)
def check_connection(env=None):
"""Best-effort read-only connectivity probe. Returns True/False, never raises."""
env = env or os.environ
try:
conn = _connect(env)
try:
conn.cursor().execute("SELECT 1 FROM RDB$DATABASE")
finally:
conn.close()
return True
except Exception:
return False
def read_products(env=None, codigos=None):
env = env or os.environ
conn = _connect(env)
try:
cursor = conn.cursor()
fields = _select_fields(cursor)
if codigos:
placeholders = ",".join("?" for _ in codigos)
cursor.execute(f"SELECT {fields} FROM C000025 WHERE CODIGO IN ({placeholders}) ORDER BY CODIGO", tuple(codigos))
else:
cursor.execute(f"SELECT {fields} FROM C000025 ORDER BY CODIGO")
names = [column[0].strip() for column in cursor.description]
return [dict(zip(names, row)) for row in cursor.fetchall()]
finally:
conn.close()
def parse_company_context(rows):
"""Pure: rows of (UF, CRT) from C000004 -> {"uf","crt"}. Fails closed (ValueError) if absent,
blank or ambiguous — the engines never fall back to a hardcoded UF/CRT."""
pairs = {(str(uf or "").strip().upper(), str(crt or "").strip()) for uf, crt in rows}
if len(pairs) != 1:
raise ValueError(f"company context unavailable or ambiguous ({len(pairs)} distinct UF/CRT rows in C000004)")
uf, crt = next(iter(pairs))
if len(uf) != 2 or crt not in ("1", "2", "3"):
raise ValueError(f"company context invalid: UF={uf!r} CRT={crt!r}")
return {"uf": uf, "crt": crt}
def read_company_context(env=None):
"""Resolve the ACTIVE company (UF/CRT) with the deterministic resolver (see company_context.py); fails closed with
CompanyContextError when the context is ambiguous, conflicting or unresolved. No hardcoded company values, no first-row fallback."""
from .company_context import CompanyContextError, preflight_company_context
result = preflight_company_context(env or os.environ)
if result["status"] != "PASS":
raise CompanyContextError(result)
return {"uf": result["company_uf"], "crt": result["company_crt"], "company_code": result["company_code"], "contribuinte_ipi": result.get("company_contribuinte_ipi"),
"source_of_truth": result["source_of_truth"], "confidence": result["confidence"]}
def read_product_evidence(conn, code):
"""Targeted, single-product evidence for an ad-hoc /v1/products/resolve call (new-product enrichment) - the same facts `read_evidence`
already gathers for a full sync job (recipes/composed/purchases/stock), but scoped to ONE product_code via an indexed WHERE, never a
full-table scan. Measured on a real production-equivalent database: read_evidence's full scan took ~40ms total for the whole catalog,
so this is not a correctness-driven optimization - it is what the mission explicitly asked for (never transport/scan the whole dataset
for one ad-hoc product). Every table/column is optional: a missing one just means less evidence (capability-based), matching
`read_evidence`. Takes an already-open connection (the SAME one the caller already opened for `read_rows`) - never opens its own,
so it works with the EnrichmentWorker's injected `read_conn` (including test fakes; a non-Firebird connection has no RDB$RELATION_FIELDS,
so the capability probe below degrades to "no evidence" rather than raising)."""
from collections import defaultdict
cur = conn.cursor()
cols = defaultdict(set)
try:
cur.execute("SELECT TRIM(RDB$RELATION_NAME), TRIM(RDB$FIELD_NAME) FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME IN ('C000032','C000088','C000100','RECEITA_INGREDIENTES')")
for t, f in cur.fetchall():
cols[t].add(f)
except Exception:
return {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}}
has = lambda t, *c: t in cols and all(x in cols[t] for x in c) # noqa: E731
s = lambda v: None if v is None else str(v).strip() # noqa: E731
ev = {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}}
code_padded = str(code).zfill(6)
if has("RECEITA_INGREDIENTES", "CODRECEITA"):
cur.execute("SELECT FIRST 1 CODRECEITA FROM RECEITA_INGREDIENTES WHERE CODRECEITA = ?", (code_padded,))
if cur.fetchone():
ev["recipes"] = {code_padded}
if has("C000032", "CODPRODUTO", "TIPO"):
# Counted, never a bare "ever happened" check: a single COMPOSICAO row among many VEND rows
# (e.g. a beer occasionally used as a recipe component) is not proof the product IS own
# production - central.app.field_contract.infer_role needs the ratio to judge that.
cur.execute("SELECT TRIM(TIPO), COUNT(*) FROM C000032 WHERE CODPRODUTO = ? GROUP BY 1", (code,))
counts = {(t or ""): n for t, n in cur.fetchall()}
composed_n = counts.get("COMPOSICAO", 0)
other_n = sum(n for t, n in counts.items() if t != "COMPOSICAO")
if composed_n or other_n:
ev["composed_ratio"][s(code)] = {"composed": composed_n, "other": other_n}
if composed_n:
ev["composed"] = {s(code)}
if has("C000088", "CODPRODUTO", "ICMS_RETIDO", "ICMS_VALORRETIDO", "SUB_VALOR"):
cur.execute("SELECT ICMS_RETIDO, ICMS_VALORRETIDO, SUB_VALOR FROM C000088 WHERE CODPRODUTO = ?", (code,))
rows = [{"icms_retido": s(ret), "valor_retido": vr, "sub_valor": sv} for ret, vr, sv in cur.fetchall()]
if rows:
ev["purchases"][s(code)] = rows
if has("C000100", "CODPRODUTO", "ULTIMA_ENTRADA", "NOTA_FISCAL"):
cur.execute("SELECT FIRST 1 ULTIMA_ENTRADA, NOTA_FISCAL FROM C000100 WHERE CODPRODUTO = ?", (code,))
row = cur.fetchone()
if row:
ev["stock_entry"][s(code)] = row[0] is not None or s(row[1]) not in (None, "", "000000")
if has("C000100", "CODPRODUTO", "ENTRADAS"):
cur.execute("SELECT FIRST 1 ENTRADAS FROM C000100 WHERE CODPRODUTO = ?", (code,))
row = cur.fetchone()
if row:
ev["stock_in"][s(code)] = bool(row[0] and float(row[0]) > 0)
return ev
def read_evidence(env=None, selected_company_code=None):
"""Read-only evidence tables used by the coverage report (purchases, stock entries, recipes, compositions) and the company's
default CSOSN. Every table/column is optional: a missing one just means less evidence (capability-based)."""
from collections import defaultdict
env = env or os.environ
conn = _connect(env)
try:
cur = conn.cursor()
cur.execute("SELECT TRIM(RDB$RELATION_NAME), TRIM(RDB$FIELD_NAME) FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME IN ('C000004','C000032','C000088','C000100','RECEITA_INGREDIENTES')")
cols = defaultdict(set)
for t, f in cur.fetchall():
cols[t].add(f)
has = lambda t, *c: t in cols and all(x in cols[t] for x in c) # noqa: E731
s = lambda v: None if v is None else str(v).strip() # noqa: E731
ev = {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": defaultdict(list), "stock_entry": {}, "stock_in": {}, "company_default_csosn": None}
if has("RECEITA_INGREDIENTES", "CODRECEITA"):
cur.execute("SELECT CODRECEITA FROM RECEITA_INGREDIENTES WHERE CODRECEITA IS NOT NULL")
ev["recipes"] = {str(r[0]).zfill(6) for r in cur.fetchall()}
if has("C000032", "CODPRODUTO", "TIPO"):
# Counted per product, never a bare "ever happened" check - see read_product_evidence for why
# (a single COMPOSICAO row among many VEND rows is not proof of own production).
cur.execute("SELECT CODPRODUTO, TRIM(TIPO), COUNT(*) FROM C000032 GROUP BY 1, 2")
counts = defaultdict(lambda: {"composed": 0, "other": 0})
for cod, tipo, n in cur.fetchall():
counts[s(cod)]["composed" if tipo == "COMPOSICAO" else "other"] += n
ev["composed_ratio"] = dict(counts)
ev["composed"] = {code for code, c in counts.items() if c["composed"]}
if has("C000088", "CODPRODUTO", "ICMS_RETIDO", "ICMS_VALORRETIDO", "SUB_VALOR"):
cur.execute("SELECT CODPRODUTO, ICMS_RETIDO, ICMS_VALORRETIDO, SUB_VALOR FROM C000088")
for cod, ret, vr, sv in cur.fetchall():
ev["purchases"][s(cod)].append({"icms_retido": s(ret), "valor_retido": vr, "sub_valor": sv})
if has("C000100", "CODPRODUTO", "ULTIMA_ENTRADA", "NOTA_FISCAL"):
cur.execute("SELECT CODPRODUTO, ULTIMA_ENTRADA, NOTA_FISCAL FROM C000100")
ev["stock_entry"] = {s(r[0]): (r[1] is not None or s(r[2]) not in (None, "", "000000")) for r in cur.fetchall()}
if has("C000100", "CODPRODUTO", "ENTRADAS"):
cur.execute("SELECT CODPRODUTO, ENTRADAS FROM C000100")
ev["stock_in"] = {s(r[0]): bool(r[1] and float(r[1]) > 0) for r in cur.fetchall()}
if has("C000004", "ICMS_CSOSN", "CODIGO"):
cur.execute("SELECT CODIGO, ICMS_CSOSN FROM C000004")
rows = {s(a): s(b) for a, b in cur.fetchall()}
ev["company_default_csosn"] = rows.get(selected_company_code) if selected_company_code else (next(iter(rows.values())) if len(rows) == 1 else None)
ev["purchases"] = dict(ev["purchases"])
return ev
finally:
conn.close()
@@ -0,0 +1,33 @@
"""Decide whether a classified product's IPI rate may be auto-applied.
Scope is deliberately narrow: C000025.IPI only, and only when the Hub's TIPI
match is a single unambiguous numeric rate. Never auto-applies NT/non-numeric
markers, blank source rates, ambiguous EX candidates, or legacy/invalid NCM.
"""
def ipi_write_decision(decision, current_ipi):
"""current_ipi must be a value freshly read from Firebird immediately before
this call — None here means the ERP field is genuinely NULL right now, not
that it wasn't read."""
if decision.get("status") == "LEGACY_NCM":
return {"eligible": False, "reason_code": "LEGACY_NCM", "proposed_value": None}
if decision.get("ipi_status") != "IPI_RESOLVED":
return {"eligible": False, "reason_code": f"IPI_STATUS_{decision.get('ipi_status')}", "proposed_value": None}
ipi = (decision.get("classification") or {}).get("ipi")
if not ipi:
return {"eligible": False, "reason_code": "NO_IPI_DECISION", "proposed_value": None}
rate_kind = ipi.get("rate_kind")
if rate_kind != "NUMERIC":
return {"eligible": False, "reason_code": f"RATE_KIND_{rate_kind}", "proposed_value": None}
proposed_value = ipi.get("rate_numeric")
if proposed_value is None:
return {"eligible": False, "reason_code": "NO_NUMERIC_VALUE", "proposed_value": None}
if current_ipi is not None and str(current_ipi).strip() == str(proposed_value).strip():
return {"eligible": False, "reason_code": "SAME_VALUE", "proposed_value": proposed_value}
return {"eligible": True, "reason_code": "CHANGE_REQUIRED", "proposed_value": proposed_value}
@@ -0,0 +1,99 @@
"""Shared per-product IPI auto-apply pipeline: Hub decision -> dry-run ->
guarded write -> local SQLite audit. Used by both the manual CLI
(run_agent_auto_apply.py) and the long-running job-polling service
(run_agent_service.py) so the processing logic exists in exactly one place.
"""
from datetime import datetime, timezone
from .ipi_auto_apply import ipi_write_decision
from .runner import product_fingerprint
from central.app.operational_state import classify_product_state, summarize_operational_states
def now():
return datetime.now(timezone.utc).isoformat()
def run_pipeline(products, decisions, state, firebird_writes, write_connection, executor, database_path, authorized_path=None):
"""Process every (product, decision) pair. Returns a summary dict.
products come from a fresh Firebird read (current IPI is always "known").
decisions come from the Hub's /v1/classify/batch, one per product, same order.
"""
summary = {
"processed": 0,
"auto_apply_eligible": 0,
"writes_attempted": 0,
"writes_succeeded": 0,
"writes_failed": 0,
"skipped_not_eligible": 0,
"no_ops": 0,
"product_states": [],
"blocked_products": [],
"resolved_products": [],
}
for product, decision in zip(products, decisions):
code = product["CODIGO"]
fingerprint = product_fingerprint(product)
current_ipi = product.get("IPI")
summary["processed"] += 1
wd = ipi_write_decision(decision, current_ipi)
decision_status = decision.get("status")
ipi_status = decision.get("ipi_status")
operational = classify_product_state(product, decision)
summary["product_states"].append(operational)
if operational["state"].startswith("BLOCKED_"):
summary["blocked_products"].append({
"company": product.get("COMPANY") or product.get("company") or "",
"product_code": code,
"gtin": product.get("GTIN") or product.get("EAN_GTIN"),
"description": product.get("PRODUTO") or product.get("description"),
"blocker_type": operational["state"],
"field": "NCM",
"reason_code": decision_status or "MISSING_EVIDENCE",
"evidence_summary": {"decision_status": decision_status, "ipi_status": ipi_status},
"input_fingerprint": fingerprint,
})
else:
summary["resolved_products"].append(code)
if not firebird_writes:
state.log_ipi_write(code, fingerprint, decision_status, ipi_status, "DRY_RUN_ONLY", current_ipi, wd["proposed_value"], None, wd["reason_code"], now())
if wd["eligible"]:
summary["auto_apply_eligible"] += 1
elif wd["reason_code"] == "SAME_VALUE":
summary["no_ops"] += 1
continue
if not wd["eligible"]:
state.log_ipi_write(code, fingerprint, decision_status, ipi_status, "SKIPPED_NOT_ELIGIBLE", current_ipi, wd["proposed_value"], None, wd["reason_code"], now())
summary["skipped_not_eligible"] += 1
if wd["reason_code"] == "SAME_VALUE":
summary["no_ops"] += 1
continue
summary["auto_apply_eligible"] += 1
summary["writes_attempted"] += 1
proposal = {
"product_code": code,
"field": "IPI",
"current_value": current_ipi,
"proposed_value": wd["proposed_value"],
"status": "CHANGE_REQUIRED",
"write_eligible": True,
"source_dataset": "TIPI_IPI",
"source_version": decision.get("dataset_versions", {}).get("tipi_ipi"),
}
try:
record = executor.execute_guarded_update(write_connection, database_path=database_path, proposal=proposal, now=now(), authorized_path=authorized_path)
state.log_ipi_write(code, fingerprint, decision_status, ipi_status, "SUCCEEDED", record.before_value, record.after_value, f"rowcount={record.rowcount}", wd["reason_code"], now())
summary["writes_succeeded"] += 1
summary["product_states"][-1] = classify_product_state(product, decision, write_status="SUCCEEDED")
except executor.WriteRefused as error:
state.log_ipi_write(code, fingerprint, decision_status, ipi_status, "FAILED", current_ipi, wd["proposed_value"], str(error), wd["reason_code"], now())
summary["writes_failed"] += 1
summary["product_states"][-1] = classify_product_state(product, decision, write_status="FAILED")
summary["operational"] = summarize_operational_states(summary["product_states"])
return summary
@@ -0,0 +1,147 @@
"""Single installation preflight for the GTI Fiscal Agent. READ-ONLY: never writes to Firebird, never prints secrets.
PREFLIGHT=PASS | FAIL (+ blockers, one line each)
Checks: config, FISCAL_MODE/FIREBIRD_WRITES, Hub reachable/auth/version, Firebird reachable + version + ODS, C000004/C000025,
capabilities (minimum: NCM, IPI, CEST), company context (UF/CRT, deterministic resolver), schema/type guards, SQLite state, log dir.
"""
import os
import sqlite3
from pathlib import Path
AGENT_VERSION_DEFAULT = "1.3.9"
REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")
REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest")
FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY")
def _check(name, ok, detail="", warn=False):
return {"name": name, "status": "PASS" if ok else ("WARN" if warn else "FAIL"), "detail": detail}
def major_minor(version):
parts = str(version or "").replace("-", ".").replace("rc", ".").split(".")
return ".".join(parts[:2])
def default_http_get(url, token=None, timeout=10):
import httpx
headers = {"Authorization": f"Bearer {token}"} if token else {}
r = httpx.get(url, headers=headers, timeout=timeout)
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
def firebird_probe(env):
"""Read-only introspection of the configured database."""
import fdb
from .company_context import collect_evidence, config_from_env, resolve
conn = fdb.connect(database=env["FIREBIRD_DATABASE_PATH"], user=env["FB_USER"], password=env["FB_PASSWORD"], fb_library_name=env.get("FIREBIRD_CLIENT_LIBRARY"),
isolation_level=fdb.ISOLATION_LEVEL_READ_COMMITED_RO)
try:
cur = conn.cursor()
cur.execute("""SELECT TRIM(rf.RDB$RELATION_NAME), TRIM(rf.RDB$FIELD_NAME), f.RDB$FIELD_TYPE, COALESCE(f.RDB$CHARACTER_LENGTH, f.RDB$FIELD_LENGTH)
FROM RDB$RELATION_FIELDS rf JOIN RDB$FIELDS f ON f.RDB$FIELD_NAME = rf.RDB$FIELD_SOURCE WHERE rf.RDB$RELATION_NAME IN ('C000004', 'C000025')""")
tables = {}
for t, c, ty, ln in cur.fetchall():
tables.setdefault(t, {})[c] = (ty, ln)
products = None
if "C000025" in tables:
cur.execute("SELECT COUNT(*) FROM C000025")
products = cur.fetchone()[0]
company = resolve(collect_evidence(cur), config_from_env(env))
return {"server_version": getattr(conn, "server_version", None), "ods": f"{getattr(conn, 'ods_version', '?')}.{getattr(conn, 'ods_minor_version', '?')}",
"tables": tables, "products": products, "company": company}
finally:
conn.close()
def run_preflight(env=None, *, http_get=default_http_get, probe=firebird_probe, agent_version=None):
from .capabilities import detect_capabilities, type_ok
from .company_context import preflight_report
env = env if env is not None else os.environ
checks, info = [], {}
agent_version = agent_version or env.get("AGENT_VERSION") or AGENT_VERSION_DEFAULT
missing = [k for k in REQUIRED_ENV if not (env.get(k) or "").strip()]
checks.append(_check("CONFIG", not missing, f"missing: {', '.join(missing)}" if missing else "required variables present"))
mode = (env.get("FISCAL_MODE") or "").strip().upper()
writes = (env.get("FIREBIRD_WRITES") or "0").strip() == "1"
checks.append(_check("FISCAL_MODE", mode in FISCAL_MODES, f"FISCAL_MODE={mode or 'unset'} (must be one of {FISCAL_MODES})"))
dry_conflict = mode == "DRY_RUN" and writes
checks.append(_check("FIREBIRD_WRITES", not dry_conflict, "FIREBIRD_WRITES=1 conflicts with FISCAL_MODE=DRY_RUN (writes stay disabled; fix the config)" if dry_conflict else f"requested={int(writes)}"))
effective = writes and mode == "AUTO_APPLY"
info["effective_firebird_writes"] = effective
if effective:
ok = bool((env.get("AUTHORIZED_WRITE_DATABASE_PATH") or "").strip() and (env.get("EXPECTED_SCHEMA_FINGERPRINT") or "").strip())
checks.append(_check("WRITE_AUTHORIZATION", ok, "AUTO_APPLY needs AUTHORIZED_WRITE_DATABASE_PATH and EXPECTED_SCHEMA_FINGERPRINT (two independent layers)"))
hub = (env.get("GTI_HUB_URL") or "").rstrip("/")
hub_ok = False
if hub:
try:
code, _ = http_get(f"{hub}/health")
hub_ok = code == 200
checks.append(_check("HUB_REACHABLE", hub_ok, f"GET /health -> {code}"))
except Exception as e: # noqa: BLE001
checks.append(_check("HUB_REACHABLE", False, type(e).__name__))
if hub_ok:
try:
code, body = http_get(f"{hub}/version")
hub_v = body.get("version")
info["hub_version"] = hub_v
checks.append(_check("HUB_VERSION_COMPATIBLE", code == 200 and major_minor(hub_v) == major_minor(agent_version), f"hub={hub_v} agent={agent_version} (major.minor must match)"))
except Exception as e: # noqa: BLE001
checks.append(_check("HUB_VERSION_COMPATIBLE", False, type(e).__name__))
try:
code, _ = http_get(f"{hub}/v1/datasets/status", token=env.get("AGENT_TOKEN"))
checks.append(_check("AUTH", code == 200, f"agent token -> HTTP {code}"))
except Exception as e: # noqa: BLE001
checks.append(_check("AUTH", False, type(e).__name__))
fb = None
if all((env.get(k) or "").strip() for k in ("FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")):
try:
fb = probe(env)
checks.append(_check("FIREBIRD_REACHABLE", True, "read-only connection opened"))
info.update(firebird_version=fb.get("server_version"), ods=fb.get("ods"), products=fb.get("products"))
checks.append(_check("FIREBIRD_VERSION_ODS", bool(fb.get("server_version")), f"{fb.get('server_version')} / ODS {fb.get('ods')}"))
except Exception as e: # noqa: BLE001
checks.append(_check("FIREBIRD_REACHABLE", False, f"{type(e).__name__}: {str(e)[:80]}"))
if fb:
t = fb["tables"]
checks.append(_check("C000004", "C000004" in t, "company table present" if "C000004" in t else "missing"))
checks.append(_check("C000025", "C000025" in t, "product table present" if "C000025" in t else "missing"))
cols = {c: ty for c, (ty, _) in t.get("C000025", {}).items()}
caps = detect_capabilities(cols)
info["capabilities"] = caps
lacking = [c for c in REQUIRED_CAPABILITIES if not caps.get(c)]
checks.append(_check("CAPABILITIES", not lacking, f"missing required: {', '.join(lacking)}" if lacking else f"required present; optional true: {sum(1 for c, v in caps.items() if v and c not in REQUIRED_CAPABILITIES)}"))
rep = preflight_report(fb["company"])
info["company_context"] = rep
checks.append(_check("COMPANY_CONTEXT", rep["COMPANY_CONTEXT"] == "PASS", "; ".join(rep.get("reasons") or []) or f"{rep['classification']} source={rep['source_of_truth']}"))
checks.append(_check("UF", bool(rep.get("company_uf")), str(rep.get("company_uf"))))
checks.append(_check("CRT", bool(rep.get("company_crt")), str(rep.get("company_crt"))))
writable = [f for f in ("IPI", "CEST", "EX_TIPI", "CLASSIFICACAO_FISCAL") if f in t.get("C000025", {})]
bad = [f for f in writable if not type_ok(f, t["C000025"][f][0])]
lens = {f: t["C000025"][f][1] for f in ("CEST", "EX_TIPI") if f in t.get("C000025", {})}
checks.append(_check("SCHEMA_TYPE_GUARDS", not bad and len(writable) >= 3, f"incompatible: {bad}" if bad else f"types ok for {writable}; text lengths {lens} (the executor re-checks length/type before any write)"))
state_db = env.get("AGENT_STATE_DB") or str(Path.cwd() / "agent_state.sqlite")
try:
Path(state_db).parent.mkdir(parents=True, exist_ok=True)
c = sqlite3.connect(state_db)
c.execute("SELECT 1")
c.close()
checks.append(_check("SQLITE", True, "state database opens"))
except Exception as e: # noqa: BLE001
checks.append(_check("SQLITE", False, type(e).__name__))
log_path = Path(env.get("AGENT_LOG_PATH") or Path.cwd() / "logs" / "agent.log")
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
checks.append(_check("LOG_DIRECTORY", os.access(log_path.parent, os.W_OK), "log directory writable"))
except Exception as e: # noqa: BLE001
checks.append(_check("LOG_DIRECTORY", False, type(e).__name__))
blockers = [f"{c['name']}: {c['detail']}" for c in checks if c["status"] == "FAIL"]
return {"PREFLIGHT": "FAIL" if blockers else "PASS", "blockers": blockers, "checks": checks, "agent_version": agent_version, "fiscal_mode": mode or None, **info}
@@ -0,0 +1,214 @@
"""RTC auto-apply: fills C000025.CCLASSTRIB, and NOTHING else, when a deterministic rule proves the class.
Safety model (defense in depth, every gate must hold, default is OFF): the Hub sets a per-installation mode DISABLED / DRY_RUN / APPROVAL_REQUIRED / ENABLED and
delivers it inside an authenticated job. The Agent only exposes the restricted capability RTC_CCLASSTRIB_WRITE_V1; there is no local switch to edit (no agent.env, no FIREBIRD_WRITES).
Write allowlist: ALLOWED_RTC_WRITE_FIELDS = ["CCLASSTRIB"] on C000025 (no free SQL exists). One short transaction per product. Immediately before the UPDATE the product,
company, RTC parameter and local catalog are re-read and the whole decision is re-derived; if ANYTHING changed that product is ABORTED and nothing is written.
An existing value is never overwritten. Every applied change returns an audit record with the old value; ROLLBACK restores it only if the value still equals what was written.
"""
from __future__ import annotations
import re
from datetime import date
from central.app.cclasstrib_resolver import RULE_VERSION, compare_catalogs, local_catalog_hash, resolve_cclasstrib
from central.app.unified_fiscal_coverage import rtc_enabled
from .unified_fiscal import detect_rtc_capabilities, load_rtc_context
ALLOWED_RTC_WRITE_FIELDS = ("CCLASSTRIB",)
DEFAULT_MAX_PER_JOB = 500
CAPABILITY = "RTC_CCLASSTRIB_WRITE_V1"
MODES = ("DISABLED", "DRY_RUN", "APPROVAL_REQUIRED", "ENABLED")
MAX_PREVIEW_CANDIDATES = 5000
class RtcApplyAborted(Exception):
"""The product is left untouched; `reason` is reported in the audit."""
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
def _text(value) -> str:
return "" if value is None else str(value).strip()
def _digits(value) -> str:
return re.sub(r"\D", "", _text(value))
def write_gate(*, mode: str, capability: bool = True, approved: bool = True) -> dict:
"""{"allowed": bool, "missing": [...]}: a real write needs the capability, an authenticated Hub mode that permits writing (ENABLED, or APPROVAL_REQUIRED for approved products)."""
mode = mode if mode in MODES else "DISABLED"
missing = []
if not capability:
missing.append("CAPABILITY")
if mode not in ("ENABLED", "APPROVAL_REQUIRED"):
missing.append("HUB_MODE_" + mode)
elif mode == "APPROVAL_REQUIRED" and not approved:
missing.append("HUB_APPROVAL")
return {"allowed": not missing, "missing": missing, "mode": mode, "capability": bool(capability)}
def candidate_fingerprint(code, ncm, description, current) -> str:
import hashlib
return hashlib.sha256("|".join((_text(code), _digits(ncm), _text(description), _text(current))).encode("utf-8")).hexdigest()[:24]
def select_candidates(findings: dict, products: list[dict], *, limit: int = DEFAULT_MAX_PER_JOB) -> tuple[list[dict], int]:
"""Products READY_TO_APPLY: empty CCLASSTRIB + a deterministic resolution, no other RTC problem. Returns (candidates up to `limit`, total ready)."""
by_code = {str(p.get("CODIGO") or ""): p for p in products}
ready = []
for code, found in findings.items():
res = found.get("rtc_resolution") or {}
if not res.get("auto_apply_candidate"):
continue
rtc_types = {b["type"] for b in found.get("blockers", []) if b["category"] == "RTC"}
if rtc_types != {"CCLASSTRIB_MISSING"}:
continue
p = by_code.get(code, {})
if _text(p.get("CCLASSTRIB")):
continue
ready.append({"product_code": code, "proposed": res["resolved_cclasstrib"], "cst": res["resolved_cst_ibscbs"], "rule_id": res["rule_id"], "ncm": _digits(p.get("CLASSIFICACAO_FISCAL")), "confidence_type": res["confidence_type"], "description": _text(p.get("PRODUTO"))[:255], "current": "", "basis": f'{res["rule_id"]}:{res["confidence_type"]}',
"fingerprint": candidate_fingerprint(code, p.get("CLASSIFICACAO_FISCAL"), p.get("PRODUTO"), "")})
return ready[:limit], len(ready)
def apply_candidate(conn, candidate: dict, *, company: dict, decision: dict, now: str, job_id=None, today: date | None = None) -> dict:
"""One product, one short transaction. `decision` = {"local_catalog_hash": ...} captured when the candidate was chosen. Raises RtcApplyAborted (nothing written)."""
field = "CCLASSTRIB"
assert field in ALLOWED_RTC_WRITE_FIELDS
today = today or date.today()
code, proposed = candidate["product_code"], candidate["proposed"]
cur = conn.cursor()
try:
schema, catalog, extras = load_rtc_context(cur) # fresh: the ERP is asked again, nothing cached
caps = detect_rtc_capabilities(schema, param_value=extras["param_value"], catalog=catalog, physical_fk=extras["physical_fk"])
if extras["param_conflict"] or not rtc_enabled(caps):
raise RtcApplyAborted("RTC_NO_LONGER_ENABLED")
if not caps["RTC_CATALOG_VALID"]:
raise RtcApplyAborted("RTC_CATALOG_INVALID")
local_rows = (catalog or {}).get("rows") or {}
fresh_hash = local_catalog_hash(local_rows)
if decision.get("local_catalog_hash") and fresh_hash != decision["local_catalog_hash"]:
raise RtcApplyAborted("LOCAL_CATALOG_CHANGED_SINCE_DECISION")
cur.execute("SELECT DISTINCT TRIM(CRT) FROM C000004")
crts = {_text(r[0]) for r in cur.fetchall()}
expected_crt = _text((company or {}).get("crt"))
if not expected_crt or crts != {expected_crt}:
raise RtcApplyAborted("COMPANY_CONTEXT_CHANGED")
if expected_crt != "3":
raise RtcApplyAborted("REGIME_NOT_NORMAL")
cur.execute(f"SELECT {field}, CLASSIFICACAO_FISCAL, PRODUTO FROM C000025 WHERE CODIGO = ?", (code,))
rows = cur.fetchall()
if len(rows) != 1:
raise RtcApplyAborted("PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND")
current, ncm = _text(rows[0][0]), _digits(rows[0][1])
if current:
raise RtcApplyAborted("VALUE_ALREADY_SET_NEVER_OVERWRITTEN" if current != proposed else "NO_WRITE_REQUIRED")
if ncm != candidate["ncm"]:
raise RtcApplyAborted("PRODUCT_CHANGED_NCM")
if candidate.get("fingerprint") and candidate_fingerprint(code, ncm, rows[0][2], current) != candidate["fingerprint"]:
raise RtcApplyAborted("PRODUCT_CHANGED_SINCE_DECISION")
compat = compare_catalogs(local_rows, today)
resolution = resolve_cclasstrib({"CODIGO": code, "CLASSIFICACAO_FISCAL": ncm, "ncm_valid": True}, company, {"rtc_enabled": True, "local_catalog_valid": True}, local_rows, compat=compat, today=today)
if not resolution.auto_apply_candidate or resolution.resolved_cclasstrib != proposed or resolution.rule_id != candidate["rule_id"]:
raise RtcApplyAborted("DECISION_CHANGED")
cur.execute(f"UPDATE C000025 SET {field} = ? WHERE CODIGO = ? AND ({field} IS NULL OR TRIM({field}) = '')", (proposed, code))
if cur.rowcount != 1:
conn.rollback()
raise RtcApplyAborted("UPDATE_ROWCOUNT_NOT_ONE")
conn.commit()
cur = conn.cursor()
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
after = _text(cur.fetchone()[0])
conn.commit()
if after != proposed:
raise RuntimeError(f"post-write verification failed for {code}: {after!r} != {proposed!r}")
official = compat["official_catalog"]
return {"outcome": "APPLIED", "product_code": code, "field": field, "old_value": None, "new_value": proposed, "cst_ibscbs": resolution.resolved_cst_ibscbs, "rule_id": resolution.rule_id,
"confidence_type": resolution.confidence_type, "evidence": {"inputs_used": resolution.inputs_used, "evidence": resolution.evidence[:5], "reason": resolution.reason},
"official_source_version": {"cclasstrib_table": official["publication"], "cclasstrib_sha256": official["sha256"], "lc214_sha256": resolution.source_references[0]["sha256"]},
"local_catalog_hash": fresh_hash, "timestamp": now, "job_id": job_id, "rowcount": 1, "verified_after": True, "action": "APPLY", "rule_version": RULE_VERSION,
"company_crt": expected_crt, "description_snapshot": _text(rows[0][2])[:255], "rtc_status": "ENABLED", "local_catalog_state": (compat.get("per_code") or {}).get(proposed), "authorization_origin": decision.get("origin")}
except RtcApplyAborted:
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
raise
except Exception:
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
raise
def apply_candidates(conn, candidates: list[dict], *, company: dict, decision: dict, now: str, job_id=None, today: date | None = None, authorize=None) -> dict:
"""Applies each candidate independently; a failure or an abort never stops the rest and never leaves a partial change.
`authorize(product_code)` (optional) re-asks the Hub, right before each UPDATE, whether the central authorization still permits it: it returns None to proceed or a
reason (AUTHORIZATION_CHANGED / AUTHORIZATION_UNVERIFIABLE) to skip the product with zero writes."""
applied, aborted, reasons = [], [], {}
for candidate in candidates:
if authorize is not None:
denied = authorize(candidate["product_code"])
if denied:
aborted.append({"outcome": "ABORTED", "product_code": candidate["product_code"], "field": "CCLASSTRIB", "reason": denied, "rule_id": candidate["rule_id"], "job_id": job_id, "timestamp": now, "action": "APPLY", "authorization_origin": decision.get("origin")})
reasons[denied] = reasons.get(denied, 0) + 1
continue
try:
applied.append(apply_candidate(conn, candidate, company=company, decision=decision, now=now, job_id=job_id, today=today))
except RtcApplyAborted as stop:
aborted.append({"outcome": "ABORTED", "product_code": candidate["product_code"], "field": "CCLASSTRIB", "reason": stop.reason, "rule_id": candidate["rule_id"], "job_id": job_id, "timestamp": now, "action": "APPLY", "authorization_origin": decision.get("origin")})
reasons[stop.reason] = reasons.get(stop.reason, 0) + 1
except Exception as error: # noqa: BLE001 - technical failure of ONE product must not abort the batch
aborted.append({"outcome": "FAILED", "product_code": candidate["product_code"], "field": "CCLASSTRIB", "reason": f"{type(error).__name__}", "rule_id": candidate["rule_id"], "job_id": job_id, "timestamp": now})
reasons["TECHNICAL_FAILURE"] = reasons.get("TECHNICAL_FAILURE", 0) + 1
return {"applied": applied, "aborted": aborted, "aborted_reasons": reasons, "write_attempts": len(candidates), "write_successes": len(applied)}
def rollback_one(conn, item: dict, *, now: str, job_id=None) -> dict:
"""Restores C000025.CCLASSTRIB to empty for ONE previously applied audit record, only if the value still equals what was written. No free SQL: the only statement is fixed."""
field = "CCLASSTRIB"
assert field in ALLOWED_RTC_WRITE_FIELDS
code, written = _text(item.get("product_code")), _text(item.get("written_value"))
base = {"product_code": code, "field": field, "job_id": job_id, "timestamp": now, "action": "ROLLBACK", "rollback_of": item.get("audit_id"), "old_value": written, "new_value": None, "rule_id": item.get("rule_id")}
if not code or not written or item.get("restore_value"):
return {**base, "outcome": "ABORTED", "reason": "ROLLBACK_REQUEST_INVALID"}
cur = conn.cursor()
try:
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
rows = cur.fetchall()
if len(rows) != 1:
conn.rollback()
return {**base, "outcome": "ABORTED", "reason": "PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND"}
current = _text(rows[0][0])
if current != written:
conn.rollback()
return {**base, "outcome": "ABORTED", "reason": "ROLLBACK_CONFLICT_VALUE_CHANGED" if current else "ALREADY_EMPTY"}
cur.execute(f"UPDATE C000025 SET {field} = NULL WHERE CODIGO = ? AND TRIM({field}) = ?", (code, written))
if cur.rowcount != 1:
conn.rollback()
return {**base, "outcome": "ABORTED", "reason": "UPDATE_ROWCOUNT_NOT_ONE"}
conn.commit()
cur = conn.cursor()
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
after = _text(cur.fetchone()[0])
conn.commit()
if after:
raise RuntimeError("post-rollback verification failed")
return {**base, "outcome": "ROLLED_BACK", "rowcount": 1, "verified_after": True}
except Exception as error: # noqa: BLE001
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
return {**base, "outcome": "FAILED", "reason": type(error).__name__}
def rollback_records(conn, items: list[dict], *, now: str, job_id=None) -> dict:
results = [rollback_one(conn, item, now=now, job_id=job_id) for item in items]
return {"rollbacks": results, "rolled_back": sum(1 for r in results if r["outcome"] == "ROLLED_BACK")}
@@ -0,0 +1,23 @@
"""Gate Firebird reads on the registered schema fingerprint."""
import hashlib, json
REFERENCE_SCHEMA_V1 = "1198f89897532b643439f06b729e93b189018f56e7d478e95095f8f56977005b"
def product_fingerprint(product):
fields = ("PRODUTO", "EAN_GTIN", "NCM", "CLASSIFICACAO_FISCAL", "CFOP", "CST", "ALIQUOTA", "CEST", "CSOSN")
return hashlib.sha256(json.dumps({key: product.get(key) for key in fields}, sort_keys=True, default=str).encode()).hexdigest()
def to_hub_product(product):
# C000025 has NCM empty and stores the confirmed eight-digit NCM in CLASSIFICACAO_FISCAL.
return {"product_id": product["CODIGO"], "description": product.get("PRODUTO", ""), "gtin": product.get("EAN_GTIN"), "current_ncm": product.get("CLASSIFICACAO_FISCAL")}
def run_sample(products, client, state, schema_fingerprint):
if schema_fingerprint != REFERENCE_SCHEMA_V1: return {"status": "SCHEMA_UNKNOWN", "results": [], "firebird_writes": 0}
outbound = []
for product in products:
fingerprint = product_fingerprint(product)
if state.enqueue(product["CODIGO"], fingerprint, "pending", "0.1.0"): outbound.append((product, fingerprint))
if not outbound: return {"status": "OK", "results": [], "firebird_writes": 0, "api_requests": 0}
try: results = client.classify_batch([to_hub_product(product) for product, _ in outbound])
except Exception:
for product, _ in outbound: state.retry(product["CODIGO"])
return {"status": "API_UNAVAILABLE", "results": [], "firebird_writes": 0}
for (product, fingerprint), result in zip(outbound, results): state.result_received(product["CODIGO"], fingerprint, result["dataset_versions"].get("ncm") or "none", result["engine_version"])
return {"status": "OK", "results": results, "firebird_writes": 0, "api_requests": 1}
@@ -0,0 +1,74 @@
"""Local SQLite outbox; it has no Firebird dependency or write path."""
import json
import sqlite3
from pathlib import Path
import uuid
class AgentState:
def __init__(self, path):
self.connection = sqlite3.connect(Path(path)); self.connection.row_factory = sqlite3.Row
self.connection.executescript("CREATE TABLE IF NOT EXISTS product_state(product_id TEXT PRIMARY KEY, source_fingerprint TEXT NOT NULL, dataset_version TEXT NOT NULL, engine_version TEXT NOT NULL, status TEXT NOT NULL, last_processed_at TEXT, last_result_hash TEXT, reason_code TEXT); CREATE TABLE IF NOT EXISTS outbox(product_id TEXT PRIMARY KEY, source_fingerprint TEXT NOT NULL, status TEXT NOT NULL, retries INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS ipi_write_log(product_id TEXT NOT NULL, source_fingerprint TEXT NOT NULL, decision_status TEXT, ipi_status TEXT, write_status TEXT NOT NULL, before_value TEXT, proposed_value TEXT, result TEXT, reason_code TEXT, timestamp TEXT NOT NULL);")
self.connection.execute("CREATE TABLE IF NOT EXISTS installation_identity (id INTEGER PRIMARY KEY CHECK(id=1), installation_id TEXT NOT NULL)")
# Last Hub decision per product + the source fingerprint it was computed for: what makes
# SYNC_INCREMENTAL a real delta (only new/changed products are re-classified).
self.connection.execute("CREATE TABLE IF NOT EXISTS decision_cache (product_id TEXT PRIMARY KEY, source_fingerprint TEXT NOT NULL, decision_json TEXT NOT NULL)")
self.connection.commit()
def stored_installation_id(self):
row = self.connection.execute("SELECT installation_id FROM installation_identity WHERE id=1").fetchone()
if row is None:
return None
try:
return str(uuid.UUID(row["installation_id"]))
except (TypeError, ValueError, AttributeError) as error:
raise ValueError("stored installation_id is not a valid UUID") from error
def installation_id(self, candidate=None):
existing = self.stored_installation_id()
if existing:
if candidate is not None and str(uuid.UUID(str(candidate))) != existing:
raise ValueError("configured installation_id conflicts with persisted identity")
return existing
if candidate is None or not str(candidate).strip():
raise ValueError("installation_id is required; refusing to generate a new identity")
try:
value = str(uuid.UUID(str(candidate)))
except (TypeError, ValueError, AttributeError) as error:
raise ValueError("installation_id must be a valid UUID") from error
self.connection.execute("INSERT OR IGNORE INTO installation_identity(id, installation_id) VALUES(1, ?)", (value,))
self.connection.commit()
row = self.connection.execute("SELECT installation_id FROM installation_identity WHERE id=1").fetchone()
if row is None or str(uuid.UUID(row["installation_id"])) != value:
raise ValueError("persisted installation_id conflicts with candidate")
return value
def cached_fingerprint(self, product_id):
row = self.connection.execute("SELECT source_fingerprint FROM decision_cache WHERE product_id=?", (product_id,)).fetchone()
return row["source_fingerprint"] if row else None
def cached_decision(self, product_id):
row = self.connection.execute("SELECT decision_json FROM decision_cache WHERE product_id=?", (product_id,)).fetchone()
return json.loads(row["decision_json"]) if row else None
def remember_decision(self, product_id, source_fingerprint, decision):
self.connection.execute("INSERT OR REPLACE INTO decision_cache(product_id,source_fingerprint,decision_json) VALUES(?,?,?)", (product_id, source_fingerprint, json.dumps(decision, default=str)))
self.connection.commit()
def forget_missing(self, present_product_ids):
"""Products no longer in the ERP leave the LOCAL cache only; Hub-side history/blockers are never deleted by this."""
present = set(present_product_ids)
gone = [row["product_id"] for row in self.connection.execute("SELECT product_id FROM decision_cache") if row["product_id"] not in present]
for product_id in gone:
self.connection.execute("DELETE FROM decision_cache WHERE product_id=?", (product_id,))
self.connection.commit()
return len(gone)
def enqueue(self, product_id, source_fingerprint, dataset_version, engine_version):
row = self.connection.execute("SELECT * FROM product_state WHERE product_id=?", (product_id,)).fetchone()
if row and tuple(row[key] for key in ("source_fingerprint", "engine_version", "status")) == (source_fingerprint, engine_version, "RESULT_RECEIVED"): return False
self.connection.execute("INSERT OR REPLACE INTO product_state(product_id,source_fingerprint,dataset_version,engine_version,status) VALUES(?,?,?,?,?)", (product_id, source_fingerprint, dataset_version, engine_version, "PENDING_API")); self.connection.execute("INSERT OR REPLACE INTO outbox(product_id,source_fingerprint,status) VALUES(?,?,?)", (product_id, source_fingerprint, "PENDING_API")); self.connection.commit(); return True
def pending(self): return [dict(row) for row in self.connection.execute("SELECT * FROM outbox WHERE status='PENDING_API'")]
def retry(self, product_id): self.connection.execute("UPDATE outbox SET retries=retries+1,status='PENDING_API' WHERE product_id=?", (product_id,)); self.connection.commit()
def result_received(self, product_id, source_fingerprint, dataset_version, engine_version):
self.connection.execute("UPDATE product_state SET source_fingerprint=?,dataset_version=?,engine_version=?,status='RESULT_RECEIVED' WHERE product_id=?", (source_fingerprint, dataset_version, engine_version, product_id)); self.connection.execute("DELETE FROM outbox WHERE product_id=?", (product_id,)); self.connection.commit()
def log_ipi_write(self, product_id, source_fingerprint, decision_status, ipi_status, write_status, before_value, proposed_value, result, reason_code, timestamp):
self.connection.execute(
"INSERT INTO ipi_write_log(product_id,source_fingerprint,decision_status,ipi_status,write_status,before_value,proposed_value,result,reason_code,timestamp) VALUES(?,?,?,?,?,?,?,?,?,?)",
(product_id, source_fingerprint, decision_status, ipi_status, write_status, str(before_value) if before_value is not None else None, str(proposed_value) if proposed_value is not None else None, result, reason_code, timestamp),
)
self.connection.commit()
def ipi_write_log_for(self, product_id):
return [dict(row) for row in self.connection.execute("SELECT * FROM ipi_write_log WHERE product_id=? ORDER BY timestamp", (product_id,))]
@@ -0,0 +1,148 @@
"""READ-ONLY ERP loader for the unified fiscal completeness (PIS/COFINS applicability + RTC when the ERP has the reform on).
The rules live in central/app/unified_fiscal_coverage.py (pure). This module only observes the ERP through an already open
READ ONLY connection (SELECT only): schema capabilities, RTC catalog, PARAMETROS.USAR_REFORMA_TRIBUTARIA and the product columns.
Nothing is written and no value is ever filled in.
"""
from __future__ import annotations
import hashlib
import json
from collections import Counter
from datetime import date, datetime, timezone
from central.app.cclasstrib_resolver import compare_catalogs, local_catalog_hash, resolve_cclasstrib
from central.app.rtc_findings import refine_rtc_finding, summarize_rtc
from central.app.unified_fiscal_coverage import PIS_COFINS_FIELDS, catalog_integrity, cclasstrib_applicability, evaluate_product, legal_effective_context, rtc_enabled
PRODUCT_TABLE = "C000025"
RTC_CCLASSTRIB_TABLE = "RTC_CCLASSTRIB"
RTC_CST_TABLE = "RTC_CST"
PRODUCT_COLUMNS = ("CODIGO", "CCLASSTRIB", "CLASSIFICACAO_FISCAL") + PIS_COFINS_FIELDS + ("PISCOFINS", "INCIDENCIA_PISCOFINS")
TRUTHY = {"1", "S", "T", "TRUE"}
def detect_rtc_capabilities(schema: dict, *, param_value=None, catalog: dict | None = None, physical_fk: bool | None = None) -> dict:
"""schema: {TABLE: {COLUMN, ...}}. Fail-closed: anything not proven is False. Old databases without RTC simply report False."""
product_cols = set(schema.get(PRODUCT_TABLE, ()))
class_cols = set(schema.get(RTC_CCLASSTRIB_TABLE, ()))
cst_cols = set(schema.get(RTC_CST_TABLE, ()))
has_product = "CCLASSTRIB" in product_cols
has_class = {"CCLASSTRIB", "CSTIBSCBS"} <= class_cols
has_cst = "CSTIBSCBS" in cst_cols
caps = {
"HAS_PRODUCT_CCLASSTRIB": has_product, "HAS_RTC_CCLASSTRIB_TABLE": has_class, "HAS_RTC_CST_TABLE": has_cst,
"HAS_RTC_RELATIONSHIP": bool(has_product and has_class and has_cst), "HAS_PHYSICAL_RTC_FK": bool(physical_fk),
"RTC_ENABLED_BY_PARAM": False if param_value is None else str(param_value).strip().upper() in TRUTHY,
"RTC_CATALOG_VALID": False,
}
if catalog is not None and has_class and has_cst:
caps["RTC_CATALOG_VALID"] = catalog_integrity(catalog)["valid"]
return caps
def load_rtc_context(cursor) -> tuple[dict, dict | None, dict]:
"""Schema + catalog + ERP parameter through a READ ONLY cursor. Never raises for a database that lacks RTC."""
schema: dict[str, set] = {}
cursor.execute("SELECT TRIM(rf.RDB$RELATION_NAME), TRIM(rf.RDB$FIELD_NAME) FROM RDB$RELATION_FIELDS rf WHERE rf.RDB$RELATION_NAME IN ('C000025','RTC_CCLASSTRIB','RTC_CST','PARAMETROS')")
for table, column in cursor.fetchall():
schema.setdefault(table, set()).add(column)
extras: dict = {"param_value": None, "param_rows": 0, "param_conflict": False, "physical_fk": False}
if "USAR_REFORMA_TRIBUTARIA" in schema.get("PARAMETROS", ()):
try:
# ALL rows are read: the source of truth is what THIS installation's database says NOW (never a cached or Hub-side value).
cursor.execute("SELECT FIRST 20 USAR_REFORMA_TRIBUTARIA FROM PARAMETROS")
values = [None if r[0] is None else str(r[0]).strip().upper() for r in cursor.fetchall()]
extras["param_rows"] = len(values)
if len(set(values)) > 1:
extras["param_conflict"] = True # rows disagree: never guess "on" (RTC stays out of scope, and the conflict is reported)
elif values:
extras["param_value"] = values[0]
except Exception: # noqa: BLE001 - unreadable parameter => RTC stays off (not enabled by the ERP)
extras["param_value"] = None
try:
cursor.execute("SELECT COUNT(*) FROM RDB$RELATION_CONSTRAINTS WHERE RDB$RELATION_NAME = 'C000025' AND RDB$CONSTRAINT_TYPE = 'FOREIGN KEY' AND RDB$CONSTRAINT_NAME CONTAINING 'CCLASSTRIB'")
extras["physical_fk"] = cursor.fetchone()[0] > 0
except Exception: # noqa: BLE001
pass
catalog = None
class_cols = schema.get(RTC_CCLASSTRIB_TABLE, set())
if {"CCLASSTRIB", "CSTIBSCBS"} <= class_cols and "CSTIBSCBS" in schema.get(RTC_CST_TABLE, set()):
validity = "INICIO_VIGENCIA" in class_cols and "FIM_VIGENCIA" in class_cols
extra = [c for c in ("TIPO_ALIQUOTA", "PRED_IBS", "PRED_CBS", "IND_NFE", "IND_NFCE") if c in class_cols]
cursor.execute("SELECT TRIM(CCLASSTRIB), TRIM(CSTIBSCBS)" + (", INICIO_VIGENCIA, FIM_VIGENCIA" if validity else "") + "".join(f", {c}" for c in extra) + " FROM RTC_CCLASSTRIB")
rows = cursor.fetchall()
classes = {r[0]: (r[1] or None) for r in rows}
cursor.execute("SELECT TRIM(CSTIBSCBS) FROM RTC_CST")
catalog = {"classes": classes, "cst": {r[0] for r in cursor.fetchall()}, "duplicates": len(rows) - len(classes)}
if validity:
catalog["validity"] = {r[0]: (r[2], r[3]) for r in rows}
offset = 4 if validity else 2
catalog["rows"] = {r[0]: {"cst": r[1], "start": (r[2] if validity else None), "end": (r[3] if validity else None), **{c: r[offset + i] for i, c in enumerate(extra)}} for r in rows}
for value in catalog["rows"].values():
for key in ("TIPO_ALIQUOTA", "PRED_IBS", "PRED_CBS", "IND_NFE", "IND_NFCE"):
if key in value:
value[key.lower()] = value.pop(key)
for flag in ("ind_nfe", "ind_nfce"):
if flag in value:
value[flag] = str(value[flag]).strip() in ("1", "True", "true")
return schema, catalog, extras
def read_products(cursor, schema: dict) -> list[dict]:
"""SELECT of only the columns that exist (old schemas keep working)."""
have = schema.get(PRODUCT_TABLE, set())
columns = [c for c in PRODUCT_COLUMNS if c in have]
if "CODIGO" not in columns:
return []
cursor.execute(f"SELECT {', '.join(columns)} FROM {PRODUCT_TABLE} ORDER BY CODIGO")
return [dict(zip(columns, row)) for row in cursor.fetchall()]
def assess_from_connection(conn, company: dict | None, *, today: date | None = None, rtc_override: bool | None = None, ncm_valid: dict | None = None) -> dict:
"""One read-only pass. Returns {"context": {...}, "findings": {product_code: evaluate_product(...)}}.
rtc_override forces RTC on/off for simulations WITHOUT touching the ERP parameter (tests / what-if only).
ncm_valid: {product_code: bool} from the decision engine (EXACT_NCM); a product whose NCM is not confirmed never gets a cClassTrib proposal."""
cursor = conn.cursor()
schema, catalog, extras = load_rtc_context(cursor)
caps = detect_rtc_capabilities(schema, param_value=extras["param_value"], catalog=catalog, physical_fk=extras["physical_fk"])
enabled = rtc_enabled(caps) if rtc_override is None else bool(rtc_override and caps["HAS_RTC_RELATIONSHIP"])
today = today or date.today()
cclasstrib_app = cclasstrib_applicability((company or {}).get("crt"), enabled, today)
rtc = {"enabled": enabled, "catalog": catalog, "catalog_valid": caps["RTC_CATALOG_VALID"], "cclasstrib_applicability": cclasstrib_app}
products = read_products(cursor, schema)
findings = {}
warn_by_type: Counter = Counter()
warn_products: set = set()
samples: list = []
applicability: Counter = Counter()
local_rows = (catalog or {}).get("rows") or {}
applicable = cclasstrib_app["state"] == "REQUIRED"
compat = compare_catalogs(local_rows, today) if (enabled and applicable and local_rows) else None
resolver_ctx = {"rtc_enabled": enabled, "cclasstrib_applicable": applicable, "local_catalog_valid": bool(caps["RTC_CATALOG_VALID"])}
for p in products:
found = evaluate_product(p, company, rtc, today)
if enabled and applicable:
code0 = str(p.get("CODIGO") or "")
resolution = resolve_cclasstrib({**p, "ncm_valid": bool((ncm_valid or {}).get(code0, False))}, company, resolver_ctx, local_rows, compat=compat, today=today)
refine_rtc_finding(found, p, resolution, compat["per_code"] if compat else None)
found["input_fingerprint"] = hashlib.sha256(json.dumps({k: (None if v is None else str(v).strip()) for k, v in p.items()} | {"rtc": enabled, "cclasstrib_applicability": cclasstrib_app}, sort_keys=True).encode()).hexdigest()
code = str(p.get("CODIGO") or "")
findings[code] = found
applicability[found["applicability"]["PIS_COFINS"]] += 1
for w in found.get("warnings", []):
warn_by_type[w["type"]] += 1
warn_products.add(code)
if len(samples) < 10:
samples.append({"product_code": code, **w})
integrity = catalog_integrity(catalog) if catalog else None
return {"context": {"rtc_enabled": enabled, "erp_rtc_enabled": enabled, "rtc_supported": caps["HAS_RTC_RELATIONSHIP"], "rtc_catalog_valid": caps["RTC_CATALOG_VALID"], "rtc_param": extras["param_value"],
"rtc_source": "SIMULATION" if rtc_override is not None else "PARAMETROS.USAR_REFORMA_TRIBUTARIA", "rtc_param_rows": extras["param_rows"], "rtc_param_conflict": extras["param_conflict"],
"rtc_observed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "cclasstrib_applicability": cclasstrib_app,
"legal_effective_context": legal_effective_context((company or {}).get("crt")),
"catalog_integrity": integrity, "crt": (company or {}).get("crt"), "capabilities": caps, "products_evaluated": len(products),
"pis_cofins_applicability": dict(applicability),
"rtc_resolution": summarize_rtc(findings, compat) if enabled else None, "local_catalog_hash": local_catalog_hash(local_rows) if local_rows else None,
"official_catalog": (compat or {}).get("official_catalog"),
"non_canonical_warnings": {"products": len(warn_products), "by_type": dict(sorted(warn_by_type.items())), "samples": samples}},
"findings": findings}
+24
View File
@@ -0,0 +1,24 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
import pytest
@pytest.fixture(autouse=True)
def _no_real_firebird_for_unified_fiscal(monkeypatch, request):
"""The unified fiscal assessment opens its own READ ONLY connection; unit tests have no Firebird, so it is neutral here unless a test opts in."""
if request.node.get_closest_marker("real_unified_fiscal"):
return
from gti_fiscal_agent import firebird_reader, unified_fiscal
class _Conn:
def close(self):
pass
monkeypatch.setattr(firebird_reader, "_connect", lambda env: _Conn())
monkeypatch.setattr(unified_fiscal, "assess_from_connection", lambda conn, company, **kw: {"context": {"rtc_enabled": False, "rtc_supported": False, "rtc_catalog_valid": False, "products_evaluated": 0}, "findings": {}})
def pytest_configure(config):
config.addinivalue_line("markers", "real_unified_fiscal: use the real unified fiscal assessment (with a fake cursor) instead of the neutral stub")
@@ -0,0 +1,239 @@
from gti_fiscal_agent import cfop_ippt_auto_apply as apply_mod
from central.app.field_contract import ALREADY, CHANGE, MANUAL, NA
class FakeFirebird:
"""A tiny C000025: every statement is recorded; only SELECT <field> and the one allowed UPDATE per field are understood."""
def __init__(self, products):
self.products = {c: dict(v) for c, v in products.items()} # code -> {"CFOP": ..., "IPPT": ...}
self.statements, self.commits, self.rollbacks = [], 0, 0
self._pending = {}
def cursor(self):
return FakeCursor(self)
def commit(self):
for (code, field), value in self._pending.items():
self.products[code][field] = value
self._pending = {}
self.commits += 1
def rollback(self):
self._pending = {}
self.rollbacks += 1
class FakeCursor:
def __init__(self, db):
self.db, self._rows, self.rowcount = db, [], -1
def fetchall(self):
return self._rows
def fetchone(self):
return self._rows[0] if self._rows else None
def execute(self, sql, params=()):
db = self.db
db.statements.append((sql, tuple(params)))
text = " ".join(sql.split())
up = text.upper()
for field in ("CFOP", "IPPT"):
if up == f"SELECT {field} FROM C000025 WHERE CODIGO = ?":
code = params[0]
self._rows = [(db.products[code][field],)] if code in db.products else []
if code in db.products and db.products[code].get("DUP"):
self._rows = self._rows * 2
return
if up.startswith(f"UPDATE C000025 SET {field} = ? WHERE CODIGO = ? AND"):
proposed, code, expected = params
product = db.products.get(code)
current = (product.get(field) if product else None) or None
hit = product is not None and current == (expected or None)
self.rowcount = 1 if hit else 0
if hit:
db._pending[(code, field)] = proposed
return
raise AssertionError(f"unexpected SQL: {sql!r}")
def candidate(code, field, expected_before, proposed, reason="X"):
return {"product_code": code, "field": field, "expected_before": expected_before, "proposed": proposed, "reason": reason,
"fingerprint": apply_mod.candidate_fingerprint(code, field, expected_before, proposed)}
def results_for(code, field, *, state, current, proposed, write_eligible):
return {code: {field: {"state": state, "current": current, "proposed": proposed, "write_eligible": write_eligible, "reason": "R"}}}
# ------------------------------------------------------------------ write_gate (flag semantics)
def test_write_gate_default_off_blocks_every_mode_but_enabled_and_approval_required():
assert apply_mod.write_gate(mode="DISABLED")["allowed"] is False
assert apply_mod.write_gate(mode="DRY_RUN")["allowed"] is False
assert apply_mod.write_gate(mode="APPROVAL_REQUIRED", approved=False)["allowed"] is False
assert apply_mod.write_gate(mode="APPROVAL_REQUIRED", approved=True)["allowed"] is True
assert apply_mod.write_gate(mode="ENABLED")["allowed"] is True
assert apply_mod.write_gate(mode="ENABLED", capability=False)["allowed"] is False
assert apply_mod.write_gate(mode="NOT_A_REAL_MODE")["allowed"] is False
# ------------------------------------------------------------------ select_candidates only picks AUTO_FILL_SAFE CFOP/IPPT
def test_select_candidates_only_change_and_write_eligible_cfop_or_ippt():
results = {
"000001": {"CFOP": {"state": CHANGE, "current": "5102", "proposed": "5101", "write_eligible": True, "reason": "R1"},
"IPPT": {"state": MANUAL, "current": None, "proposed": None, "write_eligible": False, "reason": "R2"},
"CSOSN": {"state": CHANGE, "current": None, "proposed": "102", "write_eligible": True, "reason": "R3"}}, # not CFOP/IPPT: never a candidate
"000002": {"CFOP": {"state": ALREADY, "current": "5102", "proposed": None, "write_eligible": False, "reason": "R4"},
"IPPT": {"state": CHANGE, "current": "T", "proposed": "P", "write_eligible": False, "reason": "R5"}}, # write_eligible=False: never a candidate
"000003": {"IPPT": {"state": CHANGE, "current": "T", "proposed": "P", "write_eligible": True, "reason": "R6"}},
}
cands, total = apply_mod.select_candidates(results)
assert total == 2
keys = {(c["product_code"], c["field"]) for c in cands}
assert keys == {("000001", "CFOP"), ("000003", "IPPT")}
assert all(c["field"] in apply_mod.ALLOWED_FIELDS for c in cands)
def test_weak_composed_evidence_never_becomes_a_candidate_regardless_of_the_write_flag():
"""Missão 4 / Bloco A.3: a cell whose role evidence is weak (field_contract itself refuses to
classify it OWN_PRODUCTION/RESALE with confidence) must never reach select_candidates as
AUTO_FILL_SAFE - proven end-to-end from the real resolver, not just asserted in isolation.
This holds independently of cfop_ippt_write_mode/write_gate, which only gates whether
apply_candidates() is ever invoked - it never affects what counts as a candidate."""
from central.app.field_contract import resolve_product, Context, WRITE_FIELDS
def product(**kw):
p = {f: None for f in WRITE_FIELDS}
p.update({"CODIGO": "000321", "PRODUTO": "CERVEJA LONG NECK NOVA SCHIN", "CODGRUPO": "G1", "TIPO": "VENDA", "PROD_COZINHA": None, "PRODUTO_SERVICO": None,
"CLASSIFICACAO_FISCAL": "22030000", "CFOP": "5102", "CSOSN": "102", "CST": "000", "SITUACAO_TRIBUTARIA": "T", "IAT": "A", "IPPT": "T",
"INCIDENCIA_PISCOFINS": "NENHUMA", "PISCOFINS": "N"})
p.update(kw)
return p
decision = {"status": "EXACT_NCM", "cest_status": "NO_CEST_MAPPING_FOUND", "ipi_status": "IPI_RESOLVED", "ba_st_mva_status": "NO_MVA_MAPPING_FOUND", "dataset_versions": {},
"classification": {"ncm": "22030000", "ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.9, "rate": "3.9"}, "ipi_candidates": [], "cest": None, "cest_candidates": [], "ba_st_mva": None, "ba_st_mva_candidates": []}}
refs = {"cfop_valid": {"5101", "5102"}, "csosn": {"102"}, "cst_icms": set(), "cst_pis": set(), "cst_cofins": set(), "origem": set("012345678"), "fcp_uf": {}, "cbs_rate_2026": None, "cclasstrib_count": 0}
co = {"uf": "BA", "crt": "1", "contribuinte_ipi": "N", "company_code": "000001"}
ev_weak = {"recipes": set(), "composed": set(), "composed_ratio": {"000321": {"composed": 1, "other": 19}}, "purchases": {}, "stock_entry": {"000321": True}, "stock_in": {"000321": True}}
result = resolve_product(product(), decision, co, refs, ev_weak, Context([]))
candidates, total = apply_mod.select_candidates({"000321": result})
assert total == 0 and candidates == [] # the weak-evidence beer never becomes a write candidate
def test_select_candidates_respects_limit():
results = {f"{i:06d}": {"CFOP": {"state": CHANGE, "current": None, "proposed": "5102", "write_eligible": True, "reason": "R"}} for i in range(5)}
cands, total = apply_mod.select_candidates(results, limit=2)
assert total == 5 and len(cands) == 2
# ------------------------------------------------------------------ apply_candidate: happy path, idempotency, stale guard
def test_apply_candidate_writes_verifies_and_commits():
db = FakeFirebird({"000001": {"CFOP": "5102"}})
c = candidate("000001", "CFOP", "5102", "5101")
rec = apply_mod.apply_candidate(db, c, now="t0", job_id=1)
assert rec["outcome"] == "APPLIED" and rec["old_value"] == "5102" and rec["new_value"] == "5101" and rec["verified_after"] is True
assert db.products["000001"]["CFOP"] == "5101" and db.commits >= 1 and db.rollbacks == 0
def test_second_run_over_the_same_candidate_writes_zero_no_write_required():
db = FakeFirebird({"000001": {"CFOP": "5101"}}) # already at the proposed value (as if a first pass already applied it)
c = candidate("000001", "CFOP", "5102", "5101")
try:
apply_mod.apply_candidate(db, c, now="t0", job_id=1)
assert False, "expected CfopIpptApplyAborted"
except apply_mod.CfopIpptApplyAborted as e:
assert e.reason == "NO_WRITE_REQUIRED"
assert db.products["000001"]["CFOP"] == "5101" # unchanged, no write attempted
def test_human_override_wins_stale_value_aborts_without_writing():
db = FakeFirebird({"000001": {"CFOP": "9999"}}) # a human (or anything else) set a THIRD value since the candidate was selected
c = candidate("000001", "CFOP", "5102", "5101") # candidate still expects the OLD "5102"
try:
apply_mod.apply_candidate(db, c, now="t0", job_id=1)
assert False, "expected CfopIpptApplyAborted"
except apply_mod.CfopIpptApplyAborted as e:
assert e.reason == "STALE_VALUE_CHANGED_SINCE_DECISION"
assert db.products["000001"]["CFOP"] == "9999" # the human's value is preserved, never overwritten
def test_apply_candidate_blank_expected_before_and_fill():
db = FakeFirebird({"000801": {"CFOP": None}})
c = candidate("000801", "CFOP", None, "5102")
rec = apply_mod.apply_candidate(db, c, now="t0", job_id=1)
assert rec["outcome"] == "APPLIED" and rec["old_value"] is None and rec["new_value"] == "5102"
def test_apply_candidate_product_not_found_or_duplicate_row_aborts():
db = FakeFirebird({})
try:
apply_mod.apply_candidate(db, candidate("999999", "CFOP", None, "5102"), now="t0")
assert False
except apply_mod.CfopIpptApplyAborted as e:
assert e.reason == "PRODUCT_NOT_FOUND"
db2 = FakeFirebird({"000001": {"CFOP": "5102", "DUP": True}})
try:
apply_mod.apply_candidate(db2, candidate("000001", "CFOP", "5102", "5101"), now="t0")
assert False
except apply_mod.CfopIpptApplyAborted as e:
assert e.reason == "PRODUCT_ROW_NOT_UNIQUE"
# ------------------------------------------------------------------ apply_candidates: batch semantics, authorize() gate, flag OFF => zero writes
def test_apply_candidates_batch_never_stops_on_one_abort_and_reports_reasons():
db = FakeFirebird({"000001": {"CFOP": "5102"}, "000002": {"CFOP": "9999"}})
cands = [candidate("000001", "CFOP", "5102", "5101"), candidate("000002", "CFOP", "5102", "5101")]
out = apply_mod.apply_candidates(db, cands, now="t0", job_id=1)
assert out["write_successes"] == 1 and out["write_attempts"] == 2
assert out["aborted_reasons"] == {"STALE_VALUE_CHANGED_SINCE_DECISION": 1}
assert db.products["000001"]["CFOP"] == "5101" and db.products["000002"]["CFOP"] == "9999"
def test_flag_off_means_caller_never_invokes_apply_zero_writes():
"""The Agent must gate BEFORE calling apply_candidates at all when write_gate(...).allowed is False -
this is the actual "flag OFF => zero writes" contract; apply_candidates itself is only ever reached
once the gate already said yes."""
gate = apply_mod.write_gate(mode="DISABLED")
assert not gate["allowed"]
db = FakeFirebird({"000001": {"CFOP": "5102"}})
# execute_job's real code path: `if gate["allowed"]: apply_candidates(...)` - simulate the guard directly.
if gate["allowed"]:
apply_mod.apply_candidates(db, [candidate("000001", "CFOP", "5102", "5101")], now="t0")
assert db.products["000001"]["CFOP"] == "5102" and db.commits == 0
def test_authorize_callback_can_veto_a_candidate_right_before_the_write():
db = FakeFirebird({"000001": {"CFOP": "5102"}})
out = apply_mod.apply_candidates(db, [candidate("000001", "CFOP", "5102", "5101")], now="t0", authorize=lambda code, field: "AUTHORIZATION_CHANGED")
assert out["write_successes"] == 0 and out["aborted_reasons"] == {"AUTHORIZATION_CHANGED": 1}
assert db.products["000001"]["CFOP"] == "5102"
# ------------------------------------------------------------------ rollback: only if the value still equals what was written
def test_rollback_restores_old_value_when_unchanged():
db = FakeFirebird({"000001": {"CFOP": "5101"}})
item = {"product_code": "000001", "field": "CFOP", "old_value": "5102", "new_value": "5101", "audit_id": 42}
rec = apply_mod.rollback_one(db, item, now="t1", job_id=1)
assert rec["outcome"] == "ROLLED_BACK" and db.products["000001"]["CFOP"] == "5102"
def test_rollback_refuses_when_a_human_changed_the_value_since():
db = FakeFirebird({"000001": {"CFOP": "7777"}}) # someone changed it after the write, before the rollback
item = {"product_code": "000001", "field": "CFOP", "old_value": "5102", "new_value": "5101", "audit_id": 42}
rec = apply_mod.rollback_one(db, item, now="t1", job_id=1)
assert rec["outcome"] == "ABORTED" and rec["reason"] == "ROLLBACK_CONFLICT_VALUE_CHANGED"
assert db.products["000001"]["CFOP"] == "7777" # untouched
def test_rollback_records_batch():
db = FakeFirebird({"000001": {"CFOP": "5101"}, "000002": {"IPPT": "P"}})
items = [{"product_code": "000001", "field": "CFOP", "old_value": "5102", "new_value": "5101"},
{"product_code": "000002", "field": "IPPT", "old_value": "T", "new_value": "P"}]
out = apply_mod.rollback_records(db, items, now="t1", job_id=1)
assert out["rolled_back"] == 2
+63
View File
@@ -0,0 +1,63 @@
import httpx
from gti_fiscal_agent.client import HubClient
def test_identity_requests_authenticated_identity_only(monkeypatch):
calls = {}
payload = {
"agent_id": 2,
"installation_id": "00000000-0000-4000-8000-000000000222",
"hostname": "DESKTOP-B0HL02I",
"version": "1.1.1",
"status": "OFFLINE",
}
class Response:
status_code = 200
def raise_for_status(self):
return None
def json(self):
return payload
def fake_get(url, *, headers, timeout):
calls.update(url=url, headers=headers, timeout=timeout)
return Response()
monkeypatch.setattr(httpx, "get", fake_get)
client = HubClient("https://hub.example", "test-agent-token")
assert client.identity() == payload
assert calls["url"] == "https://hub.example/v1/agents/me/identity"
assert calls["headers"] == {"Authorization": "Bearer test-agent-token"}
def test_transport_errors_and_proxy_5xx_are_retried_but_client_errors_are_not(monkeypatch):
import httpx
from gti_fiscal_agent import client as c
monkeypatch.setattr(c.time, "sleep", lambda s: None)
calls = {"n": 0}
def flaky():
calls["n"] += 1
if calls["n"] == 1:
raise httpx.ConnectError("ssl handshake failure")
if calls["n"] == 2:
return httpx.Response(502)
return httpx.Response(200, json={"ok": True})
assert c._resilient(flaky).status_code == 200 and calls["n"] == 3
calls["n"] = 0
assert c._resilient(lambda: (calls.__setitem__("n", calls["n"] + 1), httpx.Response(401))[1]).status_code == 401 and calls["n"] == 1 # 4xx: no retry
calls["n"] = 0
def always_down():
calls["n"] += 1
raise httpx.ConnectError("down")
import pytest
with pytest.raises(httpx.ConnectError):
c._resilient(always_down)
assert calls["n"] == 10 # bounded: 10 attempts (~3 min), then the job fails cleanly
+134
View File
@@ -0,0 +1,134 @@
import pytest
from gti_fiscal_agent.company_context import CompanyContextError, config_from_env, mask_cnpj, preflight_report, resolve
from gti_fiscal_agent.firebird_reader import parse_company_context
CN_A, CN_B, CN_C = "11111111000111", "22222222000122", "33333333000133"
def co(code, cnpj, uf="BA", crt="1", optante="S"):
return {"code": code, "cnpj": cnpj, "uf": uf, "crt": crt, "optante": optante, "name": "X"}
def ev(companies, emissor=None, docs=None):
return {"c000004": companies, "emissor": emissor if emissor is not None else [], "nfce_keys": docs or [], "nf_by_filial": {}}
def emis(cnpj=CN_A, uf="BA", crt="1"):
return {"cnpj": cnpj, "uf": uf, "crt": crt}
DOC_A = [{"cuf": "29", "cnpj": CN_A, "n": 100}]
def test_single_company_all_sources_agree_is_auto_resolved():
r = resolve(ev([co("000001", CN_A)], [emis()], DOC_A))
assert r["status"] == "PASS" and r["classification"] == "AUTO_RESOLVED" and r["company_uf"] == "BA" and r["company_crt"] == "1" and r["confidence"] == "HIGH"
assert "DOCUMENT_KEYS" in r["source_of_truth"]["uf"]
def test_multiple_rows_resolved_only_through_emitter_identity_never_first_row():
rows = [co("000001", CN_B, "SP", None, "N"), co("000005", CN_A), co("000007", CN_C, "BA", None, "N")]
r = resolve(ev(rows, [emis(CN_A)], DOC_A))
assert r["status"] == "PASS" and r["company_code"] == "000005" and r["source_of_truth"]["company"] == "EMISSOR_CNPJ_MATCH"
def test_multiple_rows_without_disambiguation_requires_config():
rows = [co("000001", CN_A), co("000002", CN_B)]
r = resolve(ev(rows, []))
assert r["status"] == "FAIL" and r["classification"] == "CONFIG_REQUIRED" and r["company_code"] is None
assert resolve(ev(rows, [emis(CN_C)]))["classification"] == "CONFIG_REQUIRED" # emitter matches none
ok = resolve(ev(rows, [], []), {"company_code": "000002"})
assert ok["status"] == "PASS" and ok["company_code"] == "000002" and ok["classification"] == "CONFIGURED"
def test_configured_company_must_exist_and_agree_with_emitter():
rows = [co("000001", CN_A), co("000002", CN_B)]
assert resolve(ev(rows, [emis(CN_A)]), {"company_cnpj": "99999999999999"})["classification"] == "CONFIG_REQUIRED"
r = resolve(ev(rows, [emis(CN_A)], DOC_A), {"company_code": "000002"})
assert r["status"] == "FAIL" and r["classification"] == "CONFLICT" # selected company differs from the emitter: unexplained
def test_documents_only_from_another_cnpj_conflict_but_mixed_history_is_a_warning():
r = resolve(ev([co("000001", CN_A)], [emis()], [{"cuf": "29", "cnpj": CN_B, "n": 10}]))
assert r["status"] == "FAIL" and r["classification"] == "CONFLICT"
r = resolve(ev([co("000001", CN_A)], [emis()], [{"cuf": "29", "cnpj": CN_A, "n": 10}, {"cuf": "29", "cnpj": CN_B, "n": 1}]))
assert r["status"] == "PASS" and any("HISTORICAL_NFCE_KEYS_FROM_OTHER_CNPJ" in w for w in r["warnings"])
def test_uf_conflict_and_unknown():
assert resolve(ev([co("1", CN_A, "BA")], [emis(uf="SP")]))["classification"] == "CONFLICT"
assert resolve(ev([co("1", CN_A, "BA")], [emis()], [{"cuf": "35", "cnpj": CN_A, "n": 5}]))["classification"] == "CONFLICT" # key says SP
assert resolve(ev([co("1", CN_A, "")], [emis(uf="")]))["classification"] == "UNRESOLVED"
def test_crt_conflict_needs_explicit_source_and_records_it():
base = ev([co("1", CN_A, crt="3")], [emis(crt="1")], DOC_A)
r = resolve(base)
assert r["status"] == "FAIL" and r["classification"] == "CONFLICT" and r["crt_candidates"] == {"EMISSOR": "1", "C000004": "3"}
ok = resolve(base, {"crt_source": "EMISSOR"})
assert ok["status"] == "PASS" and ok["company_crt"] == "1" and ok["classification"] == "CONFIGURED" and ok["confidence"] == "MEDIUM"
assert resolve(base, {"crt_source": "C000004"})["company_crt"] == "3"
assert resolve(base, {"crt_source_invalid": True})["classification"] == "CONFIG_REQUIRED"
def test_crt_unknown_fails_and_single_source_is_flagged():
assert resolve(ev([co("1", CN_A, crt=None)], [emis(crt="")]))["classification"] == "UNRESOLVED"
r = resolve(ev([co("1", CN_A, crt=None)], [emis(crt="1")]))
assert r["status"] == "PASS" and "CRT_HAS_A_SINGLE_SOURCE" in r["warnings"]
def test_optante_inconsistency_is_a_warning_not_a_guess():
r = resolve(ev([co("1", CN_A, crt="3", optante="S")], [emis(crt="3")]))
assert r["status"] == "PASS" and any("OPTANTE_SIMPLES" in w for w in r["warnings"])
def test_contribuinte_ipi_is_auto_discovered_from_the_same_selected_c000004_row():
"""C000004.CONTRIBUINTE_IPI (real column, same row already proven to be THE company by CRT/UF resolution) closes the fiscal-completion gap
where CST_IPI could not be resolved without asking an operator for a fact the ERP already proves."""
row = co("000001", CN_A)
row["contribuinte_ipi"] = "N"
r = resolve(ev([row], [emis()], DOC_A))
assert r["company_contribuinte_ipi"] == "N" and r["source_of_truth"]["contribuinte_ipi"] == "C000004"
def test_contribuinte_ipi_absent_or_invalid_stays_unresolved_never_guessed():
r = resolve(ev([co("000001", CN_A)], [emis()], DOC_A)) # co() never sets contribuinte_ipi
assert r["company_contribuinte_ipi"] is None
row = co("000002", CN_B); row["contribuinte_ipi"] = "X" # garbage value: never trusted
r2 = resolve(ev([row], [emis(cnpj=CN_B)], [{"cuf": "29", "cnpj": CN_B, "n": 1}]))
assert r2["company_contribuinte_ipi"] is None
def test_full_cnpj_is_never_exposed():
r = resolve(ev([co("1", CN_A)], [emis()], DOC_A))
dump = str(preflight_report(r)) + str(r)
assert CN_A not in dump and mask_cnpj(CN_A) == "11**********11"
def test_read_company_context_fails_closed(monkeypatch):
from gti_fiscal_agent import company_context as cc, firebird_reader as fr
monkeypatch.setattr(cc, "preflight_company_context", lambda env: resolve(ev([co("1", CN_A), co("2", CN_B)], [])))
with pytest.raises(CompanyContextError):
fr.read_company_context({})
monkeypatch.setattr(cc, "preflight_company_context", lambda env: resolve(ev([co("1", CN_A)], [emis()], DOC_A)))
assert fr.read_company_context({})["uf"] == "BA"
def test_config_from_env_and_legacy_parser_unchanged():
assert config_from_env({"COMPANY_CODE": " 000005 ", "COMPANY_CNPJ": "11.111.111/0001-11", "COMPANY_CRT_SOURCE": "emissor"}) == {"company_code": "000005", "company_cnpj": "11111111000111", "crt_source": "EMISSOR", "crt_source_invalid": False, "uf_override": None}
assert config_from_env({"COMPANY_CRT_SOURCE": "x"})["crt_source_invalid"] is True
assert parse_company_context([("ba", "1")]) == {"uf": "BA", "crt": "1"}
def test_auto_crt_source_and_uf_override_semantics():
assert config_from_env({"COMPANY_CRT_SOURCE": "AUTO"}) ["crt_source"] is None and config_from_env({"COMPANY_CRT_SOURCE": "AUTO"})["crt_source_invalid"] is False
conflict = ev([co("1", CN_A, crt="3")], [emis(crt="1")], DOC_A)
assert resolve(conflict, config_from_env({"COMPANY_CRT_SOURCE": "AUTO"}))["status"] == "FAIL" # AUTO never hides a CRT conflict
good = ev([co("1", CN_A)], [emis()], DOC_A)
assert resolve(good, {"uf_override": "BA"})["status"] == "PASS"
r = resolve(good, {"uf_override": "SP"})
assert r["status"] == "FAIL" and r["classification"] == "CONFLICT" # override never replaces the resolver silently
blank = ev([co("1", CN_A, uf="")], [emis(uf="")])
r = resolve(blank, {"uf_override": "BA"})
assert r["status"] == "PASS" and "UF_FROM_EXPLICIT_OVERRIDE" in r["warnings"]
+179
View File
@@ -0,0 +1,179 @@
"""DPAPI LocalMachine credential store: encrypt/decrypt round-trip, plaintext
fallback is dev-only, ACL is SYSTEM/Administrators, and the token is never
written into agent.env."""
import importlib.util
import os
import re
import stat
import subprocess
import sys
from pathlib import Path
import pytest
from gti_fiscal_agent import credential_store
import win32api
AGENT_ROOT = Path(__file__).resolve().parents[1]
RUN_SCRIPT = AGENT_ROOT / "scripts" / "run_agent_service.py"
def load_run_service():
spec = importlib.util.spec_from_file_location("run_agent_service_cred", RUN_SCRIPT)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def write_plaintext(path: Path, value: str = "TOKEN_PLAINTEXT_DEV"):
path.write_text(value, encoding="utf-8")
# ---------- DPAPI round-trip ----------
def _allow_current_user(path: Path) -> None:
"""The strict SYSTEM/Administrators DACL blocks the pytest user (not an
admin here). As the file OWNER the current user may grant itself read —
exactly like the elevated installer/local administrator does on the real
host before the credential is consumed by the SYSTEM service."""
import subprocess
import win32security
acct_sid, _domain, _type = win32security.LookupAccountName(None, win32api.GetUserName())
sid_str = win32security.ConvertSidToStringSid(acct_sid)
subprocess.run(["icacls", str(path), "/grant", f"*{sid_str}:(R)"], check=True, capture_output=True)
def test_write_then_read_roundtrip(tmp_path):
path = tmp_path / "credentials.bin"
credential_store.write_credential(path, "TOKEN_DPAPI_1")
_allow_current_user(path)
assert credential_store.read_credential(path) == "TOKEN_DPAPI_1"
def test_stored_blob_never_contains_the_plaintext_secret(tmp_path):
path = tmp_path / "credentials.bin"
credential_store.write_credential(path, "TOKEN_VERY_SECRET_9")
_allow_current_user(path)
raw = path.read_bytes()
assert b"TOKEN_VERY_SECRET_9" not in raw
def test_read_missing_file_raises(tmp_path):
with pytest.raises(credential_store.CredentialStoreError):
credential_store.read_credential(tmp_path / "nope.bin")
def test_write_detects_dpapi_unavailable_and_fails_closed(tmp_path, monkeypatch):
monkeypatch.setattr(credential_store, "_dpapi_available", lambda: False)
with pytest.raises(credential_store.CredentialStoreError):
credential_store.write_credential(tmp_path / "credentials.bin", "never")
# ---------- plaintext fallback (dev-only) ----------
def test_plaintext_rejected_by_default(tmp_path):
path = tmp_path / "credentials.bin"
write_plaintext(path)
with pytest.raises(credential_store.CredentialStoreError):
credential_store.read_credential(path)
def test_plaintext_accepted_only_with_dev_flag(tmp_path, monkeypatch):
path = tmp_path / "credentials.bin"
write_plaintext(path, "TOKEN_DEV_PLAIN")
assert credential_store.read_credential(path, env={"GTI_ALLOW_PLAINTEXT_CREDENTIAL": "1"}) == "TOKEN_DEV_PLAIN"
monkeypatch.setenv("GTI_ALLOW_PLAINTEXT_CREDENTIAL", "1")
assert credential_store.read_credential(path) == "TOKEN_DEV_PLAIN"
monkeypatch.delenv("GTI_ALLOW_PLAINTEXT_CREDENTIAL", raising=False)
with pytest.raises(credential_store.CredentialStoreError):
credential_store.read_credential(path)
# ---------- ACL (SYSTEM/Administrators only) ----------
def test_write_sets_system_and_administrators_acl(tmp_path):
import win32security
path = tmp_path / "credentials.bin"
credential_store.write_credential(path, "TOKEN_ACL_7")
admin_sid = win32security.ConvertStringSidToSid("S-1-5-32-544")
system_sid = win32security.ConvertStringSidToSid("S-1-5-18")
sd = win32security.GetFileSecurity(str(path), win32security.DACL_SECURITY_INFORMATION)
dacl = sd.GetSecurityDescriptorDacl()
assert dacl.GetAceCount() == 2
allow_trustees = []
for i in range(dacl.GetAceCount()):
ace = dacl.GetAce(i)
assert ace[0][0] == win32security.ACCESS_ALLOWED_ACE_TYPE, ace # only allows, no deny
allow_trustees.append(win32security.ConvertSidToStringSid(ace[2]))
assert set(allow_trustees) == {"S-1-5-18", "S-1-5-32-544"}
# ---------- install_env_from_credentials / Config integration ----------
def test_install_env_from_credentials_fills_agent_token(tmp_path):
path = tmp_path / "credentials.bin"
credential_store.write_credential(path, "TOKEN_FROM_FILE")
_allow_current_user(path)
env = {"AGENT_TOKEN": "", "HUB_URL": "https://x"}
install_env_from_credentials = credential_store.install_env_from_credentials
assert install_env_from_credentials(path, env) == "TOKEN_FROM_FILE"
assert env["AGENT_TOKEN"] == "TOKEN_FROM_FILE"
def test_install_env_prefers_explicit_agent_token(tmp_path):
path = tmp_path / "credentials.bin"
credential_store.write_credential(path, "TOKEN_FROM_FILE")
env = {"AGENT_TOKEN": "TOKEN_FROM_ENV"}
install_env_from_credentials = credential_store.install_env_from_credentials
assert install_env_from_credentials(path, env) == "TOKEN_FROM_ENV"
assert env["AGENT_TOKEN"] == "TOKEN_FROM_ENV"
def test_config_reads_token_from_credential_file(tmp_path, monkeypatch):
credential_path = tmp_path / "credentials.bin"
credential_store.write_credential(credential_path, "TOKEN_CRED_FILE")
_allow_current_user(credential_path)
env = {
"FIREBIRD_DATABASE_PATH": str(tmp_path / "nope.fdb"),
"FIREBIRD_WRITES": "0",
"FISCAL_MODE": "DRY_RUN",
"GTI_CREDENTIAL_FILE": str(credential_path),
}
for key in list(env):
monkeypatch.setenv(key, env[key])
service = load_run_service()
config = service.Config(os.environ)
assert config.agent_token == "TOKEN_CRED_FILE"
assert config.firebird_writes is False # FIREBIRD_WRITES stays 0 regardless of credential
# ---------- no-write enforcement (drift) ----------
def test_firebird_writes_fail_closed_with_credential_file(tmp_path, monkeypatch):
credential_path = tmp_path / "credentials.bin"
credential_store.write_credential(credential_path, "TOKEN")
_allow_current_user(credential_path)
env = {
"FIREBIRD_DATABASE_PATH": str(tmp_path / "x.fdb"),
"FIREBIRD_WRITES": "0",
"GTI_CREDENTIAL_FILE": str(credential_path),
}
for key in list(env):
monkeypatch.setenv(key, env[key])
service = load_run_service()
# Even a hostile FISCAL_MODE cannot enable writes when FIREBIRD_WRITES=0.
monkeypatch.setenv("FISCAL_MODE", "AUTO_APPLY")
config = service.Config(os.environ)
assert config.firebird_writes is False
def test_credential_file_default_lives_under_program_files():
default = credential_store.default_credential_path()
assert "Program Files" in str(default)
assert default.name == "credentials.bin"
+407
View File
@@ -0,0 +1,407 @@
"""Agent enrichment: baseline/new-product detection, feature gating, typed write, second run = 0 writes, human override (incl. deleted value)."""
import sqlite3
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "agent" / "src"))
from gti_fiscal_agent import enrichment as E # noqa: E402
class Conn:
"""sqlite stand-in for the Firebird connection; close() is a no-op so the worker can 'open' it repeatedly."""
def __init__(self, db):
self.db = db
def cursor(self):
return self.db.cursor()
def commit(self):
self.db.commit()
def rollback(self):
self.db.rollback()
def close(self):
pass
class Hub:
def __init__(self, features, decisions=None, state="EXACT_GTIN_VERIFIED"):
self.features = features
self.decisions = decisions or {}
self.state = state
self.resolve_calls, self.reports, self.overridden = [], [], {}
def authorizations(self):
return {"features": dict(self.features)}
def resolve_product(self, payload):
self.resolve_calls.append(payload)
fields = {k: dict(v) for k, v in self.decisions.items()}
for f in list(fields):
if f in self.overridden.get(payload["product_code"], set()):
fields[f] = {"status": "HUMAN_OVERRIDDEN", "value": None}
elif payload["current"].get(f) and not E.is_placeholder(f, payload["current"].get(f)):
fields[f] = {"status": "ALREADY_SET", "value": None}
return {"state": self.state, "fields": fields, "dataset_versions": {"NCM": {"sha256": "abc"}}}
def pending_enrichment(self):
codes, self.flagged = list(getattr(self, "flagged", [])), []
return codes
def report_enrichment(self, payload):
self.reports.append(payload)
for item in payload["fields"]:
if item["outcome"] == "HUMAN_OVERRIDDEN":
self.overridden.setdefault(payload["product_code"], set()).add(item["field"])
return {"ok": True}
ENABLED = {"features_configured": True, "corrector_enabled": True, "enrichment_mode": "ENABLED"}
DECISIONS = {"CLASSIFICACAO_FISCAL": {"status": "AUTO_FILL_SAFE", "value": "22021000", "rule": "NCM_KB_VERIFIED", "kb_status": "VERIFIED_KB"},
"CEST": {"status": "AUTO_FILL_SAFE", "value": "0300700", "rule": "CEST_OFFICIAL_UNIQUE_MAPPING"},
"IPI": {"status": "AUTO_FILL_SAFE", "value": "0.0", "rule": "IPI_TIPI_NUMERIC"}}
@pytest.fixture()
def env(tmp_path):
db = sqlite3.connect(":memory:", check_same_thread=False)
db.execute("CREATE TABLE C000025(CODIGO TEXT PRIMARY KEY, PRODUTO TEXT, GTIN TEXT, CLASSIFICACAO_FISCAL TEXT, CEST TEXT, IPI REAL, OTHER TEXT, CODBARRA TEXT, "
+ ", ".join(f"{f} TEXT" for f in E.FISCAL_COMPLETION_FIELDS + E.CLASSIFICATION_CONTEXT_FIELDS) + ")")
db.executemany("INSERT INTO C000025(CODIGO,PRODUTO,GTIN,OTHER) VALUES(?,?,?,?)", [(f"H{i}", f"HIST {i}", "", "keep") for i in range(50)])
db.commit()
hub = Hub(dict(ENABLED), DECISIONS)
store = E.EnrichmentStore(tmp_path / "e.db")
now = [1000.0]
reproc = []
worker = E.EnrichmentWorker(hub=hub, store=store, read_conn=lambda: Conn(db), write_conn=lambda: Conn(db), create_reprocess=reproc.append, clock=lambda: now[0], sleep=lambda s: None)
return db, hub, store, worker, now, reproc
def add(db, code, name="REFRIGERANTE COLA LATA 350ML", gtin="7891000100103"):
db.execute("INSERT INTO C000025(CODIGO,PRODUTO,GTIN,OTHER) VALUES(?,?,?,?)", (code, name, gtin, "keep"))
db.commit()
def row(db, code):
return db.execute("SELECT CLASSIFICACAO_FISCAL,CEST,IPI,OTHER FROM C000025 WHERE CODIGO=?", (code,)).fetchone()
def test_baseline_never_sends_historical_products(env):
db, hub, store, worker, now, _ = env
assert worker.step() == [] # baseline
now[0] += 5
assert worker.step() == [] and hub.resolve_calls == []
assert len(store.known()) == 50
def test_new_product_is_enriched_only_in_empty_fields_then_second_run_zero_writes(env):
db, hub, store, worker, now, reproc = env
worker.step()
add(db, "N1")
now[0] += 5
assert worker.step() == ["N1"]
assert row(db, "N1") == ("22021000", "0300700", 0.0, "keep")
assert reproc == [["N1"]]
assert row(db, "H1") == (None, None, None, "keep") # historical untouched
assert [r["outcome"] for r in hub.reports[0]["fields"]] == ["APPLIED"] * 3
now[0] += 5
before = db.total_changes
worker.step()
assert db.total_changes == before and len(hub.resolve_calls) == 1 # nothing re-processed, 0 writes
def test_current_report_includes_fiscal_completion_and_classification_columns(tmp_path):
"""`current` sent to the Hub used to carry only CLASSIFICACAO_FISCAL/CEST/IPI. field_contract.resolve_product (now wired into
/v1/products/resolve) also needs the current CFOP/CST/CSOSN/... values and the classification columns infer_role reads (TIPO/
PROD_COZINHA/PRODUTO_SERVICO/CODGRUPO) - otherwise it can never see what's already filled or auto-discover product_role."""
db = sqlite3.connect(":memory:", check_same_thread=False)
db.execute("CREATE TABLE C000025(CODIGO TEXT PRIMARY KEY, PRODUTO TEXT, GTIN TEXT, CLASSIFICACAO_FISCAL TEXT, CEST TEXT, IPI REAL, OTHER TEXT, CODBARRA TEXT, "
+ ", ".join(f"{f} TEXT" for f in E.FISCAL_COMPLETION_FIELDS + E.CLASSIFICATION_CONTEXT_FIELDS) + ")")
db.commit()
hub = Hub(dict(ENABLED), DECISIONS)
store = E.EnrichmentStore(tmp_path / "e.db")
now = [1000.0]
worker = E.EnrichmentWorker(hub=hub, store=store, read_conn=lambda: Conn(db), write_conn=lambda: Conn(db), create_reprocess=lambda c: None, clock=lambda: now[0], sleep=lambda s: None)
worker.step() # baseline
db.execute("INSERT INTO C000025(CODIGO,PRODUTO,GTIN,OTHER,CSOSN,TIPO,PRODUTO_SERVICO) VALUES(?,?,?,?,?,?,?)", ("N9", "PRODUTO X", "7891000100103", "keep", "102", "VENDA", None))
db.commit()
now[0] += 5
worker.step()
sent = hub.resolve_calls[0]["current"]
assert sent["CSOSN"] == "102" and sent["TIPO"] == "VENDA"
for f in E.FISCAL_COMPLETION_FIELDS + E.CLASSIFICATION_CONTEXT_FIELDS:
assert f in sent, f
assert hub.resolve_calls[0]["ev"] == {"recipes": [], "composed": [], "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}} # targeted evidence rides along (empty here: sqlite has no RDB$RELATION_FIELDS)
def test_existing_value_is_never_overwritten(env):
db, hub, store, worker, now, _ = env
worker.step()
add(db, "N2")
db.execute("UPDATE C000025 SET CLASSIFICACAO_FISCAL='19059090' WHERE CODIGO='N2'")
db.commit()
now[0] += 5
worker.step()
assert row(db, "N2")[0] == "19059090" and row(db, "N2")[1] == "0300700"
def test_dry_run_and_disabled_and_waiting_do_not_write_or_touch_firebird(env):
db, hub, store, worker, now, _ = env
worker.step()
for features in ({"features_configured": False}, {"features_configured": True, "enrichment_mode": "DISABLED"}):
hub.features = features
worker._features_at = -999
assert worker.step() == [] and hub.resolve_calls == []
hub.features = {"features_configured": True, "enrichment_mode": "DRY_RUN"}
worker._features_at = -999
now[0] += 5
worker.step() # re-baseline after the inactive period
add(db, "N3")
now[0] += 5
worker.step()
assert row(db, "N3") == (None, None, None, "keep") and hub.reports[0]["fields"][0]["outcome"] == "DRY_RUN"
def test_authorization_revoked_right_before_write_fails_closed(env):
db, hub, store, worker, now, _ = env
worker.step()
add(db, "N4")
calls = {"n": 0}
def flip():
calls["n"] += 1
return {"features": dict(ENABLED if calls["n"] == 1 else {"features_configured": True, "enrichment_mode": "DISABLED"})}
hub.authorizations = flip
now[0] += 5
worker._features_at = -999
worker.step()
assert row(db, "N4") == (None, None, None, "keep")
assert all(f["outcome"] == "ABORTED" and f["reason"] == "AUTHORIZATION_CHANGED" for f in hub.reports[0]["fields"])
def test_human_override_including_deleted_value_is_final(env):
db, hub, store, worker, now, _ = env
worker.step()
add(db, "N5")
now[0] += 5
worker.step()
db.execute("UPDATE C000025 SET CEST='', CLASSIFICACAO_FISCAL='19059090' WHERE CODIGO='N5'") # a person deletes the CEST and changes the NCM
db.commit()
now[0] += 30
worker.step()
assert store.is_overridden("N5", "CEST") and store.is_overridden("N5", "CLASSIFICACAO_FISCAL") and not store.is_overridden("N5", "IPI")
# the description changes a moment later: the worker re-resolves, but overridden fields must NOT be written again
db.execute("UPDATE C000025 SET PRODUTO='REFRIGERANTE COLA LATA 350ML ZERO' WHERE CODIGO='N5'")
db.commit()
now[0] += 30
worker.step()
assert row(db, "N5")[:2] == ("19059090", "")
def test_write_field_allowlist_and_shape():
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE C000025(CODIGO TEXT, COD_ORIGEM_MERCADORIA TEXT, CLASSIFICACAO_FISCAL TEXT)")
with pytest.raises(ValueError):
E.write_field(Conn(db), "1", "COD_ORIGEM_MERCADORIA", "1")
with pytest.raises(ValueError):
E.write_field(Conn(db), "1", "CCLASSTRIB", "000001")
db.execute("INSERT INTO C000025(CODIGO) VALUES('1')")
db.commit()
assert E.write_field(Conn(db), "1", "CLASSIFICACAO_FISCAL", "123")["reason"] == "VALUE_SHAPE_INVALID"
def test_hub_offline_keeps_product_pending_and_retries(env):
db, hub, store, worker, now, _ = env
worker.step()
add(db, "N6")
def boom(payload):
raise ConnectionError("down")
good = hub.resolve_product
hub.resolve_product = boom
now[0] += 5
worker.step()
assert store.get("N6")["state"] == "RETRY" and row(db, "N6")[0] is None
hub.resolve_product = good
now[0] += 40
assert worker.step() == ["N6"] and row(db, "N6")[0] == "22021000"
def test_gtin_check_parity_with_the_hub_and_codbarra_is_used():
sys.path.insert(0, str(ROOT))
from central.app.enrichment.gtin import normalize_gtin
for value in ("7891000100103", "7891000100104", "", "0", "12345678", "40170725", "036000291452", "11111111", "78910001001030", "abc", "2000000000008"):
assert E.gtin_check_ok(value) == (normalize_gtin(value)["status"] in ("VALID_GTIN", "RESTRICTED_GTIN")), value
assert E.pick_gtin({"GTIN": "", "CODBARRA": "7891000100103"}) == "7891000100103"
assert E.pick_gtin({"GTIN": "7891000100103", "CODBARRA": "999"}) == "7891000100103"
assert E.pick_gtin({"GTIN": "", "CODBARRA": "123"}) == "123" and E.pick_gtin({}) == ""
def test_products_created_while_inactive_are_not_new_when_reactivated(env):
db, hub, store, worker, now, _ = env
worker.step() # baseline
hub.features = {"features_configured": True, "enrichment_mode": "DISABLED"}
worker._features_at = -999
now[0] += 5
worker.step()
add(db, "W1") # created while the function is off
now[0] += 5
worker.step()
hub.features = dict(ENABLED)
worker._features_at = -999
now[0] += 5
assert worker.step() == [] and hub.resolve_calls == [] # re-baselined: W1 is history now
add(db, "W2")
now[0] += 5
assert worker.step() == ["W2"]
# ---- TECHNICAL_PLACEHOLDER (the SGV refuses a product without NCM: operators type 00000000) ---------------------------------------------------
def add_with_ncm(db, code, ncm, name="COCA-COLA ORIGINAL LATA 350 ML"):
db.execute("INSERT INTO C000025(CODIGO,PRODUTO,GTIN,CLASSIFICACAO_FISCAL,OTHER) VALUES(?,?,?,?,?)", (code, name, "7891000100103", ncm, "keep"))
db.commit()
def test_placeholder_parity_with_the_hub():
sys.path.insert(0, str(ROOT))
from central.app.enrichment import placeholders as hub
assert {k: set(v) for k, v in E.PLACEHOLDERS.items()} == {k: set(v) for k, v in hub.PRODUCT_ENRICHMENT_PLACEHOLDERS.items()}
for value in ("00000000", "0000.00.00", " 00000000 ", "22021000", "99999999", "0", "SEM NCM", "", None):
assert E.is_placeholder("CLASSIFICACAO_FISCAL", value) == hub.is_placeholder("CLASSIFICACAO_FISCAL", value), value
assert not E.is_placeholder("CEST", "0000000") and not E.is_placeholder("IPI", 0)
def test_placeholder_ncm_is_replaced_dry_run_first_then_enabled_and_second_run_is_zero(env):
db, hub, store, worker, now, _ = env
hub.features = {"features_configured": True, "enrichment_mode": "DRY_RUN"}
worker._features_at = -999
worker.step() # baseline
add_with_ncm(db, "P1", "00000000")
now[0] += 5
worker.step()
assert row(db, "P1")[0] == "00000000" and hub.reports[-1]["fields"][0]["outcome"] == "DRY_RUN" # DRY_RUN: detected, zero writes
assert hub.resolve_calls[-1]["current"]["CLASSIFICACAO_FISCAL"] == "00000000"
hub.features = dict(ENABLED)
worker._features_at = -999
worker._auth_cache = (None, 0.0)
add_with_ncm(db, "P2", "00000000")
now[0] += 5
worker.step()
assert row(db, "P2") == ("22021000", "0300700", 0.0, "keep")
first = [f for f in hub.reports[-1]["fields"] if f["field"] == "CLASSIFICACAO_FISCAL"][0]
assert first["outcome"] == "APPLIED" and first["old"] == "00000000"
before = db.total_changes
now[0] += 5
worker.step()
now[0] += 60
worker.step()
assert db.total_changes == before # second run: 0 writes
def test_real_or_unlisted_ncm_is_never_replaced(env):
db, hub, store, worker, now, _ = env
worker.step()
for code, ncm in (("R1", "22021000"), ("R2", "99999999"), ("R3", "0"), ("R4", "SEM NCM"), ("R5", "19059090")):
add_with_ncm(db, code, ncm)
now[0] += 5
worker.step()
for code, ncm in (("R1", "22021000"), ("R2", "99999999"), ("R3", "0"), ("R4", "SEM NCM"), ("R5", "19059090")):
assert row(db, code)[0] == ncm, code # EXISTING_REAL_VALUE_PRESERVED, even if the KB says otherwise
def test_write_field_guards_the_placeholder_swap():
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE C000025(CODIGO TEXT, CLASSIFICACAO_FISCAL TEXT)")
db.executemany("INSERT INTO C000025 VALUES(?,?)", [("a", "00000000"), ("b", "22021000"), ("c", None), ("d", "0000.00.00")])
db.commit()
assert E.write_field(Conn(db), "a", "CLASSIFICACAO_FISCAL", "19059090")["outcome"] == "APPLIED"
assert E.write_field(Conn(db), "b", "CLASSIFICACAO_FISCAL", "19059090")["reason"] == "VALUE_ALREADY_SET_NEVER_OVERWRITTEN"
assert E.write_field(Conn(db), "c", "CLASSIFICACAO_FISCAL", "19059090")["outcome"] == "APPLIED"
assert E.write_field(Conn(db), "d", "CLASSIFICACAO_FISCAL", "19059090")["outcome"] == "APPLIED"
assert [r[0] for r in db.execute("SELECT CLASSIFICACAO_FISCAL FROM C000025 ORDER BY CODIGO")] == ["19059090", "22021000", "19059090", "19059090"]
# ---- restart / race / multiple products / stale guard / reevaluation / provenance --------------------------------------------------------------
def make_worker(db, hub, store, now):
return E.EnrichmentWorker(hub=hub, store=store, read_conn=lambda: Conn(db), write_conn=lambda: Conn(db), create_reprocess=lambda codes: None, clock=lambda: now[0], sleep=lambda s: None)
def test_product_created_while_agent_is_down_is_found_after_restart(env):
db, hub, store, worker, now, _ = env
worker.step()
add(db, "D1") # created while the Agent is stopped / restarting
worker2 = make_worker(db, hub, store, now) # new process, same persisted state
now[0] += 5
assert worker2.step() == ["D1"] and row(db, "D1")[0] == "22021000"
def test_many_products_between_polls_and_a_deleted_one(env):
db, hub, store, worker, now, _ = env
worker.step()
for i in range(5):
add(db, f"M{i}")
db.execute("DELETE FROM C000025 WHERE CODIGO='M2'")
db.commit()
add(db, "M9")
now[0] += 5
done = worker.step()
assert sorted(done) == ["M0", "M1", "M3", "M4", "M9"] and all(row(db, c)[0] == "22021000" for c in done)
def test_reevaluation_flagged_by_the_hub_retries_an_unresolved_product(env):
db, hub, store, worker, now, _ = env
worker.step()
hub.decisions = {} # first attempt: nothing known yet
add(db, "U1")
now[0] += 5
worker.step()
assert row(db, "U1")[0] is None and store.get("U1")["state"] == "DONE"
hub.decisions = dict(DECISIONS) # new evidence (KB decision) + 'Reavaliar'/'Reprocessar'
hub.flagged = ["U1"]
now[0] += 30
assert "U1" in worker.step() and row(db, "U1")[0] == "22021000"
now[0] += 30
before = db.total_changes
worker.step()
assert db.total_changes == before
def test_stale_write_is_refused_when_a_person_changed_the_value_after_the_decision(env):
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE C000025(CODIGO TEXT, CLASSIFICACAO_FISCAL TEXT)")
db.execute("INSERT INTO C000025 VALUES('a','19059090')")
db.execute("INSERT INTO C000025 VALUES('b','00000000')")
db.commit()
assert E.write_field(Conn(db), "a", "CLASSIFICACAO_FISCAL", "22021000", observed="00000000", guard_observed=True)["reason"] == "STALE_CONFLICT"
assert E.write_field(Conn(db), "b", "CLASSIFICACAO_FISCAL", "22021000", observed="00000000", guard_observed=True)["outcome"] == "APPLIED"
def test_placeholder_after_a_human_override_is_not_filled_again(env):
db, hub, store, worker, now, _ = env
worker.step()
add_with_ncm(db, "HP1", "00000000") # born with the placeholder: eligible
now[0] += 5
worker.step()
assert row(db, "HP1")[0] == "22021000"
db.execute("UPDATE C000025 SET CLASSIFICACAO_FISCAL='00000000' WHERE CODIGO='HP1'") # a person puts the SAME value back: a human decision now
db.commit()
now[0] += 30
worker.step()
assert store.is_overridden("HP1", "CLASSIFICACAO_FISCAL")
hub.flagged = ["HP1"]
now[0] += 30
worker.step()
assert row(db, "HP1")[0] == "00000000" # provenance matters: same value, different meaning
def test_automatic_mode_needs_no_sync_button_or_restart(env):
db, hub, store, worker, now, _ = env
worker.step()
add(db, "A1")
now[0] += 3
assert worker.step() == ["A1"] and row(db, "A1")[0] == "22021000" # save -> next poll -> written
@@ -0,0 +1,85 @@
"""read_product_evidence: targeted, single-product evidence for the ad-hoc /v1/products/resolve call - the same recipes/composed/purchases/
stock facts read_evidence already gathers for a full sync job, scoped to ONE product_code, never a full-table scan. Measured against a real
production-equivalent database (D:\\PROJETOS\\GTISISTEMAS\\Banco\\GTI.FDB, read-only): a full read_evidence() scan took ~40ms for the whole
catalog, and a targeted per-product lookup correctly drove field_contract.infer_role to RESALE/OWN_PRODUCTION for 5 of 8 real products with
zero operator questions - this test locks in the behavior with a portable (sqlite) fixture."""
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from gti_fiscal_agent.firebird_reader import read_product_evidence
def _conn():
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE RECEITA_INGREDIENTES(CODRECEITA TEXT)")
db.execute("CREATE TABLE C000032(CODPRODUTO TEXT, TIPO TEXT)")
db.execute("CREATE TABLE C000088(CODPRODUTO TEXT, ICMS_RETIDO TEXT, ICMS_VALORRETIDO REAL, SUB_VALOR REAL)")
db.execute("CREATE TABLE C000100(CODPRODUTO TEXT, ULTIMA_ENTRADA TEXT, NOTA_FISCAL TEXT, ENTRADAS REAL)")
return db
def test_no_firebird_system_table_degrades_to_no_evidence_never_raises():
"""A non-Firebird connection (this project's own sqlite test fakes) has no RDB$RELATION_FIELDS: the capability probe must degrade
gracefully to "no evidence", exactly like a missing table would, never raise."""
db = sqlite3.connect(":memory:")
ev = read_product_evidence(db, "000001")
assert ev == {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}}
def test_targeted_lookup_is_scoped_to_the_one_product_code():
db = _conn()
db.executemany("INSERT INTO RECEITA_INGREDIENTES VALUES (?)", [("000009",), ("000010",)])
db.execute("INSERT INTO C000032 VALUES ('000011', 'COMPOSICAO')")
db.execute("INSERT INTO C000088 VALUES ('000012', 'S', 0, 0)")
db.execute("INSERT INTO C000100 VALUES ('000013', '2026-01-01', '000001', 5)")
db.commit()
def has_relation_fields():
cur = db.cursor()
cur.execute("SELECT TRIM(RDB$RELATION_NAME), TRIM(RDB$FIELD_NAME) FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME IN ('C000032','C000088','C000100','RECEITA_INGREDIENTES')")
return cur.fetchall()
# sqlite has no RDB$RELATION_FIELDS either; monkeypatch the cursor to answer the capability probe like a real Firebird database would
class ProbedConn:
def __init__(self, real):
self.real = real
def cursor(self):
return ProbedCursor(self.real.cursor())
class ProbedCursor:
def __init__(self, real):
self.real = real
self._probe_result = None
def execute(self, sql, params=()):
if "RDB$RELATION_FIELDS" in sql:
self._probe_result = [
("RECEITA_INGREDIENTES", "CODRECEITA"), ("C000032", "CODPRODUTO"), ("C000032", "TIPO"),
("C000088", "CODPRODUTO"), ("C000088", "ICMS_RETIDO"), ("C000088", "ICMS_VALORRETIDO"), ("C000088", "SUB_VALOR"),
("C000100", "CODPRODUTO"), ("C000100", "ULTIMA_ENTRADA"), ("C000100", "NOTA_FISCAL"), ("C000100", "ENTRADAS"),
]
return
self._probe_result = None
sqlite_sql = sql.replace("SELECT FIRST 1 ", "SELECT ") + (" LIMIT 1" if "FIRST 1" in sql else "")
self.real.execute(sqlite_sql, params)
def fetchall(self):
return self._probe_result if self._probe_result is not None else self.real.fetchall()
def fetchone(self):
return self.real.fetchone()
conn = ProbedConn(db)
ev = read_product_evidence(conn, "000009")
assert ev["recipes"] == {"000009"} and ev["composed"] == set() and ev["purchases"] == {} and ev["stock_entry"] == {} and ev["stock_in"] == {}
ev = read_product_evidence(conn, "000011")
assert ev["composed"] == {"000011"} and ev["recipes"] == set()
ev = read_product_evidence(conn, "000012")
assert ev["purchases"] == {"000012": [{"icms_retido": "S", "valor_retido": 0, "sub_valor": 0}]}
ev = read_product_evidence(conn, "000013")
assert ev["stock_entry"] == {"000013": True} and ev["stock_in"] == {"000013": True}
ev = read_product_evidence(conn, "999999")
assert ev == {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}}
+52
View File
@@ -0,0 +1,52 @@
from gti_fiscal_agent.ipi_auto_apply import ipi_write_decision
def _decision(ipi_status="IPI_RESOLVED", status="EXACT_NCM", ipi=None):
return {"status": status, "ipi_status": ipi_status, "classification": {"ipi": ipi}}
def test_numeric_null_current_is_eligible():
d = ipi_write_decision(_decision(ipi={"rate_kind": "NUMERIC", "rate_numeric": 3.25}), None)
assert d["eligible"] is True
assert d["proposed_value"] == 3.25
assert d["reason_code"] == "CHANGE_REQUIRED"
def test_numeric_same_value_is_not_eligible():
d = ipi_write_decision(_decision(ipi={"rate_kind": "NUMERIC", "rate_numeric": 3.25}), 3.25)
assert d["eligible"] is False
assert d["reason_code"] == "SAME_VALUE"
def test_numeric_different_value_is_eligible():
d = ipi_write_decision(_decision(ipi={"rate_kind": "NUMERIC", "rate_numeric": 3.25}), 0)
assert d["eligible"] is True
assert d["proposed_value"] == 3.25
def test_non_numeric_never_eligible():
d = ipi_write_decision(_decision(ipi={"rate_kind": "NON_NUMERIC", "rate_numeric": None}), None)
assert d["eligible"] is False
assert d["reason_code"] == "RATE_KIND_NON_NUMERIC"
def test_source_blank_never_eligible():
d = ipi_write_decision(_decision(ipi={"rate_kind": "BLANK", "rate_numeric": None}), None)
assert d["eligible"] is False
assert d["reason_code"] == "RATE_KIND_BLANK"
def test_ambiguous_ipi_status_never_eligible():
d = ipi_write_decision(_decision(ipi_status="IPI_CANDIDATES", ipi=None), None)
assert d["eligible"] is False
def test_no_match_never_eligible():
d = ipi_write_decision(_decision(ipi_status="NO_TIPI_MAPPING_FOUND", ipi=None), None)
assert d["eligible"] is False
def test_legacy_ncm_never_eligible():
d = ipi_write_decision(_decision(status="LEGACY_NCM", ipi_status="LEGACY_NCM"), None)
assert d["eligible"] is False
assert d["reason_code"] == "LEGACY_NCM"
@@ -0,0 +1,132 @@
import importlib.util
import sys
from pathlib import Path
import pytest
from gti_fiscal_agent.company_context import resolve
from gti_fiscal_agent.preflight import major_minor, run_preflight
ROOT = Path(__file__).parents[2]
spec = importlib.util.spec_from_file_location("run_agent_service", ROOT / "agent" / "scripts" / "run_agent_service.py")
service = importlib.util.module_from_spec(spec); sys.modules[spec.name] = service; spec.loader.exec_module(service)
CN = "11111111000111"
def env(tmp_path, **over):
base = {"GTI_HUB_URL": "http://hub.test", "AGENT_TOKEN": "SECRETTOKENVALUE9", "FIREBIRD_DATABASE_PATH": "x.fdb", "FB_USER": "u", "FB_PASSWORD": "SECRETPASSVALUE9", "FISCAL_MODE": "DRY_RUN", "FIREBIRD_WRITES": "0",
"AGENT_STATE_DB": str(tmp_path / "state.sqlite"), "AGENT_LOG_PATH": str(tmp_path / "logs" / "a.log"), "AGENT_VERSION": "1.0.0-rc1"}
base.update(over)
return base
def http_ok(url, token=None, timeout=10):
if url.endswith("/health"):
return 200, {"status": "ok"}
if url.endswith("/version"):
return 200, {"version": "1.0.0-rc1"}
return (200 if token == "SECRETTOKENVALUE9" else 401), {}
def fake_probe(company_over=None, caps_missing=()):
def probe(env):
cols = {"CLASSIFICACAO_FISCAL": (37, 20), "CFOP_ENTRADA": (14, 4), "CST_ENTRADA": (14, 4), "CFOP": (37, 4), "CST": (37, 10), "ALIQUOTA": (16, 8), "CEST": (37, 7), "SITUACAO_TRIBUTARIA": (37, 2),
"COD_ENTRADA_COFINS": (37, 3), "COD_SAIDA_COFINS": (37, 3), "ALIQ_COFINS": (37, 8), "COD_ENTRADA_PIS": (37, 3), "COD_SAIDA_PIS": (37, 3), "ALIQ_PIS": (37, 8), "CSOSN": (37, 60), "IPI": (27, 8),
"CST_IPI": (14, 3), "IAT": (37, 1), "INCIDENCIA_PISCOFINS": (37, 30), "PISCOFINS": (37, 1), "IPPT": (37, 1), "EX_TIPI": (37, 3), "COD_ORIGEM_MERCADORIA": (8, 4)}
for c in caps_missing:
cols.pop(c)
ev = {"c000004": [{"code": "000001", "cnpj": CN, "uf": "BA", "crt": "1", "optante": "S"}], "emissor": [{"cnpj": CN, "uf": "BA", "crt": "1"}], "nfce_keys": [{"cuf": "29", "cnpj": CN, "n": 5}], "nf_by_filial": {}}
ev.update(company_over or {})
return {"server_version": "WI-V6.3.9 Firebird 2.5", "ods": "11.2", "tables": {"C000004": {}, "C000025": cols}, "products": 3712, "company": resolve(ev, {})}
return probe
def test_preflight_passes_for_a_clean_dry_run_installation(tmp_path):
r = run_preflight(env(tmp_path), http_get=http_ok, probe=fake_probe())
assert r["PREFLIGHT"] == "PASS" and r["blockers"] == [] and r["effective_firebird_writes"] is False
names = {c["name"] for c in r["checks"]}
assert {"HUB_REACHABLE", "AUTH", "HUB_VERSION_COMPATIBLE", "FIREBIRD_REACHABLE", "C000004", "C000025", "CAPABILITIES", "COMPANY_CONTEXT", "UF", "CRT", "SCHEMA_TYPE_GUARDS", "SQLITE", "LOG_DIRECTORY", "FISCAL_MODE", "FIREBIRD_WRITES"} <= names
assert "SECRETTOKENVALUE9" not in str(r) and "SECRETPASSVALUE9" not in str(r)
@pytest.mark.parametrize("over,blocker", [
({"FISCAL_MODE": "DRY_RUN", "FIREBIRD_WRITES": "1"}, "FIREBIRD_WRITES"),
({"FISCAL_MODE": ""}, "FISCAL_MODE"),
({"AGENT_TOKEN": ""}, "CONFIG"),
])
def test_preflight_blocks_bad_config(tmp_path, over, blocker):
r = run_preflight(env(tmp_path, **over), http_get=http_ok, probe=fake_probe())
assert r["PREFLIGHT"] == "FAIL" and any(b.startswith(blocker) for b in r["blockers"])
def test_preflight_fails_on_hub_auth_version_and_missing_capability(tmp_path):
def bad_auth(url, token=None, timeout=10):
return http_ok(url, "wrong" if token else None)
assert any(b.startswith("AUTH") for b in run_preflight(env(tmp_path), http_get=bad_auth, probe=fake_probe())["blockers"])
def old_hub(url, token=None, timeout=10):
return (200, {"version": "0.9.0"}) if url.endswith("/version") else http_ok(url, token)
assert any(b.startswith("HUB_VERSION_COMPATIBLE") for b in run_preflight(env(tmp_path), http_get=old_hub, probe=fake_probe())["blockers"])
assert any(b.startswith("CAPABILITIES") for b in run_preflight(env(tmp_path), http_get=http_ok, probe=fake_probe(caps_missing=("CEST",)))["blockers"])
def test_preflight_fails_when_company_context_is_ambiguous_or_crt_conflicts(tmp_path):
two = {"c000004": [{"code": "1", "cnpj": CN, "uf": "BA", "crt": "1"}, {"code": "2", "cnpj": "22222222000122", "uf": "SP", "crt": "1"}], "emissor": []}
r = run_preflight(env(tmp_path), http_get=http_ok, probe=fake_probe(two))
assert r["PREFLIGHT"] == "FAIL" and any(b.startswith("COMPANY_CONTEXT") for b in r["blockers"])
conflict = {"emissor": [{"cnpj": CN, "uf": "BA", "crt": "3"}]}
assert any("CRT_SOURCE_CONFLICT" in b for b in run_preflight(env(tmp_path), http_get=http_ok, probe=fake_probe(conflict))["blockers"])
def test_hub_unreachable_is_a_clear_blocker(tmp_path):
def down(url, token=None, timeout=10):
raise ConnectionError("nope")
r = run_preflight(env(tmp_path), http_get=down, probe=fake_probe())
assert r["PREFLIGHT"] == "FAIL" and any(b.startswith("HUB_REACHABLE") for b in r["blockers"])
def test_major_minor():
assert major_minor("1.0.0-rc1") == "1.0" and major_minor("1.0.3") == "1.0" and major_minor("0.9.0") != major_minor("1.0.0-rc1")
# ---------------- zero-write guarantee ----------------
def test_dry_run_makes_writes_technically_impossible_whatever_firebird_writes_says():
for extra in ({"FIREBIRD_WRITES": "1", "FISCAL_MODE": "DRY_RUN"}, {"FIREBIRD_WRITES": "0", "FISCAL_MODE": "AUTO_APPLY"}, {"FIREBIRD_WRITES": "1", "FISCAL_MODE": "dry_run"}):
cfg = service.Config({"FIREBIRD_DATABASE_PATH": "x.fdb", **extra})
assert cfg.firebird_writes is False
assert service.Config({"FIREBIRD_DATABASE_PATH": "x.fdb", "FIREBIRD_WRITES": "1", "FISCAL_MODE": "AUTO_APPLY"}).firebird_writes is True
assert service.Config({"FIREBIRD_DATABASE_PATH": "x.fdb", "FIREBIRD_WRITES": "1"}).firebird_writes is True # legacy: unset FISCAL_MODE follows the flag
assert service.Config({"FIREBIRD_DATABASE_PATH": "x.fdb"}).fiscal_mode == "DRY_RUN"
def test_write_connection_cannot_be_opened_when_writes_are_disabled(monkeypatch):
opened = []
monkeypatch.setitem(sys.modules, "fdb", type("F", (), {"connect": staticmethod(lambda **kw: opened.append(kw))}))
with pytest.raises(RuntimeError):
service.open_write_connection({"FB_USER": "u", "FB_PASSWORD": "SECRETPASSVALUE9"}, "x.fdb", firebird_writes=False)
assert opened == []
def test_dry_run_job_never_opens_a_write_connection_nor_calls_the_executor(monkeypatch):
class Boom:
def __getattr__(self, name):
if name in ("execute_guarded_update", "execute_guarded_restore"):
raise AssertionError("executor write called in DRY_RUN")
return getattr(__import__("scripts.firebird_write_executor", fromlist=["x"]) if False else object(), name, None)
cfg = service.Config({"FIREBIRD_DATABASE_PATH": "x.fdb", "FIREBIRD_WRITES": "1", "FISCAL_MODE": "DRY_RUN"})
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: [{"CODIGO": "000001", "PRODUTO": "X", "CLASSIFICACAO_FISCAL": "22030000", "IPI": None}])
monkeypatch.setattr(service, "HubClient", lambda url, token: type("H", (), {"classify_batch": lambda self, p: [{"status": "EXACT_NCM", "ipi_status": "IPI_RESOLVED", "classification": {"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.9}},
"dataset_versions": {}}]})())
monkeypatch.setattr(service, "build_job_coverage", lambda *a, **k: {"TOTAL_PRODUCTS": 1})
monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(AssertionError("write connection opened in DRY_RUN")))
class State:
def log_ipi_write(self, *a): pass
class Ex:
WriteRefused = Exception
def execute_guarded_update(self, *a, **k): raise AssertionError("executor write called in DRY_RUN")
result, error = service.execute_job({"kind": "REPROCESS_ALL", "agent_id": 1, "payload": {}, "job_id": 1}, cfg, Ex(), State(), {})
assert error is None and result["writes_attempted"] == 0 and result["firebird_writes_enabled"] is False
+444
View File
@@ -0,0 +1,444 @@
import re
from datetime import date
import pytest
from central.app import rtc_reference_data as ref
from central.app.cclasstrib_resolver import local_catalog_hash
from gti_fiscal_agent import rtc_auto_apply as apply_mod
from gti_fiscal_agent import unified_fiscal as uf
pytestmark = pytest.mark.real_unified_fiscal
TODAY = date(2026, 9, 24)
NORMAL = {"crt": "3"}
CLASS_COLS = ("CCLASSTRIB", "CSTIBSCBS", "INICIO_VIGENCIA", "FIM_VIGENCIA", "TIPO_ALIQUOTA", "PRED_IBS", "PRED_CBS", "IND_NFE", "IND_NFCE")
SCHEMA = ([("C000025", c) for c in ("CODIGO", "CCLASSTRIB", "CLASSIFICACAO_FISCAL", "PISCOFINS", "INCIDENCIA_PISCOFINS")] + [("RTC_CCLASSTRIB", c) for c in CLASS_COLS] + [("RTC_CST", "CSTIBSCBS"), ("PARAMETROS", "USAR_REFORMA_TRIBUTARIA")])
class FakeFirebird:
"""A tiny SGV: PARAMETROS, the RTC catalog (from the official table) and C000025. Every statement is recorded; only SELECT and the single allowed UPDATE are understood."""
def __init__(self, products, param=(1,), catalog_tweak=None, skip=()):
self.products = {c: dict(v) for c, v in products.items()} # code -> {"CCLASSTRIB":..., "CLASSIFICACAO_FISCAL":...}
self.param = list(param)
self.catalog = {r["code"]: {"cst": r["cst"], "start": r["start"], "end": r["end"], "tipo": r["tipo_aliquota"], "pred_ibs": r["p_red_ibs"], "pred_cbs": r["p_red_cbs"], "ind_nfe": r["ind_nfe"], "ind_nfce": r["ind_nfce"]}
for r in ref.OFFICIAL_CCLASSTRIB if r["code"] not in skip and not r["end"]}
for code, change in (catalog_tweak or {}).items():
self.catalog[code] = {**self.catalog[code], **change}
self.cst_table = {r["cst"] for r in ref.OFFICIAL_CCLASSTRIB} # RTC_CST is its own table: a class may point to a CST that is not in it
self.crt = ("3",) # C000004.CRT rows
self.statements, self.commits, self.rollbacks = [], 0, 0
self._pending = {}
def cursor(self):
return FakeCursor(self)
def commit(self):
for code, value in self._pending.items():
self.products[code]["CCLASSTRIB"] = value
self._pending = {}
self.commits += 1
def rollback(self):
self._pending = {}
self.rollbacks += 1
class FakeCursor:
def __init__(self, db):
self.db, self._rows, self.rowcount = db, [], -1
def execute(self, sql, params=()):
db = self.db
db.statements.append((sql, tuple(params)))
text = " ".join(sql.split())
up = text.upper()
if up.startswith("SELECT TRIM(RF.RDB$RELATION_NAME)"):
self._rows = list(SCHEMA)
elif up.startswith("SELECT FIRST 20 USAR_REFORMA_TRIBUTARIA"):
self._rows = [(v,) for v in db.param]
elif "RDB$RELATION_CONSTRAINTS" in up:
self._rows = [(1,)]
elif up.startswith("SELECT TRIM(CCLASSTRIB)") and "FROM RTC_CCLASSTRIB" in up:
cols = [c.strip() for c in text[len("SELECT "):up.index(" FROM ")].split(",")]
rows = []
for code, r in db.catalog.items():
mapping = {"CSTIBSCBS": r["cst"], "INICIO_VIGENCIA": date.fromisoformat(r["start"]) if r["start"] else None, "FIM_VIGENCIA": None, "TIPO_ALIQUOTA": r["tipo"], "PRED_IBS": r["pred_ibs"],
"PRED_CBS": r["pred_cbs"], "IND_NFE": "1" if r["ind_nfe"] else "0", "IND_NFCE": "1" if r["ind_nfce"] else "0"}
rows.append(tuple(code if c.startswith("TRIM(CCLASSTRIB") else mapping[re.sub(r"TRIM\((\w+)\)", r"\1", c)] for c in cols))
self._rows = rows
elif up.startswith("SELECT TRIM(CSTIBSCBS) FROM RTC_CST"):
self._rows = [(c,) for c in sorted(db.cst_table)]
elif up.startswith("SELECT CODIGO, CCLASSTRIB, CLASSIFICACAO_FISCAL"):
self._rows = [(c, v["CCLASSTRIB"], v["CLASSIFICACAO_FISCAL"], "N", "NENHUMA") for c, v in sorted(db.products.items())]
elif up.startswith("SELECT DISTINCT TRIM(CRT) FROM C000004"):
self._rows = [(c,) for c in db.crt]
elif up.startswith("SELECT CCLASSTRIB, CLASSIFICACAO_FISCAL, PRODUTO FROM C000025"):
code = params[0]
self._rows = [(db.products[code]["CCLASSTRIB"], db.products[code]["CLASSIFICACAO_FISCAL"], db.products[code].get("PRODUTO", ""))] if code in db.products else []
if code in db.products and db.products[code].get("DUP"):
self._rows = self._rows * 2
elif up.startswith("SELECT CCLASSTRIB FROM C000025"):
code = params[0]
self._rows = [(db.products[code]["CCLASSTRIB"],)]
elif up.startswith("UPDATE C000025 SET CCLASSTRIB = NULL WHERE CODIGO = ? AND TRIM(CCLASSTRIB) = ?"):
code, written = params
product = db.products.get(code)
hit = product is not None and (product["CCLASSTRIB"] or "").strip() == written
self.rowcount = 1 if hit else 0
if hit:
db._pending[code] = None
elif up.startswith("UPDATE C000025 SET CCLASSTRIB = ? WHERE CODIGO = ?"):
value, code = params
product = db.products.get(code)
hit = product is not None and not (product["CCLASSTRIB"] or "").strip()
self.rowcount = (2 if product and product.get("DUP") else 1) if hit else 0
if hit and self.rowcount == 1:
db._pending[code] = value
else:
raise AssertionError(f"unexpected statement: {sql}")
def fetchall(self):
return self._rows
def fetchone(self):
return self._rows[0] if self._rows else None
def product(ncm, value=None, **extra):
return {"CCLASSTRIB": value, "CLASSIFICACAO_FISCAL": ncm, **extra}
def assess(db, ncm_ok=None, company=NORMAL):
return uf.assess_from_connection(db, company, today=TODAY, ncm_valid=ncm_ok if ncm_ok is not None else {c: True for c in db.products})
def candidates_of(db, limit=500):
out = assess(db)
products = [{"CODIGO": c, "CCLASSTRIB": v["CCLASSTRIB"], "CLASSIFICACAO_FISCAL": v["CLASSIFICACAO_FISCAL"]} for c, v in db.products.items()]
cands, total = apply_mod.select_candidates(out["findings"], products, limit=limit)
return out, cands, total
def run(db, cands, out, **kw):
return apply_mod.apply_candidates(db, cands, company=NORMAL, decision={"local_catalog_hash": out["context"]["local_catalog_hash"]}, now="2026-09-24T20:00:00+00:00", job_id=7, today=TODAY, **kw)
PRODUCTS = {"000001": product("10063021"), "000002": product("04090000"), "000003": product("15171000"), "000004": product("30049099"), "000005": product("39235000", "000001")}
def test_gate_is_driven_by_the_central_mode_only_and_defaults_to_closed():
assert apply_mod.write_gate(mode="ENABLED")["allowed"] is True
assert apply_mod.write_gate(mode="APPROVAL_REQUIRED", approved=True)["allowed"] is True
for kwargs in ({"mode": "DISABLED"}, {"mode": "DRY_RUN"}, {"mode": "APPROVAL_REQUIRED", "approved": False}, {"mode": "ENABLED", "capability": False}, {"mode": "bogus"}, {"mode": ""}):
g = apply_mod.write_gate(**kwargs)
assert g["allowed"] is False and g["missing"]
assert apply_mod.ALLOWED_RTC_WRITE_FIELDS == ("CCLASSTRIB",) and apply_mod.CAPABILITY == "RTC_CCLASSTRIB_WRITE_V1"
def test_only_proven_products_become_candidates():
db = FakeFirebird(PRODUCTS)
out, cands, total = candidates_of(db)
assert sorted((c["product_code"], c["proposed"], c["rule_id"]) for c in cands) == [("000001", "200003", "RTC-R3"), ("000002", "200034", "RTC-R3"), ("000003", "200003", "RTC-R3")]
assert total == 3 and out["context"]["rtc_resolution"]["ready_to_apply"] == 3 and out["context"]["rtc_resolution"]["ambiguous"] == 1 # medicine chapter is ambiguous
assert out["context"]["rtc_resolution"]["valid_existing"] == 1 # 000005 already has the right class
assert "000004" not in {c["product_code"] for c in cands}
def test_apply_writes_only_cclasstrib_with_audit_and_second_run_writes_nothing():
db = FakeFirebird(PRODUCTS)
out, cands, _ = candidates_of(db)
result = run(db, cands, out)
assert result["write_successes"] == 3 and result["write_attempts"] == 3 and result["aborted"] == []
assert {c: db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")} == {"000001": "200003", "000002": "200034", "000003": "200003"}
updates = [s for s, _ in db.statements if s.upper().startswith("UPDATE")]
assert len(updates) == 3 and all(re.fullmatch(r"UPDATE C000025 SET CCLASSTRIB = \? WHERE CODIGO = \? AND \(CCLASSTRIB IS NULL OR TRIM\(CCLASSTRIB\) = ''\)", u) for u in updates)
assert not [s for s, _ in db.statements if re.match(r"(INSERT|DELETE|ALTER|DROP)", s.strip(), re.I)]
audit = result["applied"][0]
assert {"product_code", "field", "old_value", "new_value", "rule_id", "evidence", "official_source_version", "local_catalog_hash", "timestamp", "job_id", "outcome"} <= set(audit)
assert audit["old_value"] is None and audit["field"] == "CCLASSTRIB" and audit["official_source_version"]["cclasstrib_sha256"] == ref.OFFICIAL_CATALOG["sha256"] and audit["job_id"] == 7
out2, cands2, total2 = candidates_of(db) # idempotent: nothing left to write
assert cands2 == [] and total2 == 0 and out2["context"]["rtc_resolution"]["valid_existing"] == 4
before = len(db.statements)
assert run(db, cands2, out2)["write_attempts"] == 0 and len(db.statements) == before
def test_rtc_switched_off_between_decision_and_write_aborts_every_product():
db = FakeFirebird(PRODUCTS)
out, cands, _ = candidates_of(db)
db.param = [0]
result = run(db, cands, out)
assert result["write_successes"] == 0 and result["aborted_reasons"] == {"RTC_NO_LONGER_ENABLED": 3} and all(p["CCLASSTRIB"] in (None, "000001") for p in db.products.values())
def test_conflicting_parameter_rows_abort():
db = FakeFirebird(PRODUCTS)
out, cands, _ = candidates_of(db)
db.param = [1, 0]
assert run(db, cands, out)["aborted_reasons"] == {"RTC_NO_LONGER_ENABLED": 3}
def test_catalog_changed_or_invalid_between_decision_and_write_aborts():
db = FakeFirebird(PRODUCTS)
out, cands, _ = candidates_of(db)
db.catalog["200003"]["pred_ibs"] = "60"
assert run(db, cands, out)["aborted_reasons"] == {"LOCAL_CATALOG_CHANGED_SINCE_DECISION": 3}
db2 = FakeFirebird(PRODUCTS)
out2, cands2, _ = candidates_of(db2)
db2.catalog["999999"] = {**db2.catalog["000001"], "cst": "555"} # class pointing to a CST that does not exist -> invalid catalog
invalid = apply_mod.apply_candidates(db2, cands2, company=NORMAL, decision={}, now="2026-09-24T20:00:00+00:00", job_id=7, today=TODAY) # no hash pinned: integrity check alone must stop it
assert invalid["aborted_reasons"] == {"RTC_CATALOG_INVALID": 3}
def test_product_changed_between_decision_and_write_is_left_alone():
db = FakeFirebird(PRODUCTS)
out, cands, _ = candidates_of(db)
db.products["000001"]["CLASSIFICACAO_FISCAL"] = "30049099" # NCM edited in the ERP meanwhile
db.products["000002"]["CCLASSTRIB"] = "200034" # operator filled it meanwhile (same value)
db.products["000003"]["CCLASSTRIB"] = "200034" # ... or with a DIFFERENT value: never overwritten
result = run(db, cands, out)
assert result["aborted_reasons"] == {"PRODUCT_CHANGED_NCM": 1, "NO_WRITE_REQUIRED": 1, "VALUE_ALREADY_SET_NEVER_OVERWRITTEN": 1} and result["write_successes"] == 0
assert db.products["000003"]["CCLASSTRIB"] == "200034" and not [s for s, _ in db.statements if s.upper().startswith("UPDATE")]
def test_duplicate_code_rowcount_protection_and_isolation_of_failures():
products = {**PRODUCTS, "000002": product("04090000", DUP=True)}
db = FakeFirebird(products)
out, cands, _ = candidates_of(db)
result = run(db, cands, out)
assert result["aborted_reasons"] == {"PRODUCT_ROW_NOT_UNIQUE": 1} and result["write_successes"] == 2 # the ambiguous row is skipped, the rest still applied
assert db.products["000002"]["CCLASSTRIB"] is None
def test_existing_different_valid_class_is_a_conflict_blocker_never_a_write():
db = FakeFirebird({"000001": product("10063021", "000001")}) # rice already classified as fully taxed: differs from the proven Anexo I class
out = assess(db)
types = {b["type"] for b in out["findings"]["000001"]["blockers"]}
assert types == {"CCLASSTRIB_CONFLICT"} and out["context"]["rtc_resolution"]["conflicts"] == 1
_, cands, _ = candidates_of(db)
assert cands == []
def test_official_mismatch_of_an_existing_class_is_reported():
db = FakeFirebird({"000001": product("39235000", "000001")}, catalog_tweak={"000001": {"ind_nfe": False, "ind_nfce": False}})
types = {b["type"] for b in assess(db)["findings"]["000001"]["blockers"]}
assert types == {"CCLASSTRIB_OFFICIAL_MISMATCH"}
def test_batch_limit_bounds_the_blast_radius():
db = FakeFirebird({f"{i:06d}": product("04090000") for i in range(1, 8)})
out, cands, total = candidates_of(db, limit=3)
assert len(cands) == 3 and total == 7
def test_simples_never_produces_candidates_even_with_rtc_on():
db = FakeFirebird(PRODUCTS)
out = uf.assess_from_connection(db, {"crt": "1"}, today=TODAY, ncm_valid={c: True for c in PRODUCTS})
products = [{"CODIGO": c, "CCLASSTRIB": v["CCLASSTRIB"], "CLASSIFICACAO_FISCAL": v["CLASSIFICACAO_FISCAL"]} for c, v in db.products.items()]
candidates, total = apply_mod.select_candidates(out["findings"], products)
assert out["context"]["rtc_resolution"]["ready_to_apply"] == 0
assert out["context"]["rtc_resolution"]["not_applicable"] == len(PRODUCTS)
assert candidates == [] and total == 0
assert not [b for f in out["findings"].values() for b in f["blockers"] if b["category"] == "RTC"]
assert all(sql.lstrip().upper().startswith("SELECT") for sql, _ in db.statements)
def test_rtc_off_has_no_resolution_and_no_candidates():
db = FakeFirebird(PRODUCTS, param=(0,))
out, cands, total = candidates_of(db)
assert cands == [] and total == 0 and out["context"]["rtc_resolution"] is None
def test_catalog_hash_is_recorded_for_audit():
db = FakeFirebird(PRODUCTS)
out = assess(db)
schema, catalog, _ = uf.load_rtc_context(db.cursor())
assert out["context"]["local_catalog_hash"] == local_catalog_hash(catalog["rows"]) and re.fullmatch(r"[0-9a-f]{64}", out["context"]["local_catalog_hash"])
# ---------------------------------------------------------------- job integration (gates)
import pytest # noqa: E402
@pytest.fixture(autouse=True)
def _hub_echoes_the_job_authorization(monkeypatch):
"""By default the Hub still says what the job said; individual tests change that to prove the pre-write re-check."""
from test_run_agent_service import service
monkeypatch.setattr(service, "_fetch_authorizations", lambda config, job: dict(job.get("authorizations") or {}))
def _service():
from test_run_agent_service import _config, service
return service, _config
def _fiscal_for(db):
return assess(db)
def _products_of(db):
return [{"CODIGO": c, "CCLASSTRIB": v["CCLASSTRIB"], "CLASSIFICACAO_FISCAL": v["CLASSIFICACAO_FISCAL"], "PRODUTO": v.get("PRODUTO", "")} for c, v in db.products.items()]
def _no_write(service, monkeypatch):
monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no write connection may be opened")))
def test_job_without_a_central_mode_or_with_disabled_or_dry_run_never_writes(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS)
fiscal = _fiscal_for(db)
_no_write(service, monkeypatch)
for auth in ({}, {"rtc_write_mode": "DISABLED"}, {"rtc_write_mode": "DRY_RUN"}, {"rtc_write_mode": "bogus"}, {"rtc_write_mode": "APPROVAL_REQUIRED", "rtc_approved_codes": []}, {"rtc_auto_apply": True}):
summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": auth}, _products_of(db), fiscal, NORMAL)
assert summary["gate"]["allowed"] is False and summary["ready_total"] == 3 and summary["write_attempts"] == 0 and summary["applied"] == []
off = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "DISABLED"}}, _products_of(db), fiscal, NORMAL)
dry = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "DRY_RUN"}}, _products_of(db), fiscal, NORMAL)
assert off["preview"] == [] and off["mode"] == "REPORT_ONLY"
assert [c["product_code"] for c in dry["preview"]] == ["000001", "000002", "000003"] and all(c["rule_id"] == "RTC-R3" for c in dry["preview"]) and dry["capability"] == "RTC_CCLASSTRIB_WRITE_V1"
def test_r4_suggestions_are_never_in_the_preview_and_never_written(monkeypatch):
service, _config = _service()
db = FakeFirebird({"000001": product("39235000"), "000002": product("39235000")})
fiscal = _fiscal_for(db)
_no_write(service, monkeypatch)
summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL)
assert summary["ready_total"] == 0 and summary["preview"] == [] and summary["write_attempts"] == 0
def test_enabled_mode_applies_without_any_local_switch(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS)
fiscal = _fiscal_for(db)
monkeypatch.setattr(service, "open_write_connection", lambda env, path: _Closeable(db))
summary = service._rtc_auto_apply({}, _config(), {"job_id": 9, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL) # default config: no firebird_writes, no env switch
assert summary["mode"] == "ENABLED" and summary["write_successes"] == 3 and summary["allowed_fields"] == ["CCLASSTRIB"] and all(a["job_id"] == 9 and a["authorization_origin"] == "HUB_MODE:ENABLED" for a in summary["applied"])
assert [db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")] == ["200003", "200034", "200003"]
a = summary["applied"][0]
assert a["rule_version"] and a["company_crt"] == "3" and a["action"] == "APPLY" and a["rtc_status"] == "ENABLED" and a["local_catalog_state"] == "MATCH_SAFE" and a["old_value"] is None
def test_approval_required_writes_only_the_approved_products(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS)
fiscal = _fiscal_for(db)
monkeypatch.setattr(service, "open_write_connection", lambda env, path: _Closeable(db))
summary = service._rtc_auto_apply({}, _config(), {"job_id": 9, "authorizations": {"rtc_write_mode": "APPROVAL_REQUIRED", "rtc_approved_codes": ["000002"]}}, _products_of(db), fiscal, NORMAL)
assert summary["write_successes"] == 1 and [db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")] == [None, "200034", None]
def test_job_never_writes_when_the_erp_says_rtc_is_off_even_in_enabled_mode(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS, param=(0,))
fiscal = _fiscal_for(db)
_no_write(service, monkeypatch)
products = [{"CODIGO": c, "CCLASSTRIB": None, "CLASSIFICACAO_FISCAL": "04090000"} for c in db.products]
summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, products, fiscal, NORMAL)
assert summary["write_attempts"] == 0 and summary["ready_total"] == 0
def test_company_regime_or_fingerprint_change_aborts_before_the_update():
db = FakeFirebird(PRODUCTS)
out, cands, _ = candidates_of(db)
db.crt = ("1",) # the company was switched to Simples between decision and write
res = run(db, cands, out)
assert res["write_successes"] == 0 and res["aborted_reasons"] == {"COMPANY_CONTEXT_CHANGED": 3}
db.crt = ("3", "1") # ambiguous company context is also refused
assert run(db, cands, out)["aborted_reasons"] == {"COMPANY_CONTEXT_CHANGED": 3}
db.crt = ("3",)
db.products["000001"]["PRODUTO"] = "RENAMED AFTER THE DECISION"
res = run(db, cands, out)
assert res["aborted_reasons"] == {"PRODUCT_CHANGED_SINCE_DECISION": 1} and res["write_successes"] == 2 and db.products["000001"]["CCLASSTRIB"] is None
assert not any("UPDATE" in q.upper() and "C000004" in q.upper() for q, _ in db.statements)
def test_rollback_restores_only_when_the_value_still_equals_the_written_one_and_reapply_works():
db = FakeFirebird(PRODUCTS)
out, cands, _ = candidates_of(db)
first = run(db, cands, out)
assert first["write_successes"] == 3
items = [{"audit_id": i + 1, "product_code": a["product_code"], "written_value": a["new_value"], "rule_id": a["rule_id"]} for i, a in enumerate(first["applied"])]
db.products["000002"]["CCLASSTRIB"] = "200035" # an operator changed it afterwards
res = apply_mod.rollback_records(db, items, now="2026-09-24T21:00:00+00:00", job_id=8)
by = {r["product_code"]: r for r in res["rollbacks"]}
assert res["rolled_back"] == 2 and by["000002"]["outcome"] == "ABORTED" and by["000002"]["reason"] == "ROLLBACK_CONFLICT_VALUE_CHANGED" and db.products["000002"]["CCLASSTRIB"] == "200035"
assert by["000001"]["outcome"] == "ROLLED_BACK" and by["000001"]["rollback_of"] == 1 and by["000001"]["old_value"] == "200003" and by["000001"]["new_value"] is None and db.products["000001"]["CCLASSTRIB"] is None
again = apply_mod.rollback_records(db, items, now="2026-09-24T21:05:00+00:00", job_id=9)["rollbacks"]
assert [r["reason"] for r in again if r["product_code"] == "000001"] == ["ALREADY_EMPTY"]
out2, cands2, _ = candidates_of(db) # reapply: the emptied products are candidates again, the changed one is not
assert sorted(c["product_code"] for c in cands2) == ["000001", "000003"] and run(db, cands2, out2)["write_successes"] == 2
def test_rollback_uses_one_fixed_statement_and_rejects_malformed_requests():
db = FakeFirebird(PRODUCTS)
bad = apply_mod.rollback_records(db, [{"audit_id": 1, "product_code": "000001", "written_value": ""}, {"audit_id": 2, "product_code": "000001", "written_value": "200003", "restore_value": "999999"}], now="t")
assert [r["reason"] for r in bad["rollbacks"]] == ["ROLLBACK_REQUEST_INVALID"] * 2 and db.statements == []
db.products["000001"]["CCLASSTRIB"] = "200003"
apply_mod.rollback_records(db, [{"audit_id": 3, "product_code": "000001", "written_value": "200003"}], now="t")
writes = [q for q, _ in db.statements if q.strip().upper().startswith(("UPDATE", "INSERT", "DELETE"))]
assert writes == ["UPDATE C000025 SET CCLASSTRIB = NULL WHERE CODIGO = ? AND TRIM(CCLASSTRIB) = ?"]
class _Closeable:
def __init__(self, db):
self._db = db
def __getattr__(self, name):
return getattr(self._db, name)
def close(self):
pass
def test_firebird_unavailable_for_the_write_connection_fails_the_job_closed_with_zero_writes(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS)
fiscal = _fiscal_for(db)
monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(OSError("Firebird unavailable")))
import pytest as _pytest
with _pytest.raises(OSError):
service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL)
assert all(v["CCLASSTRIB"] is None for c, v in db.products.items() if c != "000005") and not [q for q, _ in db.statements if q.upper().startswith("UPDATE")]
def test_connection_is_closed_even_when_a_product_fails_mid_batch(monkeypatch):
service, _config = _service()
db = FakeFirebird({**PRODUCTS, "000002": product("04090000", DUP=True)})
fiscal = _fiscal_for(db)
closed = []
conn = _Closeable(db)
conn.close = lambda: closed.append(True)
monkeypatch.setattr(service, "open_write_connection", lambda env, path: conn)
summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL)
assert closed == [True] and summary["write_successes"] == 2 and summary["aborted_reasons"]
def test_authorization_revoked_after_the_job_was_claimed_means_zero_writes(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS)
fiscal = _fiscal_for(db)
monkeypatch.setattr(service, "open_write_connection", lambda env, path: _Closeable(db))
monkeypatch.setattr(service, "_fetch_authorizations", lambda config, job: {"rtc_write_mode": "DISABLED"}) # operator switched it off while the job was running
summary = service._rtc_auto_apply({}, _config(), {"job_id": 5, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL)
assert summary["write_successes"] == 0 and summary["aborted_reasons"] == {"AUTHORIZATION_CHANGED": 3}
assert [db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")] == [None, None, None]
def test_unreachable_hub_is_not_authorization_and_approval_removal_is_honoured(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS)
fiscal = _fiscal_for(db)
monkeypatch.setattr(service, "open_write_connection", lambda env, path: _Closeable(db))
def down(config, job):
raise ConnectionError("hub down")
monkeypatch.setattr(service, "_fetch_authorizations", down)
summary = service._rtc_auto_apply({}, _config(), {"job_id": 6, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL)
assert summary["write_successes"] == 0 and summary["aborted_reasons"] == {"AUTHORIZATION_UNVERIFIABLE": 3}
monkeypatch.setattr(service, "_fetch_authorizations", lambda config, job: {"rtc_write_mode": "APPROVAL_REQUIRED", "rtc_approved_codes": ["000001"]}) # 000002 was un-approved meanwhile
summary = service._rtc_auto_apply({}, _config(), {"job_id": 7, "authorizations": {"rtc_write_mode": "APPROVAL_REQUIRED", "rtc_approved_codes": ["000001", "000002"]}}, _products_of(db), fiscal, NORMAL)
assert summary["write_successes"] == 1 and db.products["000001"]["CCLASSTRIB"] == "200003" and db.products["000002"]["CCLASSTRIB"] is None
@@ -0,0 +1,515 @@
import importlib.util
import sys
from pathlib import Path
import pytest
SCRIPT = Path(__file__).parents[1] / "scripts" / "run_agent_service.py"
AUTHORIZED = r"D:\PROJETOS\GTISISTEMAS\Projeto\TESTESOFICIAIS.FDB"
def load_module():
spec = importlib.util.spec_from_file_location("run_agent_service", SCRIPT)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
service = load_module()
class _FakeState:
def __init__(self):
self.logged = []
self.identity = None
def stored_installation_id(self):
return self.identity
def installation_id(self, candidate=None):
if self.identity and candidate and self.identity != candidate:
raise ValueError("configured installation_id conflicts with persisted identity")
if self.identity is None:
if not candidate:
raise ValueError("installation_id is required")
self.identity = candidate
return self.identity
def log_ipi_write(self, *args):
self.logged.append(args)
class _FakeExecutor:
"""Mirrors the real firebird_write_executor's public surface used by execute_job."""
class WriteRefused(Exception):
pass
def __init__(self, path_guard_ok=True, updates=None, fingerprint_guard_ok=True):
self.path_guard_ok = path_guard_ok
self.updates = updates or {}
self.fingerprint_guard_ok = fingerprint_guard_ok
def assert_authorized_write_path(self, path, authorized_path=None):
if not self.path_guard_ok:
raise self.WriteRefused(f"path is not the authorized write target: {path}")
def assert_schema_fingerprint_authorized(self, observed, expected):
if not self.fingerprint_guard_ok or observed != expected:
raise self.WriteRefused(f"schema fingerprint mismatch: expected {expected!r}, observed {observed!r}")
def execute_guarded_update(self, connection, *, database_path, proposal, now, authorized_path=None):
code = proposal["product_code"]
if code in self.updates:
raise self.WriteRefused(self.updates[code])
record = type("Record", (), {
"before_value": proposal["current_value"],
"after_value": proposal["proposed_value"],
"rowcount": 1,
})()
return record
def _config(**overrides):
base = dict(
hub_url="http://localhost:8000", agent_id="a1", agent_token="tok", company_id="c1", company_uf="BA",
database_path=AUTHORIZED, firebird_writes=False, poll_interval_seconds=0.01,
agent_version="0.1.0", schema_fingerprint="fp", state_db=":memory:", agent_jobs_enabled=True,
authorized_write_database_path=None, expected_schema_fingerprint="fp",
)
base.update(overrides)
return type("Config", (), base)()
def test_config_defaults_from_env():
env = {"FIREBIRD_DATABASE_PATH": AUTHORIZED}
config = service.Config(env)
assert config.firebird_writes is False
assert config.hub_url == "http://localhost:8000"
assert config.poll_interval_seconds == 5.0
assert config.agent_jobs_enabled is True
def test_local_agent_jobs_toggle_is_ignored_control_is_central():
# Zero-touch: a stale AGENT_JOBS_ENABLED=0 left in an old agent.env must not silence the Agent;
# only the Hub (heartbeat processing_enabled) can pause it.
config = service.Config({"FIREBIRD_DATABASE_PATH": AUTHORIZED, "AGENT_JOBS_ENABLED": "0"})
assert config.agent_jobs_enabled is True
def test_config_reads_gti_hub_url_and_writes_flag():
env = {"FIREBIRD_DATABASE_PATH": AUTHORIZED, "GTI_HUB_URL": "http://hub.internal", "FIREBIRD_WRITES": "1"}
config = service.Config(env)
assert config.hub_url == "http://hub.internal"
assert config.firebird_writes is True
def test_identity_recovery_persists_existing_hub_installation_id(tmp_path):
config = _config(agent_id="2")
state = service.AgentState(tmp_path / "agent.db")
installation_id = "00000000-0000-4000-8000-000000000222"
class FakeIdentityClient:
calls = 0
def identity(self):
self.calls += 1
return {"agent_id": 2, "installation_id": installation_id}
client = FakeIdentityClient()
recovered = service.resolve_installation_id(config, {}, state, client)
assert recovered == installation_id
assert state.installation_id() == installation_id
assert client.calls == 1
restarted = service.resolve_installation_id(config, {}, state, client)
assert restarted == installation_id
assert client.calls == 1
def test_identity_recovery_fails_closed_for_another_agent_or_missing_id(tmp_path):
config = _config(agent_id="2")
class FakeIdentityClient:
def __init__(self, payload):
self.payload = payload
def identity(self):
return self.payload
state = service.AgentState(tmp_path / "agent.db")
with pytest.raises(RuntimeError, match="identity does not match configured Agent"):
service.resolve_installation_id(
config,
{},
state,
FakeIdentityClient({"agent_id": 3, "installation_id": "00000000-0000-4000-8000-000000000333"}),
)
assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0
with pytest.raises(RuntimeError, match="identity response has no installation_id"):
service.resolve_installation_id(config, {}, state, FakeIdentityClient({"agent_id": 2}))
assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0
def test_path_guard_fails_closed_before_any_write(monkeypatch):
config = _config(firebird_writes=True, database_path=r"D:\PRODUCAO\ERP.FDB")
executor = _FakeExecutor(path_guard_ok=False)
state = _FakeState()
job = {"kind": "REPROCESS_PRODUCTS", "agent_id": 1, "payload": {"codigos": ["000026"]}}
called = {"read_products": False}
def fake_read_products(env, codigos=None):
called["read_products"] = True
return []
monkeypatch.setattr(service, "read_products", fake_read_products)
result, error = service.execute_job(job, config, executor, state, {})
assert result is None
assert "not the authorized write target" in error
assert called["read_products"] is False # never even reads Firebird once the guard refuses
def test_pilot_write_blocked_when_schema_fingerprint_does_not_match(monkeypatch):
# A real client install where the live schema fingerprint drifted from what
# this Agent installation was configured/registered to expect.
config = _config(firebird_writes=True, schema_fingerprint="observed-live-fp", expected_schema_fingerprint="registered-fp")
executor = _FakeExecutor()
state = _FakeState()
job = {"kind": "REPROCESS_PRODUCTS", "agent_id": 1, "payload": {"codigos": ["000026"]}}
called = {"read_products": False}
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: called.update(read_products=True) or [])
result, error = service.execute_job(job, config, executor, state, {})
assert result is None
assert "schema fingerprint mismatch" in error
assert called["read_products"] is False
def test_pilot_write_allowed_with_explicit_authorized_path_and_matching_fingerprint(monkeypatch):
client_path = r"D:\ClienteX\ERP\PRODUCAO.FDB"
config = _config(
firebird_writes=True, database_path=client_path, authorized_write_database_path=client_path,
schema_fingerprint="fp", expected_schema_fingerprint="fp",
)
products = [{"CODIGO": "000026", "PRODUTO": "x", "CLASSIFICACAO_FISCAL": "03055100", "IPI": None}]
decisions = [{
"status": "EXACT_NCM", "ipi_status": "IPI_RESOLVED",
"classification": {"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.25}},
"dataset_versions": {"tipi_ipi": "v1"},
}]
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: products)
class FakeClient:
def __init__(self, *a, **k): pass
def classify_batch(self, items): return decisions
monkeypatch.setattr(service, "HubClient", FakeClient)
fake_connection = type("Conn", (), {"close": lambda self: None})()
monkeypatch.setattr(service, "open_write_connection", lambda env, path: fake_connection)
executor = _FakeExecutor()
state = _FakeState()
job = {"kind": "REPROCESS_PRODUCTS", "agent_id": 1, "payload": {"codigos": ["000026"]}}
result, error = service.execute_job(job, config, executor, state, {})
assert error is None
assert result["writes_succeeded"] == 1
def test_execute_job_isolates_internal_errors_without_raising(monkeypatch):
config = _config()
def boom(env, codigos=None):
raise RuntimeError("firebird unavailable")
monkeypatch.setattr(service, "read_products", boom)
executor = _FakeExecutor()
state = _FakeState()
job = {"kind": "REPROCESS_ALL", "agent_id": 1, "payload": {}}
result, error = service.execute_job(job, config, executor, state, {})
assert result is None
assert "firebird unavailable" in error
def test_execute_job_success_summarizes_pipeline(monkeypatch):
config = _config()
products = [{"CODIGO": "000026", "PRODUTO": "x", "CLASSIFICACAO_FISCAL": "03055100", "IPI": None}]
decisions = [{
"status": "EXACT_NCM", "ipi_status": "IPI_RESOLVED",
"classification": {"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.25}},
"dataset_versions": {"tipi_ipi": "v1"},
}]
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: products)
class FakeClient:
def __init__(self, *a, **k): pass
def classify_batch(self, items): return decisions
monkeypatch.setattr(service, "HubClient", FakeClient)
executor = _FakeExecutor()
state = _FakeState()
job = {"kind": "REPROCESS_PRODUCTS", "agent_id": 1, "payload": {"codigos": ["000026"]}}
result, error = service.execute_job(job, config, executor, state, {})
assert error is None
assert result["products_processed"] == 1
assert result["writes_attempted"] == 0 # firebird_writes disabled by default in _config()
def test_reprocess_products_resolves_real_company_context_not_none(monkeypatch):
"""REPROCESS_PRODUCTS used to hardcode company=None for _unified_fiscal (it isn't in SYNC_JOB_KINDS). Company resolution is cheap and
deterministic (company_context.resolve, same as every sync job already uses) - it must never silently fall back to None when the company
genuinely resolves."""
config = _config()
products = [{"CODIGO": "000026", "PRODUTO": "x", "CLASSIFICACAO_FISCAL": "03055100", "IPI": None}]
decisions = [{"status": "EXACT_NCM", "ipi_status": "IPI_RESOLVED", "classification": {"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.25}}, "dataset_versions": {"tipi_ipi": "v1"}}]
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: products)
class FakeClient:
def __init__(self, *a, **k): pass
def classify_batch(self, items): return decisions
monkeypatch.setattr(service, "HubClient", FakeClient)
monkeypatch.setattr(service._firebird_reader, "read_company_context", lambda env: {"uf": "BA", "crt": "1", "company_code": "000001"})
captured = {}
real_unified = service._unified_fiscal
def spy_unified_fiscal(env, company, ncm_valid=None):
captured["company"] = company
return real_unified(env, company, ncm_valid)
monkeypatch.setattr(service, "_unified_fiscal", spy_unified_fiscal)
executor = _FakeExecutor()
state = _FakeState()
job = {"kind": "REPROCESS_PRODUCTS", "agent_id": 1, "payload": {"codigos": ["000026"]}}
result, error = service.execute_job(job, config, executor, state, {})
assert error is None
assert captured["company"] == {"crt": "1"} # real CRT, never None, when the company genuinely resolves
def test_reprocess_products_falls_back_to_none_only_when_company_unresolved(monkeypatch):
"""No guessing: when the company genuinely cannot be resolved (e.g. Firebird unreachable), company stays None - same as before."""
config = _config()
products = [{"CODIGO": "000026", "PRODUTO": "x", "CLASSIFICACAO_FISCAL": "03055100", "IPI": None}]
decisions = [{"status": "EXACT_NCM", "ipi_status": "IPI_RESOLVED", "classification": {"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.25}}, "dataset_versions": {"tipi_ipi": "v1"}}]
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: products)
class FakeClient:
def __init__(self, *a, **k): pass
def classify_batch(self, items): return decisions
monkeypatch.setattr(service, "HubClient", FakeClient)
def boom(env):
raise RuntimeError("firebird unavailable")
monkeypatch.setattr(service._firebird_reader, "read_company_context", boom)
captured = {}
real_unified = service._unified_fiscal
def spy_unified_fiscal(env, company, ncm_valid=None):
captured["company"] = company
return real_unified(env, company, ncm_valid)
monkeypatch.setattr(service, "_unified_fiscal", spy_unified_fiscal)
executor = _FakeExecutor()
state = _FakeState()
job = {"kind": "REPROCESS_PRODUCTS", "agent_id": 1, "payload": {"codigos": ["000026"]}}
result, error = service.execute_job(job, config, executor, state, {})
assert error is None
assert captured["company"] is None
class _FakeHubForLoop:
"""Simulates: heartbeat flaky twice then OK, one job then nothing, and captures completions."""
def __init__(self):
self.heartbeat_calls = 0
self.jobs = [{"job_id": 1, "kind": "REPROCESS_ALL", "agent_id": 1, "payload": {}}]
self.completed = []
self.failed = []
def heartbeat(self, *a, **k):
self.heartbeat_calls += 1
if self.heartbeat_calls == 1:
raise ConnectionError("hub down")
return {}
def next_job(self):
return self.jobs.pop(0) if self.jobs else None
def complete_job(self, job_id, result_summary):
self.completed.append((job_id, result_summary))
def fail_job(self, job_id, error):
self.failed.append((job_id, error))
def classify_batch(self, items):
return []
class _ExternalStopFlag:
"""Mirrors the Windows Service wrapper's stop object: no signal registration,
just a plain flag flippable from another thread."""
def __init__(self):
self.stop = False
def test_run_sends_heartbeat_but_never_polls_when_paused_centrally(monkeypatch, tmp_path):
stopper = _ExternalStopFlag()
fake_hub = _FakeHubForLoop()
installation_id = "00000000-0000-4000-8000-000000000222"
calls = {"identity": 0, "next_job": 0, "heartbeat": 0, "heartbeat_installation_id": None}
def identity():
calls["identity"] += 1
return {"agent_id": 2, "installation_id": installation_id}
def heartbeat_metadata(payload):
calls["heartbeat"] += 1
calls["heartbeat_installation_id"] = payload["installation_id"]
stopper.stop = True
return {"agent_id": 2, "processing_enabled": False} # paused centrally by the Hub
def forbidden_next_job():
calls["next_job"] += 1
raise AssertionError("processing is paused centrally; next_job must not be called")
fake_hub.identity = identity
fake_hub.heartbeat_metadata = heartbeat_metadata
fake_hub.next_job = forbidden_next_job
monkeypatch.setattr(service, "HubClient", lambda *a, **k: fake_hub)
monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor())
monkeypatch.setattr(service.time, "sleep", lambda _seconds: None)
config = _config(
agent_id="2",
poll_interval_seconds=0,
state_db=str(tmp_path / "agent.db"),
)
env = {
"AGENT_ID": "2",
"FIREBIRD_DATABASE_PATH": AUTHORIZED,
"FIREBIRD_WRITES": "0",
"FISCAL_MODE": "DRY_RUN",
}
service.run(config, env, stopper=stopper)
assert calls == {
"identity": 1,
"next_job": 0,
"heartbeat": 1,
"heartbeat_installation_id": installation_id,
}
assert service.AgentState(config.state_db).stored_installation_id() == installation_id
def test_company_context_for_heartbeat_resolves_once_and_caches(monkeypatch):
"""CRT was never sent at heartbeat: the Agent already proves it (company_context.resolve, via preflight_company_context) for coverage/RTC
jobs, but that value never reached the Hub. This locks in the fix: resolved once, cached, never re-queried once solid."""
calls = {"n": 0}
def fake_preflight(env):
calls["n"] += 1
return {"status": "PASS", "company_uf": "BA", "company_crt": "1", "company_code": "000001"}
monkeypatch.setattr("gti_fiscal_agent.company_context.preflight_company_context", fake_preflight)
result, reason = service._company_context_for_heartbeat({}, None)
assert result == {"status": "PASS", "company_uf": "BA", "company_crt": "1", "company_code": "000001"} and reason is None and calls["n"] == 1
result2, reason2 = service._company_context_for_heartbeat({}, result) # already cached: never re-queries Firebird
assert result2 is result and reason2 is None and calls["n"] == 1
def test_company_context_for_heartbeat_retries_while_unresolved_never_guesses(monkeypatch):
def fake_preflight(env):
return {"status": "FAIL", "classification": "UNRESOLVED", "reasons": ["UF_SOURCE_UNKNOWN"]}
monkeypatch.setattr("gti_fiscal_agent.company_context.preflight_company_context", fake_preflight)
result, reason = service._company_context_for_heartbeat({}, None)
assert result is None and "UNRESOLVED" in reason # never a guessed uf/crt - explicitly unresolved, retried next tick
def test_run_heartbeat_carries_the_auto_discovered_company_crt(monkeypatch, tmp_path):
"""End-to-end: once company context resolves, the CRT rides the heartbeat payload the Hub already knows how to store
(Installation.company_crt) - closing the transport gap without any Hub change."""
stopper = _ExternalStopFlag()
fake_hub = _FakeHubForLoop()
installation_id = "00000000-0000-4000-8000-000000000333"
sent = {}
def identity():
return {"agent_id": 2, "installation_id": installation_id}
def heartbeat_metadata(payload):
sent.update(payload)
stopper.stop = True
return {"agent_id": 2, "processing_enabled": False}
fake_hub.identity = identity
fake_hub.heartbeat_metadata = heartbeat_metadata
fake_hub.next_job = lambda: (_ for _ in ()).throw(AssertionError("paused centrally; next_job must not be called"))
monkeypatch.setattr(service, "HubClient", lambda *a, **k: fake_hub)
monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor())
monkeypatch.setattr(service.time, "sleep", lambda _seconds: None)
monkeypatch.setattr("gti_fiscal_agent.company_context.preflight_company_context",
lambda env: {"status": "PASS", "company_uf": "BA", "company_crt": "1", "company_code": "000001", "company_contribuinte_ipi": "N"})
config = _config(agent_id="2", poll_interval_seconds=0, state_db=str(tmp_path / "agent.db"))
env = {"AGENT_ID": "2", "FIREBIRD_DATABASE_PATH": AUTHORIZED, "FIREBIRD_WRITES": "0", "FISCAL_MODE": "DRY_RUN"}
service.run(config, env, stopper=stopper)
assert sent["company_uf"] == "BA" and sent["company_crt"] == "1" and sent["company_contribuinte_ipi"] == "N"
def test_run_honors_an_externally_supplied_stopper(monkeypatch):
# This is exactly the pattern the Windows Service wrapper uses: it never
# constructs GracefulStop() (which would crash off the main thread), it
# passes its own stop flag and flips it from the SvcStop handler.
config = _config(poll_interval_seconds=0.001)
fake_hub = _FakeHubForLoop()
fake_hub.jobs = [] # no jobs; loop would run forever without an external stop
monkeypatch.setattr(service, "HubClient", lambda *a, **k: fake_hub)
monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor())
monkeypatch.setattr(service, "AgentState", lambda path: _FakeState())
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: [])
stopper = _ExternalStopFlag()
calls = {"n": 0}
real_sleep = service.time.sleep
def counting_sleep(seconds):
calls["n"] += 1
if calls["n"] >= 3:
stopper.stop = True
monkeypatch.setattr(service.time, "sleep", counting_sleep)
service.run(config, {"AGENT_ID": "2", "INSTALLATION_ID": "00000000-0000-4000-8000-000000000222"}, stopper=stopper) # no max_iterations — only the external flag can stop it
assert stopper.stop is True
assert calls["n"] >= 3
def test_run_survives_hub_down_then_processes_one_job_and_stops(monkeypatch):
config = _config(poll_interval_seconds=0.001)
fake_hub = _FakeHubForLoop()
monkeypatch.setattr(service, "HubClient", lambda *a, **k: fake_hub)
monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor())
monkeypatch.setattr(service, "AgentState", lambda path: _FakeState())
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: [])
monkeypatch.setattr(service, "build_job_coverage", lambda *a, **k: None)
monkeypatch.setattr(service.time, "sleep", lambda seconds: None)
# first heartbeat call raises -> backoff branch; second call onward succeeds.
service.run(config, {"AGENT_ID": "2", "INSTALLATION_ID": "00000000-0000-4000-8000-000000000222"}, max_iterations=4)
assert fake_hub.heartbeat_calls >= 2
assert len(fake_hub.completed) == 1
assert fake_hub.completed[0][0] == 1
assert fake_hub.completed[0][1]["products_processed"] == 0
@@ -0,0 +1,7 @@
from gti_fiscal_agent.runner import to_hub_product
def test_reference_schema_uses_classificacao_fiscal_as_canonical_ncm():
product = {"CODIGO": "000276", "PRODUTO": "ABACAXI M/ JARRA", "NCM": "99999999", "CLASSIFICACAO_FISCAL": "08043000"}
assert to_hub_product(product)["current_ncm"] == "08043000"
+56
View File
@@ -0,0 +1,56 @@
import pytest
from gti_fiscal_agent.state import AgentState
def test_outbox_keeps_failed_work_pending(tmp_path):
state = AgentState(tmp_path / "agent.db")
assert state.enqueue("000276", "source", "v1", "0.1.0")
state.retry("000276")
assert state.pending()[0]["status"] == "PENDING_API"
state.result_received("000276", "source", "v1", "0.1.0")
assert state.enqueue("000276", "source", "v1", "0.1.0") is False
def test_ipi_write_log_records_per_product_audit(tmp_path):
state = AgentState(tmp_path / "agent.db")
state.log_ipi_write("000276", "fp1", "EXACT_NCM", "IPI_RESOLVED", "SUCCEEDED", None, 3.25, "rowcount=1", "CHANGE_REQUIRED", "2026-09-18T00:00:00Z")
log = state.ipi_write_log_for("000276")
assert len(log) == 1
assert log[0]["write_status"] == "SUCCEEDED"
assert log[0]["before_value"] is None
assert log[0]["proposed_value"] == "3.25"
def test_installation_identity_requires_an_existing_or_explicit_id(tmp_path):
state = AgentState(tmp_path / "agent.db")
assert state.stored_installation_id() is None
with pytest.raises(ValueError, match="installation_id is required"):
state.installation_id()
assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0
def test_installation_identity_persists_and_reuses_explicit_id(tmp_path):
db_path = tmp_path / "agent.db"
state = AgentState(db_path)
existing_id = "00000000-0000-4000-8000-000000000222"
assert state.installation_id(existing_id) == existing_id
assert state.installation_id() == existing_id
assert state.stored_installation_id() == existing_id
restarted_state = AgentState(db_path)
assert restarted_state.installation_id() == existing_id
def test_installation_identity_rejects_conflicting_or_malformed_ids(tmp_path):
state = AgentState(tmp_path / "agent.db")
existing_id = "00000000-0000-4000-8000-000000000222"
state.installation_id(existing_id)
with pytest.raises(ValueError, match="conflicts"):
state.installation_id("00000000-0000-4000-8000-000000000223")
with pytest.raises(ValueError, match="UUID"):
state.installation_id("not-a-uuid")
+174
View File
@@ -0,0 +1,174 @@
import re
from datetime import date
import pytest
from gti_fiscal_agent import unified_fiscal as uf
pytestmark = pytest.mark.real_unified_fiscal
SCHEMA_ROWS = ([("C000025", c) for c in ("CODIGO", "CCLASSTRIB", "COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS", "PISCOFINS", "INCIDENCIA_PISCOFINS")]
+ [("RTC_CCLASSTRIB", c) for c in ("CCLASSTRIB", "CSTIBSCBS", "INICIO_VIGENCIA", "FIM_VIGENCIA")] + [("RTC_CST", "CSTIBSCBS")] + [("PARAMETROS", "USAR_REFORMA_TRIBUTARIA")])
PRODUCT_COLS = ("CODIGO", "CCLASSTRIB", "COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS", "PISCOFINS", "INCIDENCIA_PISCOFINS")
PRODUCTS = [("1", None, None, None, None, None, None, None, "N", "NENHUMA"), ("2", "200003", None, "04", None, None, "04", None, "S", "MONOFÁSICA"), ("3", None, "2", "2", "2", "2", "2", "2", "N", "NENHUMA")]
CLASSES = [("000001", "000", date(2025, 5, 5), None), ("200003", "200", date(2025, 5, 5), None)]
class FakeCursor:
"""READ ONLY fake: answers only SELECTs and records every statement so tests can prove nothing else ran."""
def __init__(self, param=0, params=None, schema=SCHEMA_ROWS, products=PRODUCTS, classes=CLASSES, cst=(("000",), ("200",))):
self.param, self.params, self.schema, self.products, self.classes, self.cst, self.statements, self._rows = param, params, schema, products, classes, cst, [], []
def execute(self, sql, *args):
self.statements.append(sql)
text = " ".join(sql.split()).upper()
assert text.startswith("SELECT"), f"non-SELECT statement: {sql}"
if "FROM RDB$RELATION_FIELDS" in text:
self._rows = list(self.schema)
elif "FROM PARAMETROS" in text:
self._rows = [(v,) for v in (self.params if self.params is not None else [self.param])]
elif "RDB$RELATION_CONSTRAINTS" in text:
self._rows = [(1,)]
elif "FROM RTC_CCLASSTRIB" in text:
self._rows = list(self.classes)
elif "FROM RTC_CST" in text:
self._rows = list(self.cst)
elif "FROM C000025" in text:
self._rows = list(self.products)
else:
raise AssertionError(sql)
def fetchall(self):
return self._rows
def fetchone(self):
return self._rows[0] if self._rows else None
class FakeConn:
def __init__(self, **kw):
self.cur = FakeCursor(**kw)
def cursor(self):
return self.cur
def kinds(found):
return sorted({b["type"] for b in found["blockers"]})
def test_rtc_off_in_the_erp_is_out_of_scope_and_never_blocks():
conn = FakeConn(param=0)
out = uf.assess_from_connection(conn, {"crt": "1"}, today=date(2026, 9, 24))
assert out["context"]["rtc_enabled"] is False and out["context"]["rtc_supported"] is True and out["context"]["rtc_catalog_valid"] is True
assert not [b for f in out["findings"].values() for b in f["blockers"] if b["category"] == "RTC"]
assert kinds(out["findings"]["1"]) == [] and kinds(out["findings"]["2"]) == [] and kinds(out["findings"]["3"]) == [] # Simples + explicit "no special treatment": not applicable, typed "2" is only a warning
assert all(s.strip().upper().startswith("SELECT") for s in conn.cur.statements) # read-only: SELECT statements only
def test_rtc_on_in_the_erp_does_not_make_simples_cclasstrib_applicable_before_effective_date():
out = uf.assess_from_connection(FakeConn(param=1), {"crt": "1"}, today=date(2026, 9, 24))
assert out["context"]["rtc_enabled"] is True
assert out["context"]["cclasstrib_applicability"]["state"] == "NOT_APPLICABLE"
assert not [b for f in out["findings"].values() for b in f["blockers"] if b["category"] == "RTC"]
def test_rtc_on_reports_simples_cclasstrib_as_not_applicable_and_keeps_other_fields():
products = [("1", None, None, None, None, None, None, None, "S", "MONOFÁSICA")]
conn = FakeConn(param=1, products=products)
out = uf.assess_from_connection(conn, {"crt": "1"}, today=date(2026, 9, 25))
assert out["context"]["cclasstrib_applicability"]["state"] == "NOT_APPLICABLE"
assert out["context"]["cclasstrib_applicability"]["source"] == "C000004.CRT + RTC effective-date rules"
assert kinds(out["findings"]["1"]) == ["COFINS_CST_MISSING", "PIS_CST_MISSING"]
assert all(sql.strip().upper().startswith("SELECT") for sql in conn.cur.statements)
def test_cclasstrib_applicability_change_changes_product_input_fingerprint():
simples = uf.assess_from_connection(FakeConn(param=1), {"crt": "1"}, today=date(2026, 9, 25))
normal = uf.assess_from_connection(FakeConn(param=1), {"crt": "3"}, today=date(2026, 9, 25))
assert simples["findings"]["1"]["input_fingerprint"] != normal["findings"]["1"]["input_fingerprint"]
assert kinds(simples["findings"]["1"]) == []
assert any(blocker["category"] == "RTC" for blocker in normal["findings"]["1"]["blockers"])
def test_simulation_flag_never_touches_the_erp_parameter():
conn = FakeConn(param=0)
on = uf.assess_from_connection(conn, {"crt": "1"}, rtc_override=True)
assert on["context"]["rtc_enabled"] is True and on["context"]["cclasstrib_applicability"]["state"] == "NOT_APPLICABLE"
assert not [b for f in on["findings"].values() for b in f["blockers"] if b["category"] == "RTC"]
assert on["context"]["rtc_param"] == "0" and on["context"]["rtc_source"] == "SIMULATION" and not any("UPDATE" in s.upper() for s in conn.cur.statements)
def test_old_schema_without_rtc_columns_reports_unsupported_and_still_evaluates_pis_cofins():
old = [("C000025", c) for c in ("CODIGO", "COD_SAIDA_PIS", "PISCOFINS", "INCIDENCIA_PISCOFINS")]
rows = [("1", None, "N", "NENHUMA"), ("2", "049", "N", "NENHUMA")]
out = uf.assess_from_connection(FakeConn(schema=old, products=rows, classes=[], cst=()), {"crt": "3"})
assert out["context"]["rtc_supported"] is False and out["context"]["rtc_enabled"] is False
assert kinds(out["findings"]["1"]) == [] and kinds(out["findings"]["2"]) == ["PIS_CST_INVALID"]
@pytest.mark.parametrize("value,expected", [(None, False), (0, False), ("0", False), (1, True), ("1", True), ("S", True), ("N", False)])
def test_parameter_values_are_read_fail_closed(value, expected):
caps = uf.detect_rtc_capabilities({"C000025": {"CCLASSTRIB"}, "RTC_CCLASSTRIB": {"CCLASSTRIB", "CSTIBSCBS"}, "RTC_CST": {"CSTIBSCBS"}}, param_value=value)
assert caps["RTC_ENABLED_BY_PARAM"] is expected
def test_catalog_with_orphan_or_duplicate_is_invalid_and_flagged_only_when_rtc_is_on():
bad = FakeConn(param=1, classes=CLASSES + [("200003", "200", date(2025, 5, 5), None)]) # duplicate class
on = uf.assess_from_connection(bad, {"crt": "3"})
assert on["context"]["rtc_catalog_valid"] is False and kinds(on["findings"]["2"]) == ["RTC_CATALOG_INVALID"]
off = uf.assess_from_connection(FakeConn(param=0, classes=CLASSES + [("200003", "200", date(2025, 5, 5), None)]), {"crt": "1"})
assert not [b for f in off["findings"].values() for b in f["blockers"] if b["category"] == "RTC"]
def test_input_fingerprint_changes_with_the_fiscal_values_and_the_rtc_switch():
a = uf.assess_from_connection(FakeConn(param=0), {"crt": "1"})["findings"]["1"]["input_fingerprint"]
b = uf.assess_from_connection(FakeConn(param=1), {"crt": "1"})["findings"]["1"]["input_fingerprint"]
changed = [("1", None, None, "99", None, None, None, None, "N", "NENHUMA")] + PRODUCTS[1:]
c = uf.assess_from_connection(FakeConn(param=0, products=changed), {"crt": "1"})["findings"]["1"]["input_fingerprint"]
assert len({a, b, c}) == 3 and re.fullmatch(r"[0-9a-f]{64}", a)
def test_rtc_state_is_read_from_this_database_now_with_provenance_and_timestamp():
out = uf.assess_from_connection(FakeConn(param=1), {"crt": "1"})
ctx = out["context"]
assert ctx["rtc_enabled"] is True and ctx["erp_rtc_enabled"] is True and ctx["rtc_source"] == "PARAMETROS.USAR_REFORMA_TRIBUTARIA" and ctx["rtc_param"] == "1" and ctx["rtc_param_rows"] == 1
assert ctx["rtc_param_conflict"] is False and ctx["rtc_observed_at"].endswith("+00:00")
assert ctx["cclasstrib_applicability"]["state"] == "NOT_APPLICABLE" and ctx["cclasstrib_applicability"]["source"] == "C000004.CRT + RTC effective-date rules"
assert ctx["legal_effective_context"]["informational_only"] is True and ctx["legal_effective_context"]["crt"] == "1"
def test_toggle_between_syncs_is_followed_in_both_directions_without_any_cache():
conn = FakeConn(param=0)
first = uf.assess_from_connection(conn, {"crt": "1"})
assert first["context"]["rtc_enabled"] is False and not [b for f in first["findings"].values() for b in f["blockers"] if b["category"] == "RTC"]
conn.cur.param = 1 # the operator switches the reform on in the ERP between two syncs
second = uf.assess_from_connection(conn, {"crt": "1"})
assert second["context"]["rtc_enabled"] is True and second["context"]["cclasstrib_applicability"]["state"] == "NOT_APPLICABLE"
assert not [b for f in second["findings"].values() for b in f["blockers"] if b["category"] == "RTC"]
assert second["findings"]["1"]["input_fingerprint"] != first["findings"]["1"]["input_fingerprint"] # a new blocker fingerprint: the old (RTC off) state is not reused
conn.cur.param = 0 # and off again
third = uf.assess_from_connection(conn, {"crt": "1"})
assert third["context"]["rtc_enabled"] is False and not [b for f in third["findings"].values() for b in f["blockers"] if b["category"] == "RTC"]
assert third["findings"]["1"]["input_fingerprint"] == first["findings"]["1"]["input_fingerprint"]
def test_conflicting_or_repeated_parameter_rows_never_guess_on():
same = uf.assess_from_connection(FakeConn(params=[1, 1]), {"crt": "1"})["context"]
assert same["rtc_enabled"] is True and same["rtc_param_rows"] == 2 and same["rtc_param_conflict"] is False
conflict = uf.assess_from_connection(FakeConn(params=[1, 0]), {"crt": "1"})
assert conflict["context"]["rtc_enabled"] is False and conflict["context"]["rtc_param_conflict"] is True
assert not [b for f in conflict["findings"].values() for b in f["blockers"] if b["category"] == "RTC"]
assert uf.assess_from_connection(FakeConn(params=[]), {"crt": "1"})["context"]["rtc_enabled"] is False
def test_non_canonical_cst_warnings_are_reported_without_blocking():
rows = [("1", None, None, "4", None, None, "4", None, "N", "NENHUMA"), ("2", None, None, None, None, None, None, None, "N", "NENHUMA")]
out = uf.assess_from_connection(FakeConn(products=rows), {"crt": "3"})
assert kinds(out["findings"]["1"]) == [] and kinds(out["findings"]["2"]) == []
w = out["context"]["non_canonical_warnings"]
assert w["products"] == 1 and w["by_type"] == {"CST_COFINS_NON_CANONICAL_FORMAT": 1, "CST_PIS_NON_CANONICAL_FORMAT": 1} and w["samples"][0]["product_code"] == "1"
assert out["context"]["pis_cofins_applicability"] == {"OPTIONAL": 2}
@@ -0,0 +1,65 @@
import importlib.util
import sys
from pathlib import Path
from types import SimpleNamespace
SERVICE_WRAPPER = Path(__file__).parents[1] / "service" / "gti_agent_windows_service.py"
def load_service_wrapper():
spec = importlib.util.spec_from_file_location("gti_agent_windows_service_test", SERVICE_WRAPPER)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_main_enters_scm_dispatcher_when_started_without_command(monkeypatch):
service = load_service_wrapper()
calls = []
class FakeServiceManager:
def Initialize(self):
calls.append("initialize")
def PrepareToHostSingle(self, service_class):
calls.append(("prepare", service_class))
def StartServiceCtrlDispatcher(self):
calls.append("dispatcher")
def forbidden_handle_command_line(service_class):
raise AssertionError("SCM start must not be parsed as a CLI command")
monkeypatch.setattr(service, "servicemanager", FakeServiceManager())
monkeypatch.setattr(service, "win32serviceutil", SimpleNamespace(HandleCommandLine=forbidden_handle_command_line))
monkeypatch.setattr(service.sys, "argv", [str(SERVICE_WRAPPER)])
service.main()
assert calls == ["initialize", ("prepare", service.GTIFiscalAgentService), "dispatcher"]
def test_main_preserves_pywin32_cli_commands(monkeypatch):
service = load_service_wrapper()
called = []
class ForbiddenServiceManager:
def __getattr__(self, name):
raise AssertionError("CLI commands must not enter the SCM dispatcher")
monkeypatch.setattr(service, "servicemanager", ForbiddenServiceManager())
monkeypatch.setattr(service, "win32serviceutil", SimpleNamespace(HandleCommandLine=lambda cls: called.append(cls)))
monkeypatch.setattr(service.sys, "argv", [str(SERVICE_WRAPPER), "start"])
service.main()
assert called == [service.GTIFiscalAgentService]
def test_a_fatal_error_in_the_agent_loop_stops_the_service_instead_of_leaving_a_dead_one_running():
text = SERVICE_WRAPPER.read_text(encoding="utf-8")
body = text.split("agent_service.run(config, os.environ, stopper=self.stop_flag)", 1)[1].split("def main", 1)[0]
assert "except BaseException" in body and "logger" not in body.split("except BaseException")[0][-40:] # wrapped in try/except
assert "LogErrorMsg" in body and "self.stop_flag.stop = True" in body and "win32event.SetEvent(self.stop_event)" in body
+210
View File
@@ -0,0 +1,210 @@
"""Agent side of the zero-touch model: delta sync via fingerprint cache, read-only guarantee,
blockers/operational summary in the job result, central pause, Firebird credential resolution."""
import json
import time
import pytest
from test_run_agent_service import AUTHORIZED, _FakeExecutor, _config, service
class FakeHub:
def __init__(self):
self.classified = []
def classify_batch(self, products):
self.classified.append([p["product_id"] for p in products])
return [{"status": "EXACT_NCM" if p["current_ncm"] else "INVALID_NCM", "ipi_status": "IPI_RESOLVED", "classification": {}} for p in products]
def product(code, ncm="03055100", name="X"):
return {"CODIGO": code, "PRODUTO": name, "EAN_GTIN": None, "CLASSIFICACAO_FISCAL": ncm, "NCM": None, "CFOP": "5102", "CST": None, "ALIQUOTA": None, "CEST": None, "CSOSN": "102", "IPI": None}
@pytest.fixture
def env_setup(monkeypatch, tmp_path):
hub = FakeHub()
monkeypatch.setattr(service, "HubClient", lambda *a, **k: hub)
monkeypatch.setattr(service, "build_job_coverage", lambda env, client, products, decisions: {"total_products": len(products)})
state = service.AgentState(tmp_path / "s.db")
return hub, state
def run_job(kind, products, state, monkeypatch, config=None):
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: products)
return service.execute_job({"job_id": 1, "kind": kind, "agent_id": 2, "payload": {}}, config or _config(), _FakeExecutor(), state, {})
def test_initial_sync_processes_everything_and_reports_operational_summary(env_setup, monkeypatch):
hub, state = env_setup
products = [product("1"), product("2"), product("3", ncm="")]
result, error = run_job("INITIAL_SYNC", products, state, monkeypatch)
assert error is None
assert result["products_total"] == 3 and result["products_changed"] == 3 and hub.classified == [["1", "2", "3"]]
op = result["operational"]
assert op["TOTAL_PRODUCTS"] == 3 and op["COMPLETE"] == 2 and op["SAFE_BLOCKED"] == 1
assert op["COMPLETE"] + op["CORRECTED"] + op["SAFE_BLOCKED"] + op["FAILED_TECHNICAL"] == op["TOTAL_PRODUCTS"] # invariant closes
assert [b["product_code"] for b in result["blocked_products"]] == ["3"] and result["resolved_products"] == ["1", "2"]
def test_incremental_sync_reclassifies_only_new_and_changed_products(env_setup, monkeypatch):
hub, state = env_setup
base = [product("1"), product("2"), product("3")]
run_job("INITIAL_SYNC", base, state, monkeypatch)
hub.classified.clear()
result, error = run_job("SYNC_INCREMENTAL", base, state, monkeypatch) # nothing changed
assert error is None and result["products_changed"] == 0 and hub.classified == [[]] or hub.classified == []
assert result["operational"]["TOTAL_PRODUCTS"] == 3 # coverage still complete, rebuilt from the cache
hub.classified.clear()
changed = [product("1"), product("2", ncm="", name="broken now"), product("3"), product("4")] # 2 changed, 4 new
result, _ = run_job("SYNC_INCREMENTAL", changed, state, monkeypatch)
assert sorted(hub.classified[0]) == ["2", "4"] and result["products_changed"] == 2
assert result["operational"]["TOTAL_PRODUCTS"] == 4 and result["operational"]["SAFE_BLOCKED"] == 1
def test_removed_product_leaves_local_cache_but_is_not_reported_as_resolved(env_setup, monkeypatch):
hub, state = env_setup
run_job("INITIAL_SYNC", [product("1"), product("2")], state, monkeypatch)
result, _ = run_job("SYNC_INCREMENTAL", [product("1")], state, monkeypatch)
assert state.cached_decision("2") is None and result["operational"]["TOTAL_PRODUCTS"] == 1
assert result["resolved_products"] == [] # no history is auto-resolved/deleted for a product that merely disappeared
def test_every_sync_kind_is_read_only(env_setup, monkeypatch):
hub, state = env_setup
for kind in ("INITIAL_SYNC", "SYNC_INCREMENTAL", "REPROCESS_ALL"):
result, error = run_job(kind, [product("1")], state, monkeypatch)
assert error is None and result["writes_attempted"] == 0 and result["writes_succeeded"] == 0 and result["firebird_writes_enabled"] is False
with pytest.raises(RuntimeError, match="writes are disabled"):
service.open_write_connection({}, AUTHORIZED, firebird_writes=False) # no write-capable connection can even be opened
def test_hub_offline_during_job_fails_the_job_cleanly_no_crash(env_setup, monkeypatch):
hub, state = env_setup
def boom(products): raise ConnectionError("hub down")
hub.classify_batch = boom
result, error = run_job("SYNC_INCREMENTAL", [product("9")], state, monkeypatch)
assert result is None and "ConnectionError" in error # reported as a job failure, the service loop keeps running
def test_firebird_offline_during_job_fails_the_job_cleanly(env_setup, monkeypatch):
hub, state = env_setup
def down(env, codigos=None): raise OSError("Firebird service not running")
monkeypatch.setattr(service, "read_products", down)
result, error = service.execute_job({"job_id": 1, "kind": "SYNC_INCREMENTAL", "agent_id": 2, "payload": {}}, _config(), _FakeExecutor(), state, {})
assert result is None and "OSError" in error
def test_firebird_credentials_come_from_the_dpapi_file_not_agent_env(monkeypatch, tmp_path):
from gti_fiscal_agent import credential_store
path = tmp_path / "firebird.bin"
monkeypatch.setattr(credential_store, "read_credential", lambda p, env=None: json.dumps({"user": "SYSDBA", "password": "s3cret"}) if str(p) == str(path) else (_ for _ in ()).throw(FileNotFoundError()))
env = {"FIREBIRD_DATABASE_PATH": AUTHORIZED, "GTI_FIREBIRD_CREDENTIAL_FILE": str(path)}
service.Config(env)
assert env["FB_USER"] == "SYSDBA" and env["FB_PASSWORD"] == "s3cret"
explicit = {"FIREBIRD_DATABASE_PATH": AUTHORIZED, "FB_PASSWORD": "dev", "FB_USER": "u", "GTI_FIREBIRD_CREDENTIAL_FILE": str(path)}
service.Config(explicit)
assert explicit["FB_PASSWORD"] == "dev" # an explicit env value still wins (dev override)
def test_central_resume_after_pause_makes_agent_poll_again(monkeypatch, tmp_path):
class Stopper: stop = False
stopper = Stopper()
states = iter([False, True]) # paused, then resumed
calls = {"next_job": 0}
class Hub:
def identity(self): return {"agent_id": 2, "installation_id": "00000000-0000-4000-8000-000000000222"}
def heartbeat_metadata(self, payload): return {"processing_enabled": next(states)}
def next_job(self):
calls["next_job"] += 1; stopper.stop = True; return None
monkeypatch.setattr(service, "HubClient", lambda *a, **k: Hub())
monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor())
monkeypatch.setattr(service.time, "sleep", lambda s: None)
service.run(_config(agent_id="2", poll_interval_seconds=0, state_db=str(tmp_path / "a.db")), {"AGENT_ID": "2"}, stopper=stopper)
assert calls["next_job"] == 1 # first cycle paused (no poll), second cycle resumed (polled)
def test_heartbeats_keep_flowing_while_a_long_job_runs(monkeypatch, tmp_path):
beats = []
class Hub:
def identity(self): return {"agent_id": 2, "installation_id": "00000000-0000-4000-8000-000000000222"}
def heartbeat_metadata(self, payload): beats.append(payload["status"]); return {"processing_enabled": True}
def next_job(self): return {"job_id": 5, "kind": "INITIAL_SYNC", "agent_id": 2, "payload": {}}
def complete_job(self, job_id, summary): stopper.stop = True
def fail_job(self, job_id, error): stopper.stop = True
class Stopper: stop = False
stopper = Stopper()
monkeypatch.setattr(service, "BUSY_HEARTBEAT_SECONDS", 0.05)
monkeypatch.setattr(service, "HubClient", lambda *a, **k: Hub())
monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor())
monkeypatch.setattr(service, "execute_job", lambda *a, **k: (time.sleep(0.4), ({"ok": 1}, None))[1])
service.run(_config(agent_id="2", poll_interval_seconds=0, state_db=str(tmp_path / "a.db")), {"AGENT_ID": "2"}, stopper=stopper)
assert beats.count("BUSY") >= 3 # heartbeats during the job, not only before it
def _findings(**by_code):
out = {}
for code, kinds in by_code.items():
out[code] = {"blockers": [{"type": t, "category": c, "reason": "R"} for t, c in kinds], "applicability": {"PIS_COFINS": "REQUIRED", "RTC": "NOT_APPLICABLE"}, "input_fingerprint": "fp" + code, "needs_review": False}
return out
def test_unified_fiscal_blockers_change_the_single_coverage_and_the_single_queue(env_setup, monkeypatch):
from gti_fiscal_agent import unified_fiscal
findings = _findings(**{"2": [("PIS_CST_INVALID", "PIS"), ("COFINS_CST_INVALID", "COFINS")]})
monkeypatch.setattr(unified_fiscal, "assess_from_connection", lambda conn, company, **kw: {"context": {"rtc_enabled": False, "rtc_supported": True, "rtc_catalog_valid": True}, "findings": findings})
hub, state = env_setup
result, error = run_job("INITIAL_SYNC", [product("1"), product("2"), product("3", ncm="")], state, monkeypatch)
assert error is None
op = result["operational"]
assert op["TOTAL_PRODUCTS"] == 3 and op["COMPLETE_PRODUCTS"] == 1 and op["SAFE_BLOCKED"] == 2 and op["BLOCKED_FISCAL_DATA"] == 1 # one number, one definition
assert op["COMPLETE_PRODUCTS"] + op["SAFE_BLOCKED"] + op["FAILED_TECHNICAL"] + op["UNCLASSIFIED"] == 3
queue = sorted((b["product_code"], b["field"], b["reason_code"]) for b in result["blocked_products"])
assert queue == [("2", "COFINS", "COFINS_CST_INVALID"), ("2", "PIS", "PIS_CST_INVALID"), ("3", "NCM", "INVALID_NCM")] # ONE queue: NCM and fiscal blockers together
assert result["resolved_products"] == ["1"] # a fiscal blocker keeps the product out of resolved
assert result["fiscal_context"]["rtc_enabled"] is False and result["fiscal_context"]["evaluated_all"] is True
assert "expanded_scope" not in result
def test_rtc_context_is_reported_but_blockers_only_exist_when_the_evaluator_produced_them(env_setup, monkeypatch):
from gti_fiscal_agent import unified_fiscal
monkeypatch.setattr(unified_fiscal, "assess_from_connection", lambda conn, company, **kw: {"context": {"rtc_enabled": True, "rtc_supported": True, "rtc_catalog_valid": True}, "findings": _findings(**{"1": [("CCLASSTRIB_MISSING", "RTC")]})})
hub, state = env_setup
result, _ = run_job("REPROCESS_ALL", [product("1"), product("2")], state, monkeypatch)
assert [b["reason_code"] for b in result["blocked_products"]] == ["CCLASSTRIB_MISSING"] and result["blocked_products"][0]["field"] == "RTC"
assert result["fiscal_context"]["rtc_enabled"] is True and result["operational"]["BLOCKED_FISCAL_DATA"] == 1
def test_partial_reprocess_does_not_claim_a_full_fiscal_evaluation(env_setup, monkeypatch):
hub, state = env_setup
result, _ = run_job("REPROCESS_PRODUCTS", [product("1")], state, monkeypatch)
assert result["fiscal_context"] is None and result["operational"] is None
def test_rtc_toggle_between_two_syncs_is_followed_by_the_next_job(env_setup, monkeypatch):
"""The Agent asks the database EVERY sync: 0 -> 1 makes the next job report RTC blockers, 1 -> 0 makes the following job report none (Hub then closes the old ones)."""
from gti_fiscal_agent import unified_fiscal
switch = {"on": False}
def assess(conn, company, **kw):
found = _findings(**{"1": [("CCLASSTRIB_MISSING", "RTC")]}) if switch["on"] else {}
return {"context": {"rtc_enabled": switch["on"], "rtc_supported": True, "rtc_catalog_valid": True, "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA", "rtc_observed_at": "2026-09-24T20:00:00+00:00"}, "findings": found}
monkeypatch.setattr(unified_fiscal, "assess_from_connection", assess)
hub, state = env_setup
products = [product("1"), product("2")]
off1, _ = run_job("INITIAL_SYNC", products, state, monkeypatch)
switch["on"] = True
on, _ = run_job("SYNC_INCREMENTAL", products, state, monkeypatch) # nothing changed in C000025: the delta cache must not hide the RTC change
switch["on"] = False
off2, _ = run_job("SYNC_INCREMENTAL", products, state, monkeypatch)
assert off1["blocked_products"] == [] and off1["fiscal_context"]["rtc_enabled"] is False
assert [b["field"] for b in on["blocked_products"]] == ["RTC"] and on["fiscal_context"]["rtc_enabled"] is True and on["operational"]["BLOCKED_FISCAL_DATA"] == 1
assert on["fiscal_context"]["rtc_observed_at"] and on["fiscal_context"]["rtc_source"] == "PARAMETROS.USAR_REFORMA_TRIBUTARIA"
assert off2["blocked_products"] == [] and off2["fiscal_context"]["rtc_enabled"] is False and off2["fiscal_context"]["evaluated_all"] is True
assert off2["operational"]["COMPLETE_PRODUCTS"] == 2
+8
View File
@@ -0,0 +1,8 @@
"""Entry point: python audit_firebird_database.py <FDB> (read-only; see scripts/audit_firebird_database.py)."""
import runpy
import sys
from pathlib import Path
if __name__ == "__main__":
sys.argv[0] = str(Path(__file__).with_name("scripts") / "audit_firebird_database.py")
runpy.run_path(sys.argv[0], run_name="__main__")
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY central/pyproject.toml /tmp/central/pyproject.toml
RUN pip install --no-cache-dir /tmp/central
COPY central /app/central
ENV PYTHONPATH=/app
CMD ["sh", "-c", "alembic -c central/alembic.ini upgrade head && uvicorn central.app.main:app --host 0.0.0.0 --port 8000"]
+1
View File
@@ -0,0 +1 @@
"""GTI Fiscal Hub package."""
+3
View File
@@ -0,0 +1,3 @@
[alembic]
script_location = central/alembic
sqlalchemy.url = postgresql+psycopg://gti@postgres:5432/gti_fiscal
+21
View File
@@ -0,0 +1,21 @@
from alembic import context
from sqlalchemy import engine_from_config, pool
from central.app.models import Base
from central.app.config import settings
config = context.config
target_metadata = Base.metadata
def run_migrations_offline():
context.configure(url=settings.database_url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
config.set_main_option("sqlalchemy.url", settings.database_url)
engine = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool)
with engine.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,99 @@
"""initial hub persistence
Revision ID: 0001
Frozen, explicit baseline. This revision used to run
``Base.metadata.create_all()``, which builds the schema from the *current*
models: on an empty database it created tables/columns that 0002..0018 then
tried to create again (first collision: ``datasets.raw_sha256`` in 0002), so
``alembic upgrade head`` could never run from zero.
This body reproduces exactly what 0001 created on the databases that went
through the real history: the seven base tables, *before* 0002 added
``datasets.raw_sha256`` and 0006 added the agent status/timestamp columns.
Constraint and index names match the ones those databases already have
(PostgreSQL defaults for PK/FK/UNIQUE, ``ix_<table>_<column>`` for indexes).
Databases already at 0001 or later never re-run this revision, so changing
its body only affects new installations. It must never import the ORM
models again: a migration describes one point in history, not the present.
"""
from alembic import op
import sqlalchemy as sa
revision = "0001"
down_revision = None
branch_labels = depends_on = None
def upgrade():
op.create_table(
"tenants",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(128), nullable=False, unique=True),
)
op.create_table(
"datasets",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("kind", sa.String(32), nullable=False),
sa.Column("provider", sa.String(128), nullable=False),
sa.Column("sha256", sa.String(64), nullable=False),
sa.Column("version_label", sa.String(256), nullable=False),
sa.Column("act", sa.Text(), nullable=False),
sa.Column("source_url", sa.Text(), nullable=False),
sa.Column("downloaded_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("record_count", sa.Integer(), nullable=False),
sa.UniqueConstraint("provider", "sha256", name="uq_dataset_provider_hash"),
)
op.create_index("ix_datasets_kind", "datasets", ["kind"])
op.create_index("ix_datasets_status", "datasets", ["status"])
op.create_table(
"ncm_entries",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("dataset_id", sa.Integer(), sa.ForeignKey("datasets.id"), nullable=False),
sa.Column("code", sa.String(16), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("valid_from", sa.String(32), nullable=False),
sa.Column("valid_to", sa.String(32), nullable=False),
sa.UniqueConstraint("dataset_id", "code", "valid_from", "valid_to", name="uq_ncm_versioned_entry"),
)
op.create_index("ix_ncm_entries_dataset_id", "ncm_entries", ["dataset_id"])
op.create_index("ix_ncm_entries_code", "ncm_entries", ["code"])
op.create_table(
"agents",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
)
op.create_index("ix_agents_tenant_id", "agents", ["tenant_id"])
op.create_table(
"classification_cache",
sa.Column("fingerprint", sa.String(64), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("result", sa.JSON(), nullable=False),
)
op.create_index("ix_classification_cache_tenant_id", "classification_cache", ["tenant_id"])
op.create_table(
"classification_requests",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False),
sa.Column("fingerprint", sa.String(64), nullable=False, unique=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_classification_requests_tenant_id", "classification_requests", ["tenant_id"])
op.create_index("ix_classification_requests_agent_id", "classification_requests", ["agent_id"])
op.create_table(
"classification_results",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("request_id", sa.Integer(), sa.ForeignKey("classification_requests.id"), nullable=False, unique=True),
sa.Column("result", sa.JSON(), nullable=False),
)
def downgrade():
for table in ("classification_results", "classification_requests", "classification_cache", "agents", "ncm_entries", "datasets", "tenants"):
op.drop_table(table)
@@ -0,0 +1,31 @@
"""add CEST links and official legacy NCM evidence
Revision ID: 0002
Revises: 0001
"""
from alembic import op
from sqlalchemy import Column, ForeignKey, Integer, String, Text, UniqueConstraint
revision = "0002"
down_revision = "0001"
branch_labels = depends_on = None
def upgrade():
op.add_column("datasets", Column("raw_sha256", String(64), nullable=True))
op.create_table("legacy_ncm_entries", Column("code", String(16), primary_key=True), Column("source", Text, nullable=False), Column("source_date", String(32), nullable=False), Column("reason", Text, nullable=False), Column("successor_code", String(16), nullable=True))
op.create_table("cest_entries", Column("id", Integer, primary_key=True), Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False), Column("code", String(16), nullable=False), Column("description", Text, nullable=False), Column("annex", String(8), nullable=False), UniqueConstraint("dataset_id", "code", name="uq_cest_versioned_entry"))
op.create_table("cest_ncm_entries", Column("id", Integer, primary_key=True), Column("cest_entry_id", Integer, ForeignKey("cest_entries.id"), nullable=False), Column("ncm_code", String(16), nullable=False), UniqueConstraint("cest_entry_id", "ncm_code", name="uq_cest_ncm_link"))
op.create_index("ix_cest_entries_dataset_id", "cest_entries", ["dataset_id"])
op.create_index("ix_cest_entries_code", "cest_entries", ["code"])
op.create_index("ix_cest_ncm_entries_cest_entry_id", "cest_ncm_entries", ["cest_entry_id"])
op.create_index("ix_cest_ncm_entries_ncm_code", "cest_ncm_entries", ["ncm_code"])
def downgrade():
op.drop_table("cest_ncm_entries")
op.drop_table("cest_entries")
op.drop_table("legacy_ncm_entries")
op.drop_column("datasets", "raw_sha256")
@@ -0,0 +1,20 @@
"""add normalized TIPI entries
Revision ID: 0003
Revises: 0002
"""
from alembic import op
from sqlalchemy import Column, Float, ForeignKey, Integer, JSON, String, Text
revision = "0003"
down_revision = "0002"
branch_labels = depends_on = None
def upgrade():
op.create_table("tipi_entries", Column("id", Integer, primary_key=True), Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False), Column("ncm", String(8), nullable=False), Column("ex", String(32)), Column("description", Text, nullable=False), Column("ipi_rate", Float, nullable=True), Column("ipi_rate_text", String(32)), Column("source_row", Integer, nullable=False), Column("metadata_json", JSON, nullable=False))
op.create_index("ix_tipi_entries_dataset_id", "tipi_entries", ["dataset_id"])
op.create_index("ix_tipi_entries_ncm", "tipi_entries", ["ncm"])
op.create_index("ix_tipi_entries_ncm_ex", "tipi_entries", ["ncm", "ex"])
def downgrade():
op.drop_table("tipi_entries")
@@ -0,0 +1,16 @@
"""add BA ST MVA entries
Revision ID: 0004
Revises: 0003
"""
from alembic import op
from sqlalchemy import Column, ForeignKey, Integer, JSON, String, Text
revision = "0004"
down_revision = "0003"
branch_labels = depends_on = None
def upgrade():
op.create_table("ba_st_mva_entries", Column("id", Integer, primary_key=True), Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False), Column("item", String(32), nullable=False), Column("segment", Text), Column("cest", String(16)), Column("ncm_sh", String(32)), Column("description", Text), Column("mva_original", String(32)), Column("mva_adjusted", String(32)), Column("agreement_info", Text), Column("source_page", Integer, nullable=False), Column("metadata_json", JSON, nullable=False))
op.create_index("ix_ba_st_mva_entries_dataset_id", "ba_st_mva_entries", ["dataset_id"])
op.create_index("ix_ba_st_mva_entries_cest", "ba_st_mva_entries", ["cest"])
op.create_index("ix_ba_st_mva_entries_ncm_sh", "ba_st_mva_entries", ["ncm_sh"])
def downgrade(): op.drop_table("ba_st_mva_entries")
@@ -0,0 +1,15 @@
"""normalize BA ST NCM SH
Revision ID: 0005
Revises: 0004
"""
from alembic import op
from sqlalchemy import Column, ForeignKey, Integer, JSON, String, UniqueConstraint
revision="0005"; down_revision="0004"; branch_labels=depends_on=None
def upgrade():
op.drop_index("ix_ba_st_mva_entries_ncm_sh", table_name="ba_st_mva_entries")
op.drop_column("ba_st_mva_entries", "ncm_sh")
op.create_table("ba_st_mva_ncm_sh", Column("id", Integer, primary_key=True), Column("entry_id", Integer, ForeignKey("ba_st_mva_entries.id"), nullable=False), Column("ncm_sh", String(32), nullable=False), Column("metadata_json", JSON, nullable=False), UniqueConstraint("entry_id", "ncm_sh", name="uq_ba_st_mva_entry_ncm_sh"))
op.create_index("ix_ba_st_mva_ncm_sh_entry_id", "ba_st_mva_ncm_sh", ["entry_id"])
op.create_index("ix_ba_st_mva_ncm_sh_ncm_sh", "ba_st_mva_ncm_sh", ["ncm_sh"])
def downgrade(): pass
@@ -0,0 +1,43 @@
"""agent status fields and jobs table
Revision ID: 0006
Revises: 0005
"""
from alembic import op
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String, Text
revision = "0006"; down_revision = "0005"; branch_labels = depends_on = None
def upgrade():
op.add_column("agents", Column("enabled", Boolean, server_default="true", nullable=False))
op.add_column("agents", Column("last_seen_at", DateTime(timezone=True), nullable=True))
op.add_column("agents", Column("last_status", String(32), nullable=True))
op.add_column("agents", Column("schema_fingerprint", String(64), nullable=True))
op.add_column("agents", Column("agent_version", String(32), nullable=True))
op.add_column("agents", Column("created_at", DateTime(timezone=True), server_default="now()", nullable=False))
op.add_column("agents", Column("updated_at", DateTime(timezone=True), server_default="now()", nullable=False))
op.create_table(
"jobs",
Column("id", Integer, primary_key=True),
Column("agent_id", Integer, ForeignKey("agents.id"), nullable=False),
Column("kind", String(32), nullable=False),
Column("status", String(16), nullable=False, server_default="PENDING"),
Column("payload", JSON, nullable=False),
Column("created_at", DateTime(timezone=True), server_default="now()", nullable=False),
Column("started_at", DateTime(timezone=True), nullable=True),
Column("finished_at", DateTime(timezone=True), nullable=True),
Column("attempts", Integer, nullable=False, server_default="0"),
Column("result_summary", JSON, nullable=True),
Column("error", Text, nullable=True),
)
op.create_index("ix_jobs_agent_id", "jobs", ["agent_id"])
op.create_index("ix_jobs_kind", "jobs", ["kind"])
op.create_index("ix_jobs_status", "jobs", ["status"])
def downgrade():
op.drop_table("jobs")
for column in ("enabled", "last_seen_at", "last_status", "schema_fingerprint", "agent_version", "created_at", "updated_at"):
op.drop_column("agents", column)
@@ -0,0 +1,30 @@
"""fiscal_code_entries table for ORIGEM/CST_ICMS/CSOSN/CST_PIS/CST_COFINS
Revision ID: 0007
Revises: 0006
"""
from alembic import op
from sqlalchemy import Column, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
revision = "0007"; down_revision = "0006"; branch_labels = depends_on = None
def upgrade():
op.create_table(
"fiscal_code_entries",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("domain", String(32), nullable=False),
Column("code", String(8), nullable=False),
Column("description", Text, nullable=False),
Column("source_section", String(96), nullable=False),
Column("metadata_json", JSON, nullable=False),
UniqueConstraint("dataset_id", "domain", "code", name="uq_fiscal_code_entry"),
)
op.create_index("ix_fiscal_code_entries_dataset_id", "fiscal_code_entries", ["dataset_id"])
op.create_index("ix_fiscal_code_entries_domain", "fiscal_code_entries", ["domain"])
op.create_index("ix_fiscal_code_entries_code", "fiscal_code_entries", ["code"])
def downgrade():
op.drop_table("fiscal_code_entries")
@@ -0,0 +1,124 @@
"""CFOP/FCP/ANP/NCM_UTRIB/RTC_BIOFUEL_INDEX/RTC_CCREDPRES tables
Revision ID: 0008
Revises: 0007
"""
from alembic import op
from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, Text, UniqueConstraint
revision = "0008"; down_revision = "0007"; branch_labels = depends_on = None
def upgrade():
op.create_table(
"cfop_entries",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("code", String(8), nullable=False),
Column("title", Text, nullable=False),
Column("description", Text, nullable=True),
Column("effective_from", String(32), nullable=True),
Column("effective_to", String(32), nullable=True),
Column("ind_nfe", String(4), nullable=True),
Column("ind_comunica", String(4), nullable=True),
Column("ind_transp", String(4), nullable=True),
Column("ind_devol", String(4), nullable=True),
Column("ind_retor", String(4), nullable=True),
Column("ind_anula", String(4), nullable=True),
Column("ind_remes", String(4), nullable=True),
Column("ind_comb", String(4), nullable=True),
Column("ind_exc_ibscbs", String(4), nullable=True),
UniqueConstraint("dataset_id", "code", name="uq_cfop_entry"),
)
op.create_index("ix_cfop_entries_dataset_id", "cfop_entries", ["dataset_id"])
op.create_index("ix_cfop_entries_code", "cfop_entries", ["code"])
op.create_table(
"fcp_rates",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("uf_code", String(4), nullable=False),
Column("uf", String(4), nullable=False),
Column("uf_name", String(64), nullable=False),
Column("aliq_1", String(32), nullable=True),
Column("aliq_2", String(32), nullable=True),
Column("aliq_3", String(32), nullable=True),
Column("observacao", Text, nullable=True),
UniqueConstraint("dataset_id", "uf", name="uq_fcp_rate_uf"),
)
op.create_index("ix_fcp_rates_dataset_id", "fcp_rates", ["dataset_id"])
op.create_index("ix_fcp_rates_uf", "fcp_rates", ["uf"])
op.create_table(
"anp_product_codes",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("cprod_anp", String(16), nullable=False),
UniqueConstraint("dataset_id", "cprod_anp", name="uq_anp_product_code"),
)
op.create_index("ix_anp_product_codes_dataset_id", "anp_product_codes", ["dataset_id"])
op.create_index("ix_anp_product_codes_cprod_anp", "anp_product_codes", ["cprod_anp"])
op.create_table(
"ncm_utrib_entries",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("ncm", String(16), nullable=False),
Column("effective_from", String(32), nullable=True),
Column("effective_to", String(32), nullable=True),
Column("utrib_abbreviation", String(16), nullable=True),
Column("utrib_description", Text, nullable=True),
UniqueConstraint("dataset_id", "ncm", name="uq_ncm_utrib_entry"),
)
op.create_index("ix_ncm_utrib_entries_dataset_id", "ncm_utrib_entries", ["dataset_id"])
op.create_index("ix_ncm_utrib_entries_ncm", "ncm_utrib_entries", ["ncm"])
op.create_table(
"rtc_biofuel_index_entries",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("cprod_anp", String(16), nullable=True),
Column("description", Text, nullable=True),
Column("p_bio_percent", Float, nullable=True),
Column("effective_from", String(32), nullable=True),
Column("effective_to", String(32), nullable=True),
Column("is_historical", Boolean, nullable=False, server_default="false"),
)
op.create_index("ix_rtc_biofuel_index_entries_dataset_id", "rtc_biofuel_index_entries", ["dataset_id"])
op.create_index("ix_rtc_biofuel_index_entries_cprod_anp", "rtc_biofuel_index_entries", ["cprod_anp"])
op.create_table(
"rtc_cred_pres_entries",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("code", String(8), nullable=False),
Column("description", Text, nullable=False),
Column("legal_basis", Text, nullable=True),
Column("apropria_via_nf", Boolean, nullable=True),
Column("apropria_via_evento", Boolean, nullable=True),
Column("ind_deduz_cred_pres", String(8), nullable=True),
Column("ind_g_cbs_cred_pres", String(8), nullable=True),
Column("ind_g_ibs_cred_pres", String(8), nullable=True),
Column("aliquota_cbs_text", Text, nullable=True),
Column("aliquota_ibs_text", Text, nullable=True),
Column("p_aliq_cred_pres_cbs", Text, nullable=True),
Column("p_aliq_cred_pres_ibs", Text, nullable=True),
Column("p_red_transicao_ibs", Text, nullable=True),
Column("cclass_nota_referenciada", String(16), nullable=True),
Column("d_ini_vig_cbs", String(32), nullable=True),
Column("d_fim_vig_cbs", String(32), nullable=True),
Column("d_ini_vig_ibs", String(32), nullable=True),
Column("d_fim_vig_ibs", String(32), nullable=True),
UniqueConstraint("dataset_id", "code", name="uq_rtc_cred_pres_code"),
)
op.create_index("ix_rtc_cred_pres_entries_dataset_id", "rtc_cred_pres_entries", ["dataset_id"])
op.create_index("ix_rtc_cred_pres_entries_code", "rtc_cred_pres_entries", ["code"])
def downgrade():
op.drop_table("rtc_cred_pres_entries")
op.drop_table("rtc_biofuel_index_entries")
op.drop_table("ncm_utrib_entries")
op.drop_table("anp_product_codes")
op.drop_table("fcp_rates")
op.drop_table("cfop_entries")
@@ -0,0 +1,20 @@
"""widen rtc_cred_pres_entries.cclass_nota_referenciada to Text
Some official rows carry free text ("cClass de alíquota zero") in this
column, not just the numeric code — VARCHAR(16) truncated them.
Revision ID: 0009
Revises: 0008
"""
from alembic import op
from sqlalchemy import Text
revision = "0009"; down_revision = "0008"; branch_labels = depends_on = None
def upgrade():
op.alter_column("rtc_cred_pres_entries", "cclass_nota_referenciada", type_=Text, existing_nullable=True)
def downgrade():
op.alter_column("rtc_cred_pres_entries", "cclass_nota_referenciada", type_=None, existing_nullable=True)
@@ -0,0 +1,48 @@
"""RTC cClassTrib, CST-IBS/CBS and CBS rate tables
Revision ID: 0010
Revises: 0009
"""
from alembic import op
from sqlalchemy import JSON, Column, Float, ForeignKey, Integer, String, Text, UniqueConstraint
revision = "0010"; down_revision = "0009"; branch_labels = depends_on = None
def _fk():
return Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False, index=True)
def upgrade():
op.create_table(
"rtc_cst_ibscbs_entries",
Column("id", Integer, primary_key=True), _fk(),
Column("cst", String(8), nullable=False, index=True),
Column("description", Text, nullable=False),
Column("indicators", JSON, nullable=False),
UniqueConstraint("dataset_id", "cst", name="uq_rtc_cst_ibscbs"),
)
op.create_table(
"rtc_cclasstrib_entries",
Column("id", Integer, primary_key=True), _fk(),
Column("cst", String(8), nullable=False, index=True),
Column("cst_description", Text), Column("cclasstrib", String(8), nullable=False, index=True),
Column("name", Text), Column("description", Text), Column("lc_redacao", Text), Column("lc_214", Text),
Column("regulamento_cbs", Text), Column("regulamento_ibs", Text),
Column("tipo_aliquota", String(64)), Column("p_red_ibs", String(32)), Column("p_red_cbs", String(32)),
Column("d_ini_vig", String(32)), Column("d_fim_vig", String(32)), Column("data_atualizacao", String(32)),
Column("anexo", Text), Column("link", Text), Column("indicators", JSON, nullable=False),
UniqueConstraint("dataset_id", "cclasstrib", name="uq_rtc_cclasstrib"),
)
op.create_table(
"rtc_cbs_rate_entries",
Column("id", Integer, primary_key=True), _fk(),
Column("year", Integer, nullable=False, index=True),
Column("rate_text", Text, nullable=False), Column("rate", Float),
UniqueConstraint("dataset_id", "year", name="uq_rtc_cbs_rate_year"),
)
def downgrade():
for t in ("rtc_cbs_rate_entries", "rtc_cclasstrib_entries", "rtc_cst_ibscbs_entries"):
op.drop_table(t)
@@ -0,0 +1,31 @@
"""operator fiscal context entries (facts the ERP does not know: role, origin, ST retention, treatment, ...)
Revision ID: 0011
Revises: 0010
"""
from alembic import op
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
revision = "0011"; down_revision = "0010"; branch_labels = depends_on = None
def upgrade():
op.create_table(
"fiscal_context_entries",
Column("id", Integer, primary_key=True),
Column("tenant_id", Integer, ForeignKey("tenants.id"), nullable=False, index=True),
Column("company_key", String(64), nullable=False, index=True),
Column("scope", String(8), nullable=False),
Column("scope_key", String(64), nullable=False, server_default=""),
Column("key", String(48), nullable=False),
Column("value", Text, nullable=False),
Column("source", String(16), nullable=False, server_default="OPERATOR"),
Column("reason", Text, nullable=True),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
Column("updated_at", DateTime(timezone=True), server_default=func.now()),
UniqueConstraint("tenant_id", "company_key", "scope", "scope_key", "key", name="uq_fiscal_context_entry"),
)
def downgrade():
op.drop_table("fiscal_context_entries")
@@ -0,0 +1,37 @@
"""Migration 0012: AI semantic layer V1 (KB, tenant context, work items, runs).
Revision ID: 0012
Revises: 0011
"""
from alembic import op
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
revision = "0012"
down_revision = "0011"
branch_labels = depends_on = None
def upgrade():
op.create_table("kb_products", Column("id", Integer, primary_key=True), Column("fingerprint", String(64), nullable=False, unique=True), Column("gtin", String(16), unique=True), Column("identity_kind", String(16), nullable=False, server_default="FINGERPRINT"), Column("normalized_description", Text, nullable=False, server_default=""), Column("brand", Text), Column("unit", String(32)), Column("package_quantity", String(32)), Column("category", Text), Column("semantic_attributes", JSON, nullable=False), Column("created_at", DateTime(timezone=True), server_default=func.now()), Column("updated_at", DateTime(timezone=True), server_default=func.now()))
op.create_index("ix_kb_products_gtin", "kb_products", ["gtin"])
op.create_table("kb_knowledge", Column("id", Integer, primary_key=True), Column("kb_product_id", Integer, ForeignKey("kb_products.id"), nullable=False), Column("knowledge_key", String(48), nullable=False), Column("value", Text, nullable=False), Column("source", String(32), nullable=False), Column("confidence", Float, nullable=False, server_default="0"), Column("evidence", JSON, nullable=False), Column("model", String(64)), Column("prompt_version", String(32)), Column("dataset_versions", JSON, nullable=False), Column("created_at", DateTime(timezone=True), server_default=func.now()), Column("validated_at", DateTime(timezone=True)), UniqueConstraint("kb_product_id", "knowledge_key", "value", name="uq_kb_knowledge_value"))
op.create_index("ix_kb_knowledge_kb_product_id", "kb_knowledge", ["kb_product_id"])
op.create_index("ix_kb_knowledge_knowledge_key", "kb_knowledge", ["knowledge_key"])
op.create_table("kb_observations", Column("id", Integer, primary_key=True), Column("kb_product_id", Integer, ForeignKey("kb_products.id"), nullable=False), Column("tenant_id", Integer, ForeignKey("tenants.id"), nullable=False), Column("company_key", String(64), nullable=False), Column("erp_code", String(32), nullable=False), Column("database_label", String(64), nullable=False), Column("observed_fields", JSON, nullable=False), Column("source", String(32), nullable=False, server_default="ERP_OBSERVATION"), Column("observed_at", DateTime(timezone=True), server_default=func.now()))
op.create_index("ix_kb_observations_kb_product_id", "kb_observations", ["kb_product_id"])
op.create_table("tenant_product_context", Column("id", Integer, primary_key=True), Column("tenant_id", Integer, ForeignKey("tenants.id"), nullable=False), Column("company_key", String(64), nullable=False), Column("erp_code", String(32), nullable=False), Column("kb_product_id", Integer, ForeignKey("kb_products.id")), Column("product_role", String(16)), Column("st_withheld", String(4)), Column("operation_profile", JSON, nullable=False), Column("source", String(32), nullable=False, server_default="ERP_OBSERVATION"), Column("confidence", Float, nullable=False, server_default="0"), Column("created_at", DateTime(timezone=True), server_default=func.now()), Column("updated_at", DateTime(timezone=True), server_default=func.now()), UniqueConstraint("tenant_id", "company_key", "erp_code", name="uq_tenant_product_context"))
op.create_index("ix_tenant_product_context_tenant_id", "tenant_product_context", ["tenant_id"])
op.create_table("ai_work_items", Column("id", Integer, primary_key=True), Column("task_type", String(24), nullable=False), Column("dedup_key", String(160), nullable=False, unique=True), Column("question_refs", JSON, nullable=False), Column("payload", JSON, nullable=False), Column("status", String(24), nullable=False, server_default="PENDING"), Column("kb_product_id", Integer, ForeignKey("kb_products.id")), Column("cells_covered", Integer, nullable=False, server_default="0"), Column("created_at", DateTime(timezone=True), server_default=func.now()), Column("updated_at", DateTime(timezone=True), server_default=func.now()))
op.create_index("ix_ai_work_items_task_type", "ai_work_items", ["task_type"])
op.create_index("ix_ai_work_items_status", "ai_work_items", ["status"])
op.create_table("ai_runs", Column("id", Integer, primary_key=True), Column("provider", String(64), nullable=False), Column("model", String(64), nullable=False), Column("prompt_version", String(32), nullable=False), Column("work_item_ids", JSON, nullable=False), Column("input_tokens", Integer, nullable=False, server_default="0"), Column("output_tokens", Integer, nullable=False, server_default="0"), Column("cost_usd", Float, nullable=False, server_default="0"), Column("status", String(16), nullable=False, server_default="RUN"), Column("results", JSON, nullable=False), Column("budget_guard", JSON, nullable=False), Column("started_at", DateTime(timezone=True), server_default=func.now()), Column("finished_at", DateTime(timezone=True)))
def downgrade():
op.drop_table("ai_runs")
op.drop_table("ai_work_items")
op.drop_table("tenant_product_context")
op.drop_table("kb_observations")
op.drop_table("kb_knowledge")
op.drop_index("ix_kb_products_gtin", table_name="kb_products")
op.drop_table("kb_products")
@@ -0,0 +1,19 @@
"""Migration 0013: widen ai_work_items.status for V2 classification labels.
Revision ID: 0013
Revises: 0012
"""
from alembic import op
import sqlalchemy as sa
revision = "0013"
down_revision = "0012"
branch_labels = depends_on = None
def upgrade():
op.alter_column("ai_work_items", "status", existing_type=sa.String(24), type_=sa.String(32), nullable=False, existing_server_default="PENDING")
def downgrade():
op.alter_column("ai_work_items", "status", existing_type=sa.String(32), type_=sa.String(24), nullable=False, existing_server_default="PENDING")
@@ -0,0 +1,20 @@
"""Migration 0014: persistent LLM eval result cache.
Revision ID: 0014
Revises: 0013
"""
from alembic import op
from sqlalchemy import Column, DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint, func
revision = "0014"
down_revision = "0013"
branch_labels = depends_on = None
def upgrade():
op.create_table("ai_eval_cache", Column("id", Integer, primary_key=True), Column("input_fingerprint", String(64), nullable=False), Column("candidate_set_hash", String(64), nullable=False), Column("prompt_version", String(32), nullable=False), Column("model", String(64), nullable=False), Column("dataset_version_ncm", String(64), nullable=False), Column("phase", String(16), nullable=False), Column("answer", JSON, nullable=False), Column("gold_ncm", String(8)), Column("created_at", DateTime(timezone=True), server_default=func.now()), UniqueConstraint("input_fingerprint", "candidate_set_hash", "prompt_version", "model", "dataset_version_ncm", name="uq_ai_eval_cache"))
op.create_index("ix_ai_eval_cache_input_fingerprint", "ai_eval_cache", ["input_fingerprint"])
def downgrade():
op.drop_table("ai_eval_cache")
@@ -0,0 +1,52 @@
"""Add central AI usage ledger and external GTIN cache."""
from alembic import op
import sqlalchemy as sa
revision = "0015_ai_budget_and_product_cache"
down_revision = "0014"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"ai_usage_ledger",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("provider", sa.String(length=64), nullable=False),
sa.Column("model", sa.String(length=64), nullable=False),
sa.Column("run_id", sa.String(length=128), nullable=False),
sa.Column("tenant", sa.String(length=128), nullable=False),
sa.Column("items", sa.Integer(), nullable=False, server_default="0"),
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("cost", sa.Float(), nullable=False, server_default="0"),
sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_ai_usage_ledger_run_id", "ai_usage_ledger", ["run_id"])
op.create_index("ix_ai_usage_ledger_tenant", "ai_usage_ledger", ["tenant"])
op.create_index("ix_ai_usage_ledger_timestamp", "ai_usage_ledger", ["timestamp"])
op.create_table(
"external_product_cache",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("gtin", sa.String(length=16), nullable=False),
sa.Column("provider", sa.String(length=64), nullable=False),
sa.Column("provider_record_version", sa.String(length=64), nullable=False),
sa.Column("retrieved_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("raw_hash", sa.String(length=64), nullable=False),
sa.Column("normalized_data", sa.JSON(), nullable=False),
sa.Column("cache_allowed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("redistribution_allowed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.UniqueConstraint("gtin", "provider", "provider_record_version", name="uq_external_product_cache_record"),
)
op.create_index("ix_external_product_cache_gtin", "external_product_cache", ["gtin"])
def downgrade():
op.drop_index("ix_external_product_cache_gtin", table_name="external_product_cache")
op.drop_table("external_product_cache")
op.drop_index("ix_ai_usage_ledger_timestamp", table_name="ai_usage_ledger")
op.drop_index("ix_ai_usage_ledger_tenant", table_name="ai_usage_ledger")
op.drop_index("ix_ai_usage_ledger_run_id", table_name="ai_usage_ledger")
op.drop_table("ai_usage_ledger")
@@ -0,0 +1,43 @@
"""Add deduplicated blocked product registry."""
from alembic import op
import sqlalchemy as sa
revision = "0016_blocked_product_registry"
down_revision = "0015_ai_budget_and_product_cache"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"blocked_products",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("company", sa.String(length=128), nullable=False),
sa.Column("product_code", sa.String(length=64), nullable=False),
sa.Column("gtin", sa.String(length=16)),
sa.Column("description", sa.Text()),
sa.Column("blocker_type", sa.String(length=48), nullable=False),
sa.Column("field", sa.String(length=64), nullable=False),
sa.Column("reason_code", sa.String(length=96), nullable=False),
sa.Column("evidence_summary", sa.JSON(), nullable=False),
sa.Column("input_fingerprint", sa.String(length=64), nullable=False),
sa.Column("blocker_fingerprint", sa.String(length=64), nullable=False),
sa.Column("first_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("retry_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("next_retry_at", sa.DateTime(timezone=True)),
sa.Column("resolved_at", sa.DateTime(timezone=True)),
sa.Column("resolution_source", sa.String(length=48)),
sa.UniqueConstraint("blocker_fingerprint", name="uq_blocked_product_fingerprint"),
)
for name, column in (("tenant", "tenant_id"), ("company", "company"), ("product", "product_code"), ("gtin", "gtin"), ("blocker_type", "blocker_type"), ("field", "field"), ("input", "input_fingerprint")):
op.create_index(f"ix_blocked_products_{name}", "blocked_products", [column])
def downgrade():
for name in ("input", "field", "blocker_type", "gtin", "product", "company", "tenant"):
op.drop_index(f"ix_blocked_products_{name}", table_name="blocked_products")
op.drop_table("blocked_products")
@@ -0,0 +1,101 @@
"""Add the operational control-plane records."""
from alembic import op
import sqlalchemy as sa
revision = "0017_control_plane_foundation"
down_revision = "0016_blocked_product_registry"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"installations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("installation_id", sa.String(36), nullable=False),
sa.Column("display_name", sa.String(160), nullable=False, server_default=""),
sa.Column("customer_code", sa.String(64)), sa.Column("notes", sa.Text()),
sa.Column("hostname", sa.String(255)), sa.Column("windows_version", sa.String(128)),
sa.Column("machine_fingerprint", sa.String(128)), sa.Column("company_code", sa.String(64)),
sa.Column("company_name", sa.String(160)), sa.Column("company_cnpj_masked", sa.String(24)),
sa.Column("company_uf", sa.String(2)), sa.Column("company_crt", sa.String(16)),
sa.Column("firebird_version", sa.String(64)), sa.Column("firebird_ods", sa.String(32)),
sa.Column("database_identifier", sa.String(255)), sa.Column("capabilities", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("fiscal_engine_version", sa.String(64)), sa.Column("dataset_versions", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("started_at", sa.DateTime(timezone=True)), sa.Column("last_seen_at", sa.DateTime(timezone=True)),
sa.Column("last_status", sa.String(32)), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("installation_id", name="uq_installation_identity"),
sa.UniqueConstraint("agent_id", name="uq_installation_agent"),
)
op.create_index("ix_installations_agent_id", "installations", ["agent_id"])
op.create_index("ix_installations_tenant_id", "installations", ["tenant_id"])
op.create_index("ix_installations_hostname", "installations", ["hostname"])
op.create_index("ix_installations_customer_code", "installations", ["customer_code"])
op.create_index("ix_installations_last_seen_at", "installations", ["last_seen_at"])
op.create_index("ix_installations_installation_id", "installations", ["installation_id"])
op.create_table(
"agent_snapshots",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False),
sa.Column("total_products", sa.Integer(), nullable=False, server_default="0"), sa.Column("fiscally_complete", sa.Integer(), nullable=False, server_default="0"),
sa.Column("safe_blocked", sa.Integer(), nullable=False, server_default="0"), sa.Column("failed_products", sa.Integer(), nullable=False, server_default="0"),
sa.Column("coverage_percent", sa.Float(), nullable=False, server_default="0"), sa.Column("blockers", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("last_job_status", sa.String(16)), sa.Column("last_job_at", sa.DateTime(timezone=True)), sa.Column("last_successful_job_at", sa.DateTime(timezone=True)),
sa.Column("agent_health", sa.String(16), nullable=False, server_default="UNKNOWN"), sa.Column("health_reason", sa.Text()),
sa.Column("version_drift", sa.String(16), nullable=False, server_default="UNKNOWN"), sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("installation_id", name="uq_agent_snapshot_installation"),
)
for name, column in (("installation", "installation_id"), ("health", "agent_health"), ("version_drift", "version_drift"), ("updated", "updated_at")):
op.create_index(f"ix_agent_snapshots_{name}", "agent_snapshots", [column])
op.create_table(
"agent_events",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False), sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False),
sa.Column("severity", sa.String(16), nullable=False), sa.Column("event_type", sa.String(48), nullable=False), sa.Column("message", sa.Text(), nullable=False),
sa.Column("structured_context", sa.JSON(), nullable=False, server_default="{}"), sa.Column("job_id", sa.Integer(), sa.ForeignKey("jobs.id")),
sa.Column("product_code", sa.String(64)), sa.Column("blocker_id", sa.Integer(), sa.ForeignKey("blocked_products.id")), sa.Column("is_audit", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("occurred_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
for name, column in (("tenant", "tenant_id"), ("installation", "installation_id"), ("agent", "agent_id"), ("severity", "severity"), ("event_type", "event_type"), ("job", "job_id"), ("product", "product_code"), ("blocker", "blocker_id"), ("audit", "is_audit"), ("occurred", "occurred_at")):
op.create_index(f"ix_agent_events_{name}", "agent_events", [column])
op.create_table(
"admin_users",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("username", sa.String(128), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=False), sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column("last_login_at", sa.DateTime(timezone=True)),
)
op.create_index("ix_admin_users_username", "admin_users", ["username"])
op.create_table(
"web_sessions",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("admin_user_id", sa.Integer(), sa.ForeignKey("admin_users.id"), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False, unique=True), sa.Column("csrf_hash", sa.String(64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column("user_agent", sa.String(512)), sa.Column("remote_addr", sa.String(64)),
)
op.create_index("ix_web_sessions_admin_user_id", "web_sessions", ["admin_user_id"])
op.create_index("ix_web_sessions_token_hash", "web_sessions", ["token_hash"])
op.create_index("ix_web_sessions_expires_at", "web_sessions", ["expires_at"])
op.create_table(
"audit_events",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("admin_user_id", sa.Integer(), sa.ForeignKey("admin_users.id")),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id")), sa.Column("action", sa.String(64), nullable=False),
sa.Column("target_type", sa.String(64), nullable=False), sa.Column("target_id", sa.String(128), nullable=False), sa.Column("context", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("severity", sa.String(16), nullable=False, server_default="INFO"), sa.Column("occurred_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
for name, column in (("admin", "admin_user_id"), ("tenant", "tenant_id"), ("action", "action"), ("occurred", "occurred_at")):
op.create_index(f"ix_audit_events_{name}", "audit_events", [column])
def downgrade():
op.drop_table("audit_events")
op.drop_table("web_sessions")
op.drop_table("admin_users")
op.drop_table("agent_events")
op.drop_table("agent_snapshots")
op.drop_table("installations")
@@ -0,0 +1,20 @@
"""Associate new blocker rows with their authenticated installation."""
from alembic import op
import sqlalchemy as sa
revision = "0018_blocker_installation"
down_revision = "0017_control_plane_foundation"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("blocked_products", sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=True))
op.create_index("ix_blocked_products_installation_id", "blocked_products", ["installation_id"])
def downgrade():
op.drop_index("ix_blocked_products_installation_id", table_name="blocked_products")
op.drop_column("blocked_products", "installation_id")
@@ -0,0 +1,43 @@
"""Fix frozen timestamp defaults from 0006 and add model indexes never migrated.
0006 declared ``server_default="now()"`` as a plain string. SQLAlchemy renders
that as the literal ``'now()'``, which PostgreSQL casts to a timestamp once,
when the column is created — every later row got that same fixed instant
instead of the insert time (agents.created_at/updated_at, jobs.created_at).
The models rely on the server default, so Jobs and Agents registered after
0006 all carried the migration's timestamp. Existing rows are left as they
are: their real creation time is not recoverable.
The models also declare ``index=True`` on three columns that no migration
ever indexed.
Revision ID: 0019_fix_timestamp_defaults
Revises: 0018_blocker_installation
"""
from alembic import op
import sqlalchemy as sa
revision = "0019_fix_timestamp_defaults"
down_revision = "0018_blocker_installation"
branch_labels = None
depends_on = None
_TIMESTAMP_COLUMNS = (("agents", "created_at"), ("agents", "updated_at"), ("jobs", "created_at"))
_MISSING_INDEXES = (
("ix_kb_observations_tenant_id", "kb_observations", "tenant_id"),
("ix_kb_observations_company_key", "kb_observations", "company_key"),
("ix_tenant_product_context_company_key", "tenant_product_context", "company_key"),
)
def upgrade():
for table, column in _TIMESTAMP_COLUMNS:
op.alter_column(table, column, server_default=sa.func.now(), existing_type=sa.DateTime(timezone=True), existing_nullable=False)
for name, table, column in _MISSING_INDEXES:
op.create_index(name, table, [column])
def downgrade():
# The frozen-timestamp defaults are a bug and are deliberately not restored.
for name, table, _column in reversed(_MISSING_INDEXES):
op.drop_index(name, table_name=table)
@@ -0,0 +1,63 @@
"""Enforce NOT NULL on server-defaulted timestamps the models already require.
0011, 0012, 0014 and 0017 created these columns with ``server_default=now()``
but without ``nullable=False``, so the database accepted NULL while the models
declare them non-optional. No application path writes NULL to them (inserts
omit them and rely on the default; updates set ``now``), and before this
revision was written no NULL existed in production or in the development
database.
The upgrade refuses to run — changing nothing — if any NULL is found, instead
of guessing a value for it.
Revision ID: 0020_not_null_timestamps
Revises: 0019_fix_timestamp_defaults
"""
from alembic import op
import sqlalchemy as sa
revision = "0020_not_null_timestamps"
down_revision = "0019_fix_timestamp_defaults"
branch_labels = None
depends_on = None
COLUMNS = (
("admin_users", "created_at"),
("agent_events", "occurred_at"),
("agent_snapshots", "updated_at"),
("ai_eval_cache", "created_at"),
("ai_runs", "started_at"),
("ai_work_items", "created_at"),
("ai_work_items", "updated_at"),
("audit_events", "occurred_at"),
("fiscal_context_entries", "created_at"),
("fiscal_context_entries", "updated_at"),
("installations", "created_at"),
("installations", "updated_at"),
("kb_knowledge", "created_at"),
("kb_observations", "observed_at"),
("kb_products", "created_at"),
("kb_products", "updated_at"),
("tenant_product_context", "created_at"),
("tenant_product_context", "updated_at"),
("web_sessions", "created_at"),
("web_sessions", "last_seen_at"),
)
def upgrade():
bind = op.get_bind()
offenders = []
for table, column in COLUMNS:
nulls = bind.execute(sa.text(f'SELECT count(*) FROM "{table}" WHERE "{column}" IS NULL')).scalar()
if nulls:
offenders.append(f"{table}.{column}={nulls}")
if offenders:
raise RuntimeError("0020 aborted, NULL values present (fix them explicitly first): " + ", ".join(offenders))
for table, column in COLUMNS:
op.alter_column(table, column, nullable=False, existing_type=sa.DateTime(timezone=True), existing_server_default=sa.func.now())
def downgrade():
for table, column in reversed(COLUMNS):
op.alter_column(table, column, nullable=True, existing_type=sa.DateTime(timezone=True), existing_server_default=sa.func.now())
@@ -0,0 +1,59 @@
"""Federated identity for SSO from GTI Gerenciador.
admin_users stays the single human principal (web_sessions and audit_events
already point at it). Existing rows become auth_source='LOCAL' (the
break-glass admin keeps its password). Collaborators provisioned by SSO are
auth_source='GTI_GERENCIADOR': no password at all, identified by the panel's
immutable subject. The CHECK constraint makes a passwordless LOCAL account,
or a federated account with a password, impossible.
sso_used_assertions keeps every accepted assertion id (jti) so the same
assertion can never be used twice.
Revision ID: 0021_sso_federated_identity
Revises: 0020_not_null_timestamps
"""
from alembic import op
import sqlalchemy as sa
revision = "0021_sso_federated_identity"
down_revision = "0020_not_null_timestamps"
branch_labels = None
depends_on = None
AUTH_SOURCE_CHECK = (
"(auth_source = 'LOCAL' AND password_hash IS NOT NULL AND external_subject IS NULL)"
" OR (auth_source = 'GTI_GERENCIADOR' AND password_hash IS NULL AND external_subject IS NOT NULL)"
)
def upgrade():
op.add_column("admin_users", sa.Column("auth_source", sa.String(16), nullable=False, server_default="LOCAL"))
op.add_column("admin_users", sa.Column("external_subject", sa.String(128), nullable=True))
op.add_column("admin_users", sa.Column("display_name", sa.String(160), nullable=True))
op.add_column("admin_users", sa.Column("external_role", sa.String(32), nullable=True))
op.add_column("admin_users", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False))
op.alter_column("admin_users", "password_hash", existing_type=sa.String(255), nullable=True)
op.create_unique_constraint("uq_admin_users_external_subject", "admin_users", ["external_subject"])
op.create_check_constraint("ck_admin_users_auth_source", "admin_users", AUTH_SOURCE_CHECK)
op.create_table(
"sso_used_assertions",
sa.Column("jti", sa.String(64), primary_key=True),
sa.Column("subject", sa.String(128), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_sso_used_assertions_expires_at", "sso_used_assertions", ["expires_at"])
def downgrade():
bind = op.get_bind()
if bind.execute(sa.text("SELECT count(*) FROM admin_users WHERE auth_source <> 'LOCAL'")).scalar():
raise RuntimeError("0021 downgrade refused: federated users exist (their sessions/audit rows reference them)")
op.drop_index("ix_sso_used_assertions_expires_at", table_name="sso_used_assertions")
op.drop_table("sso_used_assertions")
op.drop_constraint("ck_admin_users_auth_source", "admin_users", type_="check")
op.drop_constraint("uq_admin_users_external_subject", "admin_users", type_="unique")
op.alter_column("admin_users", "password_hash", existing_type=sa.String(255), nullable=False)
for column in ("updated_at", "external_role", "display_name", "external_subject", "auth_source"):
op.drop_column("admin_users", column)
@@ -0,0 +1,88 @@
"""Agent distribution: releases + secure enrollment.
agent_releases catalogs published builds of the universal Windows Agent
package (the binary itself lives in local release storage, never in
Postgres). agent_enrollments tracks one pending-or-completed installation
per row, carrying two distinct hashed, single-use, short-TTL secrets: a
download token (the shareable package link) and an enrollment token
(embedded in the downloaded bundle, consumed once by POST /v1/agents/enroll).
Neither secret is ever stored in plaintext.
Revision ID: 0022_agent_releases_enrollment
Revises: 0021_sso_federated_identity
"""
from alembic import op
import sqlalchemy as sa
revision = "0022_agent_releases_enrollment"
down_revision = "0021_sso_federated_identity"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"agent_releases",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("version", sa.String(32), nullable=False),
sa.Column("platform", sa.String(16), nullable=False, server_default="windows"),
sa.Column("architecture", sa.String(16), nullable=False, server_default="x64"),
sa.Column("filename", sa.String(255), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("sha256", sa.String(64), nullable=False),
sa.Column("storage_path", sa.String(512), nullable=False),
sa.Column("status", sa.String(16), nullable=False, server_default="DRAFT"),
sa.Column("release_notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.Integer(), sa.ForeignKey("admin_users.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("version", "platform", "architecture", name="uq_agent_release_version"),
)
op.create_index("ix_agent_releases_version", "agent_releases", ["version"])
op.create_index("ix_agent_releases_sha256", "agent_releases", ["sha256"])
op.create_index("ix_agent_releases_status", "agent_releases", ["status"])
op.create_table(
"agent_enrollments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("enrollment_uuid", sa.String(36), nullable=False),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("display_name", sa.String(160), nullable=False),
sa.Column("expected_hostname", sa.String(255), nullable=True),
sa.Column("release_id", sa.Integer(), sa.ForeignKey("agent_releases.id"), nullable=False),
sa.Column("status", sa.String(16), nullable=False, server_default="PENDING"),
sa.Column("created_by", sa.Integer(), sa.ForeignKey("admin_users.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("download_token_hash", sa.String(64), nullable=True),
sa.Column("download_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("download_max_uses", sa.Integer(), nullable=False, server_default="1"),
sa.Column("download_uses", sa.Integer(), nullable=False, server_default="0"),
sa.Column("downloaded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("enrollment_token_hash", sa.String(64), nullable=True),
sa.Column("enrollment_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("enrollment_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("machine_fingerprint_seen", sa.String(128), nullable=True),
sa.Column("hostname_seen", sa.String(255), nullable=True),
sa.Column("hostname_mismatch", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("enrollment_uuid", name="uq_agent_enrollment_uuid"),
)
op.create_index("ix_agent_enrollments_uuid", "agent_enrollments", ["enrollment_uuid"])
op.create_index("ix_agent_enrollments_tenant", "agent_enrollments", ["tenant_id"])
op.create_index("ix_agent_enrollments_release", "agent_enrollments", ["release_id"])
op.create_index("ix_agent_enrollments_status", "agent_enrollments", ["status"])
op.create_index("ix_agent_enrollments_agent", "agent_enrollments", ["agent_id"])
def downgrade():
op.drop_index("ix_agent_enrollments_agent", table_name="agent_enrollments")
op.drop_index("ix_agent_enrollments_status", table_name="agent_enrollments")
op.drop_index("ix_agent_enrollments_release", table_name="agent_enrollments")
op.drop_index("ix_agent_enrollments_tenant", table_name="agent_enrollments")
op.drop_index("ix_agent_enrollments_uuid", table_name="agent_enrollments")
op.drop_table("agent_enrollments")
op.drop_index("ix_agent_releases_status", table_name="agent_releases")
op.drop_index("ix_agent_releases_sha256", table_name="agent_releases")
op.drop_index("ix_agent_releases_version", table_name="agent_releases")
op.drop_table("agent_releases")
@@ -0,0 +1,34 @@
"""Installation code (install_code) for the Windows installer EXE flow.
agent_enrollments gains the install-code columns used by the universal EXE
installer: the code is typed by the operator into Setup.exe and resolves the
installation on the Hub. Only the SHA-256 of the normalized code is ever
stored (install_code_hash); install_code_expires_at bounds its life and
install_code_created_at records when it was generated (rotation rewrites
these). install_code_attempts counts failed enrollment tries to support the
per-installation rate limit. No plaintext code column exists.
Revision ID: 0023_install_codes
Revises: 0022_agent_releases_enrollment
"""
from alembic import op
import sqlalchemy as sa
revision = "0023_install_codes"
down_revision = "0022_agent_releases_enrollment"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("agent_enrollments", sa.Column("install_code_hash", sa.String(64), nullable=True))
op.add_column("agent_enrollments", sa.Column("install_code_expires_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("agent_enrollments", sa.Column("install_code_attempts", sa.Integer(), nullable=False, server_default="0"))
op.add_column("agent_enrollments", sa.Column("install_code_created_at", sa.DateTime(timezone=True), nullable=True))
def downgrade():
op.drop_column("agent_enrollments", "install_code_created_at")
op.drop_column("agent_enrollments", "install_code_attempts")
op.drop_column("agent_enrollments", "install_code_expires_at")
op.drop_column("agent_enrollments", "install_code_hash")
@@ -0,0 +1,35 @@
"""Tenant mirror identity from GTI Gerenciador.
GTI Gerenciador (the panel) becomes the source of truth for clients.
tenants gains source ('LOCAL' | 'GTI_GERENCIADOR'), external_client_id
(Gerenciador's CLIENTE.ID), external_code, active and synced_at. Existing
rows (pre-dating this sync) become source='LOCAL' and are never offered for
a new installation, but keep every foreign key that already points at them.
Revision ID: 0024_tenant_gerenciador_mirror
Revises: 0023_install_codes
"""
from alembic import op
import sqlalchemy as sa
revision = "0024_tenant_gerenciador_mirror"
down_revision = "0023_install_codes"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("tenants", sa.Column("source", sa.String(16), nullable=False, server_default="LOCAL"))
op.add_column("tenants", sa.Column("external_client_id", sa.Integer(), nullable=True))
op.add_column("tenants", sa.Column("external_code", sa.String(16), nullable=True))
op.add_column("tenants", sa.Column("active", sa.Boolean(), nullable=False, server_default="true"))
op.add_column("tenants", sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_tenants_external_client_id", "tenants", ["external_client_id"])
op.create_unique_constraint("uq_tenant_external_identity", "tenants", ["source", "external_client_id"])
def downgrade():
op.drop_constraint("uq_tenant_external_identity", "tenants", type_="unique")
op.drop_index("ix_tenants_external_client_id", table_name="tenants")
for column in ("synced_at", "active", "external_code", "external_client_id", "source"):
op.drop_column("tenants", column)
@@ -0,0 +1,20 @@
"""Tenant.external_cnpj (digits) mirrored from GTI Gerenciador for the client selector search.
Revision ID: 0025_tenant_external_cnpj
Revises: 0024_tenant_gerenciador_mirror
"""
from alembic import op
import sqlalchemy as sa
revision = "0025_tenant_external_cnpj"
down_revision = "0024_tenant_gerenciador_mirror"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("tenants", sa.Column("external_cnpj", sa.String(20), nullable=True))
def downgrade():
op.drop_column("tenants", "external_cnpj")
@@ -0,0 +1,24 @@
"""Central processing control + sync bookkeeping per installation.
Revision ID: 0026_central_processing_control
Revises: 0025_tenant_external_cnpj
"""
from alembic import op
import sqlalchemy as sa
revision = "0026_central_processing_control"
down_revision = "0025_tenant_external_cnpj"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("processing_enabled", sa.Boolean(), nullable=False, server_default="true"))
op.add_column("installations", sa.Column("sync_interval_seconds", sa.Integer(), nullable=False, server_default="1800"))
op.add_column("installations", sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("installations", sa.Column("last_sync_changed", sa.Integer(), nullable=True))
def downgrade():
for column in ("last_sync_changed", "last_sync_at", "sync_interval_seconds", "processing_enabled"):
op.drop_column("installations", column)
@@ -0,0 +1,42 @@
"""RTC auto-apply: per-installation authorization (default OFF) + immutable write audit.
Revision ID: 0027_rtc_auto_apply
Revises: 0026_central_processing_control
"""
from alembic import op
import sqlalchemy as sa
revision = "0027_rtc_auto_apply"
down_revision = "0026_central_processing_control"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("rtc_auto_apply_authorized", sa.Boolean(), nullable=False, server_default="false"))
op.create_table(
"rtc_write_audits",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False, index=True),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False, index=True),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False, index=True),
sa.Column("job_id", sa.Integer(), nullable=True, index=True),
sa.Column("client", sa.String(255), nullable=True),
sa.Column("product_code", sa.String(64), nullable=False, index=True),
sa.Column("field", sa.String(32), nullable=False),
sa.Column("outcome", sa.String(16), nullable=False, index=True),
sa.Column("reason", sa.String(96), nullable=True),
sa.Column("old_value", sa.String(64), nullable=True),
sa.Column("new_value", sa.String(64), nullable=True),
sa.Column("rule_id", sa.String(32), nullable=True),
sa.Column("evidence", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("official_source_version", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("local_catalog_hash", sa.String(64), nullable=True),
sa.Column("written_at", sa.String(40), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), server_default=sa.func.now(), index=True),
)
def downgrade():
op.drop_table("rtc_write_audits")
op.drop_column("installations", "rtc_auto_apply_authorized")
@@ -0,0 +1,47 @@
"""RTC central write modes per installation, candidate preview/approval table, richer audit + rollback tracking.
Revision ID: 0028_rtc_write_modes
Revises: 0027_rtc_auto_apply
"""
from alembic import op
import sqlalchemy as sa
revision = "0028_rtc_write_modes"
down_revision = "0027_rtc_auto_apply"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("rtc_write_mode", sa.String(24), nullable=False, server_default="DISABLED"))
op.execute("UPDATE installations SET rtc_write_mode = 'ENABLED' WHERE rtc_auto_apply_authorized") # keeps an existing explicit authorization
for name, kind in (("rule_version", sa.String(32)), ("company_crt", sa.String(8)), ("authorization_origin", sa.String(48)), ("description_snapshot", sa.String(255)),
("installation_uuid", sa.String(64)), ("rtc_status", sa.String(24)), ("action", sa.String(16)), ("rollback_state", sa.String(16)), ("rollback_of", sa.Integer()),
("local_catalog_state", sa.String(32))):
op.add_column("rtc_write_audits", sa.Column(name, kind, nullable=True))
op.create_index("ix_rtc_write_audits_rollback_of", "rtc_write_audits", ["rollback_of"])
op.create_table(
"rtc_candidates",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False, index=True),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False, index=True),
sa.Column("product_code", sa.String(64), nullable=False),
sa.Column("description", sa.String(255), nullable=True),
sa.Column("ncm", sa.String(16), nullable=True),
sa.Column("current_value", sa.String(16), nullable=True),
sa.Column("proposed", sa.String(16), nullable=False),
sa.Column("cst_ibscbs", sa.String(8), nullable=True),
sa.Column("rule_id", sa.String(32), nullable=False),
sa.Column("basis", sa.String(255), nullable=True),
sa.Column("status", sa.String(16), nullable=False, server_default="PENDING", index=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("installation_id", "product_code", name="uq_rtc_candidate_product"),
)
def downgrade():
op.drop_table("rtc_candidates")
op.drop_index("ix_rtc_write_audits_rollback_of", table_name="rtc_write_audits")
for name in ("rule_version", "company_crt", "authorization_origin", "description_snapshot", "installation_uuid", "rtc_status", "action", "rollback_state", "rollback_of", "local_catalog_state"):
op.drop_column("rtc_write_audits", name)
op.drop_column("installations", "rtc_write_mode")
@@ -0,0 +1,140 @@
"""Per-installation functions (corrector / enrichment mode), KB field status, enrichment state + audit, provider state/usage/cache, official source refresh log.
Revision ID: 0029_product_enrichment
Revises: 0028_rtc_write_modes
"""
from alembic import op
import sqlalchemy as sa
revision = "0029_product_enrichment"
down_revision = "0028_rtc_write_modes"
branch_labels = None
depends_on = None
def _idx(table, *cols):
for col in cols:
op.create_index(f"ix_{table}_{col}", table, [col])
def upgrade():
# existing installations keep behaving exactly as before: corrector on, functions already configured
op.add_column("installations", sa.Column("corrector_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.add_column("installations", sa.Column("enrichment_mode", sa.String(16), nullable=False, server_default="DISABLED"))
op.add_column("installations", sa.Column("features_configured", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.add_column("agent_enrollments", sa.Column("features_selected", sa.Boolean(), nullable=False, server_default=sa.text("false")))
op.add_column("agent_enrollments", sa.Column("feature_corrector", sa.Boolean(), nullable=False, server_default=sa.text("false")))
op.add_column("agent_enrollments", sa.Column("feature_enrichment_mode", sa.String(16), nullable=False, server_default="DISABLED"))
op.add_column("kb_knowledge", sa.Column("status", sa.String(16), nullable=False, server_default="EVIDENCE_ONLY"))
op.create_index("ix_kb_knowledge_status", "kb_knowledge", ["status"])
op.create_table(
"product_enrichments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False),
sa.Column("product_code", sa.String(64), nullable=False),
sa.Column("gtin", sa.String(16), nullable=True),
sa.Column("description", sa.String(255), nullable=True),
sa.Column("status", sa.String(32), nullable=False, server_default="PENDING"),
sa.Column("decisions", sa.JSON(), nullable=False),
sa.Column("fields_applied", sa.JSON(), nullable=False),
sa.Column("human_overridden", sa.JSON(), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("detected_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("installation_id", "product_code", name="uq_product_enrichment"),
)
_idx("product_enrichments", "tenant_id", "installation_id", "gtin", "status", "next_attempt_at")
op.create_table(
"product_field_audits",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False),
sa.Column("product_code", sa.String(64), nullable=False),
sa.Column("gtin", sa.String(16), nullable=True),
sa.Column("description_snapshot", sa.String(255), nullable=True),
sa.Column("field", sa.String(48), nullable=False),
sa.Column("old_value", sa.String(64), nullable=True),
sa.Column("new_value", sa.String(64), nullable=True),
sa.Column("rule", sa.String(64), nullable=True),
sa.Column("kb_status", sa.String(16), nullable=True),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("dataset_versions", sa.JSON(), nullable=False),
sa.Column("authorization_origin", sa.String(48), nullable=True),
sa.Column("job_id", sa.Integer(), nullable=True),
sa.Column("outcome", sa.String(24), nullable=False, server_default="APPLIED"),
sa.Column("reason", sa.String(96), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
_idx("product_field_audits", "tenant_id", "installation_id", "agent_id", "product_code")
op.create_table(
"provider_states",
sa.Column("provider", sa.String(32), primary_key=True),
sa.Column("configured", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("status", sa.String(16), nullable=False, server_default="NOT_CONFIGURED"),
sa.Column("quota_kind", sa.String(16), nullable=False, server_default="DOCUMENTED_RATE"),
sa.Column("daily_limit", sa.Integer(), nullable=True),
sa.Column("remaining", sa.Integer(), nullable=True),
sa.Column("reset_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("rate_per_minute", sa.Integer(), nullable=True),
sa.Column("circuit_open_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("consecutive_failures", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_request_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.String(160), nullable=True),
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_latency_ms", sa.Integer(), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"provider_usage_daily",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("provider", sa.String(32), nullable=False),
sa.Column("day", sa.String(10), nullable=False),
*[sa.Column(name, sa.Integer(), nullable=False, server_default="0") for name in ("requests", "success", "not_found", "errors", "rate_limited", "timeouts", "cache_hits", "cache_misses")],
sa.UniqueConstraint("provider", "day", name="uq_provider_usage_day"),
)
_idx("provider_usage_daily", "provider", "day")
op.create_table(
"gtin_lookup_cache",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("gtin", sa.String(16), nullable=False),
sa.Column("provider", sa.String(32), nullable=False),
sa.Column("found", sa.Boolean(), nullable=False),
sa.Column("data", sa.JSON(), nullable=False),
sa.Column("fetched_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("gtin", "provider", name="uq_gtin_lookup_cache"),
)
_idx("gtin_lookup_cache", "gtin", "expires_at")
op.create_table(
"source_refresh_runs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("source", sa.String(32), nullable=False),
sa.Column("source_url", sa.Text(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(16), nullable=False, server_default="RUNNING"),
sa.Column("sha256", sa.String(64), nullable=True),
sa.Column("version_label", sa.String(64), nullable=True),
sa.Column("record_count", sa.Integer(), nullable=True),
sa.Column("error", sa.String(300), nullable=True),
)
_idx("source_refresh_runs", "source")
def downgrade():
for table in ("source_refresh_runs", "gtin_lookup_cache", "provider_usage_daily", "provider_states", "product_field_audits", "product_enrichments"):
op.drop_table(table)
op.drop_index("ix_kb_knowledge_status", table_name="kb_knowledge")
op.drop_column("kb_knowledge", "status")
for name in ("feature_enrichment_mode", "feature_corrector", "features_selected"):
op.drop_column("agent_enrollments", name)
for name in ("features_configured", "enrichment_mode", "corrector_enabled"):
op.drop_column("installations", name)
@@ -0,0 +1,48 @@
"""KB evidence model: wider per-field status (OBSERVED/CORROBORATED/EXTERNAL_CORROBORATED/...), promotion audit, provider counters for the panel.
Revision ID: 0030_kb_evidence_model
Revises: 0029_product_enrichment
"""
from alembic import op
import sqlalchemy as sa
revision = "0030_kb_evidence_model"
down_revision = "0029_product_enrichment"
branch_labels = None
depends_on = None
def upgrade():
op.alter_column("kb_knowledge", "status", existing_type=sa.String(16), type_=sa.String(24), existing_nullable=False, existing_server_default="EVIDENCE_ONLY")
op.execute("UPDATE kb_knowledge SET status='OBSERVED' WHERE status='EVIDENCE_ONLY'")
op.alter_column("kb_knowledge", "status", server_default="OBSERVED")
op.create_table(
"kb_promotion_audits",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("kb_product_id", sa.Integer(), sa.ForeignKey("kb_products.id"), nullable=False),
sa.Column("gtin", sa.String(16), nullable=True),
sa.Column("field", sa.String(24), nullable=False, server_default="ncm"),
sa.Column("value", sa.String(64), nullable=True),
sa.Column("old_status", sa.String(24), nullable=True),
sa.Column("new_status", sa.String(24), nullable=False),
sa.Column("families", sa.JSON(), nullable=False),
sa.Column("sources", sa.JSON(), nullable=False),
sa.Column("official_dataset", sa.JSON(), nullable=False),
sa.Column("rule_version", sa.String(32), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_kb_promotion_audits_kb_product_id", "kb_promotion_audits", ["kb_product_id"])
op.create_index("ix_kb_promotion_audits_gtin", "kb_promotion_audits", ["gtin"])
op.add_column("provider_usage_daily", sa.Column("found", sa.Integer(), nullable=False, server_default="0"))
op.add_column("provider_usage_daily", sa.Column("ncm_found", sa.Integer(), nullable=False, server_default="0"))
op.add_column("provider_usage_daily", sa.Column("kb_hits", sa.Integer(), nullable=False, server_default="0"))
def downgrade():
op.drop_column("provider_usage_daily", "kb_hits")
op.drop_column("provider_usage_daily", "ncm_found")
op.drop_column("provider_usage_daily", "found")
op.drop_table("kb_promotion_audits")
op.execute("UPDATE kb_knowledge SET status='EVIDENCE_ONLY' WHERE status IN ('OBSERVED','CORROBORATED','EXTERNAL_CORROBORATED')")
op.alter_column("kb_knowledge", "status", server_default="EVIDENCE_ONLY")
op.alter_column("kb_knowledge", "status", existing_type=sa.String(24), type_=sa.String(16), existing_nullable=False, existing_server_default="EVIDENCE_ONLY")
@@ -0,0 +1,28 @@
"""Enrichment re-evaluation + visible per-product status (no silent failures).
Revision ID: 0031_enrichment_reevaluate
Revises: 0030_kb_evidence_model
"""
from alembic import op
import sqlalchemy as sa
revision = "0031_enrichment_reevaluate"
down_revision = "0030_kb_evidence_model"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("product_enrichments", sa.Column("reevaluate", sa.Boolean(), nullable=False, server_default=sa.text("false")))
op.add_column("product_enrichments", sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("product_enrichments", sa.Column("ui_status", sa.String(32), nullable=True))
op.add_column("product_enrichments", sa.Column("reason_code", sa.String(48), nullable=True))
op.create_index("ix_product_enrichments_reevaluate", "product_enrichments", ["reevaluate"])
op.create_index("ix_product_enrichments_ui_status", "product_enrichments", ["ui_status"])
def downgrade():
op.drop_index("ix_product_enrichments_ui_status", table_name="product_enrichments")
op.drop_index("ix_product_enrichments_reevaluate", table_name="product_enrichments")
for name in ("reason_code", "ui_status", "last_attempt_at", "reevaluate"):
op.drop_column("product_enrichments", name)
@@ -0,0 +1,21 @@
"""Installation.company_contribuinte_ipi: auto-discovered (C000004.CONTRIBUINTE_IPI, same row/query as company_crt) at heartbeat, never
operator-guessed. Closes the fiscal-completion gap where CST_IPI could not be resolved without asking a human for a fact the ERP already proves.
Revision ID: 0032_contribuinte_ipi
Revises: 0031_enrichment_reevaluate
"""
from alembic import op
import sqlalchemy as sa
revision = "0032_contribuinte_ipi"
down_revision = "0031_enrichment_reevaluate"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("company_contribuinte_ipi", sa.String(1), nullable=True))
def downgrade():
op.drop_column("installations", "company_contribuinte_ipi")
@@ -0,0 +1,35 @@
"""Reference Data Registry (Missão 5, Fase 1): additive columns on datasets for licence terms,
vigência, file provenance, parse errors and admin approval tracking. Existing rows/kinds are
untouched - new nullable columns, no data migration, no behavior change for the 9 pre-existing
dataset kinds (their own service.py ingestion methods still auto-activate exactly as before).
Revision ID: 0034_reference_data_registry
Revises: 0032_contribuinte_ipi
Note: migration 0033_cfop_ippt_write_mode exists ONLY on feature/cfop-ippt-write, never merged to
master - this migration chains from 0032 (master's real head), not from 0033.
"""
from alembic import op
import sqlalchemy as sa
revision = "0034_reference_data_registry"
down_revision = "0032_contribuinte_ipi"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("datasets", sa.Column("licence_terms", sa.Text(), nullable=True))
op.add_column("datasets", sa.Column("valid_from", sa.String(32), nullable=True))
op.add_column("datasets", sa.Column("valid_to", sa.String(32), nullable=True))
op.add_column("datasets", sa.Column("file_size_bytes", sa.Integer(), nullable=True))
op.add_column("datasets", sa.Column("raw_file_path", sa.Text(), nullable=True))
op.add_column("datasets", sa.Column("parse_errors", sa.JSON(), nullable=False, server_default="{}"))
op.add_column("datasets", sa.Column("submitted_by", sa.String(128), nullable=True))
op.add_column("datasets", sa.Column("approved_by", sa.String(128), nullable=True))
op.add_column("datasets", sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True))
def downgrade():
for name in ("approved_at", "approved_by", "submitted_by", "parse_errors", "raw_file_path", "file_size_bytes", "valid_to", "valid_from", "licence_terms"):
op.drop_column("datasets", name)
@@ -0,0 +1,39 @@
"""CFOP/IPPT write capability: per-installation write mode (DEFAULT OFF) + append-only audit table.
Revision ID: 0035_cfop_ippt_write_mode
Revises: 0034_reference_data_registry
"""
from alembic import op
import sqlalchemy as sa
revision = "0035_cfop_ippt_write_mode"
down_revision = "0034_reference_data_registry"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("cfop_ippt_write_mode", sa.String(24), nullable=False, server_default="DISABLED"))
op.create_table(
"cfop_ippt_write_audits",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False, index=True),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False, index=True),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False, index=True),
sa.Column("job_id", sa.Integer(), nullable=True, index=True),
sa.Column("product_code", sa.String(64), nullable=False, index=True),
sa.Column("field", sa.String(16), nullable=False),
sa.Column("outcome", sa.String(16), nullable=False, index=True),
sa.Column("reason", sa.String(96), nullable=True),
sa.Column("old_value", sa.String(16), nullable=True),
sa.Column("new_value", sa.String(16), nullable=True),
sa.Column("action", sa.String(16), nullable=True),
sa.Column("rollback_of", sa.Integer(), nullable=True, index=True),
sa.Column("written_at", sa.String(40), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), server_default=sa.func.now(), index=True),
)
def downgrade():
op.drop_table("cfop_ippt_write_audits")
op.drop_column("installations", "cfop_ippt_write_mode")
+1
View File
@@ -0,0 +1 @@
"""Fiscal Hub application modules."""
+256
View File
@@ -0,0 +1,256 @@
"""Public, pre-authentication routes for Agent distribution.
GET /dl/agent/{token} and POST /v1/agents/enroll are reachable by anyone —
same trust level as GET /login. Every other Agent-distribution route
(create pending installation, generate a link, direct authenticated
download, list/revoke releases) is a control-panel page and lives in
web.py, gated the same way as the rest of the panel (admin session + CSRF).
The enrollment token embedded in a downloaded bundle is generated fresh at
download time and its plaintext is never written to storage — only its
SHA-256 hash persists, exactly like the download token that gated getting
it. Neither is ever the Agent's own permanent Bearer token, which is
minted only once, inside a successful POST /v1/agents/enroll.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import select
from . import config, enrollment, release_storage
from .audit import record_audit
from .auth import token_hash as agent_token_hash
from .models import Agent, AgentEnrollment, AgentRelease, Installation
from .rate_limit import blocked, record_attempt
def client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _aware(value: datetime | None) -> datetime | None:
"""SQLite (used by the test suite) drops tzinfo on round-trip even for a
DateTime(timezone=True) column; Postgres never does. Normalize either way
before comparing against datetime.now(timezone.utc) — same pattern as
human_auth.py's session-expiry check."""
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def build_and_serve(session, enr: AgentEnrollment, request: Request, *, actor_admin_id: int | None = None) -> Response:
"""Serve the release artifact for the installation — always the same
universal release binary (ZIP bundle for legacy token flow, bare EXE for
the installer flow). Audit the download; never embed the enrollment token
in an EXE download (the operator types a standalone install code instead)."""
release = session.get(AgentRelease, enr.release_id)
if release is None or release.status != "PUBLISHED":
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
path = release_storage.safe_path_for(release.storage_path)
if not path.exists():
raise enrollment.EnrollmentRejected("RELEASE_FILE_MISSING")
now = datetime.now(timezone.utc)
is_exe = release.filename.lower().endswith(".exe")
if is_exe:
# Universal EXE flow: no per-installation bundle, no enrollment.json.
# The operator types the standalone install code into Setup.exe and the
# Hub resolves the installation from the code hash alone.
enr.downloaded_at = now
if enr.status not in ("ENROLLED", "REVOKED"):
enr.status = "DOWNLOADED"
record_audit(session, admin_user_id=actor_admin_id, tenant_id=enr.tenant_id, action="INSTALLER_DOWNLOADED",
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
context={"ip": client_ip(request), "release_version": release.version, "release_id": release.id})
session.commit()
filename = release.filename
return Response(content=path.read_bytes(), media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"',
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-store"})
token = enrollment.new_token()
enr.enrollment_token_hash = enrollment.token_digest(token)
enr.enrollment_token_expires_at = now + timedelta(seconds=config.settings.agent_enrollment_ttl_seconds)
enr.enrollment_used_at = None
enr.downloaded_at = now
if enr.status not in ("ENROLLED", "REVOKED"):
enr.status = "DOWNLOADED"
plan = enrollment.BundlePlan(hub_url=config.settings.base_url, enrollment_uuid=enr.enrollment_uuid, enrollment_token=token)
data = enrollment.build_bundle(path.read_bytes(), plan)
record_audit(session, admin_user_id=actor_admin_id, tenant_id=enr.tenant_id, action="PACKAGE_DOWNLOADED",
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
context={"ip": client_ip(request), "release_version": release.version, "release_id": release.id})
session.commit()
filename = f"GTIFiscalAgent-{release.version}-{enr.enrollment_uuid[:8]}.zip"
return Response(content=data, media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"',
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-store"})
def router(session_factory):
routes = APIRouter()
@routes.get("/dl/agent/{token}")
def download_by_link(token: str, request: Request):
ip = client_ip(request)
if blocked("download", ip):
raise HTTPException(429, "too many attempts")
with session_factory() as session:
candidates = session.scalars(select(AgentEnrollment).where(AgentEnrollment.download_token_hash.is_not(None))).all()
enr = next((c for c in candidates if enrollment.token_matches(token, c.download_token_hash)), None)
now = datetime.now(timezone.utc)
try:
if enr is None:
raise enrollment.EnrollmentRejected("INVALID_LINK")
if enr.status == "REVOKED":
raise enrollment.EnrollmentRejected("REVOKED")
if not enr.download_token_expires_at or _aware(enr.download_token_expires_at) < now:
raise enrollment.EnrollmentRejected("EXPIRED_LINK")
if enr.download_uses >= enr.download_max_uses:
raise enrollment.EnrollmentRejected("LINK_ALREADY_USED")
enr.download_uses += 1
if enr.download_uses >= enr.download_max_uses:
enr.download_token_hash = None # the link's last remaining use is spent: it dies immediately
return build_and_serve(session, enr, request)
except enrollment.EnrollmentRejected as rejected:
session.rollback()
record_attempt("download", ip)
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id if enr else None, action="DOWNLOAD_LINK_REJECTED",
target_type="agent_enrollment", target_id=enr.enrollment_uuid if enr else "-",
context={"ip": ip, "reason": rejected.reason}, severity="WARNING")
session.commit()
raise HTTPException(404, "invalid or expired link")
@routes.post("/v1/agents/enroll")
async def enroll(request: Request):
ip = client_ip(request)
if blocked("enroll", ip):
raise HTTPException(429, "too many attempts")
try:
payload = await request.json()
except Exception:
payload = {}
installation_id = str(payload.get("installation_id") or "")
supplied_token = payload.get("enrollment_token")
install_code = payload.get("install_code")
# Adjustment: install_code and enrollment_token are mutually exclusive —
# the EXE flow sends a code only; sending both is a request error.
if install_code and (supplied_token or payload.get("installation_id")):
record_attempt("enroll", ip)
with session_factory() as session:
record_audit(session, admin_user_id=None, tenant_id=None, action="ENROLLMENT_REJECTED",
target_type="agent_enrollment", target_id="-",
context={"ip": ip, "reason": "MIXED_CREDENTIALS"}, severity="WARNING")
session.commit()
raise HTTPException(400, "MIXED_CREDENTIALS")
with session_factory() as session:
enr = None
try:
if install_code:
enr = _resolve_by_install_code(session, ip, install_code)
else:
enr = _resolve_by_legacy_token(session, ip, installation_id, supplied_token)
if enr is None:
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
if enr.status == "REVOKED":
raise enrollment.EnrollmentRejected("REVOKED")
if enr.status == "ENROLLED":
raise enrollment.EnrollmentRejected("ALREADY_ENROLLED")
release = session.get(AgentRelease, enr.release_id)
if release is None or release.status != "PUBLISHED":
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="ENROLLMENT_STARTED",
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
context={"ip": ip, "credential_type": "install_code" if install_code else "enrollment_token"})
hostname = str(payload.get("hostname") or "")[:255]
mismatch = bool(enr.expected_hostname) and bool(hostname) and hostname.lower() != enr.expected_hostname.lower()
plain_token = secrets.token_urlsafe(32)
agent = Agent(tenant_id=enr.tenant_id, name=enr.display_name, token_hash=agent_token_hash(plain_token),
enabled=True, agent_version=str(payload.get("agent_version") or "")[:32] or None)
session.add(agent)
session.flush()
session.add(Installation(agent_id=agent.id, tenant_id=enr.tenant_id, installation_id=enr.enrollment_uuid,
display_name=enr.display_name, hostname=hostname or None,
features_configured=bool(enr.features_selected), corrector_enabled=bool(enr.features_selected and enr.feature_corrector),
enrichment_mode=(enr.feature_enrichment_mode if enr.features_selected else "DISABLED")))
enr.status = "ENROLLED"
enr.enrollment_used_at = datetime.now(timezone.utc)
enr.enrollment_token_hash = None # replay-proof even inside its TTL window
enr.install_code_hash = None # consumed: truly one-time, replay-proof
enr.install_code_expires_at = None
enr.hostname_seen = hostname or None
enr.machine_fingerprint_seen = str(payload.get("machine_fingerprint") or "")[:128] or None
enr.hostname_mismatch = mismatch
enr.agent_id = agent.id
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="ENROLLMENT_SUCCEEDED",
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
context={"ip": ip, "hostname": hostname, "hostname_mismatch": mismatch, "agent_id": agent.id})
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="AGENT_CREDENTIAL_ISSUED",
target_type="agent", target_id=str(agent.id), context={"ip": ip, "installation": enr.enrollment_uuid})
session.commit()
return {"agent_id": agent.id, "agent_token": plain_token, "tenant_id": enr.tenant_id,
"installation_id": enr.enrollment_uuid,
"hub_url": config.settings.base_url, "hostname_mismatch": mismatch}
except enrollment.EnrollmentRejected as rejected:
session.rollback()
record_attempt("enroll", ip)
reason = rejected.reason
if enr is not None:
enr.install_code_attempts = (enr.install_code_attempts or 0) + 1
record_attempt(f"enroll_code:{enr.enrollment_uuid}", ip)
session.commit()
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id if enr else None, action="ENROLLMENT_REJECTED",
target_type="agent_enrollment", target_id=enr.enrollment_uuid if enr else (installation_id or "-"),
context={"ip": ip, "reason": reason}, severity="WARNING")
session.commit()
raise HTTPException(401, reason)
def _resolve_by_install_code(session, ip, code):
"""Resolve the Installation by the install_code hash alone. Returns the
enrollment or None. Format/unknown codes and expired codes raise
EnrollmentRejected. Per-installation and per-IP rate limits apply."""
try:
digest = enrollment.install_code_digest(code)
except ValueError:
raise enrollment.EnrollmentRejected("INVALID_INSTALL_CODE")
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.install_code_hash == digest))
if enr is None:
raise enrollment.EnrollmentRejected("INVALID_INSTALL_CODE")
# Per-installation rate limit: a hostile caller guessing codes for one
# installation must burn the whole budget even after the code rotates.
if blocked(f"enroll_code:{enr.enrollment_uuid}", ip):
raise enrollment.EnrollmentRejected("RATE_LIMITED")
now = datetime.now(timezone.utc)
if not enr.install_code_expires_at or _aware(enr.install_code_expires_at) < now:
raise enrollment.EnrollmentRejected("EXPIRED")
return enr
def _resolve_by_legacy_token(session, ip, installation_id, supplied_token):
"""Legacy enrollment_token path (published ZIP bundles). Returns the
enrollment or raises EnrollmentRejected. Order preserved from the
original flow: enrolled-again must read ALREADY_ENROLLED, not
INVALID_TOKEN."""
if not enrollment.valid_uuid(installation_id):
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_id))
if enr is None:
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
if enr.status == "REVOKED":
raise enrollment.EnrollmentRejected("REVOKED")
if enr.status == "ENROLLED":
raise enrollment.EnrollmentRejected("ALREADY_ENROLLED")
now = datetime.now(timezone.utc)
if not enr.enrollment_token_expires_at or _aware(enr.enrollment_token_expires_at) < now:
raise enrollment.EnrollmentRejected("EXPIRED")
if not enrollment.token_matches(supplied_token, enr.enrollment_token_hash):
raise enrollment.EnrollmentRejected("INVALID_TOKEN")
return enr
return routes
+8
View File
@@ -0,0 +1,8 @@
"""AI semantic layer V1 — NOT a fiscal authority.
Rules frozen by the operator contract:
- AI only selects among official candidates; it never invents codes.
- Cache first: KB exact/fingerprint hits never trigger AI calls.
- No AI for origin, ST retention, or company-level decisions.
- AI_WRITE_ALLOWED=false in V1: results stay AI_CANDIDATE_VALIDATED.
"""
+55
View File
@@ -0,0 +1,55 @@
"""Offline-safe, ID-correlated provider batch execution."""
from dataclasses import dataclass, field
from .provider import ProviderTransportError
@dataclass
class BatchRunResult:
results_by_work_item_id: dict[str, dict] = field(default_factory=dict)
errors: dict[str, str] = field(default_factory=dict)
provider_calls: int = 0
retries: int = 0
usage: dict = field(default_factory=dict)
def run_batches(items, provider, batch_size: int, max_retries: int = 2, *, scenario: str | None = None) -> BatchRunResult:
if batch_size <= 0:
raise ValueError("batch_size must be positive")
if max_retries < 0:
raise ValueError("max_retries must not be negative")
result = BatchRunResult()
for start in range(0, len(items), batch_size):
batch = list(items[start:start + batch_size])
expected_ids = [str(item.work_item_id) for item in batch]
answers = None
for attempt in range(max_retries + 1):
result.provider_calls += 1
try:
kwargs = {} if scenario is None else {"scenario": scenario}
answers = provider.select_batch(batch, **kwargs)
break
except ProviderTransportError as error:
if attempt == max_retries:
for work_item_id in expected_ids:
result.errors[work_item_id] = str(error)
else:
result.retries += 1
except Exception as error:
for work_item_id in expected_ids:
result.errors[work_item_id] = str(error)
break
if answers is None:
continue
answer_ids = [str(answer.get("work_item_id")) for answer in answers]
if len(answer_ids) != len(expected_ids) or set(answer_ids) != set(expected_ids) or len(set(answer_ids)) != len(answer_ids):
error = "provider response did not contain exactly one answer per work_item_id"
for work_item_id in expected_ids:
result.errors[work_item_id] = error
continue
result.results_by_work_item_id.update({str(answer["work_item_id"]): answer for answer in answers})
result.usage = dict(getattr(provider, "usage", {}) or {})
return result
+59
View File
@@ -0,0 +1,59 @@
"""Persistent-boundary-compatible budget guard; provider invocation happens after this check."""
from dataclasses import dataclass
from datetime import datetime, timezone
from threading import Lock
@dataclass(frozen=True)
class BudgetLimits:
max_run_cost_usd: float = 0.54
max_daily_cost_usd: float = 1.00
max_monthly_cost_usd: float = 10.00
max_requests_per_day: int = 100
max_new_products_per_run: int = 750
@dataclass(frozen=True)
class BudgetDecision:
allowed: bool
reason: str
reserved_cost_usd: float
@dataclass(frozen=True)
class UsageEntry:
run_id: str
tenant: str
cost_usd: float
requests: int
new_products: int
timestamp: str
class BudgetLedger:
"""Atomic in-process ledger with an explicit persistence boundary for Hub storage."""
def __init__(self, limits: BudgetLimits):
self.limits = limits
self.entries: list[UsageEntry] = []
self._lock = Lock()
def check_and_reserve(self, run_id: str, tenant: str, cost_usd: float, requests: int, new_products: int) -> BudgetDecision:
with self._lock:
run_cost = sum(e.cost_usd for e in self.entries if e.run_id == run_id)
day = datetime.now(timezone.utc).date()
day_entries = [e for e in self.entries if e.timestamp[:10] == day.isoformat()]
month_entries = [e for e in self.entries if e.timestamp[:7] == day.strftime("%Y-%m")]
if run_cost + cost_usd > self.limits.max_run_cost_usd:
return BudgetDecision(False, "AI_BUDGET_EXHAUSTED", 0.0)
if sum(e.cost_usd for e in day_entries) + cost_usd > self.limits.max_daily_cost_usd:
return BudgetDecision(False, "AI_BUDGET_EXHAUSTED", 0.0)
if sum(e.cost_usd for e in month_entries) + cost_usd > self.limits.max_monthly_cost_usd:
return BudgetDecision(False, "AI_BUDGET_EXHAUSTED", 0.0)
if sum(e.requests for e in day_entries) + requests > self.limits.max_requests_per_day:
return BudgetDecision(False, "AI_BUDGET_EXHAUSTED", 0.0)
if sum(e.new_products for e in self.entries if e.run_id == run_id) + new_products > self.limits.max_new_products_per_run:
return BudgetDecision(False, "AI_BUDGET_EXHAUSTED", 0.0)
self.entries.append(UsageEntry(run_id, tenant, cost_usd, requests, new_products, datetime.now(timezone.utc).isoformat()))
return BudgetDecision(True, "RESERVED", cost_usd)
+30
View File
@@ -0,0 +1,30 @@
"""Pure cache and dataset-freshness policies for offline and real evaluations."""
import hashlib
import json
VALID, NEEDS_REVALIDATION, CONFLICT = "VALID", "NEEDS_REVALIDATION", "CONFLICT"
def cache_key(input_fingerprint, candidate_set_hash, model, prompt_version, dataset_versions):
"""Return a stable key covering every input that can change an answer."""
payload = {
"input_fingerprint": str(input_fingerprint),
"candidate_set_hash": str(candidate_set_hash),
"model": str(model),
"prompt_version": str(prompt_version),
"dataset_versions": dict(sorted((dataset_versions or {}).items())),
}
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def classify_knowledge_freshness(record_versions, active_versions):
"""Classify versioned knowledge without deleting or rewriting history."""
recorded = dict(record_versions or {})
active = dict(active_versions or {})
if any(key in recorded and key in active and recorded[key] != active[key] for key in recorded):
return CONFLICT
if recorded != active:
return NEEDS_REVALIDATION
return VALID
+53
View File
@@ -0,0 +1,53 @@
"""Deterministic candidate generation from the ACTIVE official datasets.
The AI never receives an open question: for every task type the Hub first builds
the official candidate list (NCM top-K by description, CEST links for the NCM,
TIPI EX rows for the NCM). Providers may only pick from this list.
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import CestEntry, CestNcmEntry, Dataset, NcmEntry, TipiEntry
from .fingerprint import token_stems
def _active_dataset(session: Session, kind: str):
return session.scalar(select(Dataset).where(Dataset.kind == kind, Dataset.status == "ACTIVE"))
def ncm_top_k(session: Session, description, k: int = 10):
"""Top-K official NCM codes by (stemmed) token overlap with the product description."""
dataset = _active_dataset(session, "NCM")
if dataset is None:
return []
stems = token_stems(description)
if not stems:
return []
scored = []
for entry in session.scalars(select(NcmEntry).where(NcmEntry.dataset_id == dataset.id)).all():
score = len(stems & token_stems(entry.description)) / len(stems)
if score > 0:
scored.append((entry.code, entry.description, round(score, 4)))
scored.sort(key=lambda row: (-row[2], row[0]))
return scored[:k]
def cest_candidates_for_ncm(session: Session, ncm: str):
dataset = _active_dataset(session, "CEST")
if dataset is None:
return []
rows = session.execute(
select(CestEntry, CestNcmEntry.ncm_code)
.join(CestNcmEntry, CestNcmEntry.cest_entry_id == CestEntry.id)
.where(CestEntry.dataset_id == dataset.id)
).all()
return list(dict.fromkeys(entry for entry, ncm_code in rows if ncm.startswith(ncm_code)))
def ex_tipi_candidates_for_ncm(session: Session, ncm: str):
dataset = _active_dataset(session, "TIPI_IPI")
if dataset is None:
return []
return list(dict.fromkeys(
entry for entry in session.scalars(select(TipiEntry).where(TipiEntry.dataset_id == dataset.id, TipiEntry.ncm == ncm, TipiEntry.ex.is_not(None))).all()
))
+53
View File
@@ -0,0 +1,53 @@
"""Metrics and error attribution for the real LLM NCM evaluation (sections 8-9)."""
def metrics(rows) -> dict:
"""rows: [{gold_in_candidates, selected, gold, confidence, invalid}]"""
total = len(rows)
correct = sum(1 for row in rows if not row["invalid"] and row["selected"] == row["gold"])
wrong = sum(1 for row in rows if not row["invalid"] and row["selected"] is not None and row["selected"] != row["gold"])
abstain = sum(1 for row in rows if not row["invalid"] and row["selected"] is None)
invalid = sum(1 for row in rows if row["invalid"])
gold_in = sum(1 for row in rows if row["gold_in_candidates"])
answered = correct + wrong
high_conf = [row for row in rows if not row["invalid"] and row["confidence"] >= 0.95 and row["selected"] is not None]
high_conf_wrong = sum(1 for row in high_conf if row["selected"] != row["gold"])
return {
"TOTAL": total,
"CORRECT": correct,
"WRONG": wrong,
"ABSTAIN": abstain,
"INVALID": invalid,
"GOLD_IN_CANDIDATES": gold_in,
"ACCURACY_ALL": round(correct / total, 4) if total else None,
"SELECTION_ACCURACY_WHEN_GOLD_AVAILABLE": round(correct / answered, 4) if answered else None,
"PRECISION_WHEN_HIGH_CONFIDENCE": round(sum(1 for row in high_conf if row["selected"] == row["gold"]) / len(high_conf), 4) if high_conf else None,
"HIGH_CONFIDENCE_WRONG_RATE": round(high_conf_wrong / len(high_conf), 4) if high_conf else None,
"ABSTENTION_RATE": round(abstain / total, 4) if total else None,
}
def attribution(rows) -> dict:
return {
"GOLD_NOT_IN_CANDIDATES": sum(1 for row in rows if not row["gold_in_candidates"]),
"GOLD_IN_CANDIDATES_BUT_WRONG": sum(1 for row in rows if row["gold_in_candidates"] and not row["invalid"] and row["selected"] is not None and row["selected"] != row["gold"]),
"GOLD_IN_CANDIDATES_BUT_ABSTAIN": sum(1 for row in rows if row["gold_in_candidates"] and not row["invalid"] and row["selected"] is None),
"INVALID_OUTPUT": sum(1 for row in rows if row["invalid"]),
}
_BUCKETS = ((">=0.99", 0.99, 1.01), ("0.95-0.9899", 0.95, 0.99), ("0.90-0.9499", 0.90, 0.95), ("0.80-0.8999", 0.80, 0.90), ("<0.80", -0.01, 0.80))
def confidence_buckets(rows) -> dict:
out = {name: {"total": 0, "correct": 0} for name, _, _ in _BUCKETS}
for row in rows:
if row["invalid"] or row["selected"] is None:
continue
for name, low, high in _BUCKETS:
if low <= row["confidence"] < high:
out[name]["total"] += 1
if row["selected"] == row["gold"]:
out[name]["correct"] += 1
break
return out
+77
View File
@@ -0,0 +1,77 @@
"""Deterministic provider responses for offline pipeline tests."""
from collections.abc import Iterable
from .llm_provider import LlmAnswerError, LlmEvalItem, validate_answer
from .provider import ProviderContractError, ProviderTransportError
SCENARIOS = frozenset({
"CORRECT", "WRONG", "ABSTAIN", "INVALID_JSON", "MISSING_ITEM",
"DUPLICATE_ITEM", "OUT_OF_CANDIDATE", "TIMEOUT", "429", "500",
})
class DeterministicFakeSemanticProvider:
"""Return repeatable answers without persistence or network access."""
name = "deterministic-fake"
model = "deterministic-fake-v1"
prompt_version = "fake-provider-v1"
def __init__(self, *, shuffle: bool = False, fail_work_item_ids: Iterable[str] = (), failure_scenario: str = "TIMEOUT"):
self.shuffle = shuffle
self.fail_work_item_ids = {str(item_id) for item_id in fail_work_item_ids}
self.failure_scenario = failure_scenario
self.calls = 0
self.usage = {"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0}
def select_batch(self, items: list[LlmEvalItem], scenario: str | None = None) -> list[dict]:
scenario = scenario or "CORRECT"
if scenario not in SCENARIOS:
raise ValueError(f"unknown fake provider scenario: {scenario}")
self.calls += 1
if self.fail_work_item_ids and len(items) == 1 and str(items[0].work_item_id) in self.fail_work_item_ids:
self._raise_transport(self.failure_scenario)
if scenario in {"TIMEOUT", "429", "500"}:
self._raise_transport(scenario)
if scenario == "INVALID_JSON":
raise LlmAnswerError("fake provider returned invalid JSON")
answers = [self._answer(item, scenario) for item in items]
if scenario == "MISSING_ITEM" and answers:
answers.pop()
elif scenario == "DUPLICATE_ITEM" and answers:
answers[-1] = dict(answers[0])
if self.shuffle:
answers.reverse()
expected_ids = [str(item.work_item_id) for item in items]
answer_ids = [str(answer.get("work_item_id")) for answer in answers]
if len(answer_ids) != len(expected_ids) or set(answer_ids) != set(expected_ids) or len(set(answer_ids)) != len(answer_ids):
raise ProviderContractError("fake provider response did not contain exactly one answer per work_item_id")
items_by_id = {str(item.work_item_id): item for item in items}
try:
return [validate_answer(answer, items_by_id[str(answer["work_item_id"])]) for answer in answers]
except (LlmAnswerError, KeyError, TypeError) as error:
raise ProviderContractError(str(error)) from error
@staticmethod
def _answer(item: LlmEvalItem, scenario: str) -> dict:
candidates = [str(candidate[0]) for candidate in item.candidates]
selected = None
abstain = scenario == "ABSTAIN"
if not abstain and candidates:
selected = "99999999" if scenario == "OUT_OF_CANDIDATE" else candidates[min(1, len(candidates) - 1) if scenario == "WRONG" else 0]
return {
"work_item_id": item.work_item_id,
"selected_ncm": selected,
"confidence": 0.0 if abstain else 0.75,
"abstain": abstain or selected is None,
"reason_short": f"fake scenario {scenario}",
}
@staticmethod
def _raise_transport(scenario: str) -> None:
if scenario not in {"TIMEOUT", "429", "500"}:
raise ValueError(f"unknown fake transport scenario: {scenario}")
raise ProviderTransportError(f"fake provider transport failure: {scenario}")
+75
View File
@@ -0,0 +1,75 @@
"""Product identity: stable fingerprints and GTIN validation.
Global identity uses ONLY stable attributes (normalized description, brand, unit,
package quantity, GTIN). Fiscal/company context (CFOP, CSOSN, CST, rate, ST, tenant)
never participates: those vary per context and must not merge distinct knowledge.
"""
import hashlib
import re
import unicodedata
from ..classification import ENGINE_VERSION # noqa: F401 (re-exported for the AI layer)
def normalize_description(value) -> str:
"""NFKD -> ascii -> A-Z0-9 tokens, space-joined (same canonical form as classification)."""
text = unicodedata.normalize("NFKD", str(value or "")).encode("ascii", "ignore").decode().upper()
return " ".join(re.sub(r"[^A-Z0-9]+", " ", text).split())
def _normalize_token(value) -> str:
return normalize_description(value)
def _normalize_quantity(value) -> str:
digits = re.sub(r"[^0-9.,]", "", str(value or ""))
if not digits:
return ""
try:
number = float(digits.replace(",", "."))
except ValueError:
return digits
return f"{number:g}"
def product_fingerprint(description, brand=None, unit=None, package_quantity=None) -> str:
parts = [_normalize_token(description), _normalize_token(brand), _normalize_token(unit), _normalize_quantity(package_quantity)]
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
def _stem(token: str) -> str:
"""Minimal Portuguese plural stemming so AGUAS matches AGUA / MINERAIS matches MINERAL."""
if len(token) > 4 and token.endswith("AIS"):
return token[:-3] + "AL"
if len(token) > 4 and token.endswith("ES"):
return token[:-2]
if len(token) > 3 and token.endswith("S"):
return token[:-1]
return token
def token_stems(value) -> set:
return {_stem(token) for token in normalize_description(value).split()}
def overlap_score(query, official) -> float:
"""Fraction of query description stems present in the official description stems."""
stems = token_stems(query)
if not stems:
return 0.0
return len(stems & token_stems(official)) / len(stems)
def validate_gtin(value) -> str | None:
"""Returns the canonical GTIN when the checksum is valid, else None.
Accepts GTIN-8/12/13/14 (EAN-8/UPC-12/EAN-13/GTIN-14)."""
digits = re.sub(r"\D", "", str(value or ""))
if len(digits) not in (8, 12, 13, 14):
return None
total = 0
for index, char in enumerate(reversed(digits)):
digit = int(char)
total += digit * (1 if index % 2 == 0 else 3)
if total % 10 != 0:
return None
return digits

Some files were not shown because too many files have changed in this diff Show More