From e5070cf3a43a0550a134c2f052decd118f00b2f1 Mon Sep 17 00:00:00 2001 From: TheFantas Date: Wed, 2 Sep 2026 08:02:28 +0300 Subject: [PATCH 1/2] feat(perception): soportar sac:ExceptionalIndicator (campo 7) --- .../src/Core/Model/Perception/Perception.php | 31 +++++++ .../src/Xml/Parser/PerceptionParser.php | 1 + .../perception/20000000001-40-P001-2.xml | 87 +++++++++++++++++++ .../tests/Xml/Parser/PerceptionParserTest.php | 23 +++++ .../xml/src/Xml/Templates/perception.xml.twig | 3 + .../PerceptionExceptionalIndicatorTest.php | 83 ++++++++++++++++++ 6 files changed, 228 insertions(+) create mode 100644 packages/xml-parser/tests/Resources/perception/20000000001-40-P001-2.xml create mode 100644 packages/xml/tests/Xml/Builder/PerceptionExceptionalIndicatorTest.php diff --git a/packages/core/src/Core/Model/Perception/Perception.php b/packages/core/src/Core/Model/Perception/Perception.php index b6802ba5..ea670428 100644 --- a/packages/core/src/Core/Model/Perception/Perception.php +++ b/packages/core/src/Core/Model/Perception/Perception.php @@ -76,6 +76,18 @@ class Perception implements DocumentInterface */ private $observacion; + /** + * Indicador de emision excepcional. + * + * Campo 7 de la hoja «Percepciones1_0» de las reglas de validacion de + * SUNAT. Su unico valor admitido es '01' (regla 3322) y, cuando se + * consigna, el comprobante solo puede llevar un documento relacionado + * (3323) y ese documento debe ser una factura (3324). + * + * @var string + */ + private $exceptionalIndicator; + /** * Dato del Comprobante relacionado. * @@ -283,6 +295,25 @@ public function setObservacion(?string $observacion): Perception return $this; } + /** + * @return string|null + */ + public function getExceptionalIndicator(): ?string + { + return $this->exceptionalIndicator; + } + + /** + * @param string|null $exceptionalIndicator + * @return Perception + */ + public function setExceptionalIndicator(?string $exceptionalIndicator): Perception + { + $this->exceptionalIndicator = $exceptionalIndicator; + + return $this; + } + /** * @return PerceptionDetail[] */ diff --git a/packages/xml-parser/src/Xml/Parser/PerceptionParser.php b/packages/xml-parser/src/Xml/Parser/PerceptionParser.php index 09f3de07..d1b49740 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'))) + ->setExceptionalIndicator($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..1073a13b 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 testParseExceptionalIndicator() + { + $withIndicator = $this->parseResource('20000000001-40-P001-2.xml'); + $this->assertSame('01', $withIndicator->getExceptionalIndicator()); + + $without = $this->parseResource('20000000001-40-P001-1.xml'); + $this->assertNull($without->getExceptionalIndicator()); + } + + 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/src/Xml/Templates/perception.xml.twig b/packages/xml/src/Xml/Templates/perception.xml.twig index 33cf3e58..1b25eef1 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.exceptionalIndicator %} + {{ doc.exceptionalIndicator }} + {% endif %} {{ emp.ruc }} diff --git a/packages/xml/tests/Xml/Builder/PerceptionExceptionalIndicatorTest.php b/packages/xml/tests/Xml/Builder/PerceptionExceptionalIndicatorTest.php new file mode 100644 index 00000000..64959926 --- /dev/null +++ b/packages/xml/tests/Xml/Builder/PerceptionExceptionalIndicatorTest.php @@ -0,0 +1,83 @@ +build(null); + + self::assertStringNotContainsString('ExceptionalIndicator', $xml); + } + + public function testIsWrittenWithItsValue(): void + { + $xml = $this->build('01'); + + self::assertStringContainsString( + '01', + $xml + ); + } + + /** + * The sheet numbers the fields in document order: 6 is cbc:IssueTime and + * 8 is the agent's identity document, so field 7 belongs between them. + */ + public function testIsPlacedBetweenIssueTimeAndAgentParty(): void + { + $names = $this->childNames($this->build('01')); + + $indicator = array_search('sac:ExceptionalIndicator', $names, true); + self::assertNotFalse($indicator); + + self::assertGreaterThan(array_search('cbc:IssueTime', $names, true), $indicator); + self::assertLessThan(array_search('cac:AgentParty', $names, true), $indicator); + } + + private function build(?string $indicator): string + { + /** @var Perception $perception */ + $perception = (new PerceptionStore(new SharedStore()))->create(); + $perception->setExceptionalIndicator($indicator); + + return (new PerceptionBuilder())->build($perception); + } + + /** + * @return string[] + */ + private function childNames(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; + } +} From b02c554f0c16d19ec8731f0887760710f8d6fbab Mon Sep 17 00:00:00 2001 From: Giancarlos Salas Date: Fri, 4 Sep 2026 16:55:25 -0500 Subject: [PATCH 2/2] refactor(perception): renombrar exceptionalIndicator a indExcepcional y validar campo (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Propiedad y accesores en español (`indExcepcional`), siguiendo la convención del modelo; el tag XML `sac:ExceptionalIndicator` no cambia. - Restricción `Length(2)` en `PerceptionLoader` con tests en `CePerceptionValidatorTest`. - Tests del builder integrados en `CePerceptionBuilderTest` con validación contra el XSD (`greenter/ubl-validator` ^2.3). - Entrada `Unreleased` en CHANGELOG. Claude-Session: https://claude.ai/code/session_012XxTfA813bR5kFXN5FCrii Co-authored-by: giansalex --- CHANGELOG.md | 3 + composer.json | 2 +- .../src/Core/Model/Perception/Perception.php | 27 +++--- .../src/Validator/Loader/PerceptionLoader.php | 1 + .../Validator/CePerceptionValidatorTest.php | 21 +++++ .../src/Xml/Parser/PerceptionParser.php | 2 +- .../tests/Xml/Parser/PerceptionParserTest.php | 6 +- packages/xml/composer.json | 2 +- .../xml/src/Xml/Templates/perception.xml.twig | 4 +- .../Xml/Builder/CePerceptionBuilderTest.php | 53 ++++++++++++ .../PerceptionExceptionalIndicatorTest.php | 83 ------------------- 11 files changed, 101 insertions(+), 103 deletions(-) delete mode 100644 packages/xml/tests/Xml/Builder/PerceptionExceptionalIndicatorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index de42cb8a..62365404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ Los cambios notables de cada lanzamiento serán documentados en este archivo. +## Unreleased +- #274 Soportar `sac:ExceptionalIndicator` (`indExcepcional`) en comprobante de Percepción. + ## 5.0.0 - 2023-05-01 - #206 Agregar xml para nueva Guia de Remisión. - #209 Cambiar versión mínima de PHP a `7.4` diff --git a/composer.json b/composer.json index b25c0b9b..c2007727 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 ea670428..83b9a929 100644 --- a/packages/core/src/Core/Model/Perception/Perception.php +++ b/packages/core/src/Core/Model/Perception/Perception.php @@ -77,16 +77,18 @@ class Perception implements DocumentInterface private $observacion; /** - * Indicador de emision excepcional. + * Indicador excepcional. * - * Campo 7 de la hoja «Percepciones1_0» de las reglas de validacion de - * SUNAT. Su unico valor admitido es '01' (regla 3322) y, cuando se - * consigna, el comprobante solo puede llevar un documento relacionado - * (3323) y ese documento debe ser una factura (3324). + * 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 + * @var string|null */ - private $exceptionalIndicator; + private $indExcepcional; /** * Dato del Comprobante relacionado. @@ -298,18 +300,19 @@ public function setObservacion(?string $observacion): Perception /** * @return string|null */ - public function getExceptionalIndicator(): ?string + public function getIndExcepcional(): ?string { - return $this->exceptionalIndicator; + return $this->indExcepcional; } /** - * @param string|null $exceptionalIndicator + * @param string|null $indExcepcional + * * @return Perception */ - public function setExceptionalIndicator(?string $exceptionalIndicator): Perception + public function setIndExcepcional(?string $indExcepcional): Perception { - $this->exceptionalIndicator = $exceptionalIndicator; + $this->indExcepcional = $indExcepcional; return $this; } 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 d1b49740..85c40a16 100644 --- a/packages/xml-parser/src/Xml/Parser/PerceptionParser.php +++ b/packages/xml-parser/src/Xml/Parser/PerceptionParser.php @@ -57,7 +57,7 @@ public function parse($value): ?DocumentInterface $perception->setSerie($idNum[0]) ->setCorrelativo($idNum[1]) ->setFechaEmision(new DateTime($xml->getValue('cbc:IssueDate'))) - ->setExceptionalIndicator($xml->getValue('sac:ExceptionalIndicator', $root, null)) + ->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/Xml/Parser/PerceptionParserTest.php b/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php index 1073a13b..e9488df9 100644 --- a/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php +++ b/packages/xml-parser/tests/Xml/Parser/PerceptionParserTest.php @@ -47,13 +47,13 @@ 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 testParseExceptionalIndicator() + public function testParseIndExcepcional() { $withIndicator = $this->parseResource('20000000001-40-P001-2.xml'); - $this->assertSame('01', $withIndicator->getExceptionalIndicator()); + $this->assertSame('01', $withIndicator->getIndExcepcional()); $without = $this->parseResource('20000000001-40-P001-1.xml'); - $this->assertNull($without->getExceptionalIndicator()); + $this->assertNull($without->getIndExcepcional()); } private function parseResource(string $filename): Perception 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 1b25eef1..eab37778 100644 --- a/packages/xml/src/Xml/Templates/perception.xml.twig +++ b/packages/xml/src/Xml/Templates/perception.xml.twig @@ -28,8 +28,8 @@ {{ doc.serie }}-{{ doc.correlativo }} {{ doc.fechaEmision|date('Y-m-d') }} {{ doc.fechaEmision|date('H:i:s') }} - {% if doc.exceptionalIndicator %} - {{ doc.exceptionalIndicator }} + {% if doc.indExcepcional %} + {{ doc.indExcepcional }} {% endif %} 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 = [ diff --git a/packages/xml/tests/Xml/Builder/PerceptionExceptionalIndicatorTest.php b/packages/xml/tests/Xml/Builder/PerceptionExceptionalIndicatorTest.php deleted file mode 100644 index 64959926..00000000 --- a/packages/xml/tests/Xml/Builder/PerceptionExceptionalIndicatorTest.php +++ /dev/null @@ -1,83 +0,0 @@ -build(null); - - self::assertStringNotContainsString('ExceptionalIndicator', $xml); - } - - public function testIsWrittenWithItsValue(): void - { - $xml = $this->build('01'); - - self::assertStringContainsString( - '01', - $xml - ); - } - - /** - * The sheet numbers the fields in document order: 6 is cbc:IssueTime and - * 8 is the agent's identity document, so field 7 belongs between them. - */ - public function testIsPlacedBetweenIssueTimeAndAgentParty(): void - { - $names = $this->childNames($this->build('01')); - - $indicator = array_search('sac:ExceptionalIndicator', $names, true); - self::assertNotFalse($indicator); - - self::assertGreaterThan(array_search('cbc:IssueTime', $names, true), $indicator); - self::assertLessThan(array_search('cac:AgentParty', $names, true), $indicator); - } - - private function build(?string $indicator): string - { - /** @var Perception $perception */ - $perception = (new PerceptionStore(new SharedStore()))->create(); - $perception->setExceptionalIndicator($indicator); - - return (new PerceptionBuilder())->build($perception); - } - - /** - * @return string[] - */ - private function childNames(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; - } -}