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 = [