diff --git a/docs/danfse-v2-rendering.md b/docs/danfse-v2-rendering.md index bc2fbd1..00ff4e9 100644 --- a/docs/danfse-v2-rendering.md +++ b/docs/danfse-v2-rendering.md @@ -22,6 +22,7 @@ The stable contract covered by tests is: - homologation documents have a visible environment marker; - local generation returns a valid PDF artifact; - line breaks in the authorized service description are preserved as escaped HTML line breaks; +- the supplementary text follows NT 008's field order and pipe separators (including substitution, service references, construction, events and municipality information) when they exist in the authorized XML; estimated tax disclosure accepts explicitly supplied monetary **or** percentage values, preserving missing versus numeric zero; - optional contact complement and IBGE/CEP follow the authorized XML without inventing missing data; - the reference footer never fabricates an acknowledgement date or human signature. diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index d3f49bd..cca1657 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -88,7 +88,21 @@ public function buildData(array $nfse): array $authorizedValues = $this->node($infNfse, 'valores'); $tribMun = $this->node($valores, 'trib', 'tribMun'); $tribFed = $this->node($valores, 'trib', 'tribFed'); - $totTrib = $this->node($valores, 'trib', 'totTrib', 'pTotTrib'); + $totTribGroup = $this->node($valores, 'trib', 'totTrib'); + $approxPercent = $this->node($totTribGroup, 'pTotTrib'); + $approxMoney = $this->node($totTribGroup, 'vTotTrib'); + $approxValues = $approxMoney !== [] + ? [ + $this->currencyOrDash($this->val($approxMoney, 'vTotTribFed')), + $this->currencyOrDash($this->val($approxMoney, 'vTotTribEst')), + $this->currencyOrDash($this->val($approxMoney, 'vTotTribMun')), + ] + : [ + $this->percentOrDash($this->val($approxPercent, 'pTotTribFed')), + $this->percentOrDash($this->val($approxPercent, 'pTotTribEst')), + $this->percentOrDash($this->val($approxPercent, 'pTotTribMun')), + ]; + $infoComplementares = $this->node($serv, 'infoCompl'); $ibsCbs = $this->node($infNfse, 'IBSCBS'); $ibsCbsValores = $this->node($ibsCbs, 'valores'); $ibsCbsTotais = $this->node($ibsCbs, 'totCIBS'); @@ -209,9 +223,9 @@ public function buildData(array $nfse): array ], 'totais_tributos' => [ - 'federais' => $this->percentOrDash($this->val($totTrib, 'pTotTribFed')), - 'estaduais' => $this->percentOrDash($this->val($totTrib, 'pTotTribEst')), - 'municipais' => $this->percentOrDash($this->val($totTrib, 'pTotTribMun')), + 'federais' => $approxValues[0], + 'estaduais' => $approxValues[1], + 'municipais' => $approxValues[2], ], 'ibs_cbs' => [ @@ -242,10 +256,10 @@ public function buildData(array $nfse): array ], 'informacoes_complementares' => $this->complementaryInformation( - $this->val($serv, 'infoCompl', 'xInfComp'), - $this->percentOrDash($this->val($totTrib, 'pTotTribFed')), - $this->percentOrDash($this->val($totTrib, 'pTotTribEst')), - $this->percentOrDash($this->val($totTrib, 'pTotTribMun')), + $this->authorizedComplementaryFields($infNfse, $infDps, $serv, $infoComplementares), + $approxValues[0], + $approxValues[1], + $approxValues[2], ), ]; } @@ -439,6 +453,72 @@ private function joinedOrDash(string ...$values): string return implode(' / ', array_map(static fn (string $value): string => $value !== '' ? $value : '-', $values)); } + /** + * NT008 table 2.4.5 prescribes a stable order and pipe separators. + * The values below exist exclusively in the authorized NFS-e XML. + * + * @param array $infNfse + * @param array $infDps + * @param array $service + * @param array $info + */ + private function authorizedComplementaryFields( + array $infNfse, + array $infDps, + array $service, + array $info, + ): string { + $parts = []; + $fieldSources = [ + ['Inf. Cont.:', $this->val($info, 'xInfComp')], + ['NFS-e Subst.:', $this->val($infDps, 'subst', 'chSubstda')], + ['Doc. Ref.:', $this->val($info, 'docRef')], + ['Cod. Obra:', $this->val($service, 'obra', 'cObra')], + ['Insc. Imob.:', $this->val($service, 'obra', 'inscImobFisc') + ?: $this->val($infDps, 'IBSCBS', 'imovel', 'inscImobFisc')], + ['Cod. Evt.:', $this->val($service, 'atvEvento', 'idAtvEvt')], + ['Doc. Tec.:', $this->val($info, 'idDocTec')], + ['Núm. Ped.:', $this->val($info, 'xPed')], + ['Item Ped.:', $this->listItemNumbers($this->node($info, 'gItemPed'))], + ['Inf. A. T. Mun.:', $this->val($infNfse, 'xOutInf')], + ]; + + foreach ($fieldSources as [$prefix, $content]) { + if ($content !== '') { + $parts[] = $prefix . ' ' . $content; + } + } + + return implode(' | ', $parts); + } + + /** + * Preserve all repeated xItemPed occurrences from XML without dropping + * the first items (up to 99 in the active XSD). + * + * @param array $group + */ + private function listItemNumbers(array $group): string + { + $items = $group['xItemPed'] ?? null; + if (is_scalar($items)) { + return trim((string) $items); + } + + if (!is_array($items)) { + return ''; + } + + $clean = []; + foreach ($items as $item) { + if (is_scalar($item) && trim((string) $item) !== '') { + $clean[] = trim((string) $item); + } + } + + return implode(', ', $clean); + } + private function complementaryInformation( string $existing, string $federal, @@ -452,7 +532,7 @@ private function complementaryInformation( $municipal, ); - return trim($existing) === '' ? $taxes : trim($existing) . "\n" . $taxes; + return $existing === '' ? $taxes : $existing . " | " . $taxes; } private function generateQrCode(string $chaveAcesso, Ambiente $ambiente): string diff --git a/src/Danfse/XmlToArray.php b/src/Danfse/XmlToArray.php index 02036aa..40eebba 100644 --- a/src/Danfse/XmlToArray.php +++ b/src/Danfse/XmlToArray.php @@ -55,14 +55,12 @@ private function nodeToArray(\SimpleXMLElement $node): array|string $nsChildren = $node->children(self::NFSE_NS); if ($nsChildren !== null && $nsChildren->count() > 0) { foreach ($nsChildren as $name => $child) { - $result[$name] = $this->nodeToArray($child); + $this->appendNamedChild($result, $name, $this->nodeToArray($child)); } } else { // Fallback for XMLs that omit the namespace on children. foreach ($node->children() ?? [] as $name => $child) { - if (!isset($result[$name])) { - $result[$name] = $this->nodeToArray($child); - } + $this->appendNamedChild($result, $name, $this->nodeToArray($child)); } } @@ -73,4 +71,26 @@ private function nodeToArray(\SimpleXMLElement $node): array|string return $result; } + + /** + * Repeating an XML element is not a replacement: preserve each distinct + * child, but leave single-occurrence fields backward compatible. + * + * @param array $out + * @param array|string $value + */ + private function appendNamedChild(array &$out, string $name, array|string $value): void + { + if (!array_key_exists($name, $out)) { + $out[$name] = $value; + + return; + } + + if (!is_array($out[$name]) || !array_is_list($out[$name])) { + $out[$name] = [$out[$name]]; + } + + $out[$name][] = $value; + } } diff --git a/tests/Unit/Danfse/DanfseTemplateTest.php b/tests/Unit/Danfse/DanfseTemplateTest.php index f0dfabe..c71f7ab 100644 --- a/tests/Unit/Danfse/DanfseTemplateTest.php +++ b/tests/Unit/Danfse/DanfseTemplateTest.php @@ -135,6 +135,64 @@ public function testAuthorizedMunicipalTotalsUseNfseValuesNotDpsTaxFlags(): void self::assertSame('R$ 630,00', $data['tributacao_municipal']['issqn_apurado']); } + public function testComplementaryInformationFollowsOfficialOrderWithoutInventingTaxZeros(): void + { + $nfse = $this->fixtureNfseData(); + $dps = & $nfse['infNFSe']['DPS']['infDPS']; + $dps['subst'] = ['chSubstda' => str_repeat('3', 50)]; + $dps['serv']['obra'] = [ + 'cObra' => 'OBRA-FICTICIA-001', + 'inscImobFisc' => 'IMOB-TESTE-0002', + ]; + $dps['serv']['atvEvento'] = ['idAtvEvt' => 'EVENTO-TESTE-123']; + $dps['serv']['infoCompl'] = [ + 'xInfComp' => 'Observacao sintetica', + 'docRef' => 'DOC-REF-123', + 'idDocTec' => 'DOC-TEC-456', + 'xPed' => 'PED-456', + 'gItemPed' => ['xItemPed' => ['01', '02']], + ]; + $nfse['infNFSe']['xOutInf'] = 'Mensagem municipal sintetica'; + $dps['valores']['trib']['totTrib'] = [ + 'vTotTrib' => [ + 'vTotTribFed' => '0.00', + 'vTotTribEst' => '15.50', + 'vTotTribMun' => '3.25', + ], + ]; + + $data = (new DanfseTemplate())->buildData($nfse); + $actual = $data['informacoes_complementares']; + foreach (['Inf. Cont.:', 'NFS-e Subst.:', 'Doc. Ref.:', 'Cod. Obra:', + 'Insc. Imob.:', 'Cod. Evt.:', 'Doc. Tec.:', 'Núm. Ped.:', + 'Item Ped.:', 'Inf. A. T. Mun.:', 'Totais aproximados dos Tributos'] as $part) { + self::assertStringContainsString($part, $actual); + } + self::assertSame('R$ 0,00', $data['totais_tributos']['federais']); + self::assertSame('R$ 15,50', $data['totais_tributos']['estaduais']); + self::assertSame('R$ 3,25', $data['totais_tributos']['municipais']); + self::assertStringContainsString('Item Ped.: 01, 02', $actual); + self::assertStringContainsString('Federais: R$ 0,00; Estaduais: R$ 15,50; Municipais: R$ 3,25', $actual); + self::assertLessThan(strpos($actual, 'Doc. Ref.:'), strpos($actual, 'NFS-e Subst.:')); + self::assertLessThan(strpos($actual, 'Totais aproximados'), strpos($actual, 'Inf. A. T. Mun.:')); + } + + public function testAllAbsentApproximateTaxesStayAbsentAndRealZeroIsDistinct(): void + { + $nfse = $this->fixtureNfseData(); + unset($nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['totTrib']); + + $template = new DanfseTemplate(); + $data = $template->buildData($nfse); + self::assertSame('-', $data['totais_tributos']['municipais']); + self::assertStringContainsString('Municipais: -;', $data['informacoes_complementares']); + + $nfse['infNFSe']['DPS']['infDPS']['valores']['trib']['totTrib']['pTotTrib']['pTotTribMun'] = '0.00'; + $data = $template->buildData($nfse); + self::assertSame('0.00%', $data['totais_tributos']['municipais']); + self::assertStringContainsString('Municipais: 0.00%', $data['informacoes_complementares']); + } + public function testDescriptionPreservesNewlinesWithoutRenderingMarkup(): void { $nfse = $this->fixtureNfseData(); diff --git a/tests/Unit/Danfse/XmlToArrayTest.php b/tests/Unit/Danfse/XmlToArrayTest.php index a187b9f..d72bd07 100644 --- a/tests/Unit/Danfse/XmlToArrayTest.php +++ b/tests/Unit/Danfse/XmlToArrayTest.php @@ -57,6 +57,20 @@ public function testRejectsDoctypeBeforeParsingExternalXml(): void (new XmlToArray())->convert($xml); } + public function testPreservesRepeatedOrderItems(): void + { + $withItems = str_replace( + '', + '010203', + $this->fixture(), + ); + $value = (new XmlToArray())->convert($withItems); + self::assertSame( + ['01', '02', '03'], + $value['infNFSe']['DPS']['infDPS']['serv']['infoCompl']['gItemPed']['xItemPed'], + ); + } + public function testIgnoresDigitalSignature(): void { $signed = str_replace(