Delphi 中的捷克 EET 2.0:使用 TsgcEETClient 登记销售

· 组件
Delphi 中的捷克 EET 2.0:使用 TsgcEETClient 登记销售 | eSeGeCe 博客

捷克共和国正在重新推行电子销售登记。在 EET 2.0(Elektronická evidence tržeb)下,销售点在每笔销售发生时将其报送给税务机构,税务机构则回复一个确认码,即 pok,它是该笔销售已报送的证明。报送将于 2027 年 1 月 1 日开始,而用于构建和测试收银机的 playground 测试环境现已开放。

sgcSign 为此新增了一个组件 TsgcEETClient。它校验销售数据、构建消息、用纳税人证书对消息签名、发送消息、检查确认回执上的签名并返回结果。本文将介绍 EET 2.0 的要求、该组件如何完成往返流程,以及首笔销售、离线队列和确认回执验证的 Delphi 代码。

Delphi 演示中的一笔登记销售,从验证模式一直到真实的 pok。也可在 YouTube 上观看

EET 2.0 是一个新协议,而不是一次更新

如果你曾为第一代 EET 方案构建过收银机,请从零开始。数据接口 4.1 版与旧的 3.1 版不兼容,而且更加简单:无需计算 PKP 或 BKP 安全码,没有增值税明细,也不需要 TLS 客户端证书。一笔销售只有十个数据属性。剩下的就是一个标准的 SOAP 1.1 Web 服务:

注册为纳税人、通过 MOJE daně 门户获取证书以及获分配登记单元编号,这些都在运行任何代码之前完成。办完这些手续后,库只需要一个 PKCS#12 文件和两个编号:纳税人标识符和单元标识符。

TsgcEETClient 如何完成往返流程

调用一次 Send 就会按以下顺序执行每个步骤:

  1. 按照 schema 规则校验 TsgcEETSale 记录的每个字段,因此无效的销售会在本地被拒绝并给出可读的原因,永远不会到达服务。
  2. 构建 Trzba 元素并将其封装在 SOAP 1.1 信封中,同时为该消息生成新的 uuid_zpravy
  3. 使用任意 sgcSign 密钥提供者的密钥,按照 WS-Security 对 SOAP 正文签名:PFX 文件、Windows 证书存储、PKCS#11 令牌或智能卡,或者云密钥服务。
  4. 在发送任何内容之前,检查生成的信封是否超出 12 kB 上限。
  5. 将其提交给税务机构。默认端点是 playground 测试环境,因此放到窗体上的组件不会意外申报真实销售。
  6. 解析应答:pok、接收时间、测试标志、警告和错误代码。
  7. 验证确认回执上的签名。错误响应按设计不带签名,因此拒绝永远不会变成签名失败。

在 Delphi 中完成首笔销售

规范要求纳税人从验证模式开始。消息会像真实消息一样被完整检查,然后被丢弃,因此不会申报任何内容。如果检查通过,说明证书、签名、TLS 连接以及销售的每个字段都是正确的。下面的代码先运行该检查,然后再真实申报这笔销售。

var
  oProvider: TsgcPFXKeyProvider;
  oClient: TsgcEETClient;
  oSale: TsgcEETSale;
  oResponse: TsgcEETResponse;
begin
  oProvider := TsgcPFXKeyProvider.Create(nil);
  oClient := TsgcEETClient.Create(nil);
  try
    oProvider.FileName := 'CZ00000019.p12';
    oProvider.Password := '...';
    // Without LoadFromFile the certificate is empty and the message would
    // carry no token for the tax authority to verify the signature with.
    oProvider.LoadFromFile;
    oClient.KeyProvider := oProvider as IsgcKeyProvider;
    oClient.Environment := eetPlayground;

    sgcEETInitSale(oSale);
    oSale.SendDateTime := Now;
    oSale.SaleDateTime := Now;
    oSale.FirstSending := True;
    // The common name of an EET certificate IS the taxpayer identifier.
    oSale.TaxpayerEIC := oProvider.Certificate.SubjectCN;
    oSale.UnitID := 11;
    oSale.PosID := '1';
    oSale.ReceiptNumber := '0/6460/ZQ42';
    oSale.TotalAmount := 349;

    // Verification mode first. Nothing is filed.
    oClient.VerificationMode := True;
    oResponse := oClient.Send(oSale);
    if sgcEETResponseOutcome(oResponse) <> eoVerified then
      raise Exception.CreateFmt('Verification failed, code %d: %s',
        [oResponse.ErrorCode, oResponse.ErrorText]);

    // Now for real. Only eoAcknowledged reports a sale.
    oClient.VerificationMode := False;
    oResponse := oClient.Send(oSale);
    if sgcEETResponseOutcome(oResponse) = eoAcknowledged then
      PrintReceipt(oResponse.POK, oResponse.Test) // your own routine
    else
      // Not filed. Store the sale and replay it later with Resend.
      QueueSale(oSale); // your own routine
  finally
    oClient.Free;
    oProvider.Free;
  end;
end;

有几个细节决定首次运行能否成功:

对于 playground 测试环境,税务管理部门在 eet.gov.cz 上发布了共享的测试证书,其中包括 CZ00000019。来自 playground 测试环境的确认回执带有 test="true",其 pok 以 ff 结尾,它不能证明任何真实销售。

读取应答

这是协议中最容易让你意外的部分。每种结果都以 HTTP 200 返回,拒绝也不例外,所以 HTTP 状态码说明不了任何问题。而验证模式的成功结果是放在一个带代码 0 的错误元素中返回的,因此在一次完全正常的验证运行中,TsgcEETResponse.IsError 也为 True。

sgcEETResponseOutcome 应用这两条规则,并返回以下三种结果之一:

结果含义
eoAcknowledged销售已报送,pok 位于 TsgcEETResponse.POK 中。这是唯一表示销售已报送的结果。
eoVerified验证模式成功。未申报任何内容。
eoRejected其他所有情况。该销售尚未报送,仍需向税务机构报送。

警告并不严重。一个有效的确认回执最多可以附带十条警告,每条警告都会触发一次 OnWarning,而 OnError 会在错误代码不为 0 时触发。每条消息之后,请记录 LastTransactionId,即 X-Global-Transaction-Id 响应头,因为这是 EET 技术支持首先会索要的信息。LastRequestXMLLastResponseXML 会原样保留传输的两条消息。

线路中断时:离线队列

连接中断时,收银机也必须能继续销售。TsgcEETClient 将往返流程拆分开来,使销售点可以先将消息放入队列,稍后再发送:

// The line is down: sign the message now and keep it
sEnvelope := oClient.BuildMessage(oSale);
StoreInQueue(oClient.LastMessageUUID, sEnvelope); // your own storage

// The line is back: post the stored envelope exactly as it was built
oResponse := oClient.SendRaw(sEnvelope);

// Sent earlier but no answer arrived: replay the sale as a repeat
oResponse := oClient.Resend(oSale);

在构建队列之前,有一个陷阱值得了解。销售时间会带时区偏移量写入,除非销售记录自带偏移量,否则库会使用构建消息那一刻本机的偏移量。一笔七月的销售如果在十二月通过 Resend 重新发送,就会被打上十二月的偏移量。请把偏移量与销售一起存储,并在重新发送时设置 SaleOffsetMinutesHasSaleOffsetMinutes

验证确认回执

VerifyResponseSignature 默认为 True,因此开箱即会检查每个确认回执上的签名。若还要检查证书链,就需要正确的信任锚,而它们并不是显而易见的那些。确认回执由 I.CA 的商业证书签名,而不是由 playground 测试材料附带的 EET 证书签名,并且这两个 I.CA 颁发者都不在 Windows 根证书存储中。请从 ica.cz 下载 I.CA Root CA/RSA 05/2022 和 I.CA Public CA/RSA 06/2022,并将它们指定为信任锚:

oClient.TrustedCertificates.Add('ica-root-ca-rsa-05-2022.cer');
oClient.TrustedCertificates.Add('ica-public-ca-rsa-06-2022.cer');
oClient.RequireTrustedChain := True;

如果检查失败,LastVerificationDetails 会报告失败的步骤。当确认回执已到达但其签名未通过验证时,Send 会抛出异常,而 LastResponse 仍保存着解析后的应答及其 pok,因此已经登记的销售永远不会因失误被发送两次。

12 kB 上限

服务会以错误代码 7 拒绝大于 12 kB 的消息,而 BuildMessage 会在发送任何内容之前检查大小。每个销售字段都受 schema 限制,因此信封中大小真正会变化的只有 wsse:BinarySecurityToken 中的签名证书。这也是信封恰好只携带一个 SOAP 头,且组件不提供添加其他头的方法的原因。

C++Builder、.NET、服务器与命令行

set SGCSIGN_SERVER=https://sign.shop.local:8443
set SGCSIGN_APIKEY=sgcsk_...

sgcsign eet --provider eet-taxpayer --submit sale.json

动手试试

Demos\Delphi\EET 中的 Delphi 演示在一个窗体上针对 playground 测试环境完整演示整个往返流程。加载测试证书,在验证模式下发送,然后取消勾选该选项并发送一笔真实销售,即可获得带有 pok 的确认回执。Build Message (no send) 显示离线队列会存储的已签名信封,Resend Stored Sale 则将上一笔销售作为重复提交重新发送。演示中不包含测试证书,因为发放这些证书的文档是受限的,所以请从 eet.gov.cz 下载。

所有属性、方法和事件均收录在 sgcSign 在线帮助中,sgcSign 国家配置文件页面的 EET 2.0 章节对该组件做了概述。

供应情况

TsgcEETClient 随 sgcSign 2026.10 提供,支持 Delphi、C++Builder 和 .NET,同时提供 sgcSign Server 路由和命令行工具的 eet 动词。

有疑问,或者有一台必须在一月前准备就绪的收银机?联系我们。如果某些功能的表现与预期不符,请将请求和响应 XML 连同 X-Global-Transaction-Id 一起发送给我们,你将收到来自编写这些代码的人的回复。