diff --git a/.github/workflows/nt009-evidence.yml b/.github/workflows/nt009-evidence.yml new file mode 100644 index 0000000..ae0c16e --- /dev/null +++ b/.github/workflows/nt009-evidence.yml @@ -0,0 +1,59 @@ +# 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: 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 + 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..31dfc4c --- /dev/null +++ b/docs/NT009-SOURCE-AUDIT.md @@ -0,0 +1,45 @@ + + +# 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 (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 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 +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..41727d1 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,38 @@ } 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 +118,34 @@ 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 +194,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) { 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 fa48f9d..caff80e 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 */ @@ -248,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> */ @@ -258,16 +271,29 @@ private function indicators(AnnexReader $book): array { $rows = []; $seen = []; - foreach ($book->rows('INDOP') as $row) { - $rawCode = $row['G'] ?? ''; + $names = $book->sheetNames(); + if (in_array('INDOP', $names, true)) { + $sheet = 'INDOP'; + $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, + // 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[$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/src/Nt009Inspector.php b/tools/src/Nt009Inspector.php new file mode 100644 index 0000000..467a0a7 --- /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, true) 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; + } +} diff --git a/tools/tests/Nt009InspectorTest.php b/tools/tests/Nt009InspectorTest.php new file mode 100644 index 0000000..3a4d6f2 --- /dev/null +++ b/tools/tests/Nt009InspectorTest.php @@ -0,0 +1,84 @@ +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', '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" + . "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; + } +}