diff --git a/.github/workflows/nt009-evidence.yml b/.github/workflows/nt009-evidence.yml new file mode 100644 index 0000000..dd8537b --- /dev/null +++ b/.github/workflows/nt009-evidence.yml @@ -0,0 +1,65 @@ +# 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: Verify all 40 published indicators match the versioned TSV + run: | + php -r ' + require "vendor-bin/domains/vendor/autoload.php"; + $generator = new LibreCodeCoop\NfsePHP\Tools\DomainTableGenerator(); + $rows = $generator->indicatorRows(getenv("RUNNER_TEMP") . "/nt009/annex-vii-nt009.xlsx"); + $path = "resources/domains/indicadores-operacao-ibscbs-v1.03.00.tsv"; + $snapshot = array_values(array_filter( + explode("\n", trim(file_get_contents($path))), + static fn (string $line): bool => $line !== "" && !str_starts_with($line, "#") + )); + $source = array_map(static fn (array $row): string => implode("\t", $row), $rows); + if (count($source) !== 40 || $source !== $snapshot) { + throw new UnexpectedValueException("Versioned Anexo VII differs from the official signed-off source"); + } + echo "Official Anexo VII snapshot verified: 40/40 exact code/characteristic/location triplets\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..270984a --- /dev/null +++ b/docs/NT009-SOURCE-AUDIT.md @@ -0,0 +1,58 @@ + + +# 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. + +## Frozen Annex VII versioned snapshot + +`resources/domains/indicadores-operacao-ibscbs-v1.03.00.tsv` records **all 40** +rows read directly from the official `cIndOp Public` sheet (A/C/D) on +2026-10-08, sourced from the immutable XLSX with SHA-256 +`95f30a44ee94adeea57fb92f3a0337b1e62fdc106f393e73894087889be9e5aa`. +The auxiliary CI audit compares all 40 triplets byte-for-byte against the +live official workbook. It is a **separate** snapshot: the existing production +`OfficialDomainCatalog::operationIndicator()` deliberately continues to read +Anexo C v1.01 until the new fiscal contract's effective date and schema are +confirmed. The Annex VII table contains codes applicable to multiple fiscal +document types; no unverified `indNFSe` restrictions have been added. diff --git a/resources/domains/indicadores-operacao-ibscbs-v1.03.00.tsv b/resources/domains/indicadores-operacao-ibscbs-v1.03.00.tsv new file mode 100644 index 0000000..5c3c2ae --- /dev/null +++ b/resources/domains/indicadores-operacao-ibscbs-v1.03.00.tsv @@ -0,0 +1,42 @@ +# Indicadores de operação IBS/CBS — ANEXO_VII v1.03.00 (NT009 v1.01) +# Formato: cIndOpcaracterística do fornecimentolocal a identificar no DF-e +010101 Presencial, com retirada no estabelecimento do fornecedor Estabelecimento do fornecedor como o local da entrega ou disponibilização do bem ao destinatário +010102 Presencial, com retirada fora do estabelecimento do fornecedor Local da realização da operação e sua retirada +010103 Não presencial, com entrega ou disponibilização em endereço fornecido Endereço do destinatário fornecido para entrega ou disponibilização (assim considerado o destino final indicado pelo adquirente) +010104 Licitação promovida pelo poder público de bem apreendido ou abandonado ou leilão judicial Local onde se encontra o bem móvel material +010105 Constatação de irregularidade pela falta de documentação fiscal ou pelo acobertamento por documentação inidôneo Local onde se encontra o bem móvel material (Local da retirada) +010106 Locação de bem móvel material em aquisições realizadas de forma centralizada por contribuinte sujeito ao regime regular do IBS e da CBS que possui mais de um estabelecimento e que não estejam sujeitas a vedação à apropriação de créditos Local do domicílio principal do adquirente (estabelecimento matriz) +010201 Entrega ou disponibilização do bem Domicílio principal do destinatário +010202 Entrega ou disponibilização do bem Domicílio principal do destinatário +020101 Realização de operações com bem imóvel, bem imaterial, inclusive direito, relacionado a bem imóvel Localidade do imóvel +020102 Realização de operações com bem imóvel, bem imaterial, inclusive direito, relacionado a bem imóvel de Características Especiais (BICE) Localidade do imóvel (BICE) +020201 Execução de serviços diversos prestados fisicamente sobre bem imóvel Localidade do imóvel +020202 Execução de serviços sobre Bens Imóveis de Características Especiais (BICE) Localidade do imóvel (BICE) +020301 Execução dos serviços de administração e intermediação de bens imóveis Localidade do imóvel +020401 Execução de serviços sobre Bens Imóveis de Características Especiais (BICE) Localidade do imóvel (BICE) +030101 Execução de serviços diversos exclusivamente prestados fisicamente sobre a pessoa ou integralmente fruídos presencialmente por pessoa física Estabelecimento do fornecedor como local da prestação +030102 Execução de serviços diversos exclusivamente prestados fisicamente sobre a pessoa ou integralmente fruídos presencialmente por pessoa física Local da prestação do serviço diverso do estabelecimento do fornecedor +040101 Execução de serviços de planejamento, organização e administração de feiras, exposições, congressos, espetáculos, exibições e congêneres Local do evento a que se refere o serviço +050101 Execução de serviços diversos prestados fisicamente sobre bem móvel material Estabelecimento do fornecedor como local da prestação +050102 Execução de serviços diversos prestados fisicamente sobre bem móvel material Local da prestação do serviço diverso do estabelecimento do fornecedor +050201 Execução de serviços portuários Local do porto como local da prestação +060101 Execução de serviços de transporte de passageiros Local de início do transporte +070101 Execução de serviço de transporte de carga Endereço fornecido para entrega do bem +070102 Execução de serviço de transporte de carga Local da retirada do bem +080101 Execução de serviços de exploração de via Local da prestação correspondente à extensão da via explorada, proporcionalmente ao território dos entes tributantes +090101 Execução de serviços de telefonia fixa e demais serviços de comunicação prestados por meio de cabos, fios, fibras e meios similares Local de instalação do terminal +090102 Execução de serviços de telefonia fixa e demais serviços de comunicação prestados por meio de cabos, fios, fibras e meios similares Local do domicílio principal do adquirente (assim considerada a matriz), nas aquisições realizadas de forma centralizada por contribuinte sujeito ao regime regular do IBS e da CBS que possui mais de um estabelecimento e que não estejam sujeitas a vedação à apropriação de créditos +100101 Realização de operações de cessão de espaço para prestação de serviços publicitários Local do domicílio principal do adquirente residente ou domiciliado no País. Nas aquisições indicadas no art. 11, §4º, II, considera-se domicílio principal do adquirente o estabelecimento matriz +100102 Realização de operações de cessão de espaço para prestação de serviços publicitários Local do domicílio principal do destinatário residente ou domiciliado no País, caso o adquirente não seja residente ou domiciliado no País +100201 Realização de operações de cessão de espaço para prestação de serviços publicitários local do domicílio principal do destinatário residente ou domiciliado no País +100301 Execução dos demais serviços em operações não especificadas em outros indicadores ou, nos serviços de que trata o inc. III do art. 11, esses sejam, ainda que parcialmente, prestados à distância Local do domicílio principal do adquirente residente ou domiciliado no País. Nas aquisições indicadas no art. 11, §4º, II, considera-se domicílio principal do adquirente o estabelecimento matriz +100302 Execução dos demais serviços em operações não especificadas em outros indicadores ou, nos serviços de que trata o inc. III do art. 11, esses sejam, ainda que parcialmente, prestados à distância Local do domicílio principal do destinatário residente ou domiciliado no País, caso o adquirente não seja residente ou domiciliado no País +100401 Execução dos demais serviços em operações não especificadas em outros indicadores ou, nos serviços de que tratam o inc. III, estes sejam, ainda que parcialmente, prestados à distância local do domicílio principal do destinatário residente ou domiciliado no País +100501 Realização de demais operações com bens não especificadas em outros indicadores Local do domicílio principal do adquirente residente ou domiciliado no País. Nas aquisições indicadas no art. 11, §4º, II, considera-se domicílio principal do adquirente o estabelecimento matriz +100502 Realização de demais operações com bens não especificadas em outros indicadores Local do domicílio principal do destinatário residente ou domiciliado no País, caso o adquirente não seja residente ou domiciliado no País +100601 Realização de demais operações com bens não especificadas em outros indicadores local do domicílio principal do destinatário residente ou domiciliado no País +110101 Operações destinadas a consumo Local da entrega ou disponibilização +110201 Operações que não envolvam efetivo consumo: a) no fornecimento de serviços de transmissão de energia elétrica; e b) nas demais operações, inclusive nas hipóteses de geração, distribuição ou comercialização de energia elétrica. Local do estabelecimento principal do adquirente (assim considerada a matriz), conforme §4º do art. 11 +120101 Disponibilização de energia elétrica Local do estabelecimento do agente ou de seus representados que figurem na posição devedora da liquidação financeira apurada pela Câmara de Comercialização de Energia Elétrica +130101 Execução do transporte dutoviário de gás natural, na contratação de capacidade de entrada de gás natural do duto, nos termos da legislação aplicável Local do estabelecimento principal do fornecedor +130201 Execução do transporte dutoviário de gás natural, na contratação de capacidade de saída do gás natural do duto Local do estabelecimento principal do adquirente 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 fda0e2b..af0a921 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 production Anexo C "INDOP" sheet, D is the supply - * characteristic, G is cIndOp, 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,38 @@ private function indicators(AnnexReader $book): array { $rows = []; $seen = []; - foreach ($book->rows('INDOP') as $row) { - $rawCode = $row['G'] ?? ''; + $sheet = null; + $columns = null; + foreach ($book->sheetNames() as $name) { + $normalized = mb_strtoupper(trim($name), 'UTF-8'); + if ($normalized === 'INDOP') { + $sheet = $name; + $columns = ['code' => 'G', 'characteristic' => 'D', 'location' => 'H']; + break; + } + if ($normalized === 'CINDOP PUBLIC') { + $sheet = $name; + // Official NT009 v1.03.00: A=code, C=characteristic, + // D=DFe location; J is a separate NFS-e incidence hint. + $columns = ['code' => 'A', 'characteristic' => 'C', 'location' => 'D']; + break; + } + } + if ($sheet === null || $columns === null) { + throw new \UnexpectedValueException( + 'Missing official indicator sheet; found: ' . implode(', ', $book->sheetNames()), + ); + } + 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['D'] ?? ''; - $location = $row['H'] ?? ''; + $characteristic = $row[$columns['characteristic']] ?? ''; + $location = $row[$columns['location']] ?? ''; if ($characteristic === '' || $location === '') { - throw new \UnexpectedValueException("Incomplete operation indicator {$code} in INDOP D/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/DomainTableGeneratorTest.php b/tools/tests/DomainTableGeneratorTest.php index d682d32..ca7729b 100644 --- a/tools/tests/DomainTableGeneratorTest.php +++ b/tools/tests/DomainTableGeneratorTest.php @@ -100,7 +100,7 @@ public function testDuplicateCodeWithDifferentMeaningIsRejected(): void public function testSpreadsheetFormulaIsRejectedInsteadOfExecuted(): void { $path = $this->workbook('formula.xlsx', [ - 'INDOP' => [ + 'indop' => [ ['', '', '', '', '', 'Característica', 'Código', 'Local'], ['', '', '', '', '', 'Serviço', '=SUM(1,2)', 'Localidade'], ], @@ -147,12 +147,12 @@ private function fixtureFiles(): array ], ]); $c = $this->workbook('c.xlsx', [ - 'INDOP' => [ + 'IndOp' => [ ['', '', '', 'Característica', '', '', 'Código', 'Local'], ['', '', '', 'Execução sobre bem imóvel', '', '01', '020101', 'Localidade do imóvel'], ['', '', '', '', '', '02', '030101', 'Estabelecimento do fornecedor'], ], - ], ['INDOP' => ['D2:D3']]); + ], ['IndOp' => ['D2:D3']]); return [$a, $b, $c]; } 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; + } +} diff --git a/tools/tests/OfficialAnnexViiSnapshotTest.php b/tools/tests/OfficialAnnexViiSnapshotTest.php new file mode 100644 index 0000000..8f64204 --- /dev/null +++ b/tools/tests/OfficialAnnexViiSnapshotTest.php @@ -0,0 +1,39 @@ +