From 4ad3d1cc2daf238bd7cfc363887d17e5dcea2952 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:16:04 +0000 Subject: [PATCH 1/9] feat: compare NT009 indicator codes and cite original layout rows Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tools/src/DomainTableGenerator.php | 13 +++ tools/src/Nt009Inspector.php | 126 +++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tools/src/Nt009Inspector.php diff --git a/tools/src/DomainTableGenerator.php b/tools/src/DomainTableGenerator.php index fa48f9d..b73bd01 100644 --- a/tools/src/DomainTableGenerator.php +++ b/tools/src/DomainTableGenerator.php @@ -118,6 +118,19 @@ public function generate(string $annexA, string $annexB, string $annexC, ?string return $outputs; } + /** + * @return list> + */ + public function indicatorRows(string $workbookPath): array + { + $book = new AnnexReader($workbookPath); + try { + return $this->indicators($book); + } finally { + $book->close(); + } + } + /** * @param array $outputs */ diff --git a/tools/src/Nt009Inspector.php b/tools/src/Nt009Inspector.php new file mode 100644 index 0000000..78bf9f8 --- /dev/null +++ b/tools/src/Nt009Inspector.php @@ -0,0 +1,126 @@ + + */ + public function inspect(string $annexVI, string $annexVII, string $legacyTable): array + { + $reader = new AnnexReader($annexVI); + $layout = null; + foreach ($reader->sheetNames() as $name) { + if (str_contains(mb_strtoupper($name, 'UTF-8'), 'LEIAUTE DPS')) { + $layout = $name; + break; + } + } + if ($layout === null) { + throw new \UnexpectedValueException('NT009 Annex VI DPS layout worksheet is missing'); + } + try { + $matches = []; + foreach ($reader->rows($layout) as $index => $columns) { + foreach ($columns as $value) { + if (in_array($value, [ + 'IBSCBS', 'cIndOp', 'CST', 'cClassTrib', 'gIBSCBS', + 'finNFSe', 'indDest', 'dest', 'regApIBSCBSSN', + 'tpNFSeDebito', 'tpNFSeCredito', 'gPgtoVinc', + 'gIBSCBSAjuste', 'indDoacao', 'indZFMALC', + ], true)) { + $matches[] = [ + 'worksheet' => $layout, + 'row' => $index + 1, + 'tag' => $value, + 'raw_columns' => $columns, + ]; + } + } + } + if ($matches === []) { + throw new \UnexpectedValueException('No NT009 DPS field rows found'); + } + } finally { + $reader->close(); + } + + $vii = (new DomainTableGenerator())->indicatorRows($annexVII); + $old = $this->readTsv($legacyTable); + $latest = []; + foreach ($vii as $row) { + if (isset($latest[$row[0]])) { + throw new \UnexpectedValueException("Repeated Annex VII indicator {$row[0]}"); + } + $latest[$row[0]] = [$row[1], $row[2]]; + } + $added = []; + $changed = []; + $removed = []; + foreach ($latest as $code => $fields) { + if (!isset($old[$code])) { + $added[$code] = $fields; + } elseif ($old[$code] !== $fields) { + $changed[$code] = ['old' => $old[$code], 'new' => $fields]; + } + } + foreach ($old as $code => $fields) { + if (!isset($latest[$code])) { + $removed[$code] = $fields; + } + } + ksort($added); + ksort($changed); + ksort($removed); + + return [ + 'note' => 'Source-level audit only. Validate conditionality, XSD, environment and effective date before emission.', + 'layout_rows' => $matches, + 'annex_vii_count' => count($latest), + 'previous_count' => count($old), + 'added_codes' => $added, + 'changed_codes' => $changed, + 'removed_codes' => $removed, + 'annex_vii_rows' => $latest, + ]; + } + + /** + * @return array> + */ + private function readTsv(string $filename): array + { + $lines = @file($filename, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + throw new \RuntimeException('Missing previous official domain snapshot'); + } + $result = []; + foreach ($lines as $line) { + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + $fields = explode("\t", $line); + if (count($fields) !== 3 || !preg_match('/^[0-9]{6}$/D', $fields[0])) { + throw new \UnexpectedValueException('Malformed versioned legacy indicator snapshot'); + } + if (isset($result[$fields[0]])) { + throw new \UnexpectedValueException('Duplicate legacy indicator code'); + } + $result[$fields[0]] = [$fields[1], $fields[2]]; + } + + return $result; + } +} From 257a76865ddfc876bf03daefe1e8e14419eca31c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:16:46 +0000 Subject: [PATCH 2/9] feat: expose PHP NT009 source audit with optional official downloads Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/nt009-evidence.yml | 48 ++++++++++++++++++++++ docs/NT009-SOURCE-AUDIT.md | 40 +++++++++++++++++++ tools/bin/domains.php | 59 +++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/nt009-evidence.yml create mode 100644 docs/NT009-SOURCE-AUDIT.md diff --git a/.github/workflows/nt009-evidence.yml b/.github/workflows/nt009-evidence.yml new file mode 100644 index 0000000..adb2422 --- /dev/null +++ b/.github/workflows/nt009-evidence.yml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: NT009 Source Evidence + +on: + pull_request: + paths: + - "tools/src/Nt009Inspector.php" + - "tools/bin/domains.php" + - "tools/tests/Nt009InspectorTest.php" + - ".github/workflows/nt009-evidence.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + optional-official-evidence: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up PHP 8.2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: "8.2" + extensions: dom, mbstring, zip, gd, xml, xmlreader, xmlwriter, simplexml, fileinfo + coverage: none + - name: Install isolated domain tooling + run: composer --working-dir=vendor-bin/domains install --prefer-dist --no-progress --no-interaction + - name: Verify original XLSX hashes and compare official NT009 data + run: | + php tools/bin/domains.php audit \ + --download-dir="${{ runner.temp }}/nt009" \ + --report="${{ runner.temp }}/nt009-comparison.json" + - name: Retain full evidence for manual review + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nt009-official-source-evidence + path: | + ${{ runner.temp }}/nt009-comparison.json + ${{ runner.temp }}/nt009/*.xlsx + if-no-files-found: warn + retention-days: 30 diff --git a/docs/NT009-SOURCE-AUDIT.md b/docs/NT009-SOURCE-AUDIT.md new file mode 100644 index 0000000..81d9b62 --- /dev/null +++ b/docs/NT009-SOURCE-AUDIT.md @@ -0,0 +1,40 @@ + + +# NT009 v1.01: inspect published contracts without activating them + +The official [RTC portal](https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/rtc) +publishes **Anexo VI v1.04.01** (DPS/NFS-e layout and fiscal business rules) +and **Anexo VII v1.03.00** (IBS/CBS operation indicators). The publication +does not by itself establish a deployed production schema or effective date. + +A strictly read-only source review uses PHP/PhpSpreadsheet from the isolated +`vendor-bin/domains` Composer project: + +```sh +composer domains:install +composer domains:audit -- --download-dir=/tmp/nfse-nt009 --report=/tmp/nt009-report.json +# Or use previously downloaded, independently verified official files: +composer domains:audit -- --annex-vi=/path/to/VI.xlsx --annex-vii=/path/to/VII.xlsx --report=/tmp/nt009-report.json +``` + +The audit first validates both workbook bytes against SHA-256 values independently +observed on **2026-10-05**, not government-issued signatures. It then reads +the layout worksheet rows with literal field names and compares the complete +indicator-code/description/location triplets with the existing Anexo C v1.01 +snapshot. The JSON report lists additions, removals and text changes, as well +as original cell coordinates for relevant NT009 layout entries. + +**Interpretation boundary:** exported raw layout rows do *not* automatically +encode complex field conditions, business-rule activation or authorization by +the SEFIN API. The output is audit evidence, not a replacement XSD or a +compliance declaration. Neither Anexo VIII consultative correlations nor new +NT009 fields are activated in production from this tool. The existing +`DpsData`, `XmlBuilder`, `OfficialDomainCatalog` and XSD validators remain +unchanged until a separately reviewed implementation validates the full +normative conditions and deployment dates. + +If official sources cannot be downloaded, the auxiliary evidence GitHub +workflow may be inconclusive; the offline PHPUnit suite remains deterministic. diff --git a/tools/bin/domains.php b/tools/bin/domains.php index 0662449..f8db1c8 100644 --- a/tools/bin/domains.php +++ b/tools/bin/domains.php @@ -8,6 +8,7 @@ use LibreCodeCoop\NfsePHP\Tools\AnnexReader; use LibreCodeCoop\NfsePHP\Tools\DomainTableGenerator; +use LibreCodeCoop\NfsePHP\Tools\Nt009Inspector; use LibreCodeCoop\NfsePHP\Tools\PortalIndexDiscovery; use LibreCodeCoop\NfsePHP\Tools\SourceVerifier; @@ -69,6 +70,36 @@ } echo "Verified " . count($outputs) . " deterministic snapshots\n"; } elseif ($command === 'audit') { + if (isset($options['download-dir'])) { + $destination = $options['download-dir']; + if (!is_dir($destination) && !mkdir($destination, 0o700, true)) { + throw new RuntimeException('Cannot create official document download directory'); + } + $manifest = json_decode( + (string) file_get_contents($root . '/resources/domains/sources.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + foreach (['annex-vi-nt009' => 'annex-vi', 'annex-vii-nt009' => 'annex-vii'] as $id => $name) { + $source = null; + foreach ($manifest['sources'] as $entry) { + if ($entry['id'] === $id) { + $source = $entry; + break; + } + } + if ($source === null || !str_starts_with($source['url'], + 'https://www.gov.br/nfse/')) { + throw new RuntimeException('Missing or non-official source: ' . $id); + } + $file = $destination . '/' . $id . '.xlsx'; + if (file_put_contents($file, SourceVerifier::fetchOfficial($source['url'])) === false) { + throw new RuntimeException('Failed to save official source: ' . $id); + } + $options[$name] = $file; + } + } foreach (['annex-vi', 'annex-vii'] as $name) { if (!isset($options[$name])) { throw new InvalidArgumentException("Missing --{$name}=PATH"); @@ -85,6 +116,32 @@ echo "{$name}: {$hash}; sheets: " . implode(', ', $reader->sheetNames()) . "\n"; $reader->close(); } + $comparison = (new Nt009Inspector())->inspect( + $options['annex-vi'], + $options['annex-vii'], + $root . '/resources/domains/indicadores-operacao-ibscbs-v1.01.tsv' + ); + $json = json_encode($comparison, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE + | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; + if (isset($options['report'])) { + if (file_put_contents($options['report'], $json) === false) { + throw new RuntimeException('Unable to save NT009 comparison'); + } + } + echo 'NT009 Anexo VII: ' . $comparison['annex_vii_count'] + . ' indicators; added=' . count($comparison['added_codes']) + . ', changed=' . count($comparison['changed_codes']) + . ', removed=' . count($comparison['removed_codes']) . "\n"; + if (getenv('GITHUB_STEP_SUMMARY') !== false) { + file_put_contents((string) getenv('GITHUB_STEP_SUMMARY'), + "## NT009 source comparison\n\n" . 'Annex VII: ' + . $comparison['annex_vii_count'] . ' indicators, added: ' + . count($comparison['added_codes']) . ', changed: ' + . count($comparison['changed_codes']) . ', removed: ' + . count($comparison['removed_codes']) + . "\n\nA published annex does not establish production applicability.\n", + FILE_APPEND); + } } elseif ($command === 'watch') { $manifest = $options['manifest'] ?? $root . '/resources/domains/sources.json'; $verifier = new SourceVerifier(); @@ -133,7 +190,7 @@ throw new InvalidArgumentException( "Usage: php tools/bin/domains.php {generate|check|audit|watch} --name=PATH\n" . "generate/check: --annex-a= --annex-b= --annex-c= --output= [--annex-vii= --expected-vii=N]\n" - . "audit: --annex-vi= --annex-vii=\nwatch: [--manifest= --report=]\n", + . "audit: --annex-vi= --annex-vii= [--report=] OR --download-dir= [--report=]\nwatch: [--manifest= --report=]\n", ); } } catch (Throwable $error) { From 1de5c688f76718c37667609c80f57cbf16f0a007 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:17:06 +0000 Subject: [PATCH 3/9] test: verify NT009 raw layout evidence and complete indicator diffs Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tools/tests/Nt009InspectorTest.php | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tools/tests/Nt009InspectorTest.php diff --git a/tools/tests/Nt009InspectorTest.php b/tools/tests/Nt009InspectorTest.php new file mode 100644 index 0000000..44c6665 --- /dev/null +++ b/tools/tests/Nt009InspectorTest.php @@ -0,0 +1,83 @@ +directory = sys_get_temp_dir() . '/nfse-nt009-' . bin2hex(random_bytes(8)); + mkdir($this->directory, 0o700); + } + + protected function tearDown(): void + { + foreach (glob($this->directory . '/*') ?: [] as $path) { + unlink($path); + } + rmdir($this->directory); + } + + public function testComparesCompleteTripletsAndPreservesLayoutRowEvidence(): void + { + $vi = $this->workbook('vi.xlsx', 'LEIAUTE DPS_NFS-e - RT', [ + ['Tag', 'Cardinalidade', 'Condição'], + ['IBSCBS', '0-1', 'condicional'], + ['cIndOp', '0-1', 'condicional'], + ['CST', '1-1', 'dentro de trib'], + ]); + $vii = $this->workbook('vii.xlsx', 'INDOP', [ + ['', '', '', '', '', 'Característica', 'Código', 'Local'], + ['', '', '', '', '', 'Descrição alterada', '020101', 'Imóvel'], + ['', '', '', '', '', 'Serviço novo', '040101', 'Evento'], + ]); + $legacy = $this->directory . '/previous.tsv'; + file_put_contents($legacy, "# Previous production snapshot\n" + . "020101\tDescrição antiga\tImóvel\n" + . "030101\tOutro serviço\tPessoa\n"); + + $result = (new Nt009Inspector())->inspect($vi, $vii, $legacy); + self::assertSame(2, $result['annex_vii_count']); + self::assertSame(2, $result['previous_count']); + self::assertSame(['Serviço novo', 'Evento'], $result['added_codes']['040101']); + self::assertSame(['Outro serviço', 'Pessoa'], $result['removed_codes']['030101']); + self::assertSame(['Descrição antiga', 'Imóvel'], $result['changed_codes']['020101']['old']); + self::assertSame(['Descrição alterada', 'Imóvel'], $result['changed_codes']['020101']['new']); + self::assertSame('cIndOp', $result['layout_rows'][1]['tag']); + self::assertSame('0-1', $result['layout_rows'][1]['raw_columns']['B']); + } + + /** + * @param list> $rows + */ + private function workbook(string $basename, string $sheetName, array $rows): string + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle($sheetName); + foreach ($rows as $i => $row) { + foreach ($row as $j => $value) { + if ($value !== '') { + $sheet->setCellValue([$j + 1, $i + 1], $value); + } + } + } + $file = $this->directory . '/' . $basename; + (new Xlsx($spreadsheet))->save($file); + $spreadsheet->disconnectWorksheets(); + + return $file; + } +} From 3b5b3ce811ca77f00327e344611f61d0666f06f8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:18:45 +0000 Subject: [PATCH 4/9] fix: handle official NT009 workbook sheets and ignore helper formulas safely Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- docs/NT009-SOURCE-AUDIT.md | 6 ++++-- tools/bin/domains.php | 12 ++++++++---- tools/src/AnnexReader.php | 6 +++++- tools/src/DomainTableGenerator.php | 8 +++++++- tools/src/Nt009Inspector.php | 2 +- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/NT009-SOURCE-AUDIT.md b/docs/NT009-SOURCE-AUDIT.md index 81d9b62..9696c74 100644 --- a/docs/NT009-SOURCE-AUDIT.md +++ b/docs/NT009-SOURCE-AUDIT.md @@ -22,9 +22,11 @@ composer domains:audit -- --annex-vi=/path/to/VI.xlsx --annex-vii=/path/to/VII.x The audit first validates both workbook bytes against SHA-256 values independently observed on **2026-10-05**, not government-issued signatures. It then reads -the layout worksheet rows with literal field names and compares the complete +the layout worksheet rows with literal field names (ignoring, not evaluating, +spreadsheet helper formulas) and compares the complete indicator-code/description/location triplets with the existing Anexo C v1.01 -snapshot. The JSON report lists additions, removals and text changes, as well +snapshot. Both known official indicator worksheet names (`INDOP` and `cIndOp Public`) are +supported; unknown sheet names fail explicitly. The JSON report lists additions, removals and text changes, as well as original cell coordinates for relevant NT009 layout entries. **Interpretation boundary:** exported raw layout rows do *not* automatically diff --git a/tools/bin/domains.php b/tools/bin/domains.php index f8db1c8..41727d1 100644 --- a/tools/bin/domains.php +++ b/tools/bin/domains.php @@ -89,8 +89,10 @@ break; } } - if ($source === null || !str_starts_with($source['url'], - 'https://www.gov.br/nfse/')) { + if ($source === null || !str_starts_with( + $source['url'], + 'https://www.gov.br/nfse/' + )) { throw new RuntimeException('Missing or non-official source: ' . $id); } $file = $destination . '/' . $id . '.xlsx'; @@ -133,14 +135,16 @@ . ', changed=' . count($comparison['changed_codes']) . ', removed=' . count($comparison['removed_codes']) . "\n"; if (getenv('GITHUB_STEP_SUMMARY') !== false) { - file_put_contents((string) getenv('GITHUB_STEP_SUMMARY'), + file_put_contents( + (string) getenv('GITHUB_STEP_SUMMARY'), "## NT009 source comparison\n\n" . 'Annex VII: ' . $comparison['annex_vii_count'] . ' indicators, added: ' . count($comparison['added_codes']) . ', changed: ' . count($comparison['changed_codes']) . ', removed: ' . count($comparison['removed_codes']) . "\n\nA published annex does not establish production applicability.\n", - FILE_APPEND); + FILE_APPEND + ); } } elseif ($command === 'watch') { $manifest = $options['manifest'] ?? $root . '/resources/domains/sources.json'; diff --git a/tools/src/AnnexReader.php b/tools/src/AnnexReader.php index 62ecca1..3cc5046 100644 --- a/tools/src/AnnexReader.php +++ b/tools/src/AnnexReader.php @@ -42,7 +42,7 @@ public function sheetNames(): array /** * @return list> */ - public function rows(string $sheetName): array + public function rows(string $sheetName, bool $skipFormulaCells = false): array { $sheet = null; foreach ($this->workbook->getWorksheetIterator() as $candidate) { @@ -66,6 +66,10 @@ public function rows(string $sheetName): array $name = Coordinate::stringFromColumnIndex($i); $cell = $sheet->getCell($name . $r); if ($cell->getDataType() === DataType::TYPE_FORMULA) { + if ($skipFormulaCells) { + // Spreadsheet helper formula: do not execute or trust its cached result. + continue; + } throw new \UnexpectedValueException("Formula not allowed in {$sheetName}!{$name}{$r}"); } $raw = $cell->getFormattedValue(); diff --git a/tools/src/DomainTableGenerator.php b/tools/src/DomainTableGenerator.php index b73bd01..92e1fea 100644 --- a/tools/src/DomainTableGenerator.php +++ b/tools/src/DomainTableGenerator.php @@ -271,7 +271,13 @@ private function indicators(AnnexReader $book): array { $rows = []; $seen = []; - foreach ($book->rows('INDOP') as $row) { + $names = $book->sheetNames(); + $sheet = in_array('INDOP', $names, true) ? 'INDOP' + : (in_array('cIndOp Public', $names, true) ? 'cIndOp Public' : null); + if ($sheet === null) { + throw new \UnexpectedValueException('Missing indicator worksheet INDOP or cIndOp Public'); + } + foreach ($book->rows($sheet) as $row) { $rawCode = $row['G'] ?? ''; $code = self::digits($rawCode); if (strlen($code) !== 6 || !preg_match('/^[0-9.]+$/D', $rawCode)) { diff --git a/tools/src/Nt009Inspector.php b/tools/src/Nt009Inspector.php index 78bf9f8..467a0a7 100644 --- a/tools/src/Nt009Inspector.php +++ b/tools/src/Nt009Inspector.php @@ -33,7 +33,7 @@ public function inspect(string $annexVI, string $annexVII, string $legacyTable): } try { $matches = []; - foreach ($reader->rows($layout) as $index => $columns) { + foreach ($reader->rows($layout, true) as $index => $columns) { foreach ($columns as $value) { if (in_array($value, [ 'IBSCBS', 'cIndOp', 'CST', 'cClassTrib', 'gIBSCBS', From 2280c662cca72f7f61aec77440763fa94c4f3361 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:20:01 +0000 Subject: [PATCH 5/9] test: inspect genuine Annex VII column positions before extraction Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/nt009-evidence.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/nt009-evidence.yml b/.github/workflows/nt009-evidence.yml index adb2422..c345a34 100644 --- a/.github/workflows/nt009-evidence.yml +++ b/.github/workflows/nt009-evidence.yml @@ -36,6 +36,19 @@ jobs: php tools/bin/domains.php audit \ --download-dir="${{ runner.temp }}/nt009" \ --report="${{ runner.temp }}/nt009-comparison.json" + - name: Show Annex VII workbook columns to correct source mapping + if: always() + run: | + php -r ' + require "vendor-bin/domains/vendor/autoload.php"; + $file = getenv("RUNNER_TEMP") . "/nt009/annex-vii-nt009.xlsx"; + if (!is_file($file)) { exit(0); } + $r = new LibreCodeCoop\\NfsePHP\\Tools\\AnnexReader($file); + foreach (array_slice($r->rows("cIndOp Public"), 0, 32) as $i => $row) { + echo ($i + 1) . ": " . json_encode($row, JSON_UNESCAPED_UNICODE) . PHP_EOL; + } + $r->close(); + ' - name: Retain full evidence for manual review if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 From b96782f4eb63beb4b4f9477d281d2e124b0babe3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:20:19 +0000 Subject: [PATCH 6/9] fix: use PHP namespace syntax in Annex VII diagnostics Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/nt009-evidence.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nt009-evidence.yml b/.github/workflows/nt009-evidence.yml index c345a34..ac110d1 100644 --- a/.github/workflows/nt009-evidence.yml +++ b/.github/workflows/nt009-evidence.yml @@ -43,7 +43,7 @@ jobs: require "vendor-bin/domains/vendor/autoload.php"; $file = getenv("RUNNER_TEMP") . "/nt009/annex-vii-nt009.xlsx"; if (!is_file($file)) { exit(0); } - $r = new LibreCodeCoop\\NfsePHP\\Tools\\AnnexReader($file); + $r = new LibreCodeCoop\NfsePHP\Tools\AnnexReader($file); foreach (array_slice($r->rows("cIndOp Public"), 0, 32) as $i => $row) { echo ($i + 1) . ": " . json_encode($row, JSON_UNESCAPED_UNICODE) . PHP_EOL; } From 1ab4c0e5ed389162f5c352d6a06b719ef1a24c13 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:23:13 +0000 Subject: [PATCH 7/9] fix: map actual NT009 Anexo VII columns A C and D without inference Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/nt009-evidence.yml | 13 ------------- docs/NT009-SOURCE-AUDIT.md | 5 ++++- tools/src/DomainTableGenerator.php | 21 ++++++++++++++------- tools/tests/Nt009InspectorTest.php | 9 +++++---- 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/.github/workflows/nt009-evidence.yml b/.github/workflows/nt009-evidence.yml index ac110d1..adb2422 100644 --- a/.github/workflows/nt009-evidence.yml +++ b/.github/workflows/nt009-evidence.yml @@ -36,19 +36,6 @@ jobs: php tools/bin/domains.php audit \ --download-dir="${{ runner.temp }}/nt009" \ --report="${{ runner.temp }}/nt009-comparison.json" - - name: Show Annex VII workbook columns to correct source mapping - if: always() - run: | - php -r ' - require "vendor-bin/domains/vendor/autoload.php"; - $file = getenv("RUNNER_TEMP") . "/nt009/annex-vii-nt009.xlsx"; - if (!is_file($file)) { exit(0); } - $r = new LibreCodeCoop\NfsePHP\Tools\AnnexReader($file); - foreach (array_slice($r->rows("cIndOp Public"), 0, 32) as $i => $row) { - echo ($i + 1) . ": " . json_encode($row, JSON_UNESCAPED_UNICODE) . PHP_EOL; - } - $r->close(); - ' - name: Retain full evidence for manual review if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/docs/NT009-SOURCE-AUDIT.md b/docs/NT009-SOURCE-AUDIT.md index 9696c74..31dfc4c 100644 --- a/docs/NT009-SOURCE-AUDIT.md +++ b/docs/NT009-SOURCE-AUDIT.md @@ -26,7 +26,10 @@ the layout worksheet rows with literal field names (ignoring, not evaluating, spreadsheet helper formulas) and compares the complete indicator-code/description/location triplets with the existing Anexo C v1.01 snapshot. Both known official indicator worksheet names (`INDOP` and `cIndOp Public`) are -supported; unknown sheet names fail explicitly. The JSON report lists additions, removals and text changes, as well +supported; unknown sheet names fail explicitly. The NT009 sheet identifies A as the +six-digit indicator, C as the supply characteristic and D as the location in the DFe. +Its column J is a separate NFS-e IBS-incidence classification and must not be +substituted for the official DFe location text. The JSON report lists additions, removals and text changes, as well as original cell coordinates for relevant NT009 layout entries. **Interpretation boundary:** exported raw layout rows do *not* automatically diff --git a/tools/src/DomainTableGenerator.php b/tools/src/DomainTableGenerator.php index 92e1fea..9850505 100644 --- a/tools/src/DomainTableGenerator.php +++ b/tools/src/DomainTableGenerator.php @@ -272,21 +272,28 @@ private function indicators(AnnexReader $book): array $rows = []; $seen = []; $names = $book->sheetNames(); - $sheet = in_array('INDOP', $names, true) ? 'INDOP' - : (in_array('cIndOp Public', $names, true) ? 'cIndOp Public' : null); - if ($sheet === null) { + if (in_array('INDOP', $names, true)) { + $sheet = 'INDOP'; + $columns = ['code' => 'G', 'characteristic' => 'F', 'location' => 'H']; + } elseif (in_array('cIndOp Public', $names, true)) { + $sheet = 'cIndOp Public'; + // Official NT009 v1.03.00: A=Código indOp, C=Característica, + // D=Local do fornecimento a ser identificado no DFe. + // J is NFSeLocIncidIBS (an additional classification), NOT the local description. + $columns = ['code' => 'A', 'characteristic' => 'C', 'location' => 'D']; + } else { throw new \UnexpectedValueException('Missing indicator worksheet INDOP or cIndOp Public'); } foreach ($book->rows($sheet) as $row) { - $rawCode = $row['G'] ?? ''; + $rawCode = $row[$columns['code']] ?? ''; $code = self::digits($rawCode); if (strlen($code) !== 6 || !preg_match('/^[0-9.]+$/D', $rawCode)) { continue; } - $characteristic = $row['F'] ?? ''; - $location = $row['H'] ?? ''; + $characteristic = $row[$columns['characteristic']] ?? ''; + $location = $row[$columns['location']] ?? ''; if ($characteristic === '' || $location === '') { - throw new \UnexpectedValueException("Incomplete operation indicator {$code} in INDOP F/G/H"); + throw new \UnexpectedValueException("Incomplete operation indicator {$code} in {$sheet}"); } $this->appendCode($rows, $seen, [$code, $characteristic, $location]); } diff --git a/tools/tests/Nt009InspectorTest.php b/tools/tests/Nt009InspectorTest.php index 44c6665..3a4d6f2 100644 --- a/tools/tests/Nt009InspectorTest.php +++ b/tools/tests/Nt009InspectorTest.php @@ -38,10 +38,11 @@ public function testComparesCompleteTripletsAndPreservesLayoutRowEvidence(): voi ['cIndOp', '0-1', 'condicional'], ['CST', '1-1', 'dentro de trib'], ]); - $vii = $this->workbook('vii.xlsx', 'INDOP', [ - ['', '', '', '', '', 'Característica', 'Código', 'Local'], - ['', '', '', '', '', 'Descrição alterada', '020101', 'Imóvel'], - ['', '', '', '', '', 'Serviço novo', '040101', 'Evento'], + $vii = $this->workbook('vii.xlsx', 'cIndOp Public', [ + ['Código indOp', 'Tipo de operação', 'Característica do fornecimento', + 'Local do fornecimento a ser identificado no DFe'], + ['020101', 'Bem imóvel', 'Descrição alterada', 'Imóvel'], + ['040101', 'Eventos', 'Serviço novo', 'Evento'], ]); $legacy = $this->directory . '/previous.tsv'; file_put_contents($legacy, "# Previous production snapshot\n" From cde7ce72b63feacccf3fa1ecb9fa38870925bfc9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:23:54 +0000 Subject: [PATCH 8/9] fix: align Anexo C and Anexo VII with their distinct official column layouts Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tools/src/DomainTableGenerator.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/src/DomainTableGenerator.php b/tools/src/DomainTableGenerator.php index 9850505..caff80e 100644 --- a/tools/src/DomainTableGenerator.php +++ b/tools/src/DomainTableGenerator.php @@ -261,9 +261,9 @@ private function codeDescriptions(AnnexReader $book, string $sheet, int $length) } /** - * For the official Anexo C and VII "INDOP" sheet, column F is the supply - * characteristic, G is cIndOp and H is the location. Never rank free-text - * fields by length: that can silently misassign tax information. + * For the official Anexo C "INDOP" sheet, D is the supply + * characteristic, G is cIndOp and H is the location. NT009's public + * Annex VII sheet instead uses A/C/D; never infer columns by text length. * * @return list> */ @@ -274,7 +274,7 @@ private function indicators(AnnexReader $book): array $names = $book->sheetNames(); if (in_array('INDOP', $names, true)) { $sheet = 'INDOP'; - $columns = ['code' => 'G', 'characteristic' => 'F', 'location' => 'H']; + $columns = ['code' => 'G', 'characteristic' => 'D', 'location' => 'H']; } elseif (in_array('cIndOp Public', $names, true)) { $sheet = 'cIndOp Public'; // Official NT009 v1.03.00: A=Código indOp, C=Característica, From 3e9f55ccc0ab52238a616fd0ec46cc434c763631 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:26:27 +0000 Subject: [PATCH 9/9] test: capture complete verified Anexo VII code and description triplets Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/nt009-evidence.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/nt009-evidence.yml b/.github/workflows/nt009-evidence.yml index adb2422..ae0c16e 100644 --- a/.github/workflows/nt009-evidence.yml +++ b/.github/workflows/nt009-evidence.yml @@ -36,6 +36,17 @@ jobs: php tools/bin/domains.php audit \ --download-dir="${{ runner.temp }}/nt009" \ --report="${{ runner.temp }}/nt009-comparison.json" + - name: Export exact official indicator rows for review + run: | + php -r ' + require "vendor-bin/domains/vendor/autoload.php"; + $r = new LibreCodeCoop\NfsePHP\Tools\DomainTableGenerator(); + $rows = $r->indicatorRows(getenv("RUNNER_TEMP") . "/nt009/annex-vii-nt009.xlsx"); + if (count($rows) !== 40) { throw new RuntimeException("Unexpected official VII cardinality"); } + echo "OFFICIAL_NT009_INDICATORS_BEGIN\n"; + foreach ($rows as $row) { echo implode("\t", $row) . "\n"; } + echo "OFFICIAL_NT009_INDICATORS_END\n"; + ' - name: Retain full evidence for manual review if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2