phpcfdi / sat-ws-descarga-masiva
用于使用SAT大量下载服务的库
Requires
- php: >=7.3
- ext-dom: *
- ext-json: *
- ext-libxml: *
- ext-mbstring: *
- ext-openssl: *
- ext-zip: *
- eclipxe/enum: ^0.2.0
- eclipxe/micro-catalog: ^0.1.2
- phpcfdi/credentials: ^1.1
- phpcfdi/rfc: ^1.1
Requires (Dev)
- guzzlehttp/guzzle: ^7.2.0
- phpunit/phpunit: ^9.3.5
- robrichards/xmlseclibs: ^3.1.0
Suggests
- guzzlehttp/guzzle: To use GuzzleWebClient implementation
README
用于使用SAT大量下载服务的库
🇺🇸 本项目的文档使用西班牙语编写,因为这是目标受众的自然语言。
🇲🇽 项目的文档是西班牙语,因为这是用户的主要语言。也欢迎您加入 discord的#phpcfdi频道
这个库包含一个SAT CFDI和扣缴税款大量下载服务 的客户端(消费者)。
安装
使用 composer,按照以下方式安装
composer require phpcfdi/sat-ws-descarga-masiva
使用示例
所有输入和输出对象都可以导出为JSON,以便轻松调试。
创建服务
以下是一个示例,说明如何使用本地可用的FIEL创建服务。
<?php use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\FielRequestBuilder\Fiel; use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\FielRequestBuilder\FielRequestBuilder; use PhpCfdi\SatWsDescargaMasiva\Service; use PhpCfdi\SatWsDescargaMasiva\WebClient\GuzzleWebClient; // Creación de la FIEL, puede leer archivos DER (como los envía el SAT) o PEM (convertidos con openssl) $fiel = Fiel::create( file_get_contents('certificado.cer'), file_get_contents('llaveprivada.key'), '12345678a' ); // verificar que la FIEL sea válida (no sea CSD y sea vigente acorde a la fecha del sistema) if (! $fiel->isValid()) { return; } // creación del web client basado en Guzzle que implementa WebClientInterface // para usarlo necesitas instalar guzzlehttp/guzzle, pues no es una dependencia directa $webClient = new GuzzleWebClient(); // creación del objeto encargado de crear las solicitudes firmadas usando una FIEL $requestBuilder = new FielRequestBuilder($fiel); // Creación del servicio $service = new Service($requestBuilder, $webClient);
用于消费CFDI扣缴税款的客户端
存在两种类型的数字税务发票,一种是常规发票(收入、支出、转移、工资和付款),另一种是扣缴税款和信息付款的CFDI(扣缴税款)。
您可以使用这个库来消费扣缴税款的CFDI。为了实现这一点,您需要使用 ServiceEndpoints::retenciones()
规范构建服务。
ServiceEndpoints::cfdi()
和 ServiceEndpoints::retenciones()
构造函数会自动向对象添加 ServiceType
属性。在调用服务之前,此属性将用于在查询中指定值。
use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\RequestBuilderInterface; use PhpCfdi\SatWsDescargaMasiva\Service; use PhpCfdi\SatWsDescargaMasiva\Shared\ServiceEndpoints; use PhpCfdi\SatWsDescargaMasiva\WebClient\GuzzleWebClient; /** * @var GuzzleWebClient $webClient Cliente de Guzzle previamente fabricado * @var RequestBuilderInterface $requestBuilder Creador de solicitudes, previamente fabricado */ // Creación del servicio $service = new Service($requestBuilder, $webClient, null, ServiceEndpoints::retenciones());
虽然不推荐,但您也可以使用对象构造函数而不是静态方法,通过自定义服务URL来构建 ServiceEndpoints
对象。
执行查询
创建服务后,可以执行查询,如果成功执行,将返回请求标识符,然后可以使用此标识符继续到验证服务。
<?php use PhpCfdi\SatWsDescargaMasiva\Services\Query\QueryParameters; use PhpCfdi\SatWsDescargaMasiva\Shared\DateTimePeriod; // Crear la consulta $request = QueryParameters::create( DateTimePeriod::createFromValues('2019-01-13 00:00:00', '2019-01-13 23:59:59'), ); // presentar la consulta $query = $service->query($request); // verificar que el proceso de consulta fue correcto if (! $query->getStatus()->isAccepted()) { echo "Fallo al presentar la consulta: {$query->getStatus()->getMessage()}"; return; } // el identificador de la consulta está en $query->getRequestId() echo "Se generó la solicitud {$query->getRequestId()}", PHP_EOL;
查询参数
周期 (DateTimePeriod
)
查询的起始和结束日期和时间。如果没有指定,将创建一个从对象创建时刻开始的精确秒周期。
下载类型 (DownloadType
)
指定请求是文档发行 DownloadType::issued()
还是接收 DownloadType::received()
。如果没有指定,则使用发行值。
请求类型 (RequestType
)
指定请求是元数据 RequestType::metadata()
还是XML文件 RequestType::xml()
。如果没有指定,则使用元数据值。
凭证类型 (DocumentType
)
通过凭证类型筛选请求。如果没有指定,则不使用筛选器。
- 任意:
DocumentType::undefined()
(默认)。 - 收入:
DocumentType::ingreso()
。 - 支出:
DocumentType::egreso()
。 - 转移:
DocumentType::traslado()
。 - 工资:
DocumentType::nomina()
。 - 付款:
DocumentType::pago()
。
补充类型 (ComplementoCfdi
或 ComplementoRetenciones
)
通过凭证中存在的补充类型筛选请求。如果没有指定,则使用 ComplementoUndefined::undefined()
排除筛选器。
满足此参数有两个对象类型,取决于请求的凭证类型。如果请求的是CFDI常规凭证,则使用 ComplementoCfdi
类。如果请求的是扣缴税款和信息付款的CFDI,则使用 ComplementoRetenciones
类。
这些对象可以通过命名(ComplementoCfdi::leyendasFiscales10()
)、构造函数(new ComplementoCfdi('leyendasfisc')
)或静态方法create
(ComplementoCfdi::create('leyendasfisc')
)来创建。
此外,可以使用label()
方法访问补丁名称,例如:echo ComplementoCfdi::leyendasFiscales10()->label(); // 财务条例 1.0
。
同时,该对象提供了一个静态方法getLabels(): array
,用于获取包含数据的数组,其中键是补丁的标识符,值是补丁的名称。
凭证状态(《DocumentStatus》)
通过凭证状态过滤请求:有效(《DocumentStatus::active()》)和已取消(《DocumentStatus::cancelled()》)。如果未指定,则使用DocumentStatus::undefined()
排除过滤。
UUID(《Uuid》)
通过UUID过滤请求。创建过滤对象需要使用Uuid::create('96623061-61fe-49de-b298-c7156476aa8b')
。如果未指定,则使用Uuid::empty()
排除过滤。
第三方账户过滤(《RfcOnBehalf》)
通过用于第三方账户的RFC过滤请求。创建过滤对象需要使用RfcOnBehalf::create('XXX01010199A')
。如果未指定,则使用RfcOnBehalf::empty()
排除过滤。
通过RFC对手过滤(《RfcMatch)/(《RfcMatches》)
通过对手RFC过滤请求,即如果查询是已发行的,则过滤指定的RFC是接收者,如果查询是已收到的,则过滤指定的RFC是发行者。
创建过滤对象需要使用RfcMatch::create('XXX01010199A')
。如果未指定,则使用空列表RfcMatches::create()
排除过滤。
$rfcMatch = RfcMatch::create('XXX01010199A'); $parameters = $parameters->withRfcMatch(); var_dump($rfcMatch === $parameters->getRfcMatch()); // bool(true)
SAT服务允许指定最多5个RFC接收者,至少在其文档中这样规定。然而,由于是接收者,因此只能用于文档发行查询。在文档接收查询的情况下,仅使用列表中的第一个。
通常只需要使用QueryParameter::getRfcMatch(): RfcMatch
和QueryParameter::withRfcMatch(RfcMatch $rfcMatch)
方法。
但是,如果需要指定RFC列表,可以按以下方式执行
$parameters = $parameters->withRfcMatches( RfcMatches::create( RfcMatch::create('AAA010101000'), RfcMatch::create('AAA010101001'), RfcMatch::create('AAA010101002') ) );
或者,使用RFC列表作为文本字符串
$parameters = $parameters->withRfcMatches( RfcMatches::createFromValues('AAA010101000', 'AAA010101001', 'AAA010101002') );
关于RfcMatches
此对象维护一个RfcMatches
列表,但具有特殊功能
- 空或重复的
RfcMatch
对象被忽略,仅保留非空唯一值。 RfcMatch::getFirst()
方法始终返回第一个元素,如果不存在,则返回一个空对象。RfcMatch
类是可迭代的,可以对其元素进行foreach()
操作。RfcMatch
类是可计数的,可以对其元素进行count()
操作。
服务类型(《ServiceType》)
这是一个可以被认为是内部属性,不需要在查询中指定的属性。默认情况下,它未定义,值为null
。可以使用hasServiceType(): bool
属性检查该属性是否已定义,并使用withServiceType(ServiceType): self
进行更改。
不推荐定义此属性,而是让服务根据服务指向的位置设置正确的值。
当执行查询时,如果未定义此属性,服务(Service
)会自动将其定义为与ServiceEndpoints
对象中定义的相同值。如果此属性已定义,并且其值与ServiceEndpoints
对象中定义的值不同,则生成一个LogicException
。
参数指定示例
在以下示例中,创建了一个不带参数的查询,然后对其进行修改。这些方法不改变对象的属性(不是set*
),而是创建一个新的查询实例,包含新的值(是with*
)。
示例中的更改可能不合理,只是为了说明如何设置值。
- 特定时间段:从
2019-01-13 00:00:00
到2019-01-13 23:59:59
(包含)。 - 关于收到的文档。
- 请求XML文件。
- 按文档类型筛选收入。
- 筛选具有税务法律说明补充的文档。
- 仅筛选有效文档(不包括已取消的)。
- 按第三方账户的RFC
XXX01010199A
筛选。 - 按对方RFC
MAG041126GT8
筛选。由于要求为收到的,因此是此RFC的emidos。 - 按UUID
96623061-61fe-49de-b298-c7156476aa8b
筛选。
<?php use PhpCfdi\SatWsDescargaMasiva\Services\Query\QueryParameters; use PhpCfdi\SatWsDescargaMasiva\Shared\ComplementoCfdi; use PhpCfdi\SatWsDescargaMasiva\Shared\DateTimePeriod; use PhpCfdi\SatWsDescargaMasiva\Shared\DocumentStatus; use PhpCfdi\SatWsDescargaMasiva\Shared\DocumentType; use PhpCfdi\SatWsDescargaMasiva\Shared\DownloadType; use PhpCfdi\SatWsDescargaMasiva\Shared\RequestType; use PhpCfdi\SatWsDescargaMasiva\Shared\RfcMatch; use PhpCfdi\SatWsDescargaMasiva\Shared\RfcOnBehalf; use PhpCfdi\SatWsDescargaMasiva\Shared\Uuid; $query = QueryParameters::create() ->withPeriod(DateTimePeriod::createFromValues('2019-01-13 00:00:00', '2019-01-13 23:59:59')) ->withDownloadType(DownloadType::received()) ->withRequestType(RequestType::xml()) ->withDocumentType(DocumentType::ingreso()) ->withComplement(ComplementoCfdi::leyendasFiscales10()) ->withDocumentStatus(DocumentStatus::active()) ->withRfcOnBehalf(RfcOnBehalf::create('XXX01010199A')) ->withRfcMatch(RfcMatch::create('MAG041126GT8')) ->withUuid(Uuid::create('96623061-61fe-49de-b298-c7156476aa8b')) ;
UUID查询示例
在这种情况下,仅指定要查询的UUID,在示例中是96623061-61fe-49de-b298-c7156476aa8b
。
注意:所有其他查询参数都将被忽略。
<?php use PhpCfdi\SatWsDescargaMasiva\Services\Query\QueryParameters; use PhpCfdi\SatWsDescargaMasiva\Shared\Uuid; $query = QueryParameters::create() ->withUuid(Uuid::create('96623061-61fe-49de-b298-c7156476aa8b')) ;
验证查询
验证取决于查询是否被接受。
<?php use PhpCfdi\SatWsDescargaMasiva\Service; /** * @var Service $service Objeto de ayuda de consumo de servicio, previamente fabricado * @var string $requestId Identificador generado al presentar la consulta, previamente fabricado */ // consultar el servicio de verificación $verify = $service->verify($requestId); // revisar que el proceso de verificación fue correcto if (! $verify->getStatus()->isAccepted()) { echo "Fallo al verificar la consulta {$requestId}: {$verify->getStatus()->getMessage()}"; return; } // revisar que la consulta no haya sido rechazada if (! $verify->getCodeRequest()->isAccepted()) { echo "La solicitud {$requestId} fue rechazada: {$verify->getCodeRequest()->getMessage()}", PHP_EOL; return; } // revisar el progreso de la generación de los paquetes $statusRequest = $verify->getStatusRequest(); if ($statusRequest->isExpired() || $statusRequest->isFailure() || $statusRequest->isRejected()) { echo "La solicitud {$requestId} no se puede completar", PHP_EOL; return; } if ($statusRequest->isInProgress() || $statusRequest->isAccepted()) { echo "La solicitud {$requestId} se está procesando", PHP_EOL; return; } if ($statusRequest->isFinished()) { echo "La solicitud {$requestId} está lista", PHP_EOL; } echo "Se encontraron {$verify->countPackages()} paquetes", PHP_EOL; foreach ($verify->getPackagesIds() as $packageId) { echo " > {$packageId}", PHP_EOL; }
下载查询包
下载包取决于查询是否已正确验证。
一个查询生成一个请求标识符,验证返回一个或多个包标识符。您需要下载所有包以获得查询的完整信息。
<?php use PhpCfdi\SatWsDescargaMasiva\Service; /** * @var Service $service Objeto de ayuda de consumo de servicio, previamente fabricado * @var string[] $packagesIds Listado de identificadores de paquetes generado en la verificación, previamente fabricado */ // consultar el servicio de verificación foreach($packagesIds as $packageId) { $download = $service->download($packageId); if (! $download->getStatus()->isAccepted()) { echo "El paquete {$packageId} no se ha podido descargar: {$download->getStatus()->getMessage()}", PHP_EOL; continue; } $zipfile = "$packageId.zip"; file_put_contents($zipfile, $download->getPackageContent()); echo "El paquete {$packageId} se ha almacenado", PHP_EOL; }
包读取
可以使用MetadataPackageReader
和CfdiPackageReader
类分别读取元数据和CFDI包。要创建对象,可以使用它们的createFromFile
方法从现有文件创建,或使用createFromContents
方法从文件内容在内存中创建。
每个包可以包含一个或多个内部文件。每个包都单独读取。
读取元数据类型包
<?php use PhpCfdi\SatWsDescargaMasiva\PackageReader\Exceptions\OpenZipFileException; use PhpCfdi\SatWsDescargaMasiva\PackageReader\MetadataPackageReader; /** * @var string $zipfile Contiene la ruta al archivo de paquete de Metadata */ // abrir el archivo de Metadata try { $metadataReader = MetadataPackageReader::createFromFile($zipfile); } catch (OpenZipFileException $exception) { echo $exception->getMessage(), PHP_EOL; return; } // leer todos los registros de metadata dentro de todos los archivos del archivo ZIP foreach ($metadataReader->metadata() as $uuid => $metadata) { echo $metadata->uuid, ': ', $metadata->fechaEmision, PHP_EOL; }
读取CFDI类型包
<?php use PhpCfdi\SatWsDescargaMasiva\PackageReader\Exceptions\OpenZipFileException; use PhpCfdi\SatWsDescargaMasiva\PackageReader\CfdiPackageReader; /** * @var string $zipfile Contiene la ruta al archivo de paquete de archivos ZIP */ try { $cfdiReader = CfdiPackageReader::createFromFile($zipfile); } catch (OpenZipFileException $exception) { echo $exception->getMessage(), PHP_EOL; return; } // leer todos los CFDI dentro del archivo ZIP con el UUID como llave foreach ($cfdiReader->cfdis() as $uuid => $content) { file_put_contents("cfdis/$uuid.xml", $content); }
技术信息
关于RequestBuilderInterface
接口
SAT的下载大量服务Web需要特殊SOAP通信,带有身份验证和签名消息。生成这些消息需要非常详细,因为如果消息包含错误,它将被立即拒绝。
这些消息的签名使用FIEL,因此可以使用FielRequestBuilder
类,它结合了Fiel
类和库phpcfdi/credentials进行签名消息的组合。
然而,在某些分布式场景中,最好是在外部创建这些已签名消息,这样FIEL(私钥和密码)就不需要暴露在外部。对于这些(或其他)场景,可以创建一个实现RequestBuilderInterface
的实例,它包含适当的逻辑并提供必要的已签名消息以进行通信。
关于WebClientInterface
接口
为了使此库与不同的通信方式兼容,使用了一个HTTP客户端接口。你可以创建自己的实现来使用它。
如果你愿意 - 如使用示例 - 你可以安装Guzzle composer require guzzlehttp/guzzle
并使用类 GuzzleWebClient
。
服务工厂推荐
我们建议您配置应用程序的框架(依赖注入容器)或创建一个类来创建Service
、RequestBuilder
和WebClient
对象,使用您的自己的Fiel
配置,如果您有可用的证书、私钥和密码。
异常处理
在使用包读取器(PackageReader)或与Web服务器的HTTP通信(WebClient)时,库可能会抛出异常,您可以在实施时捕获和分析这些异常,也可以用于自定义错误消息。
关于CFDI和滞纳金批量下载服务Web的说明
该服务由4个部分组成
- 身份验证:使用您的FIEL进行身份验证,库隐藏了获取和使用令牌的逻辑。
- 请求:提交一个请求,包括开始日期、结束日期、发出的/接收的请求类型和所需信息类型(CFDI或元数据)。
- 验证:询问SAT是否已准备好请求。
- 下载由请求发出的包。
理解它的一个粗略方式是:想象SAT的服务由三个窗口组成,每个窗口由不同的人服务。
-
在第一个窗口,你提交一个信息请求。他们会签收,但这并不意味着你的信息已经准备好,只是他们已经收到了你的请求。
-
在第二个窗口,你询问你的请求号码,他们告诉你请求还没有准备好,你回来后还是如此,直到最后他们告诉你已经准备好了,并要求你到另一个窗口取你的信息。
-
在最后一个窗口,你到达并逐个请求每个盒子,他们交付并带走。如果你丢失了盒子并在几天后回来请求,可能已经不可用。如果你多次请求同一个盒子,他们可能会告诉你停止请求同一个盒子,并可能会使SAT官员不高兴,不再给你。
-
所有这些都在最高安全级别下进行,每次你与官员交谈时,他们都会要求你出示你的许可,如果你没有或已经过期(仅持续几分钟),他们就会让你去安全人员那里证明你是你本人,并为你发放新的许可。
官方信息
- SAT官方网站 https://www.sat.gob.mx/consultas/42968/consulta-y-recuperacion-de-comprobantes-(nuevo)
- CFDI和滞纳金下载请求:https://www.sat.gob.mx/cs/Satellite?blobcol=urldata&blobkey=id&blobtable=MungoBlobs&blobwhere=1461175180762&ssbinary=true
- 成功请求的下载验证:https://www.sat.gob.mx/cs/Satellite?blobcol=urldata&blobkey=id&blobtable=MungoBlobs&blobwhere=1579314716409&ssbinary=true
- 成功请求的下载:https://www.sat.gob.mx/cs/Satellite?blobcol=urldata&blobkey=id&blobtable=MungoBlobs&blobwhere=1579314716395&ssbinary=true
Web服务重要提示
- 每次请求最多可以恢复200,000条记录,在元数据中最多可以恢复1,000,000条。
- 只要不超过两次下载XML,就没有关于请求次数的限制。
使用说明
- 不适用官方文档中的限制:不得超过两次下载同一个XML。
发现与CFDI类型下载相关的规则在当前表述方式下不适用。然而,适用的规则是:不要超过两次请求同一时间段。当发生这种情况时,请求过程将返回消息"5002: 已耗尽终身请求次数"。
请记住,如果日期初始值或最终值更改至少一天,就已经是另一个时间段,因此如果你遇到这个问题,可以尝试以下方法解决。
在元数据类型查询中,不适用上述限制,因此建议使用此类查询进行实施测试。
- 查询提交和成功验证之间的响应时间。
未能找到一个常数来假设查询返回验证成功状态和下载包准备就绪所需的时间。
根据我们的经验,时间段越长,提交的查询越多,响应越慢,可能从几分钟到几小时不等。通常很少超过24小时。然而,一些用户遇到了罕见情况(可能是由于SAT的问题),请求完成时间长达72小时。
已知问题
兼容性
本库将保持与最新PHP支持版本的兼容性。
我们还使用语义版本2.0.0,因此你可以放心使用这个库而不用担心破坏你的应用程序。
更新
贡献
欢迎贡献。请阅读CONTRIBUTING以获取更多详细信息,并记得检查TODO文件和CHANGELOG文件。
版权和许可
phpcfdi/sat-ws-descarga-masiva
库版权©PhpCfdi,许可在MIT许可证(MIT)下使用。请参阅LICENSE以获取更多信息。