捷克共和国正在重新推行电子销售登记。在 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 服务:
- 每笔销售一条消息,即命名空间
http://fs.gov.cz/eet/schema/v4中的一个Trzba元素,通过 TLS 1.2 或更高版本的 HTTPS 提交。 - 对 SOAP 正文进行 WS-Security 签名,使用排他规范化、SHA-256 和 RSA,纳税人证书放在
wsse:BinarySecurityToken中。 - 纳税人证书是一个 PKCS#12 文件,其公用名即纳税人标识符。
- 整个信封不得超过 12 kB,即 12,288 字节。
- 应答中包含 pok、接收时间以及任何警告,或者一个错误代码。
注册为纳税人、通过 MOJE daně 门户获取证书以及获分配登记单元编号,这些都在运行任何代码之前完成。办完这些手续后,库只需要一个 PKCS#12 文件和两个编号:纳税人标识符和单元标识符。
TsgcEETClient 如何完成往返流程
调用一次 Send 就会按以下顺序执行每个步骤:
- 按照 schema 规则校验
TsgcEETSale记录的每个字段,因此无效的销售会在本地被拒绝并给出可读的原因,永远不会到达服务。 - 构建
Trzba元素并将其封装在 SOAP 1.1 信封中,同时为该消息生成新的uuid_zpravy。 - 使用任意 sgcSign 密钥提供者的密钥,按照 WS-Security 对 SOAP 正文签名:PFX 文件、Windows 证书存储、PKCS#11 令牌或智能卡,或者云密钥服务。
- 在发送任何内容之前,检查生成的信封是否超出 12 kB 上限。
- 将其提交给税务机构。默认端点是 playground 测试环境,因此放到窗体上的组件不会意外申报真实销售。
- 解析应答:pok、接收时间、测试标志、警告和错误代码。
- 验证确认回执上的签名。错误响应按设计不带签名,因此拒绝永远不会变成签名失败。
在 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;
有几个细节决定首次运行能否成功:
- 调用
LoadFromFile。设置FileName和Password只会记录这些值,而BuildMessage在没有证书时会拒绝构建消息。 TaxpayerEIC必须等于签名证书的公用名,否则应答中会带有警告 1。读取Certificate.SubjectCN可以确保这一点。- 真正分配的登记单元编号至少有两位数字,并以 1、2、3 或 4 结尾。像 1 这样随意编造的值能通过校验,但会得到警告 6。
- 收银员输入的金额请用
sgcEETParseAmount读取,而不是StrToCurr,后者遵循本机的小数分隔符。
对于 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 技术支持首先会索要的信息。LastRequestXML 和 LastResponseXML 会原样保留传输的两条消息。
线路中断时:离线队列
连接中断时,收银机也必须能继续销售。TsgcEETClient 将往返流程拆分开来,使销售点可以先将消息放入队列,稍后再发送:
BuildMessage校验、构建、签名并测量信封大小,但不发送。它使用的uuid_zpravy保存在LastMessageUUID中。SendRaw原样提交已存储的信封,包括其原始的uuid_zpravy和prvni_zaslani,这适用于从未离开过本机的消息。Resend重新发送已发送但从未获得确认的销售,使用新的uuid_zpravy并将prvni_zaslani设为 false,这正是规范所描述的重试方式。
// 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 重新发送,就会被打上十二月的偏移量。请把偏移量与销售一起存储,并在重新发送时设置 SaleOffsetMinutes 和 HasSaleOffsetMinutes。
验证确认回执
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、服务器与命令行
- C++Builder 使用同一个组件,
Demos\CBuilder\EET与 Delphi 演示相对应。 - .NET 提供具有相同 API 的
TsgcEETClient,并在demos\EET中提供一个 WinForms 演示。 - sgcSign Server 新增了
POST /api/v1/sign/eet,它使用服务器持有的密钥构建并签名一笔销售,还可以提交该销售并返回应答。这样,连锁经营的多台收银机就可以共享保存在服务器上的同一张纳税人证书,而不必每台收银机都持有一份副本。 sgcsign命令行工具提供了对应的eet动词。它从 JSON 文件读取销售数据,其退出代码会告诉收银机脚本发生了什么:6 表示税务机构拒绝了该销售,5 表示服务器无法对其签名或无法连接税务机构,4 表示无法连接 sgcSign 服务器本身。
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 一起发送给我们,你将收到来自编写这些代码的人的回复。
