diff --git a/CHANGELOG.md b/CHANGELOG.md index a909f2f0..7c1661c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ Los cambios notables de cada lanzamiento serán documentados en este archivo. ## Unreleased -- CI: tests unitarios separados de los de integración (SUNAT beta ya no bloquea PRs); matrix PHP 7.4–8.4; actions actualizadas. +- #274 Soportar `sac:ExceptionalIndicator` (`indExcepcional`) en comprobante de Percepción. +- #276 CI: tests unitarios separados de los de integración (SUNAT beta ya no bloquea PRs). - Corregir parámetros implícitamente nullable (deprecación PHP 8.4). - Registrar filtros y funciones Twig del reporte con instancias (compatibilidad con Psalm 5.26). diff --git a/composer.json b/composer.json index 3305c8e4..c0e9eae5 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ "twig/twig": "~3.0" }, "require-dev": { - "greenter/ubl-validator": "^2.0", + "greenter/ubl-validator": "^2.3", "mockery/mockery": "^1.2", "phpstan/phpstan": "^1.9", "phpunit/phpunit": "^9" diff --git a/packages/core/src/Core/Model/Perception/Perception.php b/packages/core/src/Core/Model/Perception/Perception.php index b6802ba5..83b9a929 100644 --- a/packages/core/src/Core/Model/Perception/Perception.php +++ b/packages/core/src/Core/Model/Perception/Perception.php @@ -76,6 +76,20 @@ class Perception implements DocumentInterface */ private $observacion; + /** + * Indicador excepcional. + * + * Campo 7 de la hoja «Percepciones1_0» de las Reglas de validación de SUNAT + * (actualizado al 26.08.2026, https://cpe.sunat.gob.pe/guias-y-manuales). + * Código de excepción; actualmente el único valor admitido es '01' - emisión + * excepcional (regla 3322). Cuando se consigna, el comprobante solo puede + * llevar un documento relacionado (3323) y ese documento debe ser una + * factura (3324). + * + * @var string|null + */ + private $indExcepcional; + /** * Dato del Comprobante relacionado. * @@ -283,6 +297,26 @@ public function setObservacion(?string $observacion): Perception return $this; } + /** + * @return string|null + */ + public function getIndExcepcional(): ?string + { + return $this->indExcepcional; + } + + /** + * @param string|null $indExcepcional + * + * @return Perception + */ + public function setIndExcepcional(?string $indExcepcional): Perception + { + $this->indExcepcional = $indExcepcional; + + return $this; + } + /** * @return PerceptionDetail[] */ diff --git a/packages/validator/src/Validator/Loader/PerceptionLoader.php b/packages/validator/src/Validator/Loader/PerceptionLoader.php index b7eb6cc1..34dfb27c 100644 --- a/packages/validator/src/Validator/Loader/PerceptionLoader.php +++ b/packages/validator/src/Validator/Loader/PerceptionLoader.php @@ -45,6 +45,7 @@ public function load(ClassMetadata $metadata) $metadata->addPropertyConstraint('impPercibido', new Assert\NotNull()); $metadata->addPropertyConstraint('impCobrado', new Assert\NotNull()); $metadata->addPropertyConstraint('observacion', new Assert\Length(['max' => 250])); + $metadata->addPropertyConstraint('indExcepcional', new Assert\Length(['min' => 2, 'max' => 2])); $metadata->addPropertyConstraints('details', [ new Assert\NotBlank(), new Assert\Valid(), diff --git a/packages/validator/tests/Validator/CePerceptionValidatorTest.php b/packages/validator/tests/Validator/CePerceptionValidatorTest.php index 842aa0b1..2e25d8d1 100644 --- a/packages/validator/tests/Validator/CePerceptionValidatorTest.php +++ b/packages/validator/tests/Validator/CePerceptionValidatorTest.php @@ -31,6 +31,27 @@ public function testValidatePerception() $this->assertEquals(0, $errors->count()); } + public function testValidatePerceptionWithIndExcepcional() + { + $perception = $this->getPerception(); + $perception->setIndExcepcional('01'); + $validator = $this->getValidator(); + $errors = $validator->validate($perception); + + $this->assertEquals(0, $errors->count()); + } + + public function testValidatePerceptionInvalidIndExcepcional() + { + $perception = $this->getPerception(); + $perception->setIndExcepcional('1'); + $validator = $this->getValidator(); + $errors = $validator->validate($perception); + + $this->assertEquals(1, $errors->count()); + $this->assertEquals('indExcepcional', $errors->get(0)->getPropertyPath()); + } + /** * @return Perception */ diff --git a/packages/xml-parser/src/Xml/Parser/PerceptionParser.php b/packages/xml-parser/src/Xml/Parser/PerceptionParser.php index 09f3de07..85c40a16 100644 --- a/packages/xml-parser/src/Xml/Parser/PerceptionParser.php +++ b/packages/xml-parser/src/Xml/Parser/PerceptionParser.php @@ -57,6 +57,7 @@ public function parse($value): ?DocumentInterface $perception->setSerie($idNum[0]) ->setCorrelativo($idNum[1]) ->setFechaEmision(new DateTime($xml->getValue('cbc:IssueDate'))) + ->setIndExcepcional($xml->getValue('sac:ExceptionalIndicator', $root, null)) ->setCompany($this->getCompany()) ->setProveedor($this->getClient()) ->setRegimen($xml->getValue('sac:SUNATPerceptionSystemCode')) diff --git a/packages/xml-parser/tests/Resources/perception/20000000001-40-P001-2.xml b/packages/xml-parser/tests/Resources/perception/20000000001-40-P001-2.xml new file mode 100644 index 00000000..967de917 --- /dev/null +++ b/packages/xml-parser/tests/Resources/perception/20000000001-40-P001-2.xml @@ -0,0 +1,87 @@ + + + + + + + + + + 2.0 + 1.0 + + 20000000001 + + + 20000000001 + + + TECNOMAQ V & L SRL + + + + + 20000000001 + + + + P001-2 + 2018-01-26 + 01 + + + 20000000001 + + + TECNOMAQ V & L SRL + + + 150122 + Miraflore + SANTIAGO DE CHUCO + SANTIAGO DE CHUCO + LIMA + MIRAFLORES + + PE + + + + TECNOMAQ V & L SRL + + + + + 1724754054 + + + TECNOMAQ V & L SRL + + + 01 + 2.0 + NOTA + 0.02 + 1.02 + + F001-12 + 2018-01-26 + 12.00 + + 212 + 1.00 + 2018-01-26 + + + 0.02 + 2018-01-26 + 1.02 + + PEN + PEN + 1.0 + 2018-01-26 + + + + diff --git a/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php b/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php index 7530e1e2..e9488df9 100644 --- a/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php +++ b/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php @@ -43,6 +43,29 @@ public function testParseDoc($filename) } } + /** + * Field 7 of SUNAT's "Percepciones1_0" sheet, and conditional: absent + * from a document that is not exceptionally issued. + */ + public function testParseIndExcepcional() + { + $withIndicator = $this->parseResource('20000000001-40-P001-2.xml'); + $this->assertSame('01', $withIndicator->getIndExcepcional()); + + $without = $this->parseResource('20000000001-40-P001-1.xml'); + $this->assertNull($without->getIndExcepcional()); + } + + private function parseResource(string $filename): Perception + { + $path = __DIR__.'/../../Resources/perception/'.$filename; + + /**@var $obj Perception */ + $obj = $this->getParser()->parse(file_get_contents($path)); + + return $obj; + } + public function providerDocs() { $files = glob(__DIR__.'/../../Resources/perception/*.xml'); diff --git a/packages/xml/composer.json b/packages/xml/composer.json index b4834f35..b7c1b5d4 100644 --- a/packages/xml/composer.json +++ b/packages/xml/composer.json @@ -21,7 +21,7 @@ }, "require-dev": { "greenter/data": "^5.0", - "greenter/ubl-validator": "^2.0", + "greenter/ubl-validator": "^2.3", "phpunit/phpunit": "^9" }, "autoload": { diff --git a/packages/xml/src/Xml/Templates/perception.xml.twig b/packages/xml/src/Xml/Templates/perception.xml.twig index 33cf3e58..eab37778 100644 --- a/packages/xml/src/Xml/Templates/perception.xml.twig +++ b/packages/xml/src/Xml/Templates/perception.xml.twig @@ -28,6 +28,9 @@ {{ doc.serie }}-{{ doc.correlativo }} {{ doc.fechaEmision|date('Y-m-d') }} {{ doc.fechaEmision|date('H:i:s') }} + {% if doc.indExcepcional %} + {{ doc.indExcepcional }} + {% endif %} {{ emp.ruc }} diff --git a/packages/xml/tests/Xml/Builder/CePerceptionBuilderTest.php b/packages/xml/tests/Xml/Builder/CePerceptionBuilderTest.php index d96fa861..e6927382 100644 --- a/packages/xml/tests/Xml/Builder/CePerceptionBuilderTest.php +++ b/packages/xml/tests/Xml/Builder/CePerceptionBuilderTest.php @@ -10,6 +10,8 @@ namespace Tests\Greenter\Xml\Builder; +use DOMDocument; +use DOMElement; use Greenter\Data\Generator\PerceptionStore; use Greenter\Model\Perception\Perception; use PHPUnit\Framework\TestCase; @@ -42,6 +44,39 @@ public function testCreateXmlPerceptionWithoutInformation() $this->assertSchema($xml); } + public function testCreateXmlPerceptionWithIndExcepcional() + { + /**@var $perception Perception*/ + $perception = $this->createDocument(PerceptionStore::class); + $detail = $perception->getDetails()[0]; + $detail->setTipoDoc('01'); + $perception + ->setDetails([$detail]) + ->setIndExcepcional('01'); + + $xml = $this->build($perception); + + $this->assertStringContainsString('01', $xml); + + $names = $this->getRootChildNames($xml); + $indicator = array_search('sac:ExceptionalIndicator', $names, true); + $this->assertNotFalse($indicator); + $this->assertGreaterThan(array_search('cbc:IssueTime', $names, true), $indicator); + $this->assertLessThan(array_search('cac:AgentParty', $names, true), $indicator); + + $this->assertSchema($xml); + } + + public function testCreateXmlPerceptionWithoutIndExcepcional() + { + $perception = $this->createDocument(PerceptionStore::class); + + $xml = $this->build($perception); + + $this->assertStringNotContainsString('ExceptionalIndicator', $xml); + $this->assertSchema($xml); + } + public function testPerceptionFilename() { /**@var $perception Perception*/ @@ -51,6 +86,24 @@ public function testPerceptionFilename() $this->assertEquals($this->getFilename($perception), $filename); } + /** + * @return string[] + */ + private function getRootChildNames(string $xml): array + { + $doc = new DOMDocument(); + $doc->loadXML($xml); + + $names = []; + foreach ($doc->documentElement->childNodes as $node) { + if ($node instanceof DOMElement) { + $names[] = $node->nodeName; + } + } + + return $names; + } + private function getFileName(Perception $perception) { $parts = [