commit c932419c73400ad2d18a358c9858404d386981ea Author: BBIT-Kai <2911862937@qq.com> Date: Thu Apr 30 10:47:26 2026 +0800 初始化项目 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..545a6e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +web/node_modules/ +web/dist/ diff --git a/Codex三期工程执行文档.md b/Codex三期工程执行文档.md new file mode 100644 index 0000000..29c9da0 --- /dev/null +++ b/Codex三期工程执行文档.md @@ -0,0 +1,328 @@ +# Codex 三期工程执行文档 + +适用范围:基于当前已经完成的通用后台底座,继续建设“农产品收购发票开票平台”业务闭环。 +使用方式:建议你后续给 Codex 下任务时,一次只下发一个“任务包”,不要跨太多模块,这样实现时间更短、回归范围更可控。 + +## 1. 业务功能规划 + +### 1.1 新增业务菜单 + +当前系统管理、日志管理等菜单已经具备,后续只新增业务菜单,不重复建设已有后台功能。 + +建议新增一级菜单: + +- `发票业务` + +建议在 `发票业务` 下新增 4 个菜单: + +- `待开票列表` + - 用于查看业务系统返回的待开票数据、筛选、勾选、发起开票 +- `开票任务` + - 用于查看开票状态、失败原因、票号信息、重试操作 +- `认证中心` + - 用于处理票通认证状态、短信认证、扫码认证 +- `票通账号配置` + - 用于维护公司与票通账号、默认开票参数、基础映射关系 + +说明: + +- `开票结果` 不建议单独再做一个菜单,直接并入 `开票任务` +- `票通回调`、`主动查询补偿`、`业务系统回写` 不需要前端菜单,做后端能力即可 + +### 1.2 建议目录规划 + +为了适配当前项目结构,建议继续沿用现有 `modules` / `features` 风格,不要一开始就做大规模重构。 + +后端建议新增目录: + +```text +server/src/main/kotlin/com/bbit/platform/ + database/invoice/ + InvoiceJobTable.kt + InvoiceJobItemTable.kt + TaxAccountTable.kt + InvoiceSyncLogTable.kt + + integration/biz/ + BizSystemClient.kt + BizInvoiceDtos.kt + + integration/piaotong/ + PiaotongClient.kt + PiaotongCrypto.kt + PiaotongDtos.kt + + modules/invoice/candidate/ + InvoiceCandidateModule.kt + + modules/invoice/job/ + InvoiceJobModule.kt + + modules/invoice/auth/ + InvoiceAuthModule.kt + + modules/invoice/settings/ + TaxAccountModule.kt + + modules/invoice/callback/ + PiaotongCallbackModule.kt + + modules/invoice/shared/ + InvoiceStatus.kt + InvoiceServices.kt +``` + +前端建议新增目录: + +```text +web/src/ + api/invoice/ + candidates.ts + jobs.ts + auth.ts + settings.ts + + types/invoice/ + candidate.ts + job.ts + auth.ts + settings.ts + + features/invoice/ + candidates/index.vue + jobs/index.vue + auth-center/index.vue + settings/index.vue + + components/invoice/ + InvoiceConfirmDialog.vue + InvoiceAuthDialog.vue + InvoiceJobStatusTag.vue +``` + +### 1.3 模块职责划分 + +建议后续只围绕 4 个业务模块推进: + +- `待开票模块` + - 对接业务系统待开票列表 + - 支持筛选、勾选、金额汇总、发起开票 +- `开票任务模块` + - 负责本地任务记录、状态流转、重试、详情查询 +- `认证模块` + - 负责认证状态查询、短信认证、扫码认证、认证后继续开票 +- `账号配置模块` + - 负责公司与票通账号、默认参数、启停状态维护 + +Codex 执行建议: + +- 不要把“票通对接、业务系统对接、前端页面、状态补偿”揉成一个任务 +- 最适合的粒度是“一个模块的一条主链路” +- 每次任务尽量能做到“改完即可本地验证一个页面或一个接口” + +## 2. 需要准备的内容 + +这一章只写你这边需要准备的内容。票通接口文档已经在项目 `doc/` 目录里,后续不需要你再重复整理一遍。 + +### 2.1 你自己的业务系统接口资料 + +这是最关键的一部分,建议你提前准备并确认下面这些接口或数据来源: + +- `当前登录人/公司上下文接口` + - 至少要能拿到 `userId`、`companyId`、公司名称、销方税号 +- `待开票列表接口` + - 至少要能返回待开票主数据、金额、税率、商品信息、购销双方信息、唯一业务单号 +- `开票前写库/锁单接口` + - 发起开票前锁定业务数据,避免重复开票 +- `开票结果回写接口` + - 回写成功/失败状态、发票号码、数电发票号码、失败原因、票通流水号 +- `可选:解锁或撤销接口` + - 如果开票失败后需要解除业务锁定,最好也提前明确 + +每个接口至少准备 5 类信息: + +- 请求地址和调用方式 +- 鉴权方式 +- 请求参数说明 +- 返回字段说明 +- 一份真实示例报文 + +### 2.2 业务规则说明 + +Codex 能写代码,但业务规则如果不明确,后面很容易返工。建议你提前确认这些规则: + +- 一条待开票数据是否对应一张发票,还是允许合并开票 +- 发票商品行从哪里来,是业务系统直接给,还是平台侧组装 +- 税率、税收分类编码、商品名称是否有默认规则 +- 哪些字段缺失时不能发起开票 +- 开票失败后,业务系统状态如何处理 +- 同一业务单据的重复开票判断依据是什么 +- 一个公司是否可能配置多个票通账号 + +### 2.3 联调和测试资源 + +建议你至少准备一套可联调的数据环境: + +- 业务系统测试地址 +- 可用的测试公司 +- 至少 1 个可用测试账号 +- 3 组待开票测试数据 + - 正常成功 + - 需要认证 + - 明确失败 + +最好再补两类样例: + +- 一份真实的待开票列表返回数据 +- 一份真实的开票结果回写样例 + +### 2.4 环境和配置准备 + +建议你提前准备好下面这些配置: + +- 后端数据库连接信息 +- Redis 连接信息 +- 前端、后端联调地址 +- 业务系统的接口鉴权配置 +- 票通测试账号、密钥、平台编码 +- 能接收回调的测试域名或内网穿透地址 + +说明: + +- 票通接口定义已经在 `doc/` 中,重点是确认账号和环境真实可用 +- 如果业务系统接口还没稳定,建议先给一版 mock 或示例 JSON,Codex 可以先按样例落代码 + +## 3. 具体的三期工程执行内容 + +### 总体执行原则 + +三期都建议按“任务包”推进。每个任务包尽量满足下面三个条件: + +- 改动范围集中,不跨太多目录 +- 能在一次对话里做完并验证 +- 做完后能形成明确可见结果 + +建议你后续给 Codex 下指令时,也按下面的任务包来发,不要一次塞完整三期。 + +### 第一期:业务骨架和数据接入 + +目标:先把“业务菜单、业务数据接入、基础配置、任务落库”搭起来,不急着一次打通所有票通细节。 + +一期任务包建议: + +1. `新增业务菜单和前端页面骨架` + - 新增发票业务菜单 + - 新建 4 个业务页面空壳 + - 接通路由和权限码 + +2. `新增业务表和后端模块骨架` + - 新增开票任务、任务明细、票通账号等表 + - 新增 invoice 相关后端模块目录和路由注册 + +3. `接入公司上下文和待开票列表` + - 打通当前用户所属公司信息 + - 对接业务系统待开票列表接口 + - 前端完成待开票列表查询展示 + +4. `完成票通账号配置页` + - 后端完成账号配置增删改查 + - 前端完成配置页面 + +5. `完成本地开票任务创建骨架` + - 支持勾选数据创建本地任务 + - 先把锁单、落库、状态初始化串起来 + +一期验收结果: + +- 能看到新菜单 +- 能查到待开票数据 +- 能保存票通账号配置 +- 能创建本地开票任务 + +### 第二期:开票主流程和认证流程 + +目标:把“发起开票 -> 遇到认证 -> 完成认证 -> 继续开票”这条主链路打通。 + +二期任务包建议: + +1. `接入票通开票主接口` + - 完成票通报文组装、加密、签名 + - 打通蓝字开票主调用 + +2. `补齐开票状态机和幂等控制` + - 固化 `invoiceReqSerialNo` + - 完成 `PENDING / PROCESSING / NEED_AUTH / SUCCESS / FAILED` 状态流转 + +3. `完成认证中心后端接口` + - 查询认证状态 + - 短信验证码 + - 短信登录 + - 扫码二维码和扫码状态查询 + +4. `完成前端开票确认和认证交互` + - 开票确认弹窗 + - 短信认证弹窗 + - 扫码认证弹窗 + - 认证完成后继续原任务 + +5. `完成开票任务列表和详情页` + - 展示任务状态、失败原因、票号信息 + - 支持查看任务明细 + +二期验收结果: + +- 能从待开票列表发起真实开票 +- 遇到认证时可以完成短信或扫码认证 +- 认证后可以继续原任务 +- 能看到任务状态和开票结果 + +### 第三期:回调、补偿、稳定性收口 + +目标:把“结果同步、失败补偿、重试、运维可见性”补齐,让系统进入可持续使用状态。 + +三期任务包建议: + +1. `完成票通回调接收` + - 新增回调接口 + - 完成验签、去重、状态更新 + +2. `完成主动查询补偿` + - 定时查询处理中任务 + - 补齐回调未达或超时场景 + +3. `完成业务系统结果回写和补偿` + - 开票成功/失败回写业务系统 + - 回写失败时记录补偿状态并支持重试 + +4. `完成任务重试和异常处理` + - 支持失败任务重试 + - 保证原幂等号复用 + - 明确错误提示和异常留痕 + +5. `完成页面收口和交付文档` + - 页面细节优化 + - 状态文案统一 + - 补一份联调说明和部署说明 + +三期验收结果: + +- 开票结果可以通过回调或主动查询最终落定 +- 结果能稳定回写业务系统 +- 失败任务可以重试 +- 系统具备基本可运维性 + +### 给 Codex 的推荐下发方式 + +后续你可以直接按下面这种方式给 Codex 发任务: + +- `按一期第 1 个任务包做,只做菜单、路由和 4 个页面骨架,不做接口` +- `按一期第 3 个任务包做,只打通待开票列表前后端,不碰票通` +- `按二期第 3 个任务包做,只做认证中心后端接口和页面交互` +- `按三期第 2 个任务包做,只做处理中任务的主动查询补偿` + +这样做最适合 Codex: + +- 单次上下文更清晰 +- 改动范围更集中 +- 验证更容易 +- 出问题时也更容易回退和继续推进 diff --git a/doc/C#仅供参考组装报文和加密/EncryptDes.cs b/doc/C#仅供参考组装报文和加密/EncryptDes.cs new file mode 100644 index 0000000..633ea56 --- /dev/null +++ b/doc/C#仅供参考组装报文和加密/EncryptDes.cs @@ -0,0 +1,66 @@ + +using System; +using System.Security.Cryptography; +using System.Text; +namespace ConsoleDemo +{ + public class EncryptDes + { + + /** + * aStrString 加密内容 + * aStrKey 加密秘钥 + */ + public static String Encrypt3Des(String aStrString, String aStrKey, CipherMode mode = CipherMode.ECB, String iv = "12345678") + { + + + try + { + var des = new TripleDESCryptoServiceProvider + { + Key = Encoding.UTF8.GetBytes(aStrKey), + Mode = mode + }; + if (mode == CipherMode.CBC) + { + des.IV = Encoding.UTF8.GetBytes(iv); + } + var desEncrypt = des.CreateEncryptor(); + byte[] buffer = Encoding.UTF8.GetBytes(aStrString); + return Convert.ToBase64String(desEncrypt.TransformFinalBlock(buffer, 0, buffer.Length)); + } + catch (Exception e) + { + return string.Empty; + } + } + public static string Decrypt3Des(string aStrString, string aStrKey, CipherMode mode = CipherMode.ECB, string iv = "12345678") + { + try + { + var des = new TripleDESCryptoServiceProvider + { + Key = Encoding.UTF8.GetBytes(aStrKey), + Mode = mode, + Padding = PaddingMode.PKCS7 + }; + if (mode == CipherMode.CBC) + { + des.IV = Encoding.UTF8.GetBytes(iv); + } + var desDecrypt = des.CreateDecryptor(); + var result = ""; + byte[] buffer = Convert.FromBase64String(aStrString); + result = Encoding.UTF8.GetString(desDecrypt.TransformFinalBlock(buffer, 0, buffer.Length)); + return result; + } + catch (Exception e) + { + return string.Empty; + } + } + + + } +} \ No newline at end of file diff --git a/doc/C#仅供参考组装报文和加密/PostJson.cs b/doc/C#仅供参考组装报文和加密/PostJson.cs new file mode 100644 index 0000000..230cf3e --- /dev/null +++ b/doc/C#仅供参考组装报文和加密/PostJson.cs @@ -0,0 +1,37 @@ +using System.IO; +using System.Net; +using System.Text; + +namespace ConsoleDemo +{ + public class PostJson + { + /** + * Json的请求头 post请求地址 + */ + public static string Post4Json(string url,string buildRequest) + { + string result = ""; + HttpWebRequest request =(HttpWebRequest) WebRequest.Create(url); + request.Method = "POST"; + request.Timeout = 5000; + request.ContentType = "application/json"; + byte[] byte4builde = Encoding.UTF8.GetBytes(buildRequest); + request.ContentLength = byte4builde.Length; + using (Stream reqStream=request.GetRequestStream()) + { + reqStream.Write(byte4builde,0,byte4builde.Length); + reqStream.Close(); + } + + HttpWebResponse response = (HttpWebResponse) request.GetResponse(); + Stream stream = response.GetResponseStream(); + //获得响应内容 + using (StreamReader reader=new StreamReader(stream,Encoding.UTF8)) + { + result = reader.ReadToEnd(); + } + return result; + } + } +} \ No newline at end of file diff --git a/doc/C#仅供参考组装报文和加密/Program.cs b/doc/C#仅供参考组装报文和加密/Program.cs new file mode 100644 index 0000000..8bbca4d --- /dev/null +++ b/doc/C#仅供参考组装报文和加密/Program.cs @@ -0,0 +1,4 @@ +// See https://aka.ms/new-console-template for more information +using ConsoleDemo; + +Console.WriteLine("Hello, World!"); diff --git a/doc/C#仅供参考组装报文和加密/PublicData.cs b/doc/C#仅供参考组装报文和加密/PublicData.cs new file mode 100644 index 0000000..9f9700a --- /dev/null +++ b/doc/C#仅供参考组装报文和加密/PublicData.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections; +using System.Runtime.CompilerServices; +using System.Text; + +namespace ConsoleDemo +{ + public class PublicData + { + /** + * 公共报文组装 + */ + public static string publicparam(string content,string platformCode , + string platformAlias,string privateKey,string password) + { + StringBuilder sign =new StringBuilder(); + string contentstr=EncryptDes.Encrypt3Des(content, password); + sign.Append("content="+contentstr+"&"); + sign.Append("format=JSON&"); + sign.Append("platformCode="+platformCode+"&"); + string time = DateTime.Now.ToString("yyyyMMddHHmmss"); + string serianlNo = platformAlias + time + GenerateCheckCode(8); + sign.Append("serialNo="+serianlNo+"&"); + sign.Append("signType=RSA&"); + string timestamp=DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + sign.Append("timestamp="+timestamp+"&"); + sign.Append("version=1.0"); + string rsasign = RSA.sign(sign.ToString(), privateKey); + Hashtable publictable=new Hashtable(); + publictable.Add("sign",rsasign); + publictable.Add("format","JSON"); + publictable.Add("platformCode",platformCode); + publictable.Add("serialNo",serianlNo); + publictable.Add("signType","RSA"); + publictable.Add("timestamp",timestamp); + publictable.Add("version","1.0"); + publictable.Add("content",contentstr); + return ToJson.Table2Json(publictable); + } + + public static string disposeResponse(string str,string ptpublickey, string deskey) + { + + Hashtable strtable=new Hashtable(); + StringBuilder content=new StringBuilder(); + Hashtable dispose=new Hashtable(); + + string sign = ""; + string serialNo = ""; + str=str.Replace("{","").Replace("}","").Replace("\"","").Replace(",","&").Replace(":","&"); + string[] arraystr = str.Split('&'); + + for (int i = 0; i < arraystr.Length-1; i+=2) + { + if (arraystr[i]=="sign") + { + sign = arraystr[i + 1]; + } + if (arraystr[i]=="serialNo") + { + serialNo=arraystr[i + 1]; + } + strtable.Add(arraystr[i],arraystr[i+1]); + } + strtable.Remove("serialNo"); + strtable.Remove("sign"); + ArrayList ke = new ArrayList(strtable.Keys); + ke.Sort(); + foreach (string tableEntry in ke) + { + content.Append(string.Format("{0}={1}&", tableEntry, strtable[tableEntry])); + } + content.Append("serialNo=" + serialNo); + bool res=RSA.verify(content.ToString(), sign, ptpublickey, "UTF-8"); + Console.WriteLine(res); + if (res) + { + for (int i = 0; i < arraystr.Length - 1; i += 2) + { + if (arraystr[i] == "content") + { + + arraystr[i+1]=EncryptDes.Decrypt3Des(arraystr[i+1],deskey); + } + dispose.Add(arraystr[i],arraystr[i+1]); + + } + return ToJson.Table2Json(dispose); + + } + else + { + return "验签失败"; + } + } + /** + * 随机数生成 + */ + private static string GenerateCheckCode(int codeCount) + { + int rep = 0; + string str = string.Empty; + long num2 = DateTime.Now.Ticks + rep; + rep++; + Random random = new Random(((int)(((ulong)num2) & 0xffffffffL)) | ((int)(num2 >> rep))); + for (int i = 0; i < codeCount; i++) + { + char ch; + int num = random.Next(); + if ((num % 2) == 0) + { + ch = (char)(0x30 + ((ushort)(num % 10))); + } + else + { + ch = (char)(0x41 + ((ushort)(num % 0x1a))); + } + str = str + ch.ToString(); + } + return str; + } + } +} \ No newline at end of file diff --git a/doc/C#仅供参考组装报文和加密/RSA.cs b/doc/C#仅供参考组装报文和加密/RSA.cs new file mode 100644 index 0000000..aad47d1 --- /dev/null +++ b/doc/C#仅供参考组装报文和加密/RSA.cs @@ -0,0 +1,502 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.IO; +using System.Security.Cryptography; + +namespace ConsoleDemo +{ + internal class RSA + + { + + /** + * content 签名前的报文 + * privateKey 私钥 + * input_charset 编码格式 (以下默认UTF-8) + */ + public static string sign(string content, string privateKey) + { + byte[] Data = Encoding.GetEncoding("UTF-8").GetBytes(content); + RSACryptoServiceProvider rsa = DecodePemPrivateKey(privateKey); + SHA1 sh = new SHA1CryptoServiceProvider(); + byte[] signData = rsa.SignData(Data, sh); + return Convert.ToBase64String(signData); + } + + /// + /// 验签 + /// + /// 待验签字符串 + /// 签名 + /// 公钥 + /// 编码格式 + /// true(通过),false(不通过) + public static bool verify(string content, string signedString, string publicKey, string input_charset) + { + bool result ; + byte[] Data = Encoding.GetEncoding(input_charset).GetBytes(content); + byte[] data = Convert.FromBase64String(signedString); + RSAParameters paraPub = ConvertFromPublicKey(publicKey); + RSACryptoServiceProvider rsaPub = new RSACryptoServiceProvider(); + rsaPub.ImportParameters(paraPub); + SHA1 sh = new SHA1CryptoServiceProvider(); + result = rsaPub.VerifyData(Data, sh, data); + return result; + } + + /// + /// 加密 + /// + /// 需要加密的字符串 + /// 公钥 + /// 编码格式 + /// 明文 + public static string encryptData(string resData, string publicKey, string input_charset) + { + byte[] DataToEncrypt = Encoding.ASCII.GetBytes(resData); + string result = encrypt(DataToEncrypt, publicKey, input_charset); + return result; + } + + + /// + /// 解密 + /// + /// 加密字符串 + /// 私钥 + /// 编码格式 + /// 明文 + public static string decryptData(string resData, string privateKey, string input_charset) + { + byte[] DataToDecrypt = Convert.FromBase64String(resData); + string result = ""; + for (int j = 0; j < DataToDecrypt.Length / 128; j++) + { + byte[] buf = new byte[128]; + for (int i = 0; i < 128; i++) + { + + buf[i] = DataToDecrypt[i + 128 * j]; + } + result += decrypt(buf, privateKey, input_charset); + } + return result; + } + + + + + + #region 内部方法 + + private static string encrypt(byte[] data, string publicKey, string input_charset) + { + RSACryptoServiceProvider rsa = DecodePemPublicKey(publicKey); + SHA1 sh = new SHA1CryptoServiceProvider(); + byte[] result = rsa.Encrypt(data, false); + + return Convert.ToBase64String(result); + } + + private static string decrypt(byte[] data, string privateKey, string input_charset) + { + string result = ""; + RSACryptoServiceProvider rsa = DecodePemPrivateKey(privateKey); + SHA1 sh = new SHA1CryptoServiceProvider(); + byte[] source = rsa.Decrypt(data, false); + char[] asciiChars = new char[Encoding.GetEncoding(input_charset).GetCharCount(source, 0, source.Length)]; + Encoding.GetEncoding(input_charset).GetChars(source, 0, source.Length, asciiChars, 0); + result = new string(asciiChars); + //result = ASCIIEncoding.ASCII.GetString(source); + return result; + } + + private static RSACryptoServiceProvider DecodePemPublicKey(String pemstr) + { + byte[] pkcs8publickkey; + pkcs8publickkey = Convert.FromBase64String(pemstr); + if (pkcs8publickkey != null) + { + RSACryptoServiceProvider rsa = DecodeRSAPublicKey(pkcs8publickkey); + return rsa; + } + else + return null; + } + + private static RSACryptoServiceProvider DecodePemPrivateKey(String pemstr) + { + byte[] pkcs8privatekey; + pkcs8privatekey = Convert.FromBase64String(pemstr); + if (pkcs8privatekey != null) + { + RSACryptoServiceProvider rsa = DecodePrivateKeyInfo(pkcs8privatekey); + return rsa; + } + else + return null; + } + + private static RSACryptoServiceProvider DecodePrivateKeyInfo(byte[] pkcs8) + { + byte[] SeqOID = { 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00 }; + byte[] seq = new byte[15]; + + MemoryStream mem = new MemoryStream(pkcs8); + int lenstream = (int)mem.Length; + BinaryReader binr = new BinaryReader(mem); //wrap Memory Stream with BinaryReader for easy reading + byte bt = 0; + ushort twobytes = 0; + + try + { + twobytes = binr.ReadUInt16(); + if (twobytes == 0x8130) //data read as little endian order (actual data order for Sequence is 30 81) + binr.ReadByte(); //advance 1 byte + else if (twobytes == 0x8230) + binr.ReadInt16(); //advance 2 bytes + else + return null; + + bt = binr.ReadByte(); + if (bt != 0x02) + return null; + + twobytes = binr.ReadUInt16(); + + if (twobytes != 0x0001) + return null; + + seq = binr.ReadBytes(15); //read the Sequence OID + if (!CompareBytearrays(seq, SeqOID)) //make sure Sequence for OID is correct + return null; + + bt = binr.ReadByte(); + if (bt != 0x04) //expect an Octet string + return null; + + bt = binr.ReadByte(); //read next byte, or next 2 bytes is 0x81 or 0x82; otherwise bt is the byte count + if (bt == 0x81) + binr.ReadByte(); + else + if (bt == 0x82) + binr.ReadUInt16(); + //------ at this stage, the remaining sequence should be the RSA private key + + byte[] rsaprivkey = binr.ReadBytes((int)(lenstream - mem.Position)); + RSACryptoServiceProvider rsacsp = DecodeRSAPrivateKey(rsaprivkey); + return rsacsp; + } + + catch (Exception) + { + return null; + } + + finally { binr.Close(); } + + } + + private static bool CompareBytearrays(byte[] a, byte[] b) + { + if (a.Length != b.Length) + return false; + int i = 0; + foreach (byte c in a) + { + if (c != b[i]) + return false; + i++; + } + return true; + } + + private static RSACryptoServiceProvider DecodeRSAPublicKey(byte[] publickey) + { + // encoded OID sequence for PKCS #1 rsaEncryption szOID_RSA_RSA = "1.2.840.113549.1.1.1" + byte[] SeqOID = { 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00 }; + byte[] seq = new byte[15]; + // --------- Set up stream to read the asn.1 encoded SubjectPublicKeyInfo blob ------ + MemoryStream mem = new MemoryStream(publickey); + BinaryReader binr = new BinaryReader(mem); //wrap Memory Stream with BinaryReader for easy reading + byte bt = 0; + ushort twobytes = 0; + + try + { + + twobytes = binr.ReadUInt16(); + if (twobytes == 0x8130) //data read as little endian order (actual data order for Sequence is 30 81) + binr.ReadByte(); //advance 1 byte + else if (twobytes == 0x8230) + binr.ReadInt16(); //advance 2 bytes + else + return null; + + seq = binr.ReadBytes(15); //read the Sequence OID + if (!CompareBytearrays(seq, SeqOID)) //make sure Sequence for OID is correct + return null; + + twobytes = binr.ReadUInt16(); + if (twobytes == 0x8103) //data read as little endian order (actual data order for Bit String is 03 81) + binr.ReadByte(); //advance 1 byte + else if (twobytes == 0x8203) + binr.ReadInt16(); //advance 2 bytes + else + return null; + + bt = binr.ReadByte(); + if (bt != 0x00) //expect null byte next + return null; + + twobytes = binr.ReadUInt16(); + if (twobytes == 0x8130) //data read as little endian order (actual data order for Sequence is 30 81) + binr.ReadByte(); //advance 1 byte + else if (twobytes == 0x8230) + binr.ReadInt16(); //advance 2 bytes + else + return null; + + twobytes = binr.ReadUInt16(); + byte lowbyte = 0x00; + byte highbyte = 0x00; + + if (twobytes == 0x8102) //data read as little endian order (actual data order for Integer is 02 81) + lowbyte = binr.ReadByte(); // read next bytes which is bytes in modulus + else if (twobytes == 0x8202) + { + highbyte = binr.ReadByte(); //advance 2 bytes + lowbyte = binr.ReadByte(); + } + else + return null; + byte[] modint = { lowbyte, highbyte, 0x00, 0x00 }; //reverse byte order since asn.1 key uses big endian order + int modsize = BitConverter.ToInt32(modint, 0); + + byte firstbyte = binr.ReadByte(); + binr.BaseStream.Seek(-1, SeekOrigin.Current); + + if (firstbyte == 0x00) + { //if first byte (highest order) of modulus is zero, don't include it + binr.ReadByte(); //skip this null byte + modsize -= 1; //reduce modulus buffer size by 1 + } + + byte[] modulus = binr.ReadBytes(modsize); //read the modulus bytes + + if (binr.ReadByte() != 0x02) //expect an Integer for the exponent data + return null; + int expbytes = (int)binr.ReadByte(); // should only need one byte for actual exponent data (for all useful values) + byte[] exponent = binr.ReadBytes(expbytes); + + // ------- create RSACryptoServiceProvider instance and initialize with public key ----- + RSACryptoServiceProvider RSA = new RSACryptoServiceProvider(); + RSAParameters RSAKeyInfo = new RSAParameters(); + RSAKeyInfo.Modulus = modulus; + RSAKeyInfo.Exponent = exponent; + RSA.ImportParameters(RSAKeyInfo); + return RSA; + } + catch (Exception) + { + return null; + } + + finally { binr.Close(); } + + } + + private static RSACryptoServiceProvider DecodeRSAPrivateKey(byte[] privkey) + { + byte[] MODULUS, E, D, P, Q, DP, DQ, IQ; + + // --------- Set up stream to decode the asn.1 encoded RSA private key ------ + MemoryStream mem = new MemoryStream(privkey); + BinaryReader binr = new BinaryReader(mem); //wrap Memory Stream with BinaryReader for easy reading + byte bt = 0; + ushort twobytes = 0; + int elems = 0; + try + { + twobytes = binr.ReadUInt16(); + if (twobytes == 0x8130) //data read as little endian order (actual data order for Sequence is 30 81) + binr.ReadByte(); //advance 1 byte + else if (twobytes == 0x8230) + binr.ReadInt16(); //advance 2 bytes + else + return null; + + twobytes = binr.ReadUInt16(); + if (twobytes != 0x0102) //version number + return null; + bt = binr.ReadByte(); + if (bt != 0x00) + return null; + + + //------ all private key components are Integer sequences ---- + elems = GetIntegerSize(binr); + MODULUS = binr.ReadBytes(elems); + + elems = GetIntegerSize(binr); + E = binr.ReadBytes(elems); + + elems = GetIntegerSize(binr); + D = binr.ReadBytes(elems); + + elems = GetIntegerSize(binr); + P = binr.ReadBytes(elems); + + elems = GetIntegerSize(binr); + Q = binr.ReadBytes(elems); + + elems = GetIntegerSize(binr); + DP = binr.ReadBytes(elems); + + elems = GetIntegerSize(binr); + DQ = binr.ReadBytes(elems); + + elems = GetIntegerSize(binr); + IQ = binr.ReadBytes(elems); + + // ------- create RSACryptoServiceProvider instance and initialize with public key ----- + RSACryptoServiceProvider RSA = new RSACryptoServiceProvider(); + RSAParameters RSAparams = new RSAParameters(); + RSAparams.Modulus = MODULUS; + RSAparams.Exponent = E; + RSAparams.D = D; + RSAparams.P = P; + RSAparams.Q = Q; + RSAparams.DP = DP; + RSAparams.DQ = DQ; + RSAparams.InverseQ = IQ; + RSA.ImportParameters(RSAparams); + return RSA; + } + catch (Exception) + { + return null; + } + finally { binr.Close(); } + } + + private static int GetIntegerSize(BinaryReader binr) + { + byte bt = 0; + byte lowbyte = 0x00; + byte highbyte = 0x00; + int count = 0; + bt = binr.ReadByte(); + if (bt != 0x02) //expect integer + return 0; + bt = binr.ReadByte(); + + if (bt == 0x81) + count = binr.ReadByte(); // data size in next byte + else + if (bt == 0x82) + { + highbyte = binr.ReadByte(); // data size in next 2 bytes + lowbyte = binr.ReadByte(); + byte[] modint = { lowbyte, highbyte, 0x00, 0x00 }; + count = BitConverter.ToInt32(modint, 0); + } + else + { + count = bt; // we already have the data size + } + + + + while (binr.ReadByte() == 0x00) + { //remove high order zeros in data + count -= 1; + } + binr.BaseStream.Seek(-1, SeekOrigin.Current); //last ReadByte wasn't a removed zero, so back up a byte + return count; + } + + #endregion + + #region 解析.net 生成的Pem + private static RSAParameters ConvertFromPublicKey(string pemFileConent) + { + + byte[] keyData = Convert.FromBase64String(pemFileConent); + if (keyData.Length < 162) + { + throw new ArgumentException("pem file content is incorrect."); + } + byte[] pemModulus = new byte[128]; + byte[] pemPublicExponent = new byte[3]; + Array.Copy(keyData, 29, pemModulus, 0, 128); + Array.Copy(keyData, 159, pemPublicExponent, 0, 3); + RSAParameters para = new RSAParameters(); + para.Modulus = pemModulus; + para.Exponent = pemPublicExponent; + return para; + } + + private static RSAParameters ConvertFromPrivateKey(string pemFileConent) + { + byte[] keyData = Convert.FromBase64String(pemFileConent); + if (keyData.Length < 609) + { + throw new ArgumentException("pem file content is incorrect."); + } + + int index = 11; + byte[] pemModulus = new byte[128]; + Array.Copy(keyData, index, pemModulus, 0, 128); + + index += 128; + index += 2;//141 + byte[] pemPublicExponent = new byte[3]; + Array.Copy(keyData, index, pemPublicExponent, 0, 3); + + index += 3; + index += 4;//148 + byte[] pemPrivateExponent = new byte[128]; + Array.Copy(keyData, index, pemPrivateExponent, 0, 128); + + index += 128; + index += ((int)keyData[index + 1] == 64 ? 2 : 3);//279 + byte[] pemPrime1 = new byte[64]; + Array.Copy(keyData, index, pemPrime1, 0, 64); + + index += 64; + index += ((int)keyData[index + 1] == 64 ? 2 : 3);//346 + byte[] pemPrime2 = new byte[64]; + Array.Copy(keyData, index, pemPrime2, 0, 64); + + index += 64; + index += ((int)keyData[index + 1] == 64 ? 2 : 3);//412/413 + byte[] pemExponent1 = new byte[64]; + Array.Copy(keyData, index, pemExponent1, 0, 64); + + index += 64; + index += ((int)keyData[index + 1] == 64 ? 2 : 3);//479/480 + byte[] pemExponent2 = new byte[64]; + Array.Copy(keyData, index, pemExponent2, 0, 64); + + index += 64; + index += ((int)keyData[index + 1] == 64 ? 2 : 3);//545/546 + byte[] pemCoefficient = new byte[64]; + Array.Copy(keyData, index, pemCoefficient, 0, 64); + + RSAParameters para = new RSAParameters(); + para.Modulus = pemModulus; + para.Exponent = pemPublicExponent; + para.D = pemPrivateExponent; + para.P = pemPrime1; + para.Q = pemPrime2; + para.DP = pemExponent1; + para.DQ = pemExponent2; + para.InverseQ = pemCoefficient; + return para; + } + #endregion + + } +} \ No newline at end of file diff --git a/doc/C#仅供参考组装报文和加密/StarDEMO.cs b/doc/C#仅供参考组装报文和加密/StarDEMO.cs new file mode 100644 index 0000000..319a84b --- /dev/null +++ b/doc/C#仅供参考组装报文和加密/StarDEMO.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections; +using System.Text; + + +namespace ConsoleDemo +{ + + public class StarDemo + { + //私钥(与发给票通的公钥为一对) + private static String privateKey = + "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAIVLAoolDaE7m5oMB1ZrILHkMXMF6qmC8I/FCejz4hwBcj59H3rbtcycBEmExOJTGwexFkNgRakhqM+3uP3VybWu1GBYNmqVzggWKKzThul9VPE3+OTMlxeG4H63RsCO1//J0MoUavXMMkL3txkZBO5EtTqek182eePOV8fC3ZxpAgMBAAECgYBp4Gg3BTGrZaa2mWFmspd41lK1E/kPBrRA7vltMfPj3P47RrYvp7/js/Xv0+d0AyFQXcjaYelTbCokPMJT1nJumb2A/Cqy3yGKX3Z6QibvByBlCKK29lZkw8WVRGFIzCIXhGKdqukXf8RyqfhInqHpZ9AoY2W60bbSP6EXj/rhNQJBAL76SmpQOrnCI8Xu75di0eXBN/bE9tKsf7AgMkpFRhaU8VLbvd27U9vRWqtu67RY3sOeRMh38JZBwAIS8tp5hgcCQQCyrOS6vfXIUxKoWyvGyMyhqoLsiAdnxBKHh8tMINo0ioCbU+jc2dgPDipL0ym5nhvg5fCXZC2rvkKUltLEqq4PAkAqBf9b932EpKCkjFgyUq9nRCYhaeP6JbUPN3Z5e1bZ3zpfBjV4ViE0zJOMB6NcEvYpy2jNR/8rwRoUGsFPq8//AkAklw18RJyJuqFugsUzPznQvad0IuNJV7jnsmJqo6ur6NUvef6NA7ugUalNv9+imINjChO8HRLRQfRGk6B0D/P3AkBt54UBMtFefOLXgUdilwLdCUSw4KpbuBPw+cyWlMjcXCkj4rHoeksekyBH1GrBJkLqDMRqtVQUubuFwSzBAtlc"; + + //票通公钥(票通提供) + private static String ptPublicKey = + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCJkx3HelhEm/U7jOCor29oHsIjCMSTyKbX5rpoAY8KDIs9mmr5Y9r+jvNJH8pK3u5gNnvleT6rQgJQW1mk0zHuPO00vy62tSA53fkSjtM+n0oC1Fkm4DRFd5qJgoP7uFQHR5OEffMjy2qIuxChY4Au0kq+6RruEgIttb7wUxy8TwIDAQAB"; + + //3DES秘钥(票通提供) + private static String password = "lsBnINDxtct8HZB7KCMyhWSJ"; + + //请更换请求平台简称(票通提供) + private static String platform_alias = "DEMK"; + + //请更换请求平台编码(票通提供) + private static String platform_code = "11111111"; + + public static void Main(string[] args) + { + + Console.Write(PublicData.disposeResponse(Blue(), ptPublicKey, password));//蓝票接口 + + // testqueryInvoice();//查询接口 + // testGetInvoiceRepertoryInfo();//库存接口 + // testAuthWeChatCards();//插入微信卡包接口 + // testTitleInfo();//查询抬头接口 + // testGetPTBoxStatus();//查询设备状态 + // testGetQrCodeByItems();//获取开票二维码和提取码 + // testdeleteInvoiceQrCode();//作为二维码 + // testInvoiceRed();//冲红接口 + // testRegister();// 注册接口 + //string content = "{\"code\":\"0000\",\"msg\":\"处理成功\",\"sign\":\"ZjAqLXwvEEgz2+jzP/+vUWGuvhBr4N4Gg/pLLOt90sMP160SC1RrkOy6b5p1CCx3y4QYRkbqq2NmkYXpAJX5BdkoXFYUO1hF4ufUvYPmIjQvKT9JMnXt1RV0EdNLliiEowJPjjXDSlTZdthIsTXdVirCkGohzLt3b/2YU9moAM8=\",\"serialNo\":\"CTXP20181206100927n5ObjiJM\",\"content\":\"q55jwSlpLhWV7cnEgNTvm+bswSXLiOPDbw8HvqR7SKhQDWJ/x1qlcJHAOB2lYHmQmefePoaVJ4abG7O9aJwIssDZsit2a2pqNeiCWqVmKhceLAsD/IV4DAlHmwZZhb9tqqco+HDHmZlqJy9pQv478OW0UDx/X0kTbIy4au5pZvJdODh4t31o5I2HrGm1HNcykyKMDpr5D1Mx2mYsjHm95OKBAzLPKMo+o1JrotnyjlS08CbbF6CF5OPZPB8tu88g0xl1u7/3kkjgc0KEmE+bQTEF6RoLqtQ9XRdfHf+tjzLpUcfS7j/nzPcHJnU3d1PGU0NsR+QNyHvI2cfo8HLlmnL5V7GDX+iSMNKMJ8vq7lWwcHvxZjyrHRzSpmxsQJXkQN4hungnNjiNGWzJZ8FssSLLkHw3VlQVJ8mz9sugsCn3Gr/muwUG46W7AsxUqM0Oo1JrotnyjlT6yPhLDxoIzumCiet4Hf02Gxfox417aZ6Jw+BGXo/B9KtKAIlQfkV8Zen4leGaPYo+6G+NPE2a7E+g3FRb571HMiwddiHpNVYzpc/pGTxna1JDIOODExKTJPCrI47HGZGbG7O9aJwIssDZsit2a2pqTWa8x1ePRf8eLAsD/IV4DAlHmwZZhb9tqqco+HDHmZlqJy9pQv478OW0UDx/X0kTbIy4au5pZvJdODh4t31o5JaXBuJBVtYBkyKMDpr5D1Mx2mYsjHm95F0rfY1FyxjYo1JrotnyjlS08CbbF6CF5MlEbxEcfrXqIRR3QbB604fgc0KEmE+bQTEF6RoLqtQ9XRdfHf+tjzILJodvesFM/gbYzWVOUKLKkw3+uglxfg0H9K+suDCPJFRGRT6xxFCnTglsJo/q9b0bF+jHjXtpnu9+bNNgN22dfgeGCjXTZ52gwQVlALiNUkaR5cdbKH8gRWCS9TRcrE1pnHLSywkCgr2LCkRS/wEd1863dwA7HMJuY8TDqXWlYC/kkUF84Oo8kyKMDpr5D1NA31vurQ5/BHbrS0QR43dycGeIzhqufLnEBxK01e2CYnK3sqwBwwBa6qhdLFlh9DTb7pjjR5w00A/i74mi2g8Fq91vU5nj5kkoL7fsH3ChjxJwiAQA8RwGPsTnkCcQSnzqj8s+uTYMPFzmWuWd2UYP\"}"; + + //PublicData.disposeResponse(content,ptPublicKey,password); + } + /** + * 注册接口 + */ + public static string testRegister() + { + + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/register.pt"; + Hashtable map = new Hashtable(); + map.Add("taxpayerNum", platform_alias + "0003242300000"); //销方纳税人识别号 + map.Add("enterpriseName", "测试C#"); //销方企业名称 + map.Add("legalPersonName", "AA"); //法人名称 + map.Add("contactsName", "AA"); //联系人名称 + map.Add("contactsEmail", "1121@qq.com"); //联系人邮箱 + map.Add("contactsPhone", "15111111133"); //联系人手机号 + map.Add("regionCode", "11"); //地区编码 + map.Add("cityName", "海淀区"); //市(区)名 + map.Add("enterpriseAddress", "地址"); //详细地址 + // TODO 请修改为正确的图片Base64传 + map.Add("taxRegistrationCertificate", "sdddddddddddddddddddd"); //证件图片base64 + string content = ToJson.Table2Json(map); + String builderrequest = + PublicData.publicparam(content, platform_code, platform_alias, privateKey, password); + string response = PostJson.Post4Json(url, builderrequest); + + Console.Write("最终返回:" + response); + return response; + } + + /** + * 开具蓝票 + */ + public static string Blue() + { + + string url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt"; + + ArrayList itemList = new ArrayList(); + Hashtable OuterMessage = new Hashtable(); + OuterMessage.Add("taxpayerNum", "500102201007206608"); + // TODO 请更换请求流水号前缀 + OuterMessage.Add("invoiceReqSerialNo", "SCPTT538117842484711"); + OuterMessage.Add("buyerName", "购买购买方名称购买方名称购买方名称"); + OuterMessage.Add("buyerAddress", "购买方地址"); + OuterMessage.Add("buyerTel", "1234-56789104"); + OuterMessage.Add("sellerBankAccount", "123456789"); + OuterMessage.Add("sellerAddress", "深圳市福田区沙头街道天安社区深南大道车是多少大所大所大多所大所大cdtuiolj"); + OuterMessage.Add("sellerTel", "17603327743"); + OuterMessage.Add("takerEmail", "767034475@qq.com"); + OuterMessage.Add("drawerName", ""); + OuterMessage.Add("casherName", "收款人Dd"); + OuterMessage.Add("reviewerName", "复核人Bb"); + OuterMessage.Add("takerName", ""); + OuterMessage.Add("definedData", "测试数据1,测试数据2"); + Hashtable InnerMessageOne = new Hashtable(); + InnerMessageOne.Add("taxClassificationCode", "1010101020000000000"); //税收分类编码(可以按照Excel文档填写) + InnerMessageOne.Add("quantity", "1.00"); //数量 + InnerMessageOne.Add("goodsName", "货物名称"); //货物名称 + InnerMessageOne.Add("unitPrice", "5.64"); //单价 + InnerMessageOne.Add("invoiceAmount", "5.64"); //金额 + InnerMessageOne.Add("taxRateValue", "0.16"); //税率 + InnerMessageOne.Add("includeTaxFlag", "0"); //含税标识 + Hashtable InnerMessageTwo = new Hashtable(); + InnerMessageTwo.Add("taxClassificationCode", "1010101020000000000"); //税收分类编码(可以按照Excel文档填写) + InnerMessageTwo.Add("quantity", "1.00"); //数量 + InnerMessageTwo.Add("goodsName", "货物名称"); //货物名称 + InnerMessageTwo.Add("unitPrice", "5.64"); //单价 + InnerMessageTwo.Add("invoiceAmount", "5.64"); //金额 + InnerMessageTwo.Add("taxRateValue", "0.16"); //税率 + InnerMessageTwo.Add("includeTaxFlag", "0"); //含税标识 + itemList.Add(InnerMessageOne); + itemList.Add(InnerMessageTwo); + + OuterMessage.Add("itemList", itemList); + string content = ToJson.Table2Json(OuterMessage); + String builderrequest = + PublicData.publicparam(content, platform_code, platform_alias, privateKey, password); + string response = PostJson.Post4Json(url, builderrequest); + + Console.WriteLine("最终返回:" + response); + return response; + + } + /** + * 红票开具接口 + */ + public static void testInvoiceRed() + { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceRed.pt"; + Hashtable map = new Hashtable(); + map.Add("taxpayerNum", "110101201702071"); //销方税号(请于要冲红的蓝票税号一致) + // TODO 请更换请求流水号前缀 + map.Add("invoiceReqSerialNo", platform_alias + "5678902275418903"); //发票流水号 (唯一, 与蓝票发票流水号不一致) + map.Add("invoiceCode", "150003529999"); //冲红发票的发票代码 + map.Add("invoiceNo", "61033842"); //冲红发票的发票号码 + map.Add("redReason", "冲红"); //冲红原因 + map.Add("amount", "-65.70"); //冲红金额 (要与原发票的总金额一致) + string content = ToJson.Table2Json(map); + String builderrequest = + PublicData.publicparam(content, platform_code, platform_alias, privateKey, password); + string response = PostJson.Post4Json(url, builderrequest); + + Console.Write("最终返回:" + response); + } + + /** + * 开票二维码接口 + */ + public static void testGetQrCodeByItems() { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getQrCodeByItems.pt"; + Hashtable map = new Hashtable(); + map.Add("taxpayerNum", "110101201705230001"); //销方纳税人识别号 + map.Add("enterpriseName", "测试"); //销方企业名称 + map.Add("tradeNo", platform_alias + "10002001");//订单号(唯一) + map.Add("tradeTime", "2017-06-26 09:15:54"); //交易时间 + map.Add("invoiceAmount", "100"); //发票金额(含税) + map.Add("casherName", "收款人A"); //收款人姓名(校验规则: 中文/字母大小写/及其两者组合) + map.Add("reviewerName", "审核人A"); //审核人姓名(校验规则: 中文/字母大小写/及其两者组合) + map.Add("drawerName", "开票人A"); //开票人姓名(校验规则: 中文/字母大小写/及其两者组合) + map.Add("allowInvoiceCount", "1"); //允许开票张数(非必填 默认值:1) + // map.put("smsFlag", "false"); //是否发送短信 (非必填 默认值:false 测试环境不发送短信) + // map.put("expireTime", ""); //有效时间 (非必填 默认值:永久有效 填写格式 yyyy-MM-dd HH:mm:ss) + // map.put("email","XXXXX@XX.com"); //二维码发送邮箱地址(非必填) + //其他参数见接口文档 + ArrayList list = new ArrayList(); + Hashtable listMapOne = new Hashtable(); + listMapOne.Add("itemName", "小麦"); //开票项目名 + listMapOne.Add("taxRateValue", "0.16"); //税率 + listMapOne.Add("taxClassificationCode", "1010101020000000000");//税收分类编码 + listMapOne.Add("quantity", "1"); //数量 + listMapOne.Add("unitPrice", "50"); //单价 + listMapOne.Add("invoiceItemAmount", "50"); //金额 + Hashtable listMapTwo = new Hashtable(); + listMapTwo.Add("itemName", "大米"); + listMapTwo.Add("taxRateValue", "0.16"); + listMapTwo.Add("taxClassificationCode", "1010101020000000000"); + listMapTwo.Add("quantity", "1"); + listMapTwo.Add("unitPrice", "50"); + listMapTwo.Add("invoiceItemAmount", "50"); + list.Add(listMapOne); + list.Add(listMapTwo); + map.Add("itemList", list); + string content = ToJson.Table2Json(map); + String builderrequest=PublicData.publicparam(content,platform_code,platform_alias,privateKey,password); + string response= PostJson.Post4Json(url, builderrequest); + + Console.Write("最终返回:"+response); + + } + /** + * 作废二维码接口 + */ + + public static void testdeleteInvoiceQrCode() + { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/deleteInvoiceQrCode.pt"; + string content = "[{\"taxpayerNum\":\"110101201705230001\",\"enterpriseName\":\"测试\",\"tradeNo\":\"DEMO10002001\",\"tradeTime\":\"2017-06-26 09:15:54\",\"invoiceAmount\":\"100\"}]"; + String builderrequest=PublicData.publicparam(content,platform_code,platform_alias,privateKey,password); + string response= PostJson.Post4Json(url, builderrequest); + Console.Write("最终返回:"+response); + } + + + + + /** + * 查询发票 + */ + public static void testqueryInvoice() + { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoice.pt"; + // String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoiceInfo.pt"; //查询发票票面全面信息地址 + String content = + "{ \"taxpayerNum\": \"110101201702071\", \"invoiceReqSerialNo\": \"DEMO6678997514279636\"}"; + String builderrequest = + PublicData.publicparam(content, platform_code, platform_alias, privateKey, password); + string response = PostJson.Post4Json(url, builderrequest); + Console.Write("最终返回:" + response); + } + + /* + * 获取库存接口 + */ + public static void testGetInvoiceRepertoryInfo() + { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getInvoiceRepertoryInfo.pt"; + String content = "{\"taxpayerNum\":\"110101201702071\",\"enterpriseName\":\"电子票测试新1\"}"; + String builderrequest = + PublicData.publicparam(content, platform_code, platform_alias, privateKey, password); + string response = PostJson.Post4Json(url, builderrequest); + Console.Write("最终返回:" + response); + } + + /** + * 插入微信卡包接口 + */ + public static void testAuthWeChatCards() + { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/authWeChatCards.pt"; + String content = "{\"taxpayerNum\":\"110101201705230001\",\"invoiceReqSerialNo\":\"GAGA0000000000000009\"}"; + String builderrequest = + PublicData.publicparam(content, platform_code, platform_alias, privateKey, password); + string response = PostJson.Post4Json(url, builderrequest); + Console.Write("最终返回:" + response); + } + + /** + * 查询发票抬头 + */ + public static void testTitleInfo() + { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getInvoiceTitleInfo.pt"; + String content = "{\"enterpriseName\":\"测试\"}"; + String builderrequest = + PublicData.publicparam(content, platform_code, platform_alias, privateKey, password); + string response = PostJson.Post4Json(url, builderrequest); + Console.Write("最终返回:" + response); + } + + /** + * 查询票通宝状态接口 + */ + public static void testGetPTBoxStatus() + { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getPTBoxStatus.pt"; + String content = "{\"taxpayerNum\":\"110101201702071\",\"enterpriseName\":\"电子票测试新1\"}"; + String builderrequest = + PublicData.publicparam(content, platform_code, platform_alias, privateKey, password); + string response = PostJson.Post4Json(url, builderrequest); + Console.Write("最终返回:" + response); + } + } +} \ No newline at end of file diff --git a/doc/C#仅供参考组装报文和加密/ToJson.cs b/doc/C#仅供参考组装报文和加密/ToJson.cs new file mode 100644 index 0000000..9aab986 --- /dev/null +++ b/doc/C#仅供参考组装报文和加密/ToJson.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections; +using System.Runtime.CompilerServices; +using System.Text; + +namespace ConsoleDemo +{ + internal class ToJson + { + public static String Table2Json(Hashtable table) + { + + StringBuilder jsonstr =new StringBuilder(); + jsonstr.Append("{"); + foreach (DictionaryEntry tableEntry in table) + { + if (tableEntry.Key=="itemList") + { + String liststr=list2json((ArrayList)tableEntry.Value); + + jsonstr.Append(string.Format("\"{0}\":{1},", tableEntry.Key,liststr)); + + } + else + { + jsonstr.Append(string.Format("\"{0}\":\"{1}\",", tableEntry.Key, tableEntry.Value)); + } + + } + + + jsonstr.Append("}"); + jsonstr.Remove(jsonstr.Length - 2, 1); + return jsonstr.ToString(); +// Console.Write(jsonstr.ToString()); + } + + + + + private static String list2json(ArrayList list) + { + StringBuilder jsonstr =new StringBuilder(); + jsonstr.Append("["); + foreach (Hashtable valuelist in list) + { + jsonstr.Append(Table2Json(valuelist)) ; + jsonstr.Append(","); + } + jsonstr.Remove(jsonstr.Length - 1, 1); + jsonstr.Append("]"); + + return jsonstr.ToString(); + } + + } +} \ No newline at end of file diff --git a/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/Demo.java b/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/Demo.java new file mode 100644 index 0000000..16daffe --- /dev/null +++ b/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/Demo.java @@ -0,0 +1,831 @@ +package org.example; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import java.io.UnsupportedEncodingException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.text.SimpleDateFormat; +import java.util.*; + +/** + * 票通Demo 用于参考报文组装和加密示例 + * + * HtppUtils :post请求用的java原生的IO.net,没有考虑超时/长连接等特殊情况.使用到真正项目时最好采用项目http框架 + * + * demo在json转换的时候使用了Gson,单元测试的时候使用了Junit 以下是Maven依赖有需要添加即可: + * + * com.google.code.gson + * gson + * 2.8.9 + * + * + * + * + * junit + * junit + * 4.13.2 + * test + * + * + */ +public class Demo { + + //私钥(与发给票通的公钥为一对) + private String privateKey = "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAIVLAoolDaE7m5oMB1ZrILHkMXMF6qmC8I/FCejz4hwBcj59H3rbtcycBEmExOJTGwexFkNgRakhqM+3uP3VybWu1GBYNmqVzggWKKzThul9VPE3+OTMlxeG4H63RsCO1//J0MoUavXMMkL3txkZBO5EtTqek182eePOV8fC3ZxpAgMBAAECgYBp4Gg3BTGrZaa2mWFmspd41lK1E/kPBrRA7vltMfPj3P47RrYvp7/js/Xv0+d0AyFQXcjaYelTbCokPMJT1nJumb2A/Cqy3yGKX3Z6QibvByBlCKK29lZkw8WVRGFIzCIXhGKdqukXf8RyqfhInqHpZ9AoY2W60bbSP6EXj/rhNQJBAL76SmpQOrnCI8Xu75di0eXBN/bE9tKsf7AgMkpFRhaU8VLbvd27U9vRWqtu67RY3sOeRMh38JZBwAIS8tp5hgcCQQCyrOS6vfXIUxKoWyvGyMyhqoLsiAdnxBKHh8tMINo0ioCbU+jc2dgPDipL0ym5nhvg5fCXZC2rvkKUltLEqq4PAkAqBf9b932EpKCkjFgyUq9nRCYhaeP6JbUPN3Z5e1bZ3zpfBjV4ViE0zJOMB6NcEvYpy2jNR/8rwRoUGsFPq8//AkAklw18RJyJuqFugsUzPznQvad0IuNJV7jnsmJqo6ur6NUvef6NA7ugUalNv9+imINjChO8HRLRQfRGk6B0D/P3AkBt54UBMtFefOLXgUdilwLdCUSw4KpbuBPw+cyWlMjcXCkj4rHoeksekyBH1GrBJkLqDMRqtVQUubuFwSzBAtlc"; + + //票通公钥(票通提供) + private String ptPublicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCJkx3HelhEm/U7jOCor29oHsIjCMSTyKbX5rpoAY8KDIs9mmr5Y9r+jvNJH8pK3u5gNnvleT6rQgJQW1mk0zHuPO00vy62tSA53fkSjtM+n0oC1Fkm4DRFd5qJgoP7uFQHR5OEffMjy2qIuxChY4Au0kq+6RruEgIttb7wUxy8TwIDAQAB"; + + //3DES秘钥(票通提供) + private final static String password = "lsBnINDxtct8HZB7KCMyhWSJ"; + + //请更换请求平台简称(票通提供) + private final static String platform_alias = "DEMK"; + + //请更换请求平台编码(票通提供) + private final static String platform_code = "11111111"; + //销售方税号(测试环境票通提供,正式环境使用正式税号) + private final static String taxpayerNum = "500102201007206608"; + + /** + * @throws Exception + * @title: testRSAGenerate + * @description: RAS公钥私钥的生成 1024bit pkcs8格式 公钥提供给票通 私钥保留 + */ + @Test + public void testRSAGenerate() throws Exception { + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + keyPairGen.initialize(1024); + KeyPair keyPair = keyPairGen.generateKeyPair(); + PublicKey publicKey = keyPair.getPublic(); + PrivateKey privateKey = keyPair.getPrivate(); + String publicKeyStr = RSAUtil.getKeyString(publicKey); + System.out.println("publicKeyString:" + publicKeyStr); + String privateKeyStr = RSAUtil.getKeyString(privateKey); + System.out.println("privateKeyString:" + privateKeyStr); + } + /** + * @title: testRealEstateRentalInvoiceBlue + * @description: 蓝票接口调用 + */ + @Test + public void testInvoiceBlue() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt"; + Map map = new HashMap(); + map.put("taxpayerNum", taxpayerNum); //销方税号 + map.put("invoiceReqSerialNo", date(platform_alias));//发票请求流水号 + map.put("buyerName", "购买方名称");//购买方名称 + map.put("invoiceIssueKindCode", "82");//购买方名称 + map.put("buyerTaxpayerNum", "XX0000000000000000");//购买方税号(非必填,个人发票传null) + map.put("remark", "扫码");//购买方税号(非必填,个人发票传null) + List> list = new ArrayList>(); + Map listMapOne = new HashMap(); + listMapOne.put("taxClassificationCode", "3040101000000000000");//税收分类编码(可以按照Excel文档填写) + listMapOne.put("quantity", "1.00");//数量 + listMapOne.put("goodsName", "研发服务");//货物名称 + listMapOne.put("unitPrice", "10");//单价 + listMapOne.put("invoiceIssueKindCode", "82");//发票种类 (注意:数电发票在81与82之间选择 )81:数电票(增值税专用发票)82:数电票(普通发票)10:增值税电子普通发票 + listMapOne.put("invoiceAmount", "10");//金额 + listMapOne.put("taxRateValue", "0.13");//税率(注:金税三期之后 不存在16% 与10%税率 16%自动会降为13% 10%自动降为9%) 如果使用一般人使用3% 或 5%税率请与财务确认是否享受了优惠政策 + listMapOne.put("includeTaxFlag", "0");//含税标识 + listMapOne.put("account", null);//数电账号,传入字段会指定开票员进行开票,不传则随机取一名开票员进行开票 (注: 开票员需要实现在票通登记) + /** + * 以下为零税率开票相关参数 + * 零税商品为特殊税率,正式环境使用零税要事先与相关财务人员沟通该企业是否有零税商品,且适用那种零税状态 + * (免税,不征税,普通零税率)其中一种 + */ + zeroGoodsformat(null, listMapOne);//零税商品类型 1:免税,2:不征税,3:普通零税率,4:简易征收,5:按5%简易征收.其它:非零税率, + list.add(listMapOne); + map.put("itemList", list); + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + System.out.println(buildRequest); + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + } + + /** + * @title: testRealEstateRentalInvoiceBlue + * @description: 蓝票接口调用(开具不动产租赁发票) 非特定业务无视即可. 停车费属于特定业务 + * + */ + @Test + public void testRealEstateRentalInvoiceBlue() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt"; + Map map = new HashMap(); + map.put("taxpayerNum", taxpayerNum); //销方税号 + map.put("invoiceReqSerialNo", date(platform_alias));//发票请求流水号 + map.put("buyerName", "购买方名称");//购买方名称 + map.put("invoiceIssueKindCode", "82");//购买方名称 + map.put("buyerTaxpayerNum", "XX0000000000000000");//购买方税号(非必填,个人发票传null) + map.put("remark", "扫码");//购买方税号(非必填,个人发票传null) + List> list = new ArrayList>(); + Map listMapOne = new HashMap(); + listMapOne.put("taxClassificationCode", "3040502020200000000");//税收分类编码(可以按照Excel文档填写) + listMapOne.put("quantity", "1.00");//数量 + listMapOne.put("goodsName", "*货物*货物名称");//货物名称 + listMapOne.put("unitPrice", "10");//单价 + listMapOne.put("invoiceIssueKindCode", "82");//发票种类 (注意:数电发票在81与82之间选择 )81:数电票(增值税专用发票)82:数电票(普通发票)10:增值税电子普通发票 + listMapOne.put("invoiceAmount", "10");//金额 + listMapOne.put("taxRateValue", "0.13");//税率(注:金税三期之后 不存在16% 与10%税率 16%自动会降为13% 10%自动降为9%) + listMapOne.put("includeTaxFlag", "0");//含税标识 + listMapOne.put("account", null);//数电账号,传入字段会指定开票员进行开票,不传则随机取一名开票员进行开票 (注: 开票员需要实现在票通登记) + /** + * 以下为零税率开票相关参数 + * 零税商品为特殊税率,正式环境使用零税要事先与相关财务人员沟通该企业是否有零税商品,且适用那种零税状态 + * (出口零税率,免税,不征税,普通零税率)其中一种 + */ + zeroGoodsformat(null, listMapOne);//零税商品类型 0:出口零税率,1:免税,2:不征税,3:普通零税率,4:简易征收,5:按5%简易征收.其它:非零税率, + Map realEstateRentalService = new HashMap(); + realEstateRentalService.put("region", "四川省绵阳市涪城区"); + realEstateRentalService.put("detailedAddress", "东江北路 68 号"); + realEstateRentalService.put("areaUnit", "平方米"); + realEstateRentalService.put("crossCitySign", "0"); + realEstateRentalService.put("leaseTerm", "2022-12-01 12:10 2022-12-12 15:00"); + realEstateRentalService.put("titleNo", "无"); + realEstateRentalService.put("carPlateNum", "京A4564651"); + map.put("realEstateRentalService", realEstateRentalService);//购买方税号(非必填,个人发票传null) + list.add(listMapOne); + map.put("itemList", list); + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + System.out.println(buildRequest); + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + } + + /** + * @title: testRegister + * @description: 注册接口调用 + */ + @Test + public void testRegister() throws Exception { + + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/register.pt"; + Map map = new HashMap(); + map.put("taxpayerNum", date(platform_alias));//销方纳税人识别号 测试注册时也请使用贵公司的企业税号进行注册 + map.put("enterpriseName", "票通信息");//销方企业名称 + map.put("legalPersonName", "AA");//法人名称 + map.put("contactsName", "AA");//联系人名称 + map.put("contactsEmail", "1121@qq.com");//联系人邮箱 + map.put("contactsPhone", "15111111133");//联系人手机号 + map.put("regionCode", "11");//地区编码 + map.put("cityName", "海淀区");//市(区)名 + map.put("enterpriseAddress", "地址");//详细地址 + // TODO 请修改为正确的图片Base64传 + map.put("taxRegistrationCertificate", "sdddddddddddddddddddd");//证件图片base64 + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + System.out.println(buildRequest); + String response = HttpUtils.postJson(url, buildRequest); + String responseI = this.disposeResponse(response, ptPublicKey, password); + System.out.println(response); + System.out.println(responseI); + } + + /** + * @title: testInvoiceRed + * @description: 红票接口调用 + */ + @Test + public void testInvoiceRed() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceRed.pt"; + Map map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号(请于要冲红的蓝票税号一致) + // TODO 请更换请求流水号前缀 + map.put("invoiceReqSerialNo", platform_alias + "5678902234568904");//发票流水号 (唯一, 与蓝票发票流水号不一致) + //(invoiceCode和invoiceNo) 与 (blueAllEleInvNo) 只能传一个 + map.put("invoiceCode", "");//冲红发票的发票代码 原蓝票为税控发票时传递 + map.put("invoiceNo", "");//冲红发票的发票号码 原蓝票为税控发票时传递 + map.put("blueAllEleInvNo", "25019200000097980167");//冲红发票的发票号码 原蓝票为数电发票时传递 + + map.put("amount", "-11.30");//冲红金额 (要与原发票的总金额一致) +// map.put("blueInvoiceDate", "20250101");//蓝票开票日期.冲红的原蓝票非票通开具的发票时候使用 + map.put("redReason", "01");//冲红原因冲红原因,不传默认 01 01:开票有误 02:销货退回 03:服务中止 04:销售折让 + map.put("invoiceKind", null);//发票种类 用于声明开具的红票是什么发票 不传跟随原蓝票的发票种类一致 (注意:数电发票在81与82之间选择 )81:数电票(增值税专用发票)82:数电票(普通发票)10:增值税电子普通发票 + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + System.out.println(buildRequest); + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + } + + /** + * @title: testAuthWeChatCards + * @description:查询发票 + */ + @Test + public void testqueryInvoice() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoice.pt"; +// String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoiceInfo.pt"; //查询发票票面全面信息地址 + Map map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号(要跟被查询的发票税号一致) + // TODO 请更换请求流水号前缀 + map.put("invoiceReqSerialNo", platform_alias + "2025021811251811");//发票流水号 (查询的红票或者蓝票开具时所填写的invoiceReqSerialNo一致) + Gson gson = new Gson(); + String content = gson.toJson(map); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + System.out.println(buildRequest); + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + + } + + @Test + public void testgetUnLoginUrl() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getUnLoginUrl.pt"; +// String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoiceInfo.pt"; //查询发票票面全面信息地址 + Map map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号(要跟被查询的发票税号一致) + // TODO 请更换请求流水号前缀 + map.put("redirectUrl", "jtgl.piaotong.vip");//发票流水号 (查询的红票或者蓝票开具时所填写的invoiceReqSerialNo一致) + map.put("customData", "jtgl.piaotong.vip"); + Gson gson = new Gson(); + String content = gson.toJson(map); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + System.out.println(buildRequest); + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + + } + + @Test + public void testgetUnAuthUrl() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getUnAuthUrl.pt"; +// String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoiceInfo.pt"; //查询发票票面全面信息地址 + Map map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号(要跟被查询的发票税号一致) + // TODO 请更换请求流水号前缀 + map.put("token", "9297dbfcd1ff4b179d56406453a3c9f9");//发票流水号 (查询的红票或者蓝票开具时所填写的invoiceReqSerialNo一致) +// map.put("customData", "jtgl.piaotong.vip"); + Gson gson = new Gson(); + String content = gson.toJson(map); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + System.out.println(buildRequest); + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + + } + + /** + * @title: testGetInvoiceQrAndExtractCode + * @description: 获取多项目开票二维码和提取码接口 + */ + @Test + public void testGetInvoiceIssueQrcodeItems() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getInvoiceIssueQrcode.pt"; + Map map = new HashMap(); + map.put("taxpayerNum", taxpayerNum); //销方纳税人识别号 + map.put("enterpriseName", taxpayerNum); //销方企业名称 如实填写 测试环境环境提供的测试税号名称与税号恰好一致. 正式环境不要模仿 + map.put("qrcodeNo", date(platform_alias));//二维码编号(唯一) + map.put("tradeNo", "DEMO12345678900");//订单号 + map.put("tradeTime", "2017-06-26 09:15:54"); //交易时间 + map.put("invoiceAmount", "3"); //发票金额(含税) + map.put("casherName", "收款人A"); //收款人姓名(校验规则: 中文/字母大小写/及其两者组合) + map.put("reviewerName", "审核人A"); //审核人姓名(校验规则: 中文/字母大小写/及其两者组合) + map.put("drawerName", "开票人A"); //开票人姓名(校验规则: 中文/字母大小写/及其两者组合) + map.put("allowInvoiceCount", "1"); //允许开票张数(非必填 默认值:1) + map.put("definedData", "所填软件"); + // map.put("smsFlag", "false"); //是否发送短信 (非必填 默认值:false 测试环境不发送短信) + // map.put("expireTime", ""); //有效时间 (非必填 默认值:永久有效 填写格式 yyyy-MM-dd HH:mm:ss) + // map.put("email","XXXXX@XX.com"); //二维码发送邮箱地址(非必填) + //其他参数见接口文档 + //可选发票种类及分机列表信息 + List> invoiceIssueOptionsList = new ArrayList>(); + Map invoiceIssueOptionsMap = new HashMap(); + invoiceIssueOptionsMap.put("invoiceType", "82");//可选发票种类 10税控电普 08税控电专 82数电普票 81数电纸票 +// invoiceIssueOptionsMap.put("extensionNum","0");//分机号 +// invoiceIssueOptionsMap.put("machineCode",null);//机器编码 + invoiceIssueOptionsList.add(invoiceIssueOptionsMap); + map.put("invoiceIssueOptions", invoiceIssueOptionsList); + //商品行 + List> list = new ArrayList>(); + Map listMapOne = new HashMap(); + listMapOne.put("itemName", "小麦"); //开票项目名 + listMapOne.put("taxRateValue", "0.13"); //税率(注:金税三期之后 不存在16% 与10%税率 16%自动会降为13% 10%自动降为9%) + listMapOne.put("taxClassificationCode", "1010101020000000000");//税收分类编码 + listMapOne.put("quantity", "1"); //数量 + listMapOne.put("unitPrice", "2"); //单价 + listMapOne.put("invoiceItemAmount", "2"); //金额 + Map listMapTwo = new HashMap(); + listMapTwo.put("itemName", "大米"); + listMapTwo.put("taxRateValue", "0.13"); + listMapTwo.put("taxClassificationCode", "1010101020000000000"); + listMapTwo.put("quantity", "1"); + listMapTwo.put("unitPrice", "1"); + listMapTwo.put("invoiceItemAmount", "1"); + list.add(listMapOne); + list.add(listMapTwo); + map.put("itemList", list); + Gson gson = new Gson(); + String content = gson.toJson(map); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + + } + + /** + * @title: queryInvoiceQrCode + * @description:查询二维码信息 + */ + @Test + public void queryInvoiceQrCode() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoiceQrCode.pt"; + HashMap map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号 + map.put("tradeNo", "DEMO2019090916264942");//订单号 + map.put("invoiceAmount", "3");//金额 + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + } + + /** + * @title: testdeleteInvoiceQrCode + * @description: 作废二维码接口 + */ + @Test + public void testdeleteInvoiceQrCode() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/deleteInvoiceQrCode.pt"; + List> list = new ArrayList>(); + Map map = new HashMap(); + map.put("taxpayerNum", taxpayerNum); //销方税号 + map.put("enterpriseName", taxpayerNum);//企业名称 如实填写 测试环境环境提供的测试税号名称与税号恰好一致. 正式环境不要模仿 + map.put("tradeNo", "CTXP2018091910241715");//与开票二维码的订单号一致 + map.put("tradeTime", "2017-06-26 09:15:54"); //与开票二维码的时间一致 + map.put("invoiceAmount", "1.00");//金额一致 + list.add(map); + Gson gson = new Gson(); + String content = gson.toJson(map); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + System.out.println(buildRequest); + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + } + + /** + * @title: testAuthWeChatCards + * @description:查询发票抬头信息 + */ + @Test + public void testTitleInfo() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getInvoiceTitleInfo.pt"; + String content = "{\"enterpriseName\":\"北京票通\"}"; + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + System.out.println(buildRequest); + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + + } + + /** + * @title: testAuthWeChatCards + * @description:微信卡包授 + */ + @Test + public void testAuthWeChatCards() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/authWeChatCards.pt"; + Map map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号(要跟被查询的发票税号一致) + // TODO 请更换请求流水号前缀 + map.put("invoiceReqSerialNo", platform_alias + "2025021811251811");//发票流水号 (查询的红票或者蓝票开具时所填写的invoiceReqSerialNo一致) + Gson gson = new Gson(); + String content = gson.toJson(map); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + + } + + /** + * @title: queryInvoiceQrCode + * @description:重发短信接口 + */ + @Test + public void resendEmailOrSMS() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/resendEmailOrSMS.pt"; + HashMap map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号 + map.put("invoiceReqSerialNo", "DEMO2019090916264942");//流水号 注:发票请求流水号和发票代码号码不能同时为空,如果都填写以发票请求流水号为准 + map.put("invoiceCode", "3");//发票代码 + map.put("invoiceNo", "3");//发票号码 + map.put("takerEmail", "3");// 收票人邮箱,不传的话默认使用开具时传的收票人邮箱 + map.put("takerTel", "3");// 收票人手机号,无默认,需要校验企业是否开通发送短信。 + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + } + /** + * + * + * 以下为数电专用接口 + * + */ + /** + * @title: testregisterUser + * @description:用户登记接口 + */ + @Test + public void testregisterUser() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/registerUser.pt"; + HashMap map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号 + map.put("loginMethod", "1");//登录方式。1:用户名(居民身份证号码/手机号码/用户名)+密码 只有1 + map.put("account", "DEMOadmin");//电子税局登录账号(手机号或身份证号),若平台已经存在做更新操作 + map.put("password", SecurityUtil.encrypt3DES(password, "ispassword"));//电子税局登录密码 3DES 加密 + map.put("identityType", "01");// 登录身份类型. 要与电子税局身份一致 01:法定代表人02:财务负责人03:办税员 04:涉税服务人员05:管理员07:领票人09:开票员99:其他人员 + map.put("identityPwd", null);// 登录身份密码 (只有部分地区需要.目前多数区域不需要,预留不传值) + map.put("phoneNum", "13000000000");// 手机号码。当前登记用户的手机号。如果是手机号+密码登录,该值必须和 account 一致 + map.put("name", "测试");//姓名 最好要跟登记的数电账户对应的开票人一致 + map.put("operationType", null);// 操作类型。默认 1 登记。 1:登记;2:删除 多数情况下不传 + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + } + + /** + * @title: sendLoginSmsCode + * @description:获取登录短信验证码 + */ + @Test + public void sendLoginSmsCode() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/sendLoginSmsCode.pt"; + HashMap map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号 + map.put("account", "DEMOadmin");//电子税局登录账号(手机号或身份证号),若平台已经存在做更新操作 + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + } + + /** + * @title: smsLogin + * @description:短信登录 + */ + @Test + public void smsLogin() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/smsLogin.pt"; + HashMap map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号 + map.put("account", "DEMOadmin");//电子税局登录账号(手机号或身份证号),若平台已经存在做更新操作 + map.put("smsCode", "123456");//短信验证码 + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + } + + /** + * @title: getAuthenticationQrcode + * @description:获取实名认证二维码 + */ + @Test + public void getAuthenticationQrcode() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getAuthenticationQrcode.pt"; + HashMap map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号 + map.put("account", "DEMOadmin");//电子税局登录账号(手机号或身份证号),若平台已经存在做更新操作 + map.put("qrcodeType", "1");//二维码类型 1:税务 APP; 2:个人所得税 APP .不传值默认 1 税务 APP。 + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + } + + /** + * @title: queryAuthQrcodeScanStatus + * @description:查询实名认证二维码扫码状态 + */ + @Test + public void queryAuthQrcodeScanStatus() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryAuthQrcodeScanStatus.pt"; + HashMap map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号 + map.put("account", "DEMOadmin");//电子税局登录账号(手机号或身份证号),若平台已经存在做更新操作 + map.put("authId", "1");//认证 id,推送开票结果/查询开票结果/获取实名认证二维码时会随二维码一起返回 + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + } + + /** + * @title: getTaxBureauAccountAuthStatus + * @description:查询数电账号认证状态 + */ + @Test + public void getTaxBureauAccountAuthStatus() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getTaxBureauAccountAuthStatus.pt"; + HashMap map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号 + map.put("account", "DEMOadmin");//电子税局登录账号(手机号或身份证号),若平台已经存在做更新操作 + Gson gson = new Gson(); + String content = gson.toJson(map); + System.out.println(content); + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + } + /** + * + * 以下为税控专用接口 + * + */ + /** + * @title: testGetPTBoxStatus + * @description: 获取票通宝状态接口 + */ + @Test + public void testGetPTBoxStatus() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getPTBoxStatus.pt"; + String content = "{\"taxpayerNum\":\"110105201606160003\",\"enterpriseName\":\"110105201606160003\"}"; + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + + } + + /** + * @title: testGetInvoiceRepertoryInfo + * @description: 获取库存接口 + */ + @Test + public void testGetInvoiceRepertoryInfo() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/getInvoiceRepertoryInfo.pt"; + Map map = new HashMap(); + map.put("taxpayerNum", taxpayerNum);//销方税号(要跟被查询的发票税号一致) + // TODO 请更换请求流水号前缀 + map.put("enterpriseName", taxpayerNum);//企业名称 如实填写 测试环境环境提供的测试税号名称与税号恰好一致. 正式环境不要模仿 + Gson gson = new Gson(); + String content = gson.toJson(map); + + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + + } + + /** + * @title: revokeInvoiceIssue + * @description:撤销开票 + */ + @Test + public void revokeInvoiceIssue() throws Exception { + String url = "http://fpkj.testnw.vpiaotong.cn/tp/openapi/revokeInvoiceIssue.pt"; + String content = "{\"taxpayerNum\":\"110105201606160003\",\"invoiceReqSerialNo\":\"DEMO2019090916515155\"}"; + //OpenApi参数内容(3des秘钥(票通提供),平台编码(票通提供),平台前缀(票通提供),私钥) + String buildRequest = this.buildRequestData(platform_code, platform_alias, content, password, privateKey); + ; + String response = HttpUtils.postJson(url, buildRequest); + System.out.println(response); + System.out + .println(this.disposeResponse(response, ptPublicKey, password)); + + } + + + /** + * + * 以下为便捷工具 ,非调用接口 + */ + + + /** + * @title: test3DES + * @description: 3DES加密 + */ + @Test + public void test3DES() throws Exception { + String content = "{\"taxpayerNum\": \"9120931023801231\",\"enterpriseName\": \"西单大悦城有限公司\",\"paymentTransID\": \"12109238102831023102983\",\"paymentType\": \"2\",\"paymentTransTime\": \"2017-01-19 18:20:09\",\"paymentTransMoney\": \"20\",\"orderID\": \"12109238102831023102981\",\"orderMoney\": \"30\"}"; + System.out.println(SecurityUtil.encrypt3DES(password, content)); + } + + /** + * @title: test3DESDecry + * @description: 3DES解密 + */ + @Test + public void test3DESDecry() throws Exception { + String str = "WkoTqkd08kNxEFqY6bTa/LVHoAj8nYPWJrX8KmI4rrFmJWXMlk5ik7QzwNTvN1Yiq5sGyS17ShQk6UdhwH5XftxVY9W3ytRZr35bic05cZBlq6VejY2AH9Ql5zZu/4xipBD1jTT/6CBeFU5ViYDbGChpDYf8hEVO4JQQl/H5a1SkwtEaPKT8BCQAvy2Sn0ffmCc0NPjaFWASk2bkqM1NzCqFt6BXUjao34IWG2IzUl9O/VXYFAItC/c67lLXu0ziTTK2n0FGLABED5V9uHvhNCvALC81PH1Fd+3KT11i1szg/F79DbzQOK8WrdSnsUSPbyPFC5kA4MbS1xuqEOHsgSBhw0/xPjpq4ODQwPRwjRI=\n"; + System.out.println(SecurityUtil.decrypt3DES(password, str)); + } + + /** + * @title: testBase64 + * @description: Base64编码 + */ + @Test + public void testBase64() throws Exception { + String str = "JJON0d93C9nQN013N+cCwwIYbRVYlWChGQkSgAWG8g4mD1xFU6oGPauqih5gW7ZTcpejSPS8TqRbdBFdBATSXdwZqPM0q8sVYf3xwlp8OEw6INcUCvRW7myiFkzSJLV4Ost42d5Xp+sicgMj0bn99BsRSqe06BMvYTA46L/vGGPqN4tuuy2B/enpkGLcOQdPdtC+wG8ub6+zykisJT5I7EMls73cjaSlj1iRw/PT9huULu97iPHIiqnKhK05AXkvgWMcfg42+bLeG/kPgbaAtwAkXN/yDkKACcDML2WE8TZ+BFsaQPbH+BfY/XQ4VXSYF5NGeulhDJr1DLIHgH+KNQ=="; + System.out.println(encode2String(str)); + } + + /** + * @title: testBase64 + * @description: Base64解码 + */ + @Test + public void testBase64toURL() throws Exception { + String str = "SkpPTjBkOTNDOW5RTjAxM04rY0N3d0lZYlJWWWxXQ2hHUWtTZ0FXRzhnNG1EMXhGVTZvR1BhdXFpaDVnVzdaVGNwZWpTUFM4VHFSYmRCRmRCQVRTWGR3WnFQTTBxOHNWWWYzeHdscDhPRXc2SU5jVUN2Ulc3bXlpRmt6U0pMVjRPc3Q0MmQ1WHArc2ljZ01qMGJuOTlCc1JTcWUwNkJNdllUQTQ2TC92R0dQcU40dHV1eTJCL2VucGtHTGNPUWRQZHRDK3dHOHViNit6eWtpc0pUNUk3RU1sczczY2phU2xqMWlSdy9QVDlodVVMdTk3aVBISWlxbktoSzA1QVhrdmdXTWNmZzQyK2JMZUcva1BnYmFBdHdBa1hOL3lEa0tBQ2NETUwyV0U4VForQkZzYVFQYkgrQmZZL1hRNFZYU1lGNU5HZXVsaERKcjFETElIZ0grS05RPT0="; + + System.out.println(decode2String(str)); + } + + public String decode2String(String targetString) throws UnsupportedEncodingException { + byte[] decodedBytes = Base64.getDecoder().decode(targetString); + String decodedString = new String(decodedBytes); + return decodedString; + } + + public String encode2String(String targetString) throws UnsupportedEncodingException { + return Base64.getEncoder().encodeToString(targetString.getBytes()); + } + + /** + * @param s 零税商品类型 0:出口零税率,1:免税,2:不征税,3:普通零税率,4:简易征收,5:按5%简易征收.其它:非零税率, + * @param listMapOne + */ + private void zeroGoodsformat(String s, Map listMapOne) { + if ("1".equals(s)) { + listMapOne.put("taxRateValue", "0"); + listMapOne.put("zeroTaxFlag", "1");//零税率标识(空:非零税率,0:出口零税率,1:免税,2:不征税,3:普通零税率) + listMapOne.put("preferentialPolicyFlag", "1");//优惠政策标识(空:不使用,1:使用) 注:零税率标识传非空 此字段必须填写为"1" + listMapOne.put("vatSpecialManage", "免税");//增值税特殊管理(preferentialPolicyFlag为1 此参数必填) + } else if ("0".equals(s)) { + listMapOne.put("taxRateValue", "0"); + listMapOne.put("zeroTaxFlag", "0");//零税率标识(空:非零税率,0:出口零税率,1:免税,2:不征税,3:普通零税率) + listMapOne.put("preferentialPolicyFlag", "1");//优惠政策标识(空:不使用,1:使用) 注:零税率标识传非空 此字段必须填写为"1" + listMapOne.put("vatSpecialManage", "出口零税");//增值税特殊管理(preferentialPolicyFlag为1 此参数必填) + } else if ("2".equals(s)) { + listMapOne.put("taxRateValue", "0"); + listMapOne.put("zeroTaxFlag", "2");//零税率标识(空:非零税率,0:出口零税率,1:免税,2:不征税,3:普通零税率) + listMapOne.put("preferentialPolicyFlag", "1");//优惠政策标识(空:不使用,1:使用) 注:零税率标识传非空 此字段必须填写为"1" + listMapOne.put("vatSpecialManage", "不征税");//增值税特殊管理(preferentialPolicyFlag为1 此参数必填) + } else if ("3".equals(s)) { + listMapOne.put("taxRateValue", "0"); + listMapOne.put("zeroTaxFlag", "3");//零税率标识(空:非零税率,0:出口零税率,1:免税,2:不征税,3:普通零税率) + listMapOne.put("preferentialPolicyFlag", null);//优惠政策标识(空:不使用,1:使用) 注:零税率标识传非空 此字段必须填写为"1" + listMapOne.put("vatSpecialManage", null);//增值税特殊管理(preferentialPolicyFlag为1 此参数必填) + } else if ("4".equals(s)) { + listMapOne.put("preferentialPolicyFlag", "1");//优惠政策标识(空:不使用,1:使用) 注:零税率标识传非空 此字段必须填写为"1" + listMapOne.put("vatSpecialManage", "简易征收");//增值税特殊管理(preferentialPolicyFlag为1 此参数必填) + } else if ("5".equals(s)) { + listMapOne.put("taxRateValue", "0.05"); + listMapOne.put("preferentialPolicyFlag", "1");//优惠政策标识(空:不使用,1:使用) 注:零税率标识传非空 此字段必须填写为"1" + listMapOne.put("vatSpecialManage", "按5%简易征收");//增值税特殊管理(preferentialPolicyFlag为1 此参数必填) + } else { + listMapOne.put("zeroTaxFlag", null);//零税率标识(空:非零税率,0:出口零税率,1:免税,2:不征税,3:普通零税率) + listMapOne.put("preferentialPolicyFlag", null);//优惠政策标识(空:不使用,1:使用) 注:零税率标识传非空 此字段必须填写为"1" + listMapOne.put("vatSpecialManage", null);//增值税特殊管理(preferentialPolicyFlag为1 此参数必填) + } + + } + + public String buildRequestData(String platformCode, String prefix, String content, String password, String privateKey) throws Exception { + Map map = new HashMap(); + String reqContent = SecurityUtil.encrypt3DES(password, content); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + map.put("platformCode", platformCode); + map.put("signType", "RSA"); + map.put("format", "JSON"); + map.put("version", "1.0"); + map.put("content", reqContent); + map.put("timestamp", sdf.format(new Date())); + map.put("serialNo", date(prefix)); + map.put("sign", RSAUtil.sign(RSAUtil.getSignatureContent(map), privateKey)); + Gson gson = new Gson(); + return gson.toJson(map); + } + + public String disposeResponse(String jsonStr, String publicKey, String password) { + JsonObject jsonObject = (new JsonParser()).parse(jsonStr).getAsJsonObject(); + Gson gson = new Gson(); + HashMap map = gson.fromJson(jsonStr, HashMap.class); + String sign = (String) map.remove("sign"); + if (RSAUtil.verify(RSAUtil.getSignatureContent(map), sign, publicKey)) { + String plainContent = SecurityUtil.decrypt3DES(password, (String) map.get("content")); + jsonObject.add("content", (new JsonParser()).parse(plainContent)); + return jsonObject.toString(); + } else { + throw new IllegalStateException("验签失败"); + } + } + + public String date(String prefix) { + Date date = new Date(); + SimpleDateFormat sdf = new SimpleDateFormat("YYYYMMddHHmmss"); + String str = prefix + sdf.format(date) + (int) (Math.random() * 90 + 10); + System.out.println(str); + return str; + } +} + + diff --git a/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/HttpUtils.java b/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/HttpUtils.java new file mode 100644 index 0000000..c75834d --- /dev/null +++ b/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/HttpUtils.java @@ -0,0 +1,109 @@ +package org.example; + +/** + * packageName com.example.demo + * + * @author congtiexin + * @date 2025/6/3 + * @description TODO + */ + +import com.google.gson.Gson; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +public class HttpUtils { + + private static final Gson gson = new Gson(); + + /** + * 发送 POST 请求并返回 JSON 响应 + * + * @param url 请求地址 + * @param request 请求对象(Java Bean 或 Map) + * @return 响应内容(Map 或 自定义对象) + */ + public static T postJson(String url, Object request, Class responseType) throws Exception { + HttpURLConnection connection = null; + try { + // 初始化连接 + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + connection.setRequestProperty("Accept", "application/json"); + connection.setDoOutput(true); + + // 序列化请求体 + String jsonInput = gson.toJson(request); + try (OutputStream os = connection.getOutputStream()) { + os.write(jsonInput.getBytes("UTF-8")); + os.flush(); + } + + // 处理响应 + int responseCode = connection.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + throw new RuntimeException("请求失败,状态码: " + responseCode); + } + + StringBuilder response = new StringBuilder(); + try (BufferedReader br = new BufferedReader( + new InputStreamReader(connection.getInputStream(), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) { + response.append(line); + } + } + + // 反序列化响应 + return gson.fromJson(response.toString(), responseType); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + /** + * 快速发送 JSON POST 请求并返回原始字符串响应 + */ + public static String postJson(String url, String jsonBody) throws Exception { + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + connection.setDoOutput(true); + + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonBody.getBytes("UTF-8"); + os.write(input, 0, input.length); + os.flush(); + } + + int responseCode = connection.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + throw new RuntimeException("请求失败,状态码: " + responseCode); + } + + StringBuilder response = new StringBuilder(); + try (BufferedReader br = new BufferedReader( + new InputStreamReader(connection.getInputStream(), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) { + response.append(line); + } + } + + return response.toString(); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } +} diff --git a/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/RSAUtil.java b/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/RSAUtil.java new file mode 100644 index 0000000..0ce7b69 --- /dev/null +++ b/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/RSAUtil.java @@ -0,0 +1,121 @@ +package org.example; + + +import javax.crypto.Cipher; +import java.nio.charset.Charset; +import java.security.*; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.*; + +public class RSAUtil { + public static final String SIGN_ALGORITHMS = "SHA1WithRSA"; + public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); + + public RSAUtil() { + } + + public static String sign(String content, String privateKey) { + try { + PKCS8EncodedKeySpec priPKCS8 = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)); + KeyFactory keyf = KeyFactory.getInstance("RSA"); + PrivateKey priKey = keyf.generatePrivate(priPKCS8); + Signature signature = Signature.getInstance("SHA1WithRSA"); + signature.initSign(priKey); + signature.update(content.getBytes(DEFAULT_CHARSET)); + byte[] signed = signature.sign(); + return Base64.getEncoder().encodeToString(signed); + } catch (Exception var7) { + var7.printStackTrace(); + return null; + } + } + + public static boolean verify(String content, String sign, String publicKey) { + try { + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + byte[] encodedKey = Base64.getDecoder().decode(publicKey); + PublicKey pubKey = keyFactory.generatePublic(new X509EncodedKeySpec(encodedKey)); + Signature signature = Signature.getInstance("SHA1WithRSA"); + signature.initVerify(pubKey); + signature.update(content.getBytes(DEFAULT_CHARSET)); + boolean bverify = signature.verify(Base64.getDecoder().decode(sign)); + return bverify; + } catch (Exception var8) { + var8.printStackTrace(); + return false; + } + } + + public static String encrpyt(String content, String publicKeyStr) throws Exception { + Cipher cipher = Cipher.getInstance("RSA"); + cipher.init(1, getPublicKey(publicKeyStr)); + byte[] enBytes = cipher.doFinal(content.getBytes(DEFAULT_CHARSET)); + return Base64.getEncoder().encodeToString(enBytes); + } + + public static String decrypt(String content, String privateKeyStr) throws Exception { + Cipher cipher = Cipher.getInstance("RSA"); + cipher.init(2, getPrivateKey(privateKeyStr)); + byte[] deBytes = cipher.doFinal(Base64.getDecoder().decode(content)); + return new String(deBytes, DEFAULT_CHARSET); + } + + public static PublicKey getPublicKey(String key) throws Exception { + byte[] keyBytes = Base64.getDecoder().decode(key); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PublicKey publicKey = keyFactory.generatePublic(keySpec); + return publicKey; + } + + public static PrivateKey getPrivateKey(String key) throws Exception { + byte[] keyBytes = Base64.getDecoder().decode(key); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PrivateKey privateKey = keyFactory.generatePrivate(keySpec); + return privateKey; + } + + public static String getKeyString(Key key) throws Exception { + byte[] keyBytes = key.getEncoded(); + return Base64.getEncoder().encodeToString(keyBytes); + } + + public static String getSignatureContent(Map params) { + if (params == null) { + return null; + } else { + StringBuffer content = new StringBuffer(); + List keys = new ArrayList(params.keySet()); + Collections.sort(keys); + + for(int i = 0; i < keys.size(); ++i) { + String key = (String)keys.get(i); + if (params.get(key) != null) { + String value = String.valueOf(params.get(key)); + content.append((i == 0 ? "" : "&") + key + "=" + value); + } + } + + return content.toString(); + } + } + + public static String getListSignatureContent(List mapList) { + if (mapList == null) { + return null; + } else { + List listStr = new ArrayList(); + Iterator i$ = mapList.iterator(); + + while(i$.hasNext()) { + Map map = (Map)i$.next(); + listStr.add(getSignatureContent(map)); + } + + Collections.sort(listStr); + return listStr.toString(); + } + } +} diff --git a/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/SecurityUtil.java b/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/SecurityUtil.java new file mode 100644 index 0000000..7493cad --- /dev/null +++ b/doc/JAVA参考DEMO(用于参考报文组装和加密示例)/SecurityUtil.java @@ -0,0 +1,67 @@ +// +// Source code recreated from a .class file by IntelliJ IDEA +// (powered by FernFlower decompiler) +// + +package org.example; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.Charset; +import java.util.Base64; + +public class SecurityUtil { + private static final String ALGORITHM_3DES = "DESede"; + public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); + + public SecurityUtil() { + } + + public static byte[] encrypt3DES(String encryptPassword, byte[] encryptByte) { + try { + Cipher cipher = init3DES(encryptPassword, 1); + byte[] doFinal = cipher.doFinal(encryptByte); + return doFinal; + } catch (Exception var4) { + return null; + } + } + + public static String encrypt3DES(String encryptPassword, String encryptStr) { + try { + Cipher cipher = init3DES(encryptPassword, 1); + byte[] enBytes = cipher.doFinal(encryptStr.getBytes(DEFAULT_CHARSET)); + return Base64.getEncoder().encodeToString(enBytes); + } catch (Exception var4) { + return null; + } + } + + public static byte[] decrypt3DES(String decryptPassword, byte[] decryptByte) { + try { + Cipher cipher = init3DES(decryptPassword, 2); + byte[] doFinal = cipher.doFinal(decryptByte); + return doFinal; + } catch (Exception var4) { + return null; + } + } + + public static String decrypt3DES(String decryptPassword, String decryptString) { + try { + Cipher cipher = init3DES(decryptPassword, 2); + byte[] deBytes = cipher.doFinal(Base64.getDecoder().decode(decryptString)); + return new String(deBytes, DEFAULT_CHARSET); + } catch (Exception var4) { + return null; + } + } + + private static Cipher init3DES(String decryptPassword, int cipherMode) throws Exception { + SecretKey deskey = new SecretKeySpec(decryptPassword.getBytes(), "DESede"); + Cipher cipher = Cipher.getInstance("DESede"); + cipher.init(cipherMode, deskey); + return cipher; + } +} diff --git a/doc/代理商平台.txt b/doc/代理商平台.txt new file mode 100644 index 0000000..eae1a45 --- /dev/null +++ b/doc/代理商平台.txt @@ -0,0 +1 @@ +代理商后台入口https://dl.vpiaotong.com/#/login 邀请码:K1N9TP 密码:www.bbitcn.com \ No newline at end of file diff --git a/doc/票通公司介绍.pdf b/doc/票通公司介绍.pdf new file mode 100644 index 0000000..4538ae2 Binary files /dev/null and b/doc/票通公司介绍.pdf differ diff --git a/doc/票通数电发票接口文档3.3.5.pdf b/doc/票通数电发票接口文档3.3.5.pdf new file mode 100644 index 0000000..8a69a3f Binary files /dev/null and b/doc/票通数电发票接口文档3.3.5.pdf differ diff --git a/doc/票通数电发票接口文档3.3.5.txt b/doc/票通数电发票接口文档3.3.5.txt new file mode 100644 index 0000000..fce5261 --- /dev/null +++ b/doc/票通数电发票接口文档3.3.5.txt @@ -0,0 +1,26899 @@ +票通数电发票接口文档 + +票通数电发票接口文档 +版本号 V3.3.5 + +文档编号: + +PT_WBJK_011 + +编 + +写: + +黄凯 + +审 + +核: + +审核日期 + +批 + +准: + +批准日期: + +编写日期: + +北京票通信息技术有限公司 +2022 年 9 月 + +2022-09-27 + +票通数电发票接口文档 + +修订文档历史记录 +日期 + +版本 + +2022-09-27 + +<1.0> + +说明 +起草 + +作者 +黄凯 + +(1)2.1 用户登记接口新增字段电子税局登录身份密码 +identityPwd; +2022-10-17 + +<1.1> + +(2)修改数电发票种类代码,电子发票(增值税专用发票)改 + +黄凯 + +为 81,电子发票(普通发票)改为 82; +(3)2.8、2.9、2.10、2.11 接口新增字段电子税局账号 account。 +(1)优化更改 2.2 接口; +2022-10-18 + +<1.2> + +(2)新增 2.11 红字发票确认单查询接口; +(3)新增 2.12 查看红字发票确认单接口。 + +黄凯 + +(4)调整接口顺序。 +(1)2.3 开具数电蓝字发票接口请求报文新增“购买方是否自 +2022-11-12 + +<1.3> + +然人标识 naturalPersonFlag”字段。 +(2)新增 2.16 蓝字开票统计查询接口; + +黄凯 + +(3)新增 2.17 企业数电票种查询接口。 +(1)去掉获取实名认证二维码接口。 +(2)2.3 查看发票主要信息、2.4 查询发票全票面信息、2.5 +2022-11-15 + +<1.4> + +推送发票主要信息、2.6 推送发票全票面信息接口报文新增实 +名认证二维码字段。开票失败状态码为 code 为 3999 时会返回 + +黄凯 + +实名认证二维码,需要开票账户使用电子税局 APP 扫码认证后 +重新开票。 +2022-11-16 + +<1.4.1> + +(1)2.7 红字发票确认单申请接口返回报文新增确认单处理状 +态。 + +黄凯 + +(1)优化 2.7 红字发票确认单申请接口请求和响应报文及接口 +名; +(2)优化 2.8 红字发票确认单确认接口请求和响应报文; +2022-11-17 + +<1.5> + +(3)优化 2.9 红字发票确认单撤销接口请求和响应报文及接口 +名; + +黄凯 + +(4)优化 2.10 红字发票确认单查询(下载)接口,改为异步 +模式。 +(5)新增 2.11 查询红字发票确认单查询结果接口。 +2022-11-22 + +<1.6> + +(1)优化 2.10 红字发票确认单(下载)接口请求报文。 + +黄凯 + +(1)优化 2.7 红字发票确认单申请接口,原发票开票日期格式 +改为 yyyyMMdd。 +2022-11-23 + +<1.7> + +(2)新增 2.18 发票数据获取接口。 +(3)增加用户登记、发票开具扫码验证、发票冲红等主要流程 + +黄凯 +饶森林 + +说明。 +(1)2.2 用户登记新增操作类型字段,支持删除操作。 +2022-12-06 + +<1.7.1> + +(2)2.3 开具数电蓝字发票新增数电发票差额征税标识字段 + +黄凯 + +variableLevyFlag。 +2022-12-08 + +<1.8> + +(1)2.2 用户登记接口登录身份类型新增枚举值 04 涉税服务 + +黄凯 + +票通数电发票接口文档 +人员; +(2)新增 2.20 数电发票文件获取接口,获取 PDF/XML/OFD 等; +(3)新增 2.21 发票领用及开票数据统计查询接口; +(4)新增 2.22 开具不动产租赁蓝字数电发票接口; +(5)更改接口 2.4、2.5、2.6、2.7 数电 xml 文件说明,发票 +开具成功 xml 文件不一定能及时下载到,若需要可以调用 2.20 +接口获取。 +(1)新增 2.23 开具旅客运输服务蓝字数电发票接口; +2023-01-13 + +<1.9> + +(2)新增 2.24 开具货物运输服务蓝字数电发票接口; + +黄凯 + +(3)新增 2.25 开具建筑服务蓝字数电发票接口。 +(1)修改 2.2 用户登记接口登录方式枚举值及说明。 +(2)新增 2.26 获取实名认证二维码接口。 +2023-02-20 + +<2.0> + +(3)新增 2.27 查询实名认证二维码扫码状态接口。 +(4)2.4 查询发票主要信息、2.5 查询发票全票面信息、2.6 + +黄凯 + +推送发票主要信息、2.7 推送发票全票面信息接口新增 autdId +实名认证二维码认证 id 字段,发票状态为 3999 时返回。 +(1)新增 2.28 快捷冲红数电发票接口。 +(2)2.4 查询发票主要信息、2.5 查询发票全票面新增发票状 +态码 4999 红字发票确认单申请中、5999 红字发票确认单审核 +2023-03-08 + +<2.1> + +中。 +(3)2.6 推送发票主要信息、2.7 推送发票全票面信息新增发 + +黄凯 + +票状态码 5999 红字发票确认单审核中。 +(4)2.7 推送发票全票面信息发票状态 invoiceStatus 字段新 +增状态码:4 红字发票确认单申请中、5 红字发票确认单审核中。 +(1)新增 2.29 开具不动产销售蓝字数电发票接口。 +2023-04-04 + +<2.2> + +(2)新增 2.30 获取登录短信验证码接口。 + +黄凯 + +(3)新增 2.31 短信验证码登录接口。 +(1)调整接口顺序。 +(2)新增 2.2 注册企业接口。 +(3)新增 2.8 查询数电账号状态接口。 +(4)新增 2.18 查询二维码开票信息接口。 +2023-06-21 + +<2.3> + +(4)新增 2.19 重新发送邮件或短信接口。 +(5)2.15 获取数电发票文件请求报文新增数电账号、开票日 + +黄凯 + +期字段。 +(6)2.20 蓝字开票统计查询接口请求报文新增数电账号字段。 +(7)优化 2.11/2.12/2.13/2.14 认证二维码说明,状态码为 +3999 时认证二维码不一定有值。 +(1)2.9 开具数电蓝字发票、2.23 开具不动产租赁蓝字数电发 +票、2.24 开具旅客运输服务蓝字数电发票、2.25 开具货物运输 +服务蓝字数电发票、2.26 开具建筑服务蓝字数电发票、2.27 开 +2023-06-25 + +<2.3.1> + +具不动产销售蓝字发票新增字段是否显示购买方开户行及账号 +到发票备注 showBuyerBank、是否显示销售方开户行及账号到 +发票备注 showSellerBank、收款人 casherName、复核人 +reviewerName。 + +黄凯 + +票通数电发票接口文档 +(2)2.12 查询发票全票面信息、2.14 推送发票全票面信息新 +增收款人 casherName、复核人 reviewerName。 +(3)2.16 获取开票二维码新增收款人 casherName、复核人 +reviewerName。 +(1)2.10 快捷冲红数电发票接口新增蓝字发票字段,以解决 +冲红非票通平台开具的蓝票; +(2)修改 2.21 发票领用及开票数据统计查询接口响应报文, +2023-07-10 + +<2.3.2> + +去掉按项目统计,新增按税率/征收率统计; +(3)2.23 开具不动产租赁蓝字数电发票特定信息面积单位新 + +黄凯 + +增枚举值; +(4)2.26 开具建筑服务蓝字数电发票特定信息详细地址改为 +非必填。 +(1)2.16 获取开票二维码开票项目新增 vatSpecialManage 增 +2023-07-13 + +<2.3.3> + +值税特殊管理字段,以支持简易征收、按 5%简易征收等优惠政 + +黄凯 + +策。 +(1)2.21 发票领用及开票数据统计查询请求报文新增数电账 +2023-07-26 + +<2.3.4> + +号字段。 + +黄凯 + +(2)2.22 发票数据获取接口请求报文新增数电账号字段。 +2023-08-28 + +<2.3.5> + +(1)2.16 获取开票二维码接口新增微信交易单号字段。 + +黄凯 + +2023-09-25 + +<2.3.6> + +(1)2.9 开具数电蓝字发票支持自产农产品销售发票。 + +黄凯 + +2023-10-20 + +<2.3.7> + +(1)2.6 获取实名认证二维码新增认证类型 authType 字段, +区分风险认证和登录认证。 + +黄凯 + +(1)2.10.快捷冲红数电发票接口返回报文新增红字确认单申 +2023-11-03 + +<2.3.8> + +请流水号字段 redApplySerialNo。 +(2)2.1 主要流程说明新增企业作为销方数电红字确认单状态 + +黄凯 + +图。 +2023-12-07 + +<2.3.9> + +2023-12-20 + +<2.4.0> + +2023-12-21 + +<2.4.1> + +(1)2.9 开具数电蓝字发票支持农产品收购发票。 +(2)2.9 开具数电蓝字发票新增优惠政策传值说明。 +(1)2.8 查询数电账号认证状态接口新增可切换状态字段 +switchable。 +(1)新增 2.35 切换数电企业接口。 + +黄凯 + +黄凯 +黄凯 + +(1)2.20 快捷冲红数电发票、2.30 开具红字数电发票新增收 +票人名称、收票人邮箱、收票人手机号字段。 +2024-01-25 + +<2.5.0> + +(2)新增 2.36 初始化红字信息确认单接口。 + +黄凯 + +(3)新增 2.37 快捷冲红数电发票(全额冲红、部分冲红)接 +口。 +2024-03-29 + +<2.6.0> + +(1)2.9 开具蓝字数电发票支持差额征税-差额开票。 + +黄凯 + +(1)新增 2.38 红字确认单申请(支持全额、部分;销方、购 +2024-04-11 + +<2.7.0> + +方申请)接口。 +(2)2.27 开具不动产销售蓝字数电发票接口支持多行开票项 + +黄凯 + +目、多行特定信息及共同购买人。 +2024-05-07 + +<2.7.1> + +(1)修改发票开具等接口发票备注字段校验规则。 +(2)2.14 推送发票全票面信息新增订单列表字段。 + +黄凯 + +票通数电发票接口文档 + +2024-06-03 + +<2.7.2> + +2024-06-12 + +<2.7.3> + +(1)2.9 开具蓝字数电发票新增订单列表,支持第三方平台合 +并开票的情况。 +(1)2.6 获取实名认证二维码接口新增 qrcodeType 二维码类 +型字段,支持获取个税 APP 认证二维码。 + +黄凯 + +黄凯 + +(1)新增 2.39 机动车车辆信息查询接口。 +2024-06-20 + +<2.8.0> + +(2)新增 2.40 开具数电专票机动车特定业务接口。 +(3)新增 2.41 查询支持未开发票代码号码接口。 + +黄凯 + +(4)新增 2.42 开具机动车销售统一发票(数电纸票)接口。 +2024-07-01 + +<2.8.1> + +(1)2.16 获取开票二维码支持不动产经营租赁特定业务。 +(2)优化冲红相关接口原发票代码、原发票号码描述。 + +黄凯 + +(1)2.9 开具数电蓝字发票、2.23 开具不动产租赁蓝字数电发 +票、2.24 开具旅客运输服务蓝字数电发票、2.25 开具货物运输 +服务蓝字数电发票、2.26 开具建筑服务蓝字数电发票、2.27 开 +2024-07-18 + +<2.8.2> + +具不动产销售蓝字发票新增字段是否显示购买方地址电话到发 +票备注 showBuyerAddrTel、是否显示销售方地址电话到发票备 + +黄凯 + +注 showSellerAddrTel。 +(2)2.23 开具不动产租赁蓝字数电发票新增减按征税类型字 +段 reducedTaxType。 +2024-07-25 + +<2.9.0> + +(1)2.3 数电账号登记接口返回报文新增绑定微信二维码。 +(2)新增 2.43 获取数电账号绑定微信二维码接口。 + +黄凯 + +(1)修改 2.37 快捷冲红数电发票(全额冲红、部分冲红)和 +2024-07-29 + +<2.9.1> + +2.38 红字确认单申请(支持全额、部分;销方、购方申请)的 +规则说明,销方部分冲红时不再校验增值税用途状态、消费税 + +黄凯 + +用途状态、入账状态。 +2024-08-20 + +<2.9.2> + +2024-08-26 + +<2.9.3> + +(1)2.25 开具货物运输服务蓝字发票特定信息运输工具种类 +新增“其他运输工具”。 +(1)2.26 开具建筑服务蓝字发票特定信息新增跨区域涉税事 +项报验管理编号。 + +黄凯 + +黄凯 + +(1)新增 2.44 查询建筑服务跨区域涉税事项报验管理编号信 +息接口。 +2024-08-29 + +<3.0.0> + +(2)2.8 查询数电账号认证状态新增返回登录认证状态、最新 + +黄凯 + +登录认证时间、风险认证状态、最新风险认证时间。 +(3)新增 2.45 查询数电账号列表接口。 +2024-09-09 + +<3.0.1> + +(1)新增 2.46 退出电子税局登录接口。 + +黄凯 + +(1)2.4 获取登录短信验证码响应结果代码新增 6666,解决无 +需发送验证码的问题。 +2024-11-13 + +<3.1.0> + +(2)2.23 开具不动产租赁蓝字数电发票接口调整不动产租赁 +期起止格式。 + +黄凯 + +(3)新增 2.47 查询企业信息接口。 +(4)新增 2.48 企业审核结果推送接口。 +(1)2.8 查询数电账号认证状态、2.45 查询数电账号列表新增 +2024-12-11 + +<3.2.0> + +响应字段是否绑定微信公众号 wechatUserBindStatus。 +(2)2.16 获取开票二维码不动产租赁发票特定信息支持多行, +新增字段车牌号 carPlateNum。 + +黄凯 + +票通数电发票接口文档 +(3)2.23 开具不动产租赁蓝字数电发票特定信息支持多行, +新增字段车牌号 carPlateNum。 +(1)2.11 查询发票主要信息、2.12 查询发票全票面响应新增 +2024-12-20 + +<3.3.0> + +发票删除标志 invDeletedFlag。 + +黄凯 + +(2)新增 2.49 查询企业开户行及账号信息接口。 +(1)2.12 查询发票全票面信息、2.14 推送发票全票面信息接 +口新增字段是否显示购买方开户行及账号到发票备注 +2025-02-21 + +<3.3.1> + +showBuyerBank、是否显示销售方开户行及账号到发票备注 +showSellerBank、是否显示购买方地址电话到发票备注 + +黄凯 + +showBuyerAddrTel、是否显示销售方地址电话到发票备注 +showSellerAddrTel。 +(1)修改 2.20 接口名称为开票统计及授信额度查询,支持查 +2025-04-18 + +<3.3.2> + +询红字发票开票统计。 + +黄凯 + +(2)2.22 发票数据获取接口新增字段扣除额 deduction。 +(1)修改 2.44 查询建筑服务跨区域涉税事项报验管理编号信 +息接口,请求报文去掉字段跨区域涉税事项报验管理编号 +taxDeclareManageNum,新增字段跨区域涉税事项报验管理编号 +前缀 taxDeclareManageNumPrefix、跨区域涉税事项报验管理 +2025-05-08 + +<3.3.3> + +编号年份 taxDeclareManageNumYear、跨区域涉税事项报验管 +理编号具体的号 taxDeclareManageNumNo、报告开具期限起 + +黄凯 + +entryDateStart、报告开具期限止 entryDateEnd,响应报文新 +增字段有效标志 validFlag、作废标志 destroyFlag、跨区域涉 +税事项有效期起 taxRelatedEffectiveStart、跨区域涉税事项 +有效期止 taxRelatedEffectiveEnd。 +(1)修改 2.2 注册企业接口请求报文字段必填说明。 +(2)修改 2.9 开具数电蓝字发票、2.23 开具不动产租赁蓝字 +数电发票、2.24 开具旅客运输服务蓝字数电发票、2.25 开具货 +2025-05-20 + +<3.3.4> + +物运输服务蓝字数电发票、2.26 开具建筑服务蓝字数电发票、 +2.27 开具不动产销售蓝字发票请求报文字段 goodsName 货物名 + +黄凯 + +称长度校验说明,长度由 GBK 字节 100 位改为 100 个字符。 +(3)2.8 查询数电账号认证状态、2.45 查询数电账号列表返回 +报文新增字段姓名 name、身份类型 identityType。 +(1)2.16 获取开票二维码接口新增字段是否强制更新二维码 +2025-06-27 + +<3.3.5> + +标识 updateFlag。 +(2)2.33 红字发票确认单查询(下载)新增字段红字发票信 +息确认单编号 redBillNo。 + +黄凯 + +票通数电发票接口文档 + +1. 概述 +1.1. 安全机制 +安全机制: +1、公共报文通过 RSA 类型验签进行验证,简要规则如下: +a. 生成秘钥为 1024bit,秘钥格式为 PKCS8 的秘钥对,第三方平台与票 +通互相提供公钥为对方。 +b. 按照 content, format, platformCode, serialNo, signType, +timestamp, version 字段名称字母升序的顺序将所有参数用&连接起来,通过公 +钥进行验签; +待签名串示例: +content=Base64.(3DES(content))&format=JSON&platformCode=platformC +ode&serialNo=DEMO20170504145147zEm9cQ05&signType=RSA×tamp=2017-0 +5-04 14:51:47&version=1.0 +需要使用 RSA 私钥对上面的待签名串进行签名。 +RSA 签名结果需要 Base64 编码。 +2、业务报文通过 3DES 进行加解密,加密模式为 ECB,编码统一为 UTF-8 密 +钥不在报文中体现。 +JAVA 对接票通提供签名、加密等工具的 SDK 和接口调用 Demo(Java 版)。 + +1.2. 调用方式 +采用 HTTP/HTTPS 作为通信协议,报文格式为 json。 +全局公共参数说明如下: +请求参数-公共部分: +字段 +platformCode + +名称 + +长度 + +必填 + +说明 + +第三方平台编 + +8位 + +是 + +票通分配给开发者的第三方平 + +码 + +台编码 + +signType + +加密类型 + +10 位 + +是 + +目前支持 RSA + +sign + +签名串 + +256 位 + +是 + +商户请求参数的签名串 + +format + +业务报文格式 + +10 位 + +否 + +目前支持 JSON + +票通数电发票接口文档 +timestamp + +请求时间 + +19 位 + +是 + +yyyy-MM-dd HH:mm:ss + +version + +版本号 + +4位 + +是 + +调用接口版本,固定为 1.0 + +serialNo + +交易请求流水 + +26 位 + +是 + +4 位 平 台 简 称 + 14 位 日 期 + +号 + +yyyymmddhhmmss)+8 位 随 机 数 +(通过 SDK 生成) + +content + +业务报文内容 + +不定长 + +是 + +业务报文 3DES 加密,除公共参 +数外所有请求参数都必须放在 +这个参数中传递 + +请求报文示例: +{ +"platformCode": "XXXXXXXX", +"signType ": "RSA", +"sign": "XXXXXX", +"format": "JSON", +"timestamp": "2022-09-28 08:08:08", +"version": "1.0", +"serialNo": "DEMO20161212082056ADB34DF3", +"content": 业务报文 3DES 加密 +} + +响应参数-公共部分: +字段 +code + +名称 +响应状态 + +长度 + +必填 + +4-6 位 + +是 + +说明 +业务返回码。 +0000:成功; +9999:验签失败; +9998:平台编码无效; +8996:业务异常; +8995:数据校验不通过; +其他:见各个业务接口错误码 +说明 + +msg + +响应消息 + +0-60 位 + +否 + +业务返回码描述 + +sign + +签名串 + +256 位 + +是 + +签名串 + +serialNo + +交易请求流水号 + +26 位 + +是 + +交易请求流水号 + +content + +业务报文内容 + +不定长 + +是 + +业务报文 3DES 加密 + +响应报文示例: +{ +"code": "0000", +"msg": "成功", +"sign": "XXXXXXXX", +"serialNo": "XXXXXXXX", + +票通数电发票接口文档 +"content":业务报文 3DES 加密 +} + +2. 业务接口说明 +接口详见下表: +编号 + +接口名称 + +调用关系 + +2.1 主要流程说明 +2.2 注册企业 +2.3 数电账号登记 +2.4 获取登录短信验证码 +2.5 短信登录 +2.6 获取实名认证二维码 +2.7 + +描述说明用户登记、发票开具、发票冲红等流程 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 + +查询实名认证二维码扫 第三方平台或集团 +码状态 + +2.8 查询数电账号认证状态 +2.9 开具蓝字数电发票 +2.10 快捷冲红数电发票 +2.11 查询发票主要信息 +2.12 查询发票全票面信息 +2.13 推送发票主要信息 +2.14 推送发票全票面信息 +2.15 获取数电发票文件 +2.16 获取开票二维码 +2.17 批量作废开票二维码 + +接口描述 + +企业——票通 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 + +通过该接口注册企业 +登记电子税局账号信息 +通过该接口获取登录电子税局的短信验证码 +通过该接口登录电子税局 +通过该接口获取实名认证二维码 +通过该接口查询实名认证二维码扫码状态 +通过该接口查询数电账号的认证状态 +通过该接口开具蓝字数电发票 + +第三方平台或集团 通过该接口完成申请红字发票确认单并开具红字 +企业——票通 + +发票 + +第三方平台或集团 查询发票主要信息,开票成功返回代码、号码等, +企业——票通 + +失败返回失败原因 + +第三方平台或集团 查询发票全票面信息,开票成功返回代码、号码 +企业——票通 +票通——第三方平 +台或集团企业 +票通——第三方平 +台或集团企业 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 + +及全票面信息,失败返回失败原因 +开票成功或失败向第三方推送发票信息 +开票成功或失败向第三方推送发票全票面信息 +通过该接口获取数电发票文件 +通过该接口获取开票二维码,消费者扫码开票 +通过该接口批量作废开票二维码 + +票通数电发票接口文档 + +2.18 查询二维码开票信息 +2.19 重新发送邮件或短信 +2.20 +2.21 + +2.24 +2.25 +2.26 +2.27 + +查询 +统计查询 + +数电发票 + +企业——票通 +企业——票通 +第三方平台或集团 +企业——票通 +企业——票通 + +开具旅客运输服务蓝 第三方平台或集团 +字数电发票 + +企业——票通 + +开具货物运输服务蓝 第三方平台或集团 +字数电发票 + +企业——票通 + +开具建筑服务蓝字数 第三方平台或集团 +电发票 + +企业——票通 + +开具不动产销售蓝字 第三方平台或集团 +数电发票 + +2.30 开具红字数电发票 +2.31 红字发票确认单审核 +2.32 红字发票确认单撤销 + +企业——票通 + +通过该接口查询二维码开票信息 +通过该接口重新发送发票交付邮件或短信 + +(下载) + +企业——票通 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 +企业——票通 + +获取红字发票确认单 第三方平台或集团 +查询(下载)结果 + +额、税额及授信额度 +通过该接口查询发票领用及开票数据统计 +通过该接口从局端获取发票信息 +通过该接口开具不动产租赁蓝字数电发票 +通过该接口开具旅客运输服务蓝字数电发票 +通过该接口开具货物运输服务蓝字数电发票 +通过该接口开具建筑服务蓝字数电发票 +通过该接口开具不动产销售蓝字数电发票 + +第三方平台或集团 申请红字发票确认单,对发票进行冲红前需要先 + +红字发票确认单查询 第三方平台或集团 + +2.35 切换数电企业 + +2.36 + +企业——票通 + +开具不动产租赁蓝字 第三方平台或集团 + +2.29 查看红字发票确认单 + +2.34 + +第三方平台或集团 + +发票领用及开票数据 第三方平台或集团 + +2.28 红字发票确认单申请 + +2.33 + +企业——票通 + +开票统计及授信额度 第三方平台或集团 通过该接口查询企业当月已开蓝字发票数量、金 + +2.22 发票数据获取 +2.23 + +第三方平台或集团 + +企业——票通 +第三方平台或集团 +企业——票通 + +申请确认单 +通过该接口查询某个红字发票确认单详情 +通过该接口开具红字数电发票 +审核或拒绝红字发票确认单 +撤销红字发票确认单 +从局端查询红字发票确认单 +通过改接口查询红字确认单查询结果 +通过该接口切换一个数电账号下面的关联的企业 +(只支持同一个地区的切换,比如只能从四川的 +企业切换到四川的企业) + +初始化红字信息确认 第三方平台或集团 通过该接口初始化红字信息确认单,需要调用税 +单 +快捷冲红数电发票 + +2.37 (全额冲红、部分冲 +红) + +企业——票通 +第三方平台或集团 +企业——票通 + +局接口,需保持数电账号是登录状态 +通过该接口完成申请红字发票确认单并开具红字 +发票,因为要传蓝字明细序号,建议在部分冲红 +之前调用 2.36 初始化红字信息确认单,以核实蓝 + +票通数电发票接口文档 +票的入账状态、增值税用途代码、消费税用途代 +码以及剩余可冲红金额。 +红字确认单申请(支 + +第三方平台或集团 通过该接口申请数电发票红字确认单,支持销方、 +2.38 持全额、部分;销方、 +企业——票通 +购方申请,支持全额及部分金额申请 +购方申请) +2.39 机动车车辆信息查询 +2.40 +2.41 + +企业——票通 + +便查询该车架号是否能开票及补全车辆信息 + +开具数电专票机动车 第三方平台或集团 通过该接口开具数电票(增值税专用发票)机动 +特定业务 + +企业——票通 + +查询纸质未开发票代 第三方平台或集团 +码号码 +开具数电纸质发票 + +2.42 (机动车销售统一发 +票) +2.43 + +第三方平台或集团 通过该接口使用机动车车架号查询车辆信息,以 + +企业——票通 + +查询建筑服务跨区域 +2.44 涉税事项报验管理编 +号信息 +2.45 查询数电账号列表 +2.46 退出电子税局登录 +2.47 查询企业信息 +2.48 企业审核结果推送 +2.49 查询企业开户行及账号 + +通过该接口查询未开具的纸质发票代码号码 + +第三方平台或集团 通过该接口开具数电纸质发票(机动车销售统一 +企业——票通 + +获取数电账号绑定微 第三方平台或集团 +信二维码 + +车特定业务 + +企业——票通 + +发票) + +通过该接口获取数电账号绑定微信的二维码 + +第三方平台或集团 通过该接口查询建筑服务跨区域涉税事项报验管 +企业——票通 + +理编号信息 + +第三方平台或集团 通过该接口查询某个企业绑定的数电账号列 +企业——票通 +第三方平台或集团 +企业——票通 +第三方平台或集团 +企业——票通 +票通——第三方平 +台或集团企业 +票通——第三方平 +台或集团企业 + +表。 +通过该接口退出电子税局登录。 +通过该接口查询企业的信息 +通过该接口将企业审核结果推送给第三方平台 +通过该接口查询企业开户行及账号信息。 + +2.1. 主要流程说明 +1. 数电账号登记 +使用数电业务时,企业的注册、审核等流程,和原增值税流程保持一致,在此基础上增 +加了用户登记,用户维护电子税务局的用户名、密码等信息。该接口为实时验证接口。 +也可不调试该接口,通过票通产品功能完成用户登记。第三方平台可记录用户名信息, +在处理业务传入用户名(如果只有一个用户,也可以不传入,票通会选择默认的用户进行业 +务处理)。 +用户登记只需要调用一次,如果在电子税局修改了登录密码,需要再次用户登记。 +2. 发票开具 +调用数电蓝字发票开具,等待消息推送,如果需用户扫码实名认证或登录认证时,将会 +在推送发票主要信息或推送发票全票面信息接口中,返回开票失败状态为 3999 的错误码。 + +票通数电发票接口文档 + +查询发票主要信息和查询发票全票面信息时,若在开具发票时开票账号是风险认证或登录认 +证,也会返回 3999 的错误码。 +第三方平台获得该错误码时,需提醒该用户使用电子税务局 APP 进行二维码扫描认证 +或短信认证。可以通过 2.8 查询数电账号的当前认证状态及操作建议,提醒客户进行扫码认 +证或短信认证。 +发票查询和推送返回的认证二维码均为税务 APP 认证二维码。 +如果实名认证二维码过期,可调用 2.6 获取实名认证二维码接口获取新的二维码。 +3. 发票冲红 +数电发票冲红,均需发起红字发票确认单申请, +普通发票申请,如果对方未入账,则申请后无需确认,调用红字信息表查询接口获取到 +已确认(或无需确认)状态后,可进行冲红。 +专用发票申请,如果对方未入账未勾选,则申请后无需确认,其他情况需对方确认,对 +方确认通过后,调用红字信息表查询接口获取已确认(或无需确认)状态,接下来可进行冲 +红。 +对方企业发起的红字信息表,可通过红字信息表查询(下载)接口获取红字信息表,对 +需要审核的红字信息表,可通过红字信息表审核接口,进行拒绝或者通过。 +数电发票(普通发票)可冲红对应的增值税普通发票,包括普通电子发票。 +数电发票(增值税专用发票),可冲红对应的增值税专用发票,包括专用电子发票。 +发票冲红票通了两套接口,一套是 2.10 接口,和原票通增值税电子普通发票冲红接口 +保持一致,方便快捷一键冲红,由票通整合红字信单申请管理和冲红功能,冲红完成后,合 +作方可通过查询发票接口或推送开票信息接口,获取红字发票开具结果;一套是 2.28、2.29、 +2.30、2.31、2.32、2.33、2.34 接口,用于精细化管理红字发票确认单及冲红。 + +票通数电发票接口文档 + +票通数电发票接口文档 + +2.2. 注册企业 +2.2.1.调用说明 +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/op +enapi/register.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi +/register.pt +字符编码 + +UTF-8 + +接口描述 + +接口提供企业的注册 + +2.2.2.请求报文 +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 +验规则为字符长度,只能包括 +大写英文字母或数字 + +enterpriseName + +销售方企业名称 + +String(4-100) + +是 + +销售方企业名称,长度校验规 +则为 GBK 字节长度,不能包含 +<>字符 + +contactsPhone + +联系人手机 + +String(11) + +是 + +联系人手机号,长度校验规则 +为字符长度,只能是数字 + +regionCode + +地区编码 + +String(2) + +是 + +地区编码,填写为省级或者直 +辖市编码,编码表见下 + +cityName + +市(直辖市的区)名称 + +String(1-20) + +是 + +市(直辖市的区)名称,为省级 +下属市的名称或直辖市下属区 +的名称,cityName 根据 +regionCode 联动。参照提供的 +地区表。如果不是计划单列市 +(大连市、青岛市、宁波市、 +厦门市、深圳市)可以不填 +例:regionCode=11,cityName= +海淀区 (表示:北京市海淀 +区); +regionCode=37,cityName=青 + +票通数电发票接口文档 +岛市 (表示:山东省青岛市) +legalPersonName + +法人名称 + +String(2-50) + +否 + +注册企业法人代表名称,长度 +校验规则为字符长度,不能包 +含<>字符 + +contactsName + +联系人名称 + +String(1-16) + +否 + +企业联系人名称,长度校验规 +则为 GBK 字节长度,不能包含 +<>字符 + +contactsEmail + +联系人邮箱 + +String(4-50) + +否 + +联系人邮箱,长度校验规则为 +字符长度,只能包括英文字母 +或数字 + +enterpriseAddre + +详细地址 + +String(1-89) + +否 + +ss + +营业执照地址,长度校验规则 +字节长度,不能包含<>字符 + +taxRegistrationCertif + +营 业 执 照 图 片 + +icate + +(Base64) + +taxControlDeviceType + +企业税控设备类型 + +String + +否 + +不定长,Base64 字符串,需小 +于 2M + +String + +否 + +企业税控设备类型。多个用| +分割。 +1:金税盘/税控盘; +2:税务 UKEY; +4:区块链发票; +5:数电发票。 + +地区编码表: +地区编码 + +地区名称 + +11 + +北京市 + +31 + +上海市 + +12 + +天津市 + +13 + +河北省 + +14 + +山西省 + +15 + +内蒙古自治区 + +21 + +辽宁省 + +22 + +吉林省 + +23 + +黑龙江省 + +32 + +江苏省 + +33 + +浙江省 + +34 + +安徽省 + +35 + +福建省 + +36 + +江西省 + +37 + +山东省 + +41 + +河南省 + +42 + +湖北省 + +43 + +湖南省 + +44 + +广东省 + +45 + +广西壮族自治区 + +票通数电发票接口文档 +46 + +海南省 + +50 + +重庆市 + +51 + +四川省 + +52 + +贵州省 + +53 + +云南省 + +54 + +西藏自治区 + +61 + +陕西省 + +62 + +甘肃省 + +63 + +青海省 + +64 + +宁夏回族自治区 + +65 + +新疆维吾尔自治区 + +报文示例: +{ +"taxpayerNum": "BHDWSDFDDD123459", +"enterpriseName": "北京 XXXXXXX 信息技术有限公司", +"legalPersonName": "AA", +"contactsName": "AA", +"contactsEmail": "11@qq.com", +"contactsPhone": "15111111111", +"regionCode": "11", +"cityName": "海淀区", +"enterpriseAddress": "知春路", +"taxRegistrationCertificate": "XXXXXXXX" +} + +2.2.3.响应报文 +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +taxpayerNum + +销售方纳税识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +enterpriseName + +销售方企业名称 + +String(2-40) + +是 + +销售方企业名称 + +报文示例: +{ +"taxpayerNum": "BHDWSDFDDD123459", +"enterpriseName": "北京 XXXXXXX 信息技术有限公司" +} + +2.2.4.业务错误码 +从业务中抽取代码并进行定义 + +说明 + +票通数电发票接口文档 + +错误(code) 含义说明(msg) +0000 + +成功,企业状态为审核中(待审核) + +9999 + +验签失败 + +9998 + +平台编码无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +9001 + +地区编码不存在 + +9002 + +市(地区)名称不存在,请对照编码表 + +9003 + +税务登记证图片保存失败,请检查税务登记证是否正确 + +9004 + +与第一次注册的平台信息不匹配,没有权限修改 + +9005 + +企业信息尚未审核,不能修改 + +2.3. 数电账号登记 +2.3.1.调用说明 +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/registerUser.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/registerUser.pt +字符编码 + +UTF-8 + +2.3.2.请求报文 +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +loginMethod + +登录方式 + +String(1) + +是 + +登录方式。 +1:用户名(居民身份证号码/ +手机号码/用户名)+密码 + +account + +登录账户 + +String(50) + +是 + +电子税局登录账号(手机号或 +身份证号),若平台已经存在 +做更新操作 + +password + +登录密码 + +String(100) + +是 + +电子税局登录密码,请使用票 +通提供的第三方平台密码进行 +3DES 加密 + +票通数电发票接口文档 +identityType + +登录身份类型 + +String(2) + +是 + +登录身份类型。 +01:法定代表人 +02:财务负责人 +03:办税员 +04:涉税服务人员 +05:管理员 +07:领票人 +09:开票员 +99:其他人员 + +identityPwd + +登录身份密码 + +String(100) + +否 + +电子税局登录身份密码,请使 +用票通提供的第三方平台密码 +进行 3DES 加密。部分地区需要 +身份密码,不需要的地区无需 +填写,目前需要的省份:无; +不需要的省份:广东省、上海 +市(待完善) + +phoneNum + +手机号码 + +String(11) + +是 + +手机号码。当前登记用户的手 +机号。如果是手机号+密码登 +录,该值必须和 account 一致。 + +name + +姓名 + +String(20) + +是 + +姓名 + +operationType + +操作类型 + +String(1) + +否 + +操作类型。默认 1 登记。 +1:登记; +2:删除 + +报文示例: +{ +"taxpayerNum": "XXXXXXXX", +"loginMethod": "1", +"account": "185XXXXXXXX", +"password": "XXXXXXXX", +"identityType": "01", +"phoneNum": "185XXXXXXXX", +"name": "XXXXXXXX" +} + +2.3.3.响应报文 +响应参数-业务报文部分: +字段 +resultCode + +名称 +结果代码 + +类型 + +必填 + +String(4) + +是 + +说明 +登记结果代码。 +0000:登记成功。 +其他:登记失败,失败原因见 +resultMsg + +票通数电发票接口文档 +resultMsg + +结果描述 + +String(不定长) + +是 + +登记结果描述 + +resultData + +结果业务报文 + +String(不定长) + +是 + +登记结果业务报文 + +qrcodeImgUrl + +绑定微信二维码图片 + +String(不定长) + +否 + +绑 定 微 信 二 维 码 图 片 url , + +url + +base64 字符串,访问该 url 直 +接可以返回二维码图片 + +qrcodePath + +绑定微信二维码内容 + +String(不定长) + +否 + +绑定微信二维码内容,base64 +字符串,需要自行转为二维码 + +failureTime + +绑定微信二维码失效 + +String(不定长) + +否 + +时间 + +绑定微信二维码失效时间。 +格式 yyyy-MM-dd HH:mm:ss + +报文示例: +{ +"resultCode": "0000", +"resultMsg": "登记成功", +"resultData": "XXXXXXXX", +"qrcodePath": "aHR0cDovL3dlaXhpbi5xcS5jb20vcS8wMnNMRTB0enY0YjYtMWQzQXUxQWNI", +"qrcodeImgUrl": +"aHR0cHM6Ly9tcC53ZWl4aW4ucXEuY29tL2NnaS1iaW4vc2hvd3FyY29kZT90aWNrZXQ9Z1FGVjhEd0FBQUFBQUFB +QUFTNW9kSFJ3T2k4dmQyVnBlR2x1TG5GeExtTnZiUzl4THpBeWMweEZNSFI2ZGpSaU5pMHhaRE5CZFRGQlkwZ0FBZ +1JEVi1kakF3UUFqU2NB", +"failureTime": "2023-03-25 20:11:04" +} + +2.3.4.业务错误码 +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能开票(企业尚未审核通过),请联系平台客服审核开通 + +2.4. 获取登录短信验证码 +2.4.1.调用说明 +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/sendLoginSmsCode.p + +备注 +POST 方式提交 + +票通数电发票接口文档 +t +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/sendLoginSmsCode.pt +字符编码 + +UTF-8 + +2.4.2.请求报文 +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +account + +登录账户 + +String(50) + +是 + +电子税局登录账号 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +account + +登录账户 + +String(50) + +是 + +电子税局登录账号 + +resultCode + +结果代码 + +String(4) + +是 + +结果代码 + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823" +} + +2.4.3.响应报文 +响应参数-业务报文部分: +字段 + +0000:获取成功。 +6666:登录成功,此时无需真 +正的发送短信验证码,系统会 +自动登录成功; +其他:获取失败,失败原因见 +resultMsg +resultMsg + +结果描述 + +phoneNum + +手机号(脱敏) + +activeTime + +有效时长 + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823", + +String(不定长) + +是 + +查询结果描述 + +String(11) + +否 + +手机号(脱敏) + +String(不定长) + +否 + +短信有效时长,单位:秒 + +票通数电发票接口文档 +"resultCode": "0000", +"resultMsg": "获取成功", +"phoneNum": "189****6823", +"activeTime": "600" +} + +2.4.4.业务错误码 +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能开票(企业尚未审核通过),请联系平台客服审核开通 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +2.5. 短信登录 +2.5.1.调用说明 +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/smsLogin.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/smsLogin.pt +字符编码 + +UTF-8 + +2.5.2.请求报文 +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +account + +登录账户 + +String(50) + +是 + +电子税局登录账号 + +smsCode + +短信验证码 + +String(6) + +是 + +短信验证码 + +报文示例: + +票通数电发票接口文档 +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823", +"smsCode": "196095" +} + +2.5.3.响应报文 +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +account + +登录账户 + +String(50) + +是 + +电子税局登录账号 + +resultCode + +结果代码 + +String(4) + +是 + +结果代码 +0000:登录成功。 +其他:登录失败,失败原因见 +resultMsg + +resultMsg + +结果描述 + +String(不定长) + +是 + +查询结果描述 + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823", +"resultCode": "0000", +"resultMsg": "登录成功" +} + +2.5.4.业务错误码 +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能开票(企业尚未审核通过),请联系平台客服审核开通 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +2.6. 获取实名认证二维码 +此接口用来获取实名认证二维码,若开票失败返回 3999 时,需要调用此接口获取实名认证 +二维码。二维码有效期一般在 5 分钟左右,如果获取失败或过期则需要重新获取。 + +票通数电发票接口文档 + +2.6.1.调用说明 +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/getAuthenticationQ +rcode.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/getAuthenticationQrcode +.pt +字符编码 + +UTF-8 + +2.6.2.请求报文 +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +account + +数电账户 + +String(50) + +是 + +电子税局登录账号 + +qrcodeType + +二维码类型 + +String(1) + +否 + +二维码类型。 +1:税务 APP; +2:个人所得税 APP。不传值默 +认 1 税务 APP。 + +报文示例: +{ +"taxpayerNum": "XXXXXXXX", +"account": "185XXXXXXXX" +} + +2.6.3.响应报文 +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +account + +登录账户 + +String(50) + +是 + +电子税局登录账号(手机号或 +身份证号) + +resultCode + +结果代码 + +String(4) + +是 + +获取结果代码。 +0000:获取成功。 + +票通数电发票接口文档 +其他:获取失败,失败原因见 +resultMsg +resultMsg + +结果描述 + +String(不定长) + +是 + +获取结果描述 + +qrcodeContent + +实名认证二维码内容 + +String(不定长) + +否 + +实名认证二维码内容,base64 +字符串,需要自行生成二维码 +图片 + +qrcodeImg + +实名认证二维码图片 + +String(不定长) + +否 + +实名认证二维码图片,base64, +需要显示图片给客户 + +genTime + +生成时间 + +String(19) + +否 + +二维码生成时间。格式 +yyyy-MM-dd HH:mm:ss + +authId + +认证 id + +String(32-50) + +否 + +认证 id,查询二维码扫码状态 +使用 + +authType + +认证类型 + +String(1) + +否 + +认证类型。 +0:登录认证; +1:风险认证 + +qrcodeType + +二维码类型 + +String(1) + +否 + +二维码类型。 +1:税务 APP; +2:个人所得税 APP。 + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823", +"resultCode": "0000", +"resultMsg": "获取成功", +"qrcodeContent": +"cXJjb2RlX2lkPUxoUk5SS3RINDR6bm9vZEM3YzFzdHZtUmVwTVpaRVMrem1vWmt4UmtJcHNGQWRBdm1EcDdpN1lv +Yms3enprTk0mYXJlYVByZWZpeD01MTAwJmludGVyZmFjZUNvZGU9MDAwNA==", +"qrcodeImg": "XXXXXXXXXXXXXX", +"genTime": "2023-02-21 09:54:39", +"authId": "c7690958b37a40918c4a1186a9db5f0c", +"authType": "0", +"qrcodeType": "1" +} + +2.6.4.业务错误码 +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能开票(企业尚未审核通过),请联系平台客服审核开通 + +票通数电发票接口文档 +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +2.7. 查询实名认证二维码扫码状态 +此接口用来查询实名认证二维码扫码状态,把实名认证二维码展示给客户后可以调用此接 +口查询扫码状态。如果二维码过期,无论是否扫码扫码 scanStatus 都会返回 3。此接口比较耗时, +请调长超时时间。 + +2.7.1.调用说明 +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryAuthQrcodeSca +nStatus.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/queryAuthQrcodeScanStat +us.pt +字符编码 + +UTF-8 + +2.7.2.请求报文 +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +account + +登录账户 + +String(50) + +是 + +电子税局登录账号 + +authId + +认证 id + +String(32-50) + +是 + +认证 id,推送开票结果/查询 +开票结果/获取实名认证二维 +码时会随二维码一起返回 + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823", +"authId": "c7690958b37a40918c4a1186a9db5f0c" +} + +票通数电发票接口文档 + +2.7.3.响应报文 +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +account + +登录账户 + +String(50) + +是 + +电子税局登录账号 + +authId + +认证 id + +String(32-50) + +是 + +认证 id + +resultCode + +结果代码 + +String(4) + +是 + +结果代码 +0000:查询成功。 +其他:查询失败,失败原因见 +resultMsg + +resultMsg + +结果描述 + +String(不定长) + +是 + +查询结果描述 + +scanStatus + +扫码状态 + +String(1) + +否 + +扫码状态。 +1:未扫码; +2:已扫码; +3:二维码已过期。 + +scanStatusMsg + +扫码状态描述 + +String(不定长) + +否 + +扫码状态描述 + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823", +"authId": "8b14000c20104bcbb4c51ebd65c676f9", +"resultCode": "0000", +"resultMsg": "查询成功", +"scanStatus": "2", +"scanStatusMsg": "已扫码" +} + +2.7.4.业务错误码 +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能开票(企业尚未审核通过),请联系平台客服审核开通 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +票通数电发票接口文档 + +2.8. 查询数电账号认证状态 +此接口用来查询数电账号的认证状态,会返回当前数电账号的认证状态,例如无需认证、 +风险认证及登录认证等,并返回具体的操作建议。 + +2.8.1.调用说明 +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/getTaxBureauAccoun +tAuthStatus.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/getTaxBureauAccountAuth +Status.pt +字符编码 + +UTF-8 + +2.8.2.请求报文 +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +account + +登录账号 + +String(50) + +是 + +电子税局登录账号 + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识别 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(50) + +是 + +电子税局登录账号 + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823" +} + +2.8.3.响应报文 +响应参数-业务报文部分: +字段 +taxpayerNum + +号 +account + +登录账户 + +票通数电发票接口文档 +name + +姓名 + +String(20) + +是 + +姓名 + +identityType + +登录身份类型 + +String(2) + +是 + +登录身份类型。 +01:法定代表人 +02:财务负责人 +03:办税员 +04:涉税服务人员 +05:管理员 +07:领票人 +09:开票员 +99:其他人员 + +operationProposed + +操作建议 + +String(1) + +是 + +操作建议,注意:该操作建议是 +根据账号状态和不同地区的登 +录方式区分出来的,并非和账 +号状态一一对应。 +0:无需认证; +1:需扫码认证; +2:需扫码或短信认证; +3:需短信认证 + +authStatus + +账号状态 + +String(1) + +是 + +账号状态 +0:无需认证; +1:风险认证; +2:登录认证; +3:风险+登录认证 +注:目前是根据调用税局返回 +的结果判断的账号状态,风险 +认证只有开具蓝票才能得知。 + +switchable + +可切换状态 + +String(1) + +是 + +当前企业是否可切换。 +0:不可切换; +1:可切换,代表该数电账号在 +该地区的其他企业有登录状 +态,当前企业不需要做登录认 + +证,如果有开票,会自动调度 +切换到当前企业开票。 +wechatUserBindStatus + +是否绑定微信公众 + +String(1) + +是 + +号 + +是否绑定票通云服务微信公众 +号。 +0:否; +1:是。 + +lastAuthSuccTime + +loginAuthStatus + +最新认证成功(登 + +String(19) + +否 + +最新认证成功(登录认证或风 + +录认证或风险认 + +险认证)时间 + +证)时间 + +格式 yyyy-MM-dd HH:mm:ss + +登录认证状态 + +String(1) + +是 + +登录认证状态。 +0:未登录; +1:已登录 + +lastLoginAuthTime + +最新登录认证时间 + +String(19) + +否 + +最新登录认证时间。 + +票通数电发票接口文档 +格式 yyyy-MM-dd HH:mm:ss +风险认证状态 + +riskAuthStatus + +String(1) + +是 + +风险认证状态。 +0:未认证; +1:已认证 + +最新风险认证时间 + +lastRiskAuthTime + +String(19) + +否 + +最新风险认证时间。 +格式 yyyy-MM-dd HH:mm:ss + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823", +"name": "张三", +"identityType": "09", +"authStatus": "2", +"operationProposed": "2", +"switchable": "1", +"wechatUserBindStatus": "1", +"lastAuthSuccTime": "2023-06-20 00:50:39", +"loginAuthStatus": "0", +"lastLoginAuthTime": "2023-06-20 00:50:39", +"riskAuthStatus": "1", +"lastRiskAuthTime": "2023-06-21 10:50:39" +} + +2.8.4.业务错误码 +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能开票(企业尚未审核通过),请联系平台客服审核开通 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +2.9. 开具蓝字数电发票 +注:价税合计金额=不含税金额+不含税金额*税率 + +2.9.1.调用说明 +项目 +调用关系 + +说明内容 +第三方平台调用票通平台 + +备注 + +票通数电发票接口文档 +调用方式 + +https + +POST 方式提交 + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/invoiceBlue.pt + +字符编码 + +UTF-8 + +2.9.2.请求报文 +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +是 + +销售方纳税人识别号,长度校 + +开票基本信息 +taxpayerNum + +销售方纳税人 + +String(15-20) + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +号 + +4 位平台简称+16 位随机数,长 +度校验规则为字符长度,只能 +包括英文字母或数字,唯一 + +invoiceIssueKindCode + +开具发票种类 + +String(1-2) + +是 + +开具发票种类。 +81:数电票(增值税专用发票) +82:数电票(普通发票) +10:增值税电子普通发票 +08:增值税电子专用发票 +不传值默认 10:增值税电子普 +通发票 + +buyerName + +购买方名称 + +String(1-100) + +是 + +票面信息,发票抬头,无默认 +长度校验规则为 GBK 字节长 +度,不能包含<>字符 + +purchaseInvSellerId + +农产品收购发 + +String(3) + +否 + +农产品收购发票销售方证件类 + +Type + +票销售方证件 + +型。开具农产品收购发票时必 + +类型 + +填。buyerTaxpayerNum 根据证 +件类型传值。见 3.2 农产品收 +购发票销方证件类型。 + +buyerTaxpayerNum + +购买方纳税人 + +String(15-20) + +否 + +识别号 + +票面信息,无默认,长度校验规 +则为字符长度,只能包括大写 +英文字母或数字。开具农产品 +收购发票时必填。 + +naturalPersonFlag + +是否开具给自 +然人 + +String(1) + +否 + +是否开具给自然人。默认 0。 +0:否; +1:是。电子税局勾选“是”时 +的提示:请您确认受票方为自 +然人,并在纳税人识别号档次 +填入“自然人纳税人识别号” + +票通数电发票接口文档 +(自然人受票方可登录个人所 +得税 APP 查看“自然人纳税人 +识别号”),该张发票将在受票 +方自然人个人票夹中展示。 +buyerAddress + +购买方地址 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度, +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerTel + +购买方电话 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为字符长度,只能是数字、 +中英文括号、中英文横杠。 +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerBankName + +购买方开户行 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +buyerBankAccount + +购买方银行账 + +String(1-50) + +否 + +号 + +票面信息,无默认,长度校验 +规则为字符长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +sellerAddress + +销货方地址 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的企业开票 +地址电话信息(如有多条取默 +认的,没有默认取最后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerTel + +销货方电话 + +String(1-20) + +否 + +票面信息,长度校验规则为字 +符长度,只能是数字、中英文 +括号、中英文横杠。如果不传 +值,取平台企业开票设置中的 +企业开票地址电话信息(如有 +多条取默认的,没有默认取最 +后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerBankName + +销货方开户行 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK + +票通数电发票接口文档 +字节长度。如果不传值,取平 +台企业开票设置中的开户行及 +银行账号信息(如有多条取默 +认的,如有一条则使用该开户 +行及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 +sellerBankAccount + +销货方银行账 + +String(1-50) + +否 + +号 + +票面信息,长度校验规则为字 +符长度。如果不传值,取平台 +企业开票设置中的开户行及银 +行账号信息(如有多条取默认 +的,如有一条则使用该开户行 +及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +showBuyerBank + +是否显示购方 + +String(1) + +否 + +是否显示购方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 + +showSellerBank + +是否显示销方 + +String(1) + +否 + +是否显示销方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 +注:最终版式文件销方银行账 +户取税局维护的开户行及账号 + +showBuyerAddrTel + +是否显示购方 + +String(1) + +否 + +是否显示购方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 + +showSellerAddrTel + +是否显示销方 + +String(1) + +否 + +是否显示销方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 +注:最终版式文件上的销方地 +址电话取税局维护的地址电话 + +account + +开票人税局账 + +String(50) + +否 + +号 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,随机取已在票通平台 +登记的账号。 + +variableLevyFlag + +数电发票差额 +征税标识 + +String(1) + +否 + +数电发票差额征税标识。只有 +差额征税时需要填写。 + +票通数电发票接口文档 +1:差额征税-全额开票 +2:差额征税-差额开票,差额 +开票时可不传该字段,以开票 +项目明细传的扣除额及差额凭 +证为准 +casherName + +收款人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的收款人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该收款人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +reviewerName + +复核人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的复核人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该复核人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +takerName + +收票人名称 + +String(1-10) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerTel + +收票人手机号 + +String(11) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +客户信息,填写后,票通会给 +客户发送发票邮件,不填写则 +不发送,长度校验规则为字符 +长度 + +specialInvoiceKind + +特殊票种 + +String(2) + +否 + +特殊票种,默认为空。 +08:成品油发票; +02:农产品收购发票,只能开 +具数电票(普通发票),注: +购方信息代表实际的销方信 +息,销方信息是实际的购方; +12:自产农产品销售发票,只 +能开具数电票(普通发票) + +remark + +备注 + +String(0-200) + +否 + +备注,校验规则字符长度 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 +中会按照定义返回,长度校验 +规则为字符长度 + +tradeNo + +订单号 + +String(0-200) + +否 + +订单号,长度校验规则为字符 + +票通数电发票接口文档 +长度。如果没有传值,票通平 +台默认使用发票请求流水号 +invoiceReqSerialNo 赋值 +shopNum + +门店编号 + +String(6-20) + +否 + +门店编号,取值集团版门店编 +号,没有则不用填写。只允许 +字母、数字 + +开票项目列表信息(itemList) +itemList + +开票项目列表 + +goodsName + +货物名称 + +数组或集合 + +是 + +最大 2000 行(包括折扣行)。 + +String(1-100) + +是 + +票面信息,此项不填写时默认 +为 taxClassificationCode 对 +应的名称,长度校验规则为字 +符长度 + +taxClassificationCo + +对应税收分类 + +de + +编码 + +specificationModel + +对应规格型号 + +String(1-50) + +是 + +统一编码表的信息,长度校验 +规则为字符长度 + +String(1-40) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度 + +meteringUnit + +单位 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度 + +quantity + +数量 + +16 位(精确到 8 + +否 + +位小数) + +票面信息,支持到小数点前 8 +位。数量和单价必须同时为空 +或同时不为空。 +成品油发票数量不能为空。 + +includeTaxFlag + +含税标示 + +unitPrice + +单价 + +String(1) + +否 + +0:不含税,1:含税,默 +认为 0 不含税 + +16 位(精确到 8 + +否 + +票面信息,支持到小数点前 8 + +位小数) + +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。数量和单价必须 +同时为空或同时不为空。 +成品油发票单价不能为空。 + +invoiceAmount + +金额 + +10 位(精确到 2 + +是 + +位小数) + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +taxRateValue + +税率 + +4 位(精确到 2 位 + +是 + +票面信息,例:0.13 + +否 + +票面信息,如果不填写,默认 + +小数) +taxRateAmount + +税额 + +10 位(精确到 2 +位小数) + +会根据税率计算税额,如果填 +写此值会直接使用此税额,可 +通过含税标示 +includeTaxFlag,定义此字段 +为含税 + +discountAmount + +折扣金额 + +10 位(精确到 2 +位小数) + +否 + +该商品行的折扣金额,传负数。 +差额征税-差额开票不支持折 + +票通数电发票接口文档 +扣。 +discountTaxRateAmou + +折扣税额 + +nt +deductionAmount + +10 位(精确到 2 + +否 + +位小数) +差额开票扣除 +金额 + +10 位(精确到 2 + +票面信息,使用折扣金额进行 +计算 + +否 + +位小数) + +差额开票扣除金额,填写时为 +差额开票。如果不填, +variableLevyProofList 有数 +据,将按差额开票。 + +preferentialPolicyF + +优惠政策标识 + +String(1-50) + +否 + +lag + +空:不使用,1:使用 +零税率标识为 1、2 时该值必填 +1。增值税特殊管理有值时该值 +必填 1。 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +税率为 0 时该值必填。空:非 +零税率, +1:免税, +2:不征税, +3:普通零税率 + +vatSpecialManage + +增值税特殊管 + +String(0-100) + +否 + +理 + +preferentialPolicyFlag 优惠 +政策标识位 1 时必填,填免税、 +不征税、简易征收等 + +差额凭证明细列表信息(variableLevyProofList) +variableLevyProofList + +差额凭证明细 + +数组或集合 + +否 + +列表 +proofType + +凭证类型 + +最大 100 行,差额征税-差额开 +票必填 + +String(2) + +是 + +凭证类型。 +01:数电票; +02:增值税专用发票; +03:增值税普通发票; +04:营业税发票; +05:财政票据; +06:法院裁决书; +07:契税完税凭证; +08:其他发票类; +09:其他扣除凭证。 + +electronicInvoiceNo + +数电票号码 + +String(20) + +否 + +数电票号码。 +凭证类型为 01 数电票时必填。 + +invoiceCode + +发票代码 + +String(10/12) + +否 + +发票代码。 +凭证类型为 02 增值税专用发 +票时必填。 +凭证类型为 03 增值税普通发 +票时必填。 +凭证类型为 04 营业税发票时 +必填。 + +invoiceNo + +发票号码 + +String(8) + +否 + +发票号码。 +凭证类型为 02 增值税专用发 +票时必填。 + +票通数电发票接口文档 +凭证类型为 03 增值税普通发 +票时必填。 +凭证类型为 04 营业税发票时 +必填。 +proofNo + +凭证号码 + +String(0-100) + +否 + +凭证号码 + +issueDate + +开具日期 + +String(10) + +否 + +开具日期。 +格式 yyyy-MM-dd。 +凭证类型为 01 数电票时必填。 +凭证类型为 02 增值税专用发 +票时必填。 +凭证类型为 03 增值税普通发 +票时必填。 +凭证类型为 04 营业税发票时 +必填。 + +proofAmount + +凭证合计金额 + +10 位(精确到 2 + +是 + +位小数) +deductionAmount + +本次扣除金额 + +10 位(精确到 2 + +凭证合计金额。 +不能等于 0。 + +是 + +位小数) + +本次扣除金额。 +不能等于 0。所有凭证的扣除 +金额要等于 itemList 中的 +deductionAmount。 + +proofRemark + +备注 + +String(0-200) + +否 + +备注。 +凭证类型为 08 其他发票类时 +必填。 +凭证类型为 09 其他扣除凭证 +时必填。 + +source + +来源 + +String(4) + +否 + +来源。默认手工录入。 +手工录入; +勾选录入; +模板录入。 + +订单列表 +orderList + +订单列表 + +数组或集合 + +否 + +订单列表,合并订单开票可以 +使用该字段传值 + +orderNo + +单据号 + +String(1-50) + +是 + +单据号 + +优惠政策传值说明: +优惠政策 +传值 + +免税 + +不征税 + +普通零税率 + +简易征收 + +按 5%简易征收 + +taxRateValue + +0 + +0 + +0 + +0.03/0.05 + +0.05 + +zeroTaxFlag + +1 + +2 + +3 + +null + +null + +preferentialPolicyFlag + +1 + +1 + +null + +1 + +1 + +免税 + +不征税 + +null + +简易征收 + +按 5%简易征收 + +字段名 + +vatSpecialManage + +报文示例: + +票通数电发票接口文档 +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceIssueKindCode ": "82", +"buyerName": "北京 XXXXX 技术有限公司", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"buyerAddress": "北京市海淀区 XXXXXX15 号 5 层", +"buyerTel": ""010-1234567", +"buyerBankName": "XXXX 银行", +"buyerBankAccount": "9878XXXXXX45666", +"sellerAddress": "北京海淀区 XXX 路 15 号", +"sellerTel": "010-7654321", +"sellerBankName": "XXXXX 银行", +"sellerBankAccount": "6217XXXXXXX0678", +"remark": "备注", +"tradeNo": "DEMO1111111111", +"definedData": "自定义数据", +"account": "185XXXXXXXX", +"takerName": "XXX", +"takerTel": "XXXXXXX", +"takerEmail": "XXXXX@qq.com", +"itemList": [{ +"goodsName": "小麦", +"taxClassificationCode": "1010101020000000000", +"specificationModel": "", +"meteringUnit": "袋", +"quantity": "1.00", +"includeTaxFlag": "1", +"unitPrice": "100", +"invoiceAmount": "100.00", +"taxRateValue": "0.13" +}, +{ +"goodsName": "稻谷商品", +"taxClassificationCode": "1010101010000000000", +"specificationModel": "", +"meteringUnit": "", +"quantity": "1.00", +"includeTaxFlag": "1", +"unitPrice": "2.00", +"invoiceAmount": "2.00", +"taxRateValue": "0.00", +"preferentialPolicyFlag": "1", +"zeroTaxFlag": "1", + +票通数电发票接口文档 +"vatSpecialManage": "免税" +} +] +} + +2.9.3.响应报文 +响应参数-业务报文部分: +字段 + +名称 + +invoiceReqSerialNo + +发票请求流水 + +类型 + +必填 + +说明 + +String(20) + +是 + +4 位平台简称+16 位随机数 + +String + +否 + +不定长,Base64 字符串,电子 + +号 +qrCodePath + +二维码 url + +发票该值必传 +qrCode + +二维码图片 + +String + +否 + +Base64 字符串 + +扫码查看发票开票状态,二维 +码的内容是 qrCodePath,电子 +发票该值必传 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"qrCodePath": "xxxxxxxxxxxxx", +"qrCode": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} + +2.9.4.业务错误码 +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8993 + +开票请求处理失败(对应参考返回的详细信息) + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8004 + +找不到对应的开票企业信息,请检查税号 + +8005 + +找不到对应的税收分类编码,请参照税收分类编码表,检查编码号 + +8006 + +找不到对应的税率,请输入正确的税率 + +8007 + +折扣金额不可大于对应商品行金额/折扣金额和折扣率不匹配 + +8008 + +优惠政策不为空时,增值税特殊管理不能为空 + +8009 + +企业注册/修改中不能开票 + +票通数电发票接口文档 +8011 + +税额与税率不匹配 + +8012 + +差额开票抵扣金额过大,不能超过价税合计金额 + +8013 + +差额开票只允许单个商品行 + +8014 + +税率为 0 时,零税率标示必须选择 + +8015 + +不存在对应的零税率标示 + +8016 + +单价数量金额不匹配 + +8017 + +折扣税额和税率不匹配 + +8021 + +发票请求流水号已存在,请更换发票请求流水号 + +8040 + +该发票请求流水号正在处理中,请稍后查询开票结果 + +2.10. 快捷冲红数电发票(全额冲红) +此接口为了简化数电冲红操作,将申请数电红字发票确认单、开具红字数电发票接口集成, +票通平台将自动申请数电红字发票确认单并开具红字数电发票。此接口只针对销方冲红发票使 +用。 +票通侧主要流程说明: +(1)业务方调用后,票通校验系统内是否存在该发票的红字确认单,若已存在该发票的红 +字确认单(状态为无需确认或购销双方已确认),将会直接使用该红字发票确认单进行冲红;若 +红字发票确认单状态为“销方录入待购方确认”,将会查询税局红字发票确认单状态,若为“购 +销双方已确认”将会冲红发票。 +(2)票通收到冲红请求后,如没有冲红,票通将保存红票信息,红票状态为“红字发票确 +认单申请中”; +(3)接下来票通帮助企业自动申请红字发票确认单, +如返回“无需确认”将自动进行开具红票;开具成功推送结果给业务系统。 +如返回“销方录入待购方确认”将会修改红票状态为“红字确认单审核中”并通过发票 +开具结果推送接口推送给业务系统,业务系统也可以主动查询该红票状态,处于“红字确 +认单审核中”状态时,销方需要联系购方进行确认处理,购方确认后可以再次调用该接口 +进行冲红,票通平台使用步骤 1 进行冲红处理;处于“红字确认单审核中”状态的确认单, +票通也会使用定时任务每小时一次主动查询审核结果,审核通过票通将自动冲红,如超过 +72 时,电子税务局将自动作废该确认单,等同购方审核驳回操作,票通将冲红状态置为失 +败,推送结果给业务系统。 +如返回其他状态,均会把红票作为失败处理,失败原因会通过推送发票或查询发票返 +回给接口调用方,待失败问题解决后可以调用该接口进行冲红操作。 + +2.10.1. +项目 + +调用说明 +说明内容 + +备注 + +票通数电发票接口文档 +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/op +enapi/invoiceRed.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi +/invoiceRed.pt +字符编码 + +UTF-8 + +2.10.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(20) + +是 + +红票请求流水号 4 位平台简称 + +别号 +invoiceReqSerialNo + +发票请求流水号 + ++16 位随机数 +invoiceCode + +发票代码 + +String(12) + +否 + +需冲红的原发票代码,冲红增 +值税发票管理系统开具的发票 +或数电纸质发票时必填 + +invoiceNo + +发票号码 + +String(8) + +否 + +需冲红的原发票号码,冲红增 +值税发票管理系统开具的发票 +或数电纸质发票时必填 + +blueAllEleInvNo + +原数电发票号码 + +String(20) + +否 + +需冲红原数电发票号码,冲红 +数电发票时必填 + +blueInvoiceDate + +蓝字发票开票日 + +String(8) + +否 + +期 + +蓝字发票开票日期,格式 +yyyyMMdd。冲红非票通平台开 +具的发票时必填,需要使用该 +字段及数电发票号码从税局拉 +取发票 + +redReason + +冲红原因 + +String(1-100) + +是 + +冲红原因,不传默认 01 +01:开票有误 +02:销货退回 +03:服务中止 +04:销售折让 + +amount + +价税合计金额 + +10 位(精确到 2 + +是 + +位小数) +account + +电子税局登录账 +号 + +String(50) + +原发票的价税合计金额的负数 +值 + +否 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,取蓝票的开票账号, +若蓝票的开票账号不再使用, + +票通数电发票接口文档 +取企业现有的。 +invoiceKind + +发票种类代码 + +String(2) + +否 + +开具红字发票种类。 +81:数电发票(增值税专用发 +票) +82:数电发票(普通发票)。 +默认蓝票的发票种类代码。 +此字段目的解决数电发票冲红 +增值税发票,只有企业不再使 +用增值税系统时才可以跨票种 +冲红。 +数电发票(普通发票)可以冲 +红数电发票(普通发票)、增 +值税电子普通发票、增值税纸 +质普通发票。 +数电发票(增值税专用发票) +可以冲红数电发票(增值税专 +用发票)、增值税电子专用发 +票、增值税纸质专用发票。 + +takerName + +收票人名称 + +String(1-10) + +否 + +收票人名称,长度校验规则为 +字符长度 + +takerTel + +收票人手机号 + +String(11) + +否 + +收票人手机号,长度校验规则 +为字符长度,若在票通平台设 +置了红字发票发送短信且企业 +有可用短信条数,填写该值则 +发送短信 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +收票人邮箱,长度校验规则为 +字符长度,若在票通平台设置 +了红字发票发送邮件,填写该 +值则发送邮件 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 +中会按照定义返回,长度校验 +规则为字符长度 + +报文示例: +{ +"taxpayerNum": "9120931023801231", +"invoiceReqSerialNo": "XXXX5678901234567891", +"invoiceCode": "123456789012", +"invoiceNo": "12345678", +"redReason": "01", +"amount": "-100", +"account": "zhangsan", +"definedData": "自定义数据" +} + +票通数电发票接口文档 + +2.10.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +4 位平台简称+16 位随机数 + +号 +qrCodePath + +二维码 url + +String + +是 + +不定长,Base64 字符串 + +qrCode + +二维码图片 + +String + +否 + +扫码打开查看发票开票状 + +Base64 +redApplySerialNo + +红字确认单申 + +态 +String(20) + +否 + +请流水号 + +红字确认单申请流水号,如 +果受理成功,该值必填。可 +以使用该字段调用 2.29.查 +看红字发票确认单接口查 +看红字确认单信息 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"qrCodePath": "XXXXXXXXXXXXXXXXXXXXXXXX", +"qrCode": "XXXXXXXXXXXXXXXXXXXXXXXX", +"redApplySerialNo": "P1234567891234567890" +} + +2.10.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8001 + +开票处理失败,开票企业不存在 + +8002 + +找不到对应的税号信息 + +8003 + +找不到对应的发票 + +8004 + +找不到对应的开票企业信息,请检查税号 + +6005 + +该蓝票已经冲红,不可冲红 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +票通数电发票接口文档 + +2.11. 查询发票主要信息 +此接口用来查询发票状态信息。如果开票成功会返回发票代码、发票号码、数电发票号码等发票信息; +如果开票失败则返回失败原因。 + +2.11.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoice.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/queryInvoice.pt +字符编码 + +UTF-8 + +2.11.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +invoiceReqSerialNo + +发票请求流水号 + +String(20) + +是 + +发票请求流水号 + +说明 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890" +} + +2.11.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(20) + +是 + +发票请求流水号 + +String(1) + +是 + +发票类型。 + +别号 +invoiceReqSerialNo + +发票请求流水号 + +invoiceType + +开票类型 + +1:蓝票 +2:红票 +invoiceKind + +发票种类代码 + +String(2) + +是 + +发票种类代码。 + +票通数电发票接口文档 +81:电子发票(增值税专用 +发票) +82:电子发票(普通发票) +87:数电纸质发票(机动车 +销售统一发票) +10:增值税电子普通发票 +08:增值税电子专用发票 +04:增值税普通发票 +01:增值税专用发票 +code + +发票状态码 + +String(4) + +是 + +发票状态码。 +0000:开票成功; +6666:未开票; +7777:开票中; +9999:开票失败; +3999:开票失败,需要扫码 +或短信认证,可以使用 2.8 +接口查询数电账号状态,来 +告知商家需要如何认证; +4999:红字发票确认单申请 +中,只有红票会有此状态; +5999:红字发票确认单审核 +中,具体状态描述见 msg, +只有红票会有此状态; + +msg + +发票状态描述 + +String(100) + +是 + +发票状态描述(成功/失败原 +因) + +account + +开票人税局账号 + +String(50) + +否 + +电子税局登录账号(手机号 +或身份证号) + +authenticationQrcode +authId + +实名认证二维码 + +String ( 不 定 + +否 + +实名认证二维码图片, + +图片 + +长) + +实名认证二维码 + +String(32-50) + +否 + +实名认证二维码认证 id + +base64 字符串 + +认证 id +tradeNo + +订单号 + +String(20) + +否 + +发票状态为成功时,必传 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据 + +qrCode + +二维码 + +String(不定 + +否 + +发票状态为成功时,必传 + +长) +invoiceCode + +发票代码 + +String(12) + +否 + +发票状态为成功时,必传 + +invoiceNo + +发票号码 + +String(8) + +否 + +发票状态为成功时,必传 + +electronicInvoiceNo + +数电发票号码 + +String(20) + +否 + +数电发票号码,发票为数电 +电票时:数电发票号码=发票 +代码+发票号码 + +invoiceDate + +开票日期 + +String(19) + +否 + +发票状态为成功时,必传 +yyyy-MM-dd HH:mm:ss + +noTaxAmount + +不含税金额 + +String(16) + +否 + +发票状态为成功时,必传, +小发票状态为成功时,必传, + +票通数电发票接口文档 +数点后 2 位,以元为单位精 +确到分 +taxAmount + +税额 + +String(16) + +否 + +发票状态为成功时,必传, +小数点后 2 位,以元为单位 +精确到分 + +invoiceLayoutFileType + +版式文件类型 + +String(3) + +否 + +版式文件类型。 +pdf:pdf 文件格式; +ofd:ofd 文件格式。 + +invoicePdf + +发票版式文件 + +String(不定 + +否 + +长) + +发票状态为成功时,必传, +不定长,Base64 字符串(文 +件流) + +invoiceXml + +发票 xml 文件 + +String(不定 + +否 + +长) + +非必传,若开票成功没传, +需要调用获取 2.15 获取数 +电发票文件接口获取,不定 +长,Base64 字符串 + +downloadUrl + +发票下载 Url + +String(不定 + +否 + +长) +invPreviewQrcodePath + +电子发票预览二 +维码 url + +String(不定 + +发票状态为成功时,必 +传,Base64 字符串 + +否 + +长) + +不定长,Base64 字符串。同 +2.9 开具蓝字数电发票接口 +返回的 qrCodePath,电子发 +票该值必传 + +invPreviewQrcode + +电子发票预览二 +维码图片 + +String(不定 + +否 + +长) + +不定长,Base64 字符串,扫 +码查看发票开票状态。同 +2.9 开具蓝字数电发票接口 +返回的 qrCode,电子发票该 +值必传 + +invDeletedFlag + +发票删除标志 + +String(1) + +是 + +发票删除标志。 +开具失败/未开票的情况会 +被删除,可以发起重开。 +0:未删除; +1:已删除。 +默认不查询已删除的发票, +如需要查询已删除的发票, +联系票通对接人员配置。 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceType": "1", +"invoiceKind": "82", +"code": "0000", +"msg": "开票成功", +"tradeNo": "DEMO1111111111", + +票通数电发票接口文档 +"definedData": "自定义数据", +"qrCode": "XXXXXXXX", +"invoiceCode": "123456789012", +"invoiceNo": "12345678", +"electronicInvoiceNo": "12345678901212345678", +"invoiceDate": "2022-09-28 08:15:54", +"noTaxAmount": "90.50", +"taxAmount": "11.50", +"invoiceLayoutFileType": "pdf", +"invoicePdf": "XXXXXXXX", +"invoiceXml": "XXXXXXXX", +"downloadUrl": "XXXXXXXX", +"invPreviewQrcodePath": "XXXXXXXX", +"invPreviewQrcode": "XXXXXXXX", +"invDeletedFlag": "0" +} + +2.11.4. + +业务错误码 + +从业务中抽取代码并进行定义: + +错误码(code) + +含义说明(msg) + +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8003 + +找不到对应的发票 + +2.12. 查询发票全票面信息 +此接口用来查询发票状态及发票全面信息。如果开票成功会返回发票代码、发票号码、数电发票号码 +等发票信息;如果开票失败则返回失败原因。 + +2.12.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvoiceInfo.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/queryInvoiceInfo.pt + +字符编码 + +UTF-8 + +备注 +POST 方式提交 + +票通数电发票接口文档 + +2.12.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +invoiceReqSerialNo + +发票请求流水号 + +String(20) + +是 + +发票请求流水号 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890" +} + +2.12.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +发票请求流水号 + +String(4) + +是 + +发票状态码。 + +号 +code + +发票状态码 + +0000:开票成功; +6666:未开票; +7777:开票中; +9999:开票失败; +3999:开票失败,需要扫码 +或短信认证,可以使用 2.8 +接口查询数电账号状态,来 +告知商家需要如何认证; +4999:红字发票确认单申请 +中,只有红票会有此状态; +5999:红字发票确认单审核 +中,具体状态描述见 msg, +只有红票会有此状态; +msg + +发票状态描述 + +String(100) + +是 + +发票状态描述(成功/失败 +原因) + +authenticationQrcode + +实名认证二维 + +String(不定长) + +否 + +码图片 +authId + +实名认证二维 + +实名认证二维码图片, +base64 字符串 + +String(32-50) + +否 + +实名认证二维码认证 id + +码认证 id +buyerName + +购买方名称 + +String(4-100) + +否 + +购买方名称 + +buyerTaxpayerNum + +购买方纳税人 + +String(15-20) + +否 + +购买方纳税人识别号 + +票通数电发票接口文档 +识别号 +buyerAddress + +购买方地址 + +String(1-100) + +否 + +购买方地址 + +buyerTel + +购买方电话 + +String(1-20) + +否 + +购买方电话 + +buyerBankName + +购买方开户行 + +String(1-100) + +否 + +购买方开户行 + +buyerBankAccount + +购买方银行账 + +String(1-50) + +否 + +购买方银行账号 + +String(15-20) + +是 + +销售方纳税人识别号 + +号 +sellerTaxpayerNum + +销售方纳税人 +识别号 + +sellerName + +销售方名称 + +String(4-100) + +否 + +销售方名称 + +sellerAddress + +销售方地址 + +String(1-100) + +否 + +销售方地址 + +sellerTel + +销售方电话 + +String(1-20) + +否 + +销售方电话 + +sellerBankName + +销售方开户行 + +String(1-100) + +否 + +销售方开户行 + +sellerBankAccount + +销售方银行账 + +String(1-50) + +否 + +销售方银行账号 + +号 +showBuyerBank + +是否显示购方 + +String(1) + +否 + +是否显示购方开户行及账 + +开户行及账号 + +号到发票备注,默认 0 不显 + +到发票备注 + +示 +0:不显示 +1:显示 + +showSellerBank + +是否显示销方 + +String(1) + +否 + +是否显示销方开户行及账 + +开户行及账号 + +号到发票备注,默认 0 不显 + +到发票备注 + +示 +0:不显示 +1:显示 + +showBuyerAddrTel + +是否显示购方 + +String(1) + +否 + +是否显示购方地址电话到 + +地址电话到发 + +发票备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 + +showSellerAddrTel + +是否显示销方 + +String(1) + +否 + +是否显示销方地址电话到 + +地址电话到发 + +发票备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 + +invoiceKind + +发票种类代码 + +String(2) + +是 + +发票种类代码。 +81:电子发票(增值税专用 +发票) +82:电子发票(普通发票) +87:数电纸质发票(机动车 +销售统一发票) +10:增值税电子普通发票 +08:增值税电子专用发票 +04:增值税普通发票 +01:增值税专用发票 + +specialInvoiceKind + +特殊票种标识 + +String(2) + +是 + +发票状态为成功时,必传 +00:普通发票 + +票通数电发票接口文档 +08:成品油发票 +02:收购发票 +invoiceDate + +开票日期 + +String(19) + +否 + +发票状态为成功时,必传 +格式 yyyy-MM-dd HH:mm:ss + +invoiceCode + +发票代码 + +String(12) + +否 + +发票状态为成功时,必传 + +invoiceNo + +发票号码 + +String(8) + +否 + +发票状态为成功时,必传 + +electronicInvoiceNo + +数电发票号码 + +String(20) + +否 + +发票状态为成功时,必传 +发票为数电电票时:数电发 +票号码=发票代码+发票号 +码 + +invoiceType + +开票类型 + +String(1) + +否 + +发票状态为成功时,必传 +1:蓝票 +2:红票 + +blueInvoiceCode + +原发票代码 + +String(12) + +否 + +发票类型为 2 时,必传 + +blueInvoiceNo + +原发票号码 + +String(8) + +否 + +发票类型为 2 时,必传 + +blueAllEleInvNo + +原数电发票号 + +String(8) + +否 + +数电发票发票类型为 2 时, + +码 +noTaxAmount + +不含税金额 + +必传 +String(16) + +否 + +发票状态为成功时,必传, +保留小数点后 2 位 + +taxAmount + +税额 + +String(16) + +否 + +发票状态为成功时,必传, +保留小数点后 2 位 + +amountWithTax + +价税合计 + +String(16) + +否 + +发票状态为成功时,必传, +保留小数点后 2 位 + +invalidFlag + +作废标志 + +String(20) + +否 + +发票状态为成功时,必传, +NOT_DESTROY:未作废; +ALREADY_DESTROY:已作废; +DESTROYING:作废中; +DESTROY_FAIL:作废失败。 + +redFlag + +冲红标志 + +String(20) + +否 + +发票状态为成功时,必传, +NOT_RED:未冲红; +ALREADY_RED:已冲红; +REDING:冲红中; +RED_FAIL: 冲红失败; +PART_RED:部分冲红。 + +drawerName + +开票人名称 + +String(1-20) + +否 + +发票状态为成功时,必传 + +casherName + +收款人名称 + +String(1-16) + +否 + +发票状态为成功时,必传 + +reviewerName + +复核人名称 + +String(1-16) + +否 + +发票状态为成功时,必传 + +account + +开票人税局账 + +String(50) + +否 + +电子税局登录账号(手机号 + +号 +detailedListFlag + +清单标识 + +或身份证号) +String(1) + +否 + +0:非清单 +1:清单 + +detailedListItemName + +清单项目名称 + +String(1-100) + +否 + +清单项目名称 + +qrCode + +二维码 + +String(1000) + +否 + +发票状态为成功时,必传 + +remark + +备注 + +String(0-200) + +否 + +备注,校验规则字符长度 + +票通数电发票接口文档 +tradeNo + +订单号 + +String(0-200) + +否 + +订单号 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接 +口中会按照定义返回,长度 +校验规则为字符长度 + +invoiceLayoutFileType + +版式文件类型 + +String(3) + +否 + +版式文件类型。 +pdf:pdf 文件格式; +ofd:ofd 文件格式。 + +invoicePdf + +发票 pdf 文件 + +String + +否 + +发票状态为成功时,必传, +不定长,Base64 字符串 + +invoiceXml + +发票 xml 文件 + +String(不定长) + +否 + +非必传,若开票成功没传, +需要调用获取 2.15 获取数 +电发票文件接口获取,不定 +长,Base64 字符串 + +downloadUrl + +发票下载 Url + +String + +否 + +发票状态为成功时,必 +传,Base64 字符串 + +invPreviewQrcodePath + +电子发票预览 + +String + +否 + +二维码 url + +不定长,Base64 字符串。同 +2.9 开具蓝字数电发票接口 +返回的 qrCodePath,电子发 +票该值必传 + +invPreviewQrcode + +电子发票预览 + +String + +否 + +二维码图片 + +不定长,Base64 字符串,扫 +码查看发票开票状态。同 +2.9 开具蓝字数电发票接口 +返回的 qrCode,电子发票该 +值必传 + +invDeletedFlag + +发票删除标志 + +String(1) + +是 + +发票删除标志。 +开具失败/未开票的情况会 +被删除,可以发起重开。 +0:未删除; +1:已删除。 +默认不查询已删除的发票, +如需要查询已删除的发票, +联系票通对接人员配置。 + +项目明细信息(itemList,数组) +itemList + +项目明细 + +数组,发票状态为成功时, +必传 + +goodsName + +货物名称 + +String(1-100) + +是 + +发票状态为成功时,必传 + +taxClassificationCode + +税收分类编码 + +String(1-50) + +是 + +发票状态为成功时,必传 + +specificationModel + +规格型号 + +String(1-20) + +否 + +meteringUnit + +单位 + +String(1-16) + +否 + +quantity + +数量 + +String(1-16) + +是 + +保留小数点后 8 位 + +unitPrice + +不含税单价 + +String(1-16) + +是 + +保留小数点后 8 位 + +itemAmount + +项目金额 + +String(10) + +是 + +保留小数点后 2 位 + +taxRate + +税率 + +String(4) + +是 + +保留小数点后 2 位,例如 +0.16 + +票通数电发票接口文档 +taxRateAmount + +税额 + +String(10) + +是 + +保留小数点后 2 位 + +deduction + +扣除额 + +String(10) + +否 + +保留小数点后 2 位 + +preferentialPolicyFl + +优惠政策标识 + +String(1-50) + +否 + +优惠政策标识。 + +ag + +0:不使用; +1:使用 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +空:非零税率, +1:免税, +2:不征税, +3:普通零税率 + +vatSpecialManage + +增值税特殊管 + +String(0-100) + +否 + +增值税特殊管理 + +String(1) + +是 + +0:正常行 + +理 +itemProperty + +发票行性质 + +1:折扣行 +2:被折扣行 +itemNo + +项目序号 + +blueInvOrderNo + +蓝字发票明细 + +String(11) + +是 + +用来表示项目的先后顺序 + +String(1-4) + +否 + +蓝字发票明细序号,红字数 + +序号 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"code": "0000", +"msg": "开具成功", +"buyerName": "北京 XXXXX 技术有限公司", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"buyerAddress": "北京市海淀区 XXXXXX15 号 5 层", +"buyerTel": "010-1234567", +"buyerBankName": "XXXX 银行", +"buyerBankAccount": "9878XXXXXX45666", +"sellerTaxpayerNum": "91XXXXXXXXXXXXX31", +"sellerName": "北京 XXXXX 技术有限公司", +"sellerAddress": "北京海淀区 XXX 路 15 号", +"sellerTel": "010-7654321", +"sellerBankName": "XXXXX 银行", +"sellerBankAccount": "6217XXXXXXX0678", +"invoiceKind": "82", +"specialInvoiceKind": "00", +"invoiceDate": "2022-09-28 08:15:54", +"invoiceCode": "123456789012", +"invoiceNo": "12345678", +"electronicInvoiceNo": "12345678901212345678", +"invoiceType": "1", +"noTaxAmount": "90.50", +"taxAmount": "11.50", + +电发票的时候可能有值 + +票通数电发票接口文档 +"amountWithTax": "102.00", +"invalidFlag": "NOT_DESTROY", +"redFlag": "NOT_RED", +"drawerName": "XXXXXX", +"account": "185XXXXXXXX", +"detailedListFlag": "0", +"detailedListItemName": "0", +"qrCode": "XXXXXXXX", +"remark": "备注", +"tradeNo": "DEMO1111111111", +"definedData": "自定义数据", +"invoiceLayoutFileType": "pdf", +"invoicePdf": "XXXX", +"invoiceXml": "XXXXXXXX", +"downloadUrl": "XXXX", +"invPreviewQrcodePath": "XXXXXXXX", +"invPreviewQrcode": "XXXXXXXX", +"invDeletedFlag": "0", +"itemList": [{ +"goodsName": "小麦", +"taxClassificationCode": "1010101020000000000", +"specificationModel": "", +"meteringUnit": "袋", +"quantity": "1.00", +"unitPrice": "88.50", +"itemAmount": "88.50", +"taxRate": "0.13", +"taxRateAmount": "11.50", +"preferentialPolicyFlag": "0", +"itemProperty": "0", +"itemNo": "1" +}, +{ +"goodsName": "稻谷商品", +"taxClassificationCode": "1010101010000000000", +"specificationModel": "", +"meteringUnit": "", +"quantity": "1.00", +"unitPrice": "2.00", +"invoiceAmount": "2.00", +"taxRate": "0.00", +"taxRateAmount": "0.00", +"preferentialPolicyFlag": "1", +"zeroTaxFlag": "1", + +票通数电发票接口文档 +"vatSpecialManage": "免税", +"itemProperty": "0", +"itemNo": "2" +} +] +} + +2.12.4. + +业务错误码 + +从业务中抽取代码并进行定义: + +错误码(code) + +含义说明(msg) + +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8003 + +找不到对应的发票 + +2.13. 推送发票主要信息 +2.13.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +票通平台调用第三方平台 + +调用方式 + +https + +接口地址 + +第三方平台提供 + +字符编码 + +UTF-8 + +2.13.2. + +请求报文 + +备注 +POST 方式提交 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(20) + +是 + +发票请求流水号 + +String(1) + +是 + +开票类型 + +识别号 +invoiceReqSerialNo + +发票请求流水 +号 + +invoiceType + +开票类型 + +1:蓝票; +2:红票; +invoiceKind + +发票种类代码 + +String(2) + +是 + +发票种类代码。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票) + +票通数电发票接口文档 +87:数电纸质发票(机动车销 +售统一发票) +10:增值税电子普通发票 +08:增值税电子专用发票 +04:增值税普通发票 +01:增值税专用发票 +code + +发票状态码 + +String(4) + +是 + +发票状态码。 +0000:开票成功; +6666:未开票; +9999:开票失败; +3999:开票失败,需要扫码或 +短信认证,可以使用 2.8 接口 +查询数电账号状态,来告知商 +家需要如何认证; +5999:红字发票确认单审核中, +具体状态描述见 msg,只有红 +票会有此状态; + +msg + +发票状态描述 + +String(100) + +否 + +发票状态描述(成功/失败原 +因) + +account + +开票人税局账 + +String(50) + +否 + +号 +authenticationQrcode + +实名认证二维 + +身份证号) +String(不定长) + +否 + +码图片 +authId + +实名认证二维 + +电子税局登录账号(手机号或 +实名认证二维码图片,base64 +字符串 + +String(32-50) + +否 + +实名认证二维码认证 id + +码认证 id +tradeNo + +订单号 + +String(20) + +否 + +订单号 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在开票接口中使 +用,会按照定义返回 + +qrCode + +二维码 + +String(1000) + +否 + +开票成功时,必传,失败时, +不传 + +invoiceCode + +发票代码 + +String(12) + +否 + +开票成功时,必传,失败时, +不传 + +invoiceNo + +发票号码 + +String(8) + +否 + +开票成功时,必传,失败时, +不传 + +electronicInvoiceNo + +数电发票号码 + +String(20) + +否 + +数电发票号码,发票为数电电 +票时:数电发票号码=发票代码 ++发票号码 + +invoiceDate + +开票日期 + +String(19) + +否 + +开票成功时,必传 yyyy-MM-dd +HH:mm:ss,失败时,不传 + +noTaxAmount + +不含税金额 + +String(16) + +否 + +开票成功时,必传,保留小数 +点后 2 位,以元为单位精确到 +分,失败时,不传 + +taxAmount + +税额 + +String(16) + +否 + +开票成功时,必传,保留小数 +点后 2 位,以元为单位精确到 + +票通数电发票接口文档 +分,失败时,不传 +invoiceLayoutFileType + +版式文件类型 + +String(3) + +否 + +版式文件类型。 +pdf:pdf 文件格式; +ofd:ofd 文件格式。 + +invoicePdf + +发票 pdf 文件 + +String(不定长) + +否 + +电子发票开票成功时,必传, +失败时,不传,不定长,Base64 +字符串 + +invoiceXml + +发票 xml 文件 + +String(不定长) + +否 + +非必传,若开票成功没传,需 +要调用获取 2.15 获取数电发 +票 文 件 接 口 获 取, 不 定 长 , +Base64 字符串 + +downloadUrl + +发票下载 Url + +String(不定长) + +否 + +电子发票开票成功时,必传, +失败时,不传,不定长,Base64 +字符串 + +invoiceQrcodeNo + +二维码编号 + +String(10-20) + +否 + +二维码编号,二维码唯一标识, +扫码开票的发票才有该值 + +invPreviewQrcodePath + +电子发票预览 + +String + +否 + +二维码 url + +不定长,Base64 字符串。同 2.9 +开具蓝字数电发票接口返回的 +qrCodePath,电子发票该值必 +传 + +invPreviewQrcode + +电子发票预览 + +String + +二维码图片 + +否 + +不定长,Base64 字符串,扫码 +查看发票开票状态。同 2.9 开 +具 蓝 字 数 电 发 票 接 口 返 回的 +qrCode,电子发票该值必传 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceType": "1", +"invoiceKind": "82", +"code": "0000", +"msg": "开具成功", +"tradeNo": "DEMO1111111111", +"definedData": "自定义数据", +"qrCode": "XXXXXXXX", +"invoiceCode": "123456789012", +"invoiceNo": "12345678", +"electronicInvoiceNo": "12345678901212345678", +"invoiceDate": "2022-09-28 08:15:54", +"noTaxAmount": "90.50", +"taxAmount": "11.50", +"invoiceLayoutFileType": "pdf", +"invoicePdf": "XXXX", + +票通数电发票接口文档 +"invoiceXml": "XXXXXXXX", +"downloadUrl": "XXXX", +"invPreviewQrcodePath": "XXXXXXXX", +"invPreviewQrcode": "XXXXXXXX" +} + +2.13.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(6-20) + +是 + +销售方纳税人识别号 + +String(20) + +是 + +发票请求流水号 + +识别号 +invoiceReqSerialNo + +发票请求流水 +号 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890" +} + +2.13.4. + +业务错误码 + +从业务中抽取代码并进行定义: + +错误码(code) + +含义说明(msg) + +0000 + +接收成功 + +9999 + +接收失败 + +2.14. 推送发票全票面信息 +2.14.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +票通平台调用第三方平台 + +调用方式 + +https + +接口地址 + +第三方平台提供 + +字符编码 + +UTF-8 + +备注 +POST 方式提交 + +票通数电发票接口文档 + +2.14.2. + +请求报文 + +求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +invoiceReqSerialNo + +发票开具请求 + +String(20) + +是 + +发票请求流水号 + +String(20) + +是 + +发票开具类型。 + +流水号 +invoiceIssueType + +发票开具类型 + +1:蓝票; +2:红票; +invoiceIssueResultCode + +发票开具结果 + +String(1) + +是 + +状态码 + +发票状态码。 +0000:开票成功; +6666:未开票; +9999:开票失败; +3999:开票失败,需要扫码或 +短信认证,可以使用 2.8 接口 +查询数电账号状态,来告知商 +家需要如何认证; +5999:红字发票确认单审核中, +具 + +体 + +状 + +态 + +描 + +述 + +见 + +invoiceIssueResultMsg,只有 +红票会有此状态; +invoiceIssueResultMsg + +发票开具结果 + +String(4) + +是 + +描述 +authenticationQrcode + +实名认证二维 + +因) +String(不定长) + +否 + +码图片 +authId + +实名认证二维 + +发票状态描述(成功/失败原 +实名认证二维码图片,base64 +字符串 + +String(32-50) + +否 + +实名认证二维码认证 id + +码认证 id +发票信息 +invoiceInfo + +发票信息 + +Object + +是 + +发票信息 + +invoiceReqSerialNo + +发票请求流水 + +String(15-20) + +是 + +发票请求流水号 + +String(2) + +是 + +发票操作代码。 + +号 +invoiceOperationCode + +发票操作代码 + +10:开具蓝票 +20:开具红票 +sellerTaxpayerNum + +销方纳税人识 + +String(15-20) + +是 + +销方纳税人识别号 + +别号 +sellerName + +销方名称 + +String(4-100) + +是 + +销方名称 + +sellerAddress + +销方地址 + +String(1-100) + +否 + +销方地址 + +sellerTel + +销方电话 + +String(1-20) + +否 + +销方电话 + +sellerBankName + +销方开户行 + +String(1-100) + +否 + +销方开户行 + +sellerBankAccount + +销方银行账号 + +String(1-50) + +否 + +销方银行账号 + +buyerTaxpayerNum + +购买方税号 + +String(15-20) + +否 + +购买方税号 + +票通数电发票接口文档 +buyerName + +购买方名称 + +String(1-100) + +是 + +购买方名称 + +buyerAddress + +购买方地址 + +String(1-100) + +否 + +购买方地址 + +buyerTel + +购买方电话 + +String(1-20) + +否 + +购买方电话 + +buyerBankName + +购买方开户行 + +String(1-100) + +否 + +购买方开户行 + +buyerBankAccount + +购买方银行账号 + +String(1-50) + +否 + +购买方银行账号 + +showBuyerBank + +是否显示购方 + +String(1) + +否 + +是否显示购方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 + +showSellerBank + +是否显示销方 + +String(1) + +否 + +是否显示销方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 + +showBuyerAddrTel + +是否显示购方 + +String(1) + +否 + +是否显示购方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 + +showSellerAddrTel + +是否显示销方 + +String(1) + +否 + +是否显示销方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 + +account + +开票人税局账 + +String(50) + +否 + +号 + +电子税局登录账号(手机号或 +身份证号) + +drawerName + +开票员 + +String(1-20) + +是 + +开票员 + +casherName + +收款人 + +String(1-16) + +否 + +收款人名称 + +reviewerName + +复核人 + +String(1-16) + +否 + +复核人名称 + +noTaxAmount + +不含税金额 + +String(16) + +是 + +不含税金额,保留 2 位小数 + +taxAmount + +税额 + +String(16) + +是 + +税额,保留 2 位小数 + +amountWithTax + +价税合计 + +String(16) + +是 + +价税合计,保留 2 位小数 + +remark + +备注 + +String(0-200) + +否 + +备注,校验规则字符长度 + +tradeNo + +订单号 + +String(0-200) + +是 + +订单号,如果开具时没有传值, +默认为发票请求流水号为订单 +号 + +invoiceKindCode + +发票种类代码 + +String(2) + +是 + +发票种类代码。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票) +87:数电纸质发票(机动车销 +售统一发票) +10:增值税电子普通发票 +08:增值税电子专用发票 +04:增值税普通发票 +01:增值税专用发票 + +票通数电发票接口文档 +specialInvoiceKind + +特殊票种标识 + +String(2) + +是 + +特殊票种标识。 +00:普通票 +08:成品油发票 +02:收购发票 + +invoiceType + +发票类型 + +String(1) + +是 + +1 蓝票 +2 红票 + +agentInvoiceFlag + +代开标志 + +String(1) + +是 + +0 自开 + +taxRateFlag + +税率标识 + +String(1) + +是 + +0 正常票 +2 差额票 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据 + +takerName + +收票人名称 + +String(0-100) + +否 + +收票人名称 + +takerPhone + +收票人手机号 + +String(11) + +否 + +收票人手机号 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +收票人邮箱 + +shopNum + +门店编号 + +String(6-20) + +否 + +门店编号 + +invoiceStatus + +开票状态 + +String(1) + +是 + +0:未开票(撤回开票中发票), +1:开票成功, +2:开票失败, +3:开票中, +4:红字发票确认单申请中 +5:红字发票确认单审核中 + +invoiceDate + +开票日期 + +String(19) + +否 + +开票成功必传,格式 +yyyy-MM-dd HH:mm:ss + +invoiceIssueFailReason + +开票失败原因 + +String(不定长) + +否 + +开票失败原因,开具失败时该 +值必填 + +invoiceCode + +发票代码 + +String(12) + +否 + +开票成功必传,发票代码 + +invoiceNo + +发票号码 + +String(8) + +否 + +开票成功必传,发票号码 + +electronicInvoiceNo + +数电发票号码 + +String(20) + +否 + +开票成功必传,发票为数电电 +票时:数电发票号码=发票代码 ++发票号码 + +qrCode + +二维码 + +String(1000) + +否 + +票面二维码 + +invoiceLayoutFileType + +版式文件类型 + +String(3) + +否 + +版式文件类型。 +pdf:pdf 文件格式; +ofd:ofd 文件格式。 + +invoicePdf + +发票 pdf 文件 + +String(不定长) + +否 + +电子发票开票成功时,即发票 +种类代码为 10、08、81、82 时, +必传;失败时,不传;不定长, +Base64 字符串 + +invoiceXml + +发票 xml 文件 + +String(不定长) + +否 + +非必传,若开票成功没传,需 +要调用获取 2.15 获取数电发 +票 文 件 接 口获 取 , 不 定 长 , +Base64 字符串 + +票通数电发票接口文档 +downloadUrl + +发票下载 Url + +String(不定长) + +否 + +电子发票开票成功时,即发票 +种类代码为 10 时,必传,失败 +时,不传,不定长,Base64 字 +符串,通过票通提供的 SDK 进 +行解码,其他发票种类不传 + +redFlag + +冲红标志 + +String(20) + +否 + +0:未冲红;1:已冲红; +2:冲红失败;3:冲红中; +4:部分冲红。 + +invoiceRedReason + +冲红原因 + +String(1-100) + +否 + +冲红原因 + +oldInvoiceCode + +原发票代码 + +String(12) + +否 + +红票必传,原发票代码 + +oldInvoiceNo + +原发票号码 + +String(8) + +否 + +红票必传,原发票号码 + +oldElectronicInvoice + +原数电发票号 + +否 + +原数电发票号码 + +No + +码 + +destroyFlag + +作废标识 + +否 + +0 未作废 1 已作废 + +String(20) +String(20) + +2 作废失败 3 作废中 +invoiceQrcodeNo + +二维码编号 + +String(10-20) + +否 + +二维码编号,二维码唯一标识, +扫码开票的发票才有该值 + +invPreviewQrcodePath + +电子发票预览 + +String + +否 + +二维码 url + +不定长,Base64 字符串。同 2.9 +开具蓝字数电发票接口返回的 +qrCodePath,电子发票该值必 +传 + +invPreviewQrcode + +电子发票预览 + +String + +否 + +二维码图片 + +不定长,Base64 字符串,扫码 +查看发票开票状态。同 2.9 开 +具 蓝 字 数 电发 票 接 口 返 回 的 +qrCode,电子发票该值必传 + +发票项目明细 + +itemList + +发票明细 + +数组或集合 + +是 + +发票项目明细 + +goodsName + +货物名称 + +String(1-100) + +是 + +货物名称 + +taxClassificationCod + +税收分类编码 + +String(1-50) + +否 + +税收分类编码,红票清单情况 + +e + +此值为空 + +selfCode + +自行编码 + +String(1-20) + +否 + +自行编码 + +specificationModel + +规格型号 + +String(1-20) + +否 + +规格型号 + +meteringUnit + +单位 + +String(1-16) + +否 + +单位 + +quantity + +数量 + +String(1-16) + +否 + +保留小数点后八位,折扣行不 +传值 + +unitPrice + +单价 + +String(1-16) + +否 + +保留小数点后八位,折扣行不 +传值 + +taxIncludeFlag + +含税标识 + +String(1) + +是 + +0 不含税,1 含税 + +itemAmount + +项目金额 + +String(10) + +是 + +保留小数点后两位 + +taxRateValue + +税率 + +String(4) + +是 + +保留小数点后两位 + +taxRateAmount + +税额 + +String(10) + +是 + +保留小数点后两位 + +deduction + +扣除额 + +String(10) + +否 + +差额发票必传,保留小数点后 +两位 + +票通数电发票接口文档 +preferentialPolicyFlag + +优惠政策标识 + +String(1-50) + +是 + +0:不使用,1:使用 + +zeroTaxFlag + +零税率标识 + +String(1) + +是 + +零税率标识, +空:非零税率 , +1 免税 , +2 不征税 , +3 普票零税率 + +vatSpecialManage + +增值税特殊管 + +String(0-100) + +否 + +增值税特殊管理 + +理 +itemProperty + +发票行性质 + +String(1) + +是 + +0 正常行 1 折扣行 2 被折扣行 + +itemNo + +项目序号 + +String(11) + +是 + +项目序号,用来表示项目的先 +后顺序 + +blueInvOrderNo + +蓝字发票明细 + +String(1-4) + +否 + +序号 + +蓝字发票明细序号,红字数电 +发票的时候可能有值 + +订单列表 +orderList + +订单列表 + +数组或集合 + +否 + +订单列表,通过票通集团版单 +据合并开票时有值 + +orderNo + +单据号 + +String(1-50) + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceIssueType": "1", +"invoiceIssueResultCode": "0000", +"invoiceIssueResultMsg": "发票开具成功", +"invoiceInfo": { +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceOperationCode": "10", +"sellerTaxpayerNum": "91XXXXXXXXXXXXX31", +"sellerName": "北京 XXXXX 技术有限公司", +"sellerAddress": "北京海淀区 XXX 路 15 号", +"sellerTel": "010-7654321", +"sellerBankName": "XXXXX 银行", +"sellerBankAccount": "6217XXXXXXX0678", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"buyerName": "北京 XXXXX 技术有限公司", +"buyerAddress": "北京市海淀区 XXXXXX15 号 5 层", +"buyerTel": "010-1234567", +"buyerBankName": "XXXX 银行", +"buyerBankAccount": "9878XXXXXX45666", +"drawerName": "XXXXXX", +"account": "185XXXXXXXX", +"noTaxAmount": "90.50", +"taxAmount": "11.50", + +是 + +单据号 + +票通数电发票接口文档 +"amountWithTax": "102.00", +"remark": "备注", +"tradeNo": "DEMO1111111111", +"invoiceKind": "82", +"specialInvoiceKind": "00", +"invoiceType": "1", +"agentInvoiceFlag": "0", +"taxRateFlag": "0", +"definedData": "自定义数据", +"takerName": "XXX", +"takerPhone": "XXXXXXX", +"takerEmail": "XXXXX@qq.com", +"shopNum": "XXX", +"invoiceStatus": "1", +"invoiceDate": "2022-09-28 08:15:54", +"invoiceCode": "123456789012", +"invoiceNo": "12345678", +"electronicInvoiceNo": "12345678901212345678", +"qrCode": "XXXXXXXX", +"invoiceLayoutFileType": "pdf", +"invoicePdf": "XXXX", +"invoiceXml": "XXXXXXXX", +"downloadUrl": "XXXX", +"redFlag": "0", +"destroyFlag": "0", +"invPreviewQrcodePath": "XXXXXXXX", +"invPreviewQrcode": "XXXXXXXX" +"itemList": [{ +"goodsName": "小麦", +"taxClassificationCode": "1010101020000000000", +"specificationModel": "", +"meteringUnit": "袋", +"quantity": "1.00", +"unitPrice": "88.50", +"taxIncludeFlag": "0", +"itemAmount": "88.50", +"taxRateValue": "0.13", +"taxRateAmount": "11.50", +"preferentialPolicyFlag": "0", +"itemProperty": "0", +"itemNo": "1" +}, +{ +"goodsName": "稻谷商品", + +票通数电发票接口文档 +"taxClassificationCode": "1010101010000000000", +"specificationModel": "", +"meteringUnit": "", +"quantity": "1.00", +"unitPrice": "2.00", +"taxIncludeFlag": "0", +"itemAmount": "2.00", +"taxRateValue": "0.00", +"taxRateAmount": "0.00", +"preferentialPolicyFlag": "1", +"zeroTaxFlag": "1", +"vatSpecialManage": "免税", +"itemProperty": "0", +"itemNo": "2" +} +], +"orderList": [{ +"orderNo": "1715050188237" +}, { +"orderNo": "1715050188238" +}] +} +} + +2.14.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(20) + +是 + +销售方纳税人识别号 + +String(20) + +是 + +发票请求流水号 + +识别号 +invoiceReqSerialNo + +发票请求流水 +号 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890" +} + +2.14.4. + +业务错误码 + +从业务中抽取代码并进行定义: + +错误码(code) + +含义说明(msg) + +票通数电发票接口文档 +0000 + +接收成功 + +9999 + +接收失败 + +2.15. 获取数电发票文件 +通过该接口获取数电发票文件,此接口比较耗时,请调长超时时间。 + +2.15.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/getAllEleInvFile.p +t +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/getAllEleInvFile.pt +字符编码 + +UTF-8 + +2.15.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceReqSerialNo + +发票请求流水号 + +String(20) + +否 + +发票请求流水号 + +electronicInvoiceNo + +数电发票号码 + +String(12) + +否 + +数电发票号码 +注:发票请求流水号和数电发 +票号码不能同时为空,如果都 +填写以发票请求流水号为准 + +fileType + +文件类型 + +String(3) + +是 + +文件类型。PDF、OFD、XML 或 +pdf、ofd、xml + +invoiceDate + +开票日期 + +String(19) + +否 + +开票日期 +格式 yyyy-MM-dd HH:mm:ss +如需下载非票通平台开具数电 +发票文件,数电发票号码和开 +票日期必填 + +account + +电子税局登录账 +号 + +String(50) + +否 + +电子税局登录账号 + +票通数电发票接口文档 + +报文示例: +{ +"taxpayerNum": "XXXXXXXXXXXXXX", +"invoiceReqSerialNo": "12345678901234567890", +"fileType": "PDF" +} + +2.15.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceReqSerialNo + +发票请求流水号 + +String(20) + +否 + +发票请求流水号 + +electronicInvoiceNo + +数电发票号码 + +String(20) + +否 + +数电发票号码 + +fileType + +文件类型 + +String + +是 + +文件类型,对应请求报文。 +PDF、OFD、XML 或 pdf、ofd、 +xml + +resultCode + +结果代码 + +String(4) + +是 + +获取结果代码。 +0000:获取成功。 +其他:获取失败,失败原因见 +resultMsg + +resultMsg + +结果描述 + +String(不定长) + +否 + +结果描述 + +fileContent + +文件内容 + +String(不定长) + +否 + +文件内容,base64 字符串 + +报文示例: +{ +"taxpayerNum": "XXXXXXXXXXXXXX", +"invoiceReqSerialNo": "12345678901234567890", +"fileType": "PDF", +"resultCode": "0000", +"resultMsg": "获取成功", +"fileContent": "XXXXXXXXXXXXXXXXX" +} + +2.15.4. + +业务错误码 + +从业务中抽取代码并进行定义: + +错误码(code) + +含义说明(msg) + +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8104 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +票通数电发票接口文档 +8003 + +找不到对应的发票 + +8022 + +企业已被禁用 + +2.16. 获取开票二维码 +2.16.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/getInvoiceIssueQ +rcode.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/getInvoiceIssueQrcode +.pt +字符编码 + +UTF-8 + +2.16.2. + +请求报文 + +请求参数-业务报文部分: +必 +字段 + +名称 + +类型 + +说明 +填 + +taxpayerNum + +销售方纳税人识别 + +String(15-20) + +是 + +销售方纳税人识别号 + +号 +enterpriseName + +销售方企业名称 + +String(4-100) + +是 + +销售方企业名称 + +shopNum + +门店编号 + +String(6-20) + +否 + +门店编号,取值集团版门店编号, +没有则不用填写。只允许字母、 +数字 + +qrcodeNo + +开票二维码编号 + +String(10-20) + +是 + +开票二维码编号,二维码唯一标 +识。只能为字母、数字 + +updateFlag + +是否更新二维码 + +String(1) + +否 + +是否更新二维码。 +0:否; +1:是。默认 0 否。 +如果开票二维码编号一致,且二 +维码未开票,此字段传 1 将修改 +开票二维码的信息。 + +tradeNo + +订单号 + +String(50) + +是 + +订单号 + +transactionId + +微信交易单号 + +String(28-50) + +否 + +微信支付交易单号,线下配置后 +可以在微信支付--账单页面显示 + +票通数电发票接口文档 +“开发票”入口 + +tradeTime + +交易时间 + +String(19) + +是 + +业 务 实 际 交 易 时 间 (yyyy-MM-dd +HH:mm:ss) + +invoiceAmount + +发票金额(含税) + +String(16) + +是 + +小数点后 2 位,以元为单位精确 +到分,此金额需等于开票项目列 +表(见下)中开票项目金额之和 + +sellerAddress + +销货方地址 + +String(1-100) + +否 + +票面信息,不能包含<>字符。如 +果不填写,则取集团版设置的门 +店地址,如没有设置则取集团版 +设置的企业地址,如没有设置则 +取平台企业注册地址。 +注:销方地址电话之和不能超 100 +字节 + +sellerTel + +销货方电话 + +String(20) + +否 + +票面信息,只能是数字、中英文 +括号、中英文横杠。如果不填写, +则取集团版设置的门店电话,如 +没有设置则取集团版设置的企业 +电话,如没有设置则取平台设置 +的企业联系电话,如果企业联系 +电话没设置,取值企业注册时的 +联系电话。 +注:销方地址电话之和不能超 100 +字节 + +sellerBankName + +销货方开户行 + +String(1-100) + +否 + +票面信息。如果不填写,则取集 +团版设置的门店开户行,如没有 +设置则取集团版设置的企业开户 +行,如没有设置则取平台设置的 + +票通数电发票接口文档 +企业开户行。 +注:销方银行账户之和不能超 100 +字节 +sellerBankAccount + +销货方银行账号 + +String(1-50) + +否 + +票面信息。如果不填写,则取集 +团版设置的门店银行账号,如没 +有设置则取集团版设置的企业银 +行账号,如没有设置则取平台设 +置的默认的企业银行账号。 +注:销方银行账户之和不能超 100 +字节 + +account + +开票人税局账号 + +String(50) + +否 + +电子税局登录账号(手机号或身 +份证号),必须是通过 2.3 接口 +进行用户登记的账号。如果不填, +随机取已在票通平台登记的账 +号。 + +casherName + +收款人名称 + +String(1-16) + +否 + +票面信息。如果不填写,则取集 +团版设置的门店收款人,如没有 +设置则取集团版设置的企业收款 +人,如没有设置则取平台设置的 +默认的收款人。长度校验规则为 +GBK 字节长度。若需要显示到备 +注,需要在企业版开票设置进行 +设置 + +reviewerName + +复核人名称 + +String(1-16) + +否 + +票面信息。如果不填写,则取集 +团版设置的门店复核人,如没有 +设置则取集团版设置的企业复核 +人,如没有设置则取平台设置的 +默认的复核人。长度校验规则为 +GBK 字节长度。若需要显示到备 +注,需要在企业版开票设置进行 +设置 + +allowInvoiceCount + +允许开票张数 + +int + +否 + +一个二维码允许开票张数,默认 +为1张 + +mobileRequiredFlag + +手机号是否必填 + +Boolean + +否 + +该字段控制扫码开票页面上收票 +人手机号是否必填。 +true:必填; +false:非必填; +默认非必填。 + +smsFlag + +是否发送短信 + +Boolean + +否 + +是否发送短信,默认不发送。对 +开通发送短信功能的企业有效。 +true:发送短信, +false:不发送短信 + +emailRequiredFlag + +邮箱是否必填 + +Boolean + +否 + +该字段控制扫码开票页面上收票 +人邮箱是否必填。 + +票通数电发票接口文档 +true:必填; +false:非必填; +默认非必填。 +emailSendFlag + +是否发送邮件 + +Boolean + +否 + +是否发送邮件,默认发送。 +true:发送邮件 +false:不发送邮件 + +expireTime + +过期时间 + +String(19) + +否 + +时间(yyyy-MM-dd HH:mm:ss),如 +果不传的话默认失效时间为 30 天 + +specialInvoiceKind + +特殊票种 + +String(0-240) + +否 + +特殊票种。 +08:成品油 +06:不动产租赁服务 + +invoiceRemark + +发票备注 + +String(0-200) + +否 + +发票备注,扫码开票时显示到发 +票备注里面,校验规则字符长度 + +email + +邮箱 + +String(4-50) + +否 + +收取开票二维码的邮箱地址 + +buyerName + +购买方名称 + +String(1-100) + +否 + +预设抬头购买方名称。不能包含 +<>字符。如果预设抬头,购买方 +名称必填。 + +buyerTaxpayerNum + +购买方税号 + +String(6-20) + +invoiceTitleType + +发票抬头类型 + +String(1) + +否 + +预设抬头购买方税号 +预设发票抬头类型。0:企业;1: +个人。不传值会根据税号判断, +若传税号默认为企业。 + +buyerAddress + +购买方地址 + +String(1-100) + +否 + +预设抬头购买方地址,不能包含 +<>字符。buyerAddress、buyerTel +两个字段总长度不超 100 位 GBK +字节。 + +buyerTel + +购买方电话 + +String(20) + +否 + +预设抬头购买方电话,只能是数 +字、中英文括号、中英文横杠。 +buyerAddress、buyerTel 两个字 +段总长度不超 100 位 GBK 字节。 + +buyerBankName + +购买方开户行 + +String(1-100) + +否 + +预设抬头购买方开户行,不能包 +含<>字符。buyerBankName、 +buyerBankAccount 两个字段总长 +度不超 100 位 GBK 字节。 + +buyerBankAccount + +购买方银行账号 + +String(1-50) + +否 + +预设抬头购买方开户行银行账 +号,不能包含<>字符。 +buyerBankName、 +buyerBankAccount 两个字段总长 +度不超 100 位 GBK 字节。 + +takerTel + +收票人手机号 + +String(11) + +否 + +预设收票人手机号 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +预设收票人邮箱 + +allowEditFlag + +预设抬头是否允许 + +Boolean + +否 + +控制扫码开票时预设抬头是否可 + +编辑 + +以编辑,不控制收票人手机号、 +收票人邮箱是否编辑。 +true:可以编辑 + +票通数电发票接口文档 +false:不可编辑 +可选发票种类及分机列表信息(invoiceIssueOptions) +invoiceIssueOptions + +可选种类及分机信 + +数组 + +是 + +String(2) + +是 + +息 +invoiceType + +可选开票发票种类 + +可选开票发票种类 +81:数电票(增值税专用发票) +82:数电票(普通发票) + +开票项目列表信息(itemList) +itemList + +开票项目列表 + +数组 + +是 + +itemName + +开票项目名称 + +String(100) + +是 + +开票项目名称 + +specificationModel + +规格型号 + +String(1-40) + +否 + +票面信息,无默认,长度校验规 +则为 GBK 字节长度 + +meteringUnit + +单位 + +String(1-20) + +否 + +票面信息,无默认,长度校验规 +则为 GBK 字节长度。成品油单位 +传“升” + +税收分类编码 + +String(3-19) + +是 + +税收分类编码 + +taxRateValue + +税率 + +String(5) + +是 + +0.13,小数点后 2 位 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +零税率标识,税率为 0 时必填。 + +taxClassificationCo +de + +如税率为 0 时,同时填写了零税 +率标识及增值税特殊管理,以零 +税率标识为准。 +空:非零税率,1:免税,2:不 +征税,3.普通零税率 +vatSpecialManage + +增值税特殊管理 + +String(0-100) + +否 + +增值税特殊管理,填 简易征收、 +按 5%简易征收 + +unitPrice + +单价(含税) + +String(16) + +否 + +等 + +小数点后 8 位,如果不传值,将 +根据项目金额、数量计算该值。 +为 0 时票面单价显示为空。 + +quantity + +数量 + +String(10) + +否 + +小数点后 8 位,如果不传值,将 +根据项目金额、单价计算该值。 +如果单价也没传值,数量将默认 +为 1。为 0 时票面数量显示为空。 +单价数量必须同时为 0 或同时不 +为 0。 + +invoiceItemAmount + +开票项目金额 + +String(16) + +是 + +小数点后 2 位,以元为单位精确 +到分(=单价*数量) + +invoiceItemDisAmount + +开票项目折扣金额 + +String(16) + +否 + +小数点后 2 位,只能为负数。开 +票金额需减去折扣金额 + +特定业务--不动产经营租赁服务列表(realEstateRentalList),specialInvoiceKind 为 06 时必填 +realEstateRentalList + +不动产经营租赁服 + +数组或集合 + +否 + +务列表 + +特定业务不动产经营租赁服务必 +填。行数要与开票项目列表信息 +(itemList)行数保持一致 + +region + +省市(县)区 + +String(4-50) + +是 + +不动产所在省市(县)区。 + +票通数电发票接口文档 +例如:上海市黄浦区/四川省绵阳 +市涪城区/广东省河源市和平县 +detailedAddress + +详细地址 + +String(1-100 + +是 + +字符) +areaUnit + +面积单位 + +String + +不动产详细地址。例如:东江北 +路 68 号 + +是 + +面积单位。 +米(铁路线与管道等使用) +平方千米 +平方米 +公顷 +亩 +h㎡ +k㎡ +㎡ + +crossCitySign + +跨地(市)标志 + +String(1) + +是 + +跨地(市)标志。 +0:否; +1:是 + +leaseTerm + +租赁期起止 + +String(21) + +是 + +租赁期起止。例如 +2022-12-01 2022-12-12 +注: +税收分类编码为 +3040502020200000000(车辆停放 +服务)时格式应为 yyyy-MM-dd +HH:mm yyyy-MM-dd HH:mm +其他税收分类编码格式应为 +yyyy-MM-dd yyyy-MM-dd + +titleNo +carPlateNum + +产权证书/不动产 + +String(0-40 + +权号 + +字符) + +车牌号 + +String(0-20 + +否 + +产权证书/不动产权号。 +若没有证书填写“无”。 + +否 + +字符) + +车牌号。注: +税收分类编码为 +3040502020200000000(车辆停放 +服务)时可以填值,其他情况不 +能填值 + +报文示例: +{ +"taxpayerNum": "11010120181206000", +"enterpriseName": "ceshi", +"shopNum": "123456", +"qrcodeNo": "DEMO202093013520912", +"tradeNo": "2022093013520912", +"tradeTime": "2022-09-30 13:52:09", +"invoiceAmount": "20", +"sellerAddress": "北京市海淀区万泉庄路 15 号 5 层 5", +"sellerTel": "010-123456789", + +票通数电发票接口文档 +"sellerBankName": "中国建设银行股份有限公司北京万泉支行", +"sellerBankAccount": "8888888888", +"account": "185XXXXXXXX", +"allowInvoiceCount": "1", +"mobileRequiredFlag": false, +"smsFlag": false, +"emailRequiredFlag": true, +"emailSendFlag": true, +"expireTime": "2022-10-30 15:44:09", +"invoiceRemark": "备注", +"invoiceIssueOptions": [{ +"invoiceType": "81" +}, { +"invoiceType": "82" +}], +"itemList": [{ +"itemName": "小麦", +"specificationModel": "规格型号", +"meteringUnit": "kg", +"taxClassificationCode": "1030102010100000000", +"taxRateValue": "0.13", +"unitPrice": "10", +"quantity": "1", +"invoiceItemAmount": "10" +}, { +"itemName": "小麦", +"specificationModel": "规格型号", +"meteringUnit": "kg", +"taxClassificationCode": "1030102010100000000", +"taxRateValue": "0.13", +"unitPrice": "10", +"quantity": "1", +"invoiceItemAmount": "10" +}] +} + +2.16.3. + +响应报文 + +响应参数-业务报文部分: +字段 +qrcodeNo + +名称 + +类型 + +必填 + +说明 + +开票二维码编 + +String(10-20) + +是 + +开票二维码编号,二维码唯 + +号 + +一标识 + +票通数电发票接口文档 +tradeNo + +交易单号 + +String(50) + +是 + +交易单号 + +tradeTime + +交易时间 + +String(19) + +是 + +业务实际交易时间 +(yyyy-MM-dd HH:mm:ss) + +invoiceQrCode + +二维码 + +String + +是 + +不定长,Base64 字符串 + +invoiceUrl + +开票 URL + +String + +是 + +不定长,Base64 字符串 + +extractCode + +提取码 + +String(19) + +是 + +提取码,需要在“发票夹” +公众号提取码开票处使用 + +报文示例: +{ +"qrcodeNo": "DEMO202093013520912", +"tradeNo": "XXXXXXXX", +"tradeTime": "2019-12-13 13:52:09", +"invoiceQrCode": "XXXXXXXX", +"invoiceUrl": "XXXXXXXX", +"extractCode": "XXXXXXXX" +} + +2.16.4. + +业务错误码 + +从业务中抽取代码并进行定义: + +错误码(code) + +含义说明(msg) + +8016 + +单价数量金额不匹配 + +8104 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +7001 + +项目数量不能小于可开票数量 + +7002 + +项目的金额之和不等于发票金额 + +8037 + +无该票种授权 + +8038 + +二维码编号已经存在,请更换二维码编号 + +8039 + +集团版校验不通过 + +8055 + +该开票二维码已经开票,不能修改该开票二维码信息 + +8056 + +没有权限修改该开票二维码信息 + +2.17. 批量作废开票二维码 +2.17.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +票通数电发票接口文档 +http://fpkj.testnw.vpiaotong.cn/tp/openapi/batchDelInvoiceQr +code.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/batchDelInvoiceQrcode. +pt +字符编码 + +UTF-8 + +2.17.2. 请求报文 +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +qrcodeNo + +二维码编号 + +String(10-20) + +是 + +二维码编号,唯一标识 + +invoiceAmount + +发票金额(含税) + +String(16) + +是 + +小数点后 2 位 + +报文示例: +[ +{ +"taxpayerNum": "11010120181206000", +"qrcodeNo": "DEMO202093013520912", +"invoiceAmount": "20" +}, +{ +"taxpayerNum": "11010120181206000", +"qrcodeNo": "DEMO202093013520913", +"invoiceAmount": "20" +} +] + +2.17.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(15-20) + +是 + +销售方纳税人识别号 + +识别号 +qrcodeNo + +二维码编号 + +String(10-20) + +是 + +二维码编号,唯一标识 + +invoiceAmount + +发票金额 + +String(16) + +是 + +发票金额 + +isDelete + +是否删除 + +Boolean + +是 + +true:作废成功 +false:作废失败 + +deleteMsg + +删除描述 + +String + +是 + +删除描述 + +票通数电发票接口文档 + +报文示例: +[{ +"taxpayerNum": "11010120181206000", +"qrcodeNo": "DEMO202093013520912", +"invoiceAmount": "20", +"isDelete": "true", +"deleteMsg": "作废二维码成功" +}, { +"taxpayerNum": "11010120181206000", +"qrcodeNo": "DEMO202093013520913", +"invoiceAmount": "20", +"isDelete": "true", +"deleteMsg": "作废二维码成功" +}] + +2.17.4. + +业务错误码 + +从业务中抽取代码并进行定义: + +错误码(code) + +含义说明(msg) + +8104 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +2.18. 查询二维码开票信息 +2.18.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/op +enapi/queryQrcodeInvoiceInfo.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi +/queryQrcodeInvoiceInfo.pt + +字符编码 + +UTF-8 + +2.18.2. + +请求报文 + +请求参数-业务报文部分: + +备注 +POST 方式提交 + +票通数电发票接口文档 + +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +别号 +qrcodeNo + +二维码编号 + +String(10-20) + +是 + +二维码编号,唯一标识 + +invoiceAmount + +发票金额(含税) + +String(16) + +是 + +小数点后 2 位 + +名称 + +类型 + +必填 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +报文示例: +{ +"taxpayerNum": "9120931023801231", +"qrcodeNo": "DEMO2019121313520912", +"invoiceAmount": "200" +} + +2.18.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +说明 + +别号 +qrcodeNo + +二维码编号 + +String(10-20) + +是 + +二维码编号,唯一标识 + +tradeNo + +交易单号 + +String(50) + +是 + +第三方平台生成的唯一标识 +交易单号 + +invoiceUrl + +开票 URL + +String(不定 + +是 + +不定长,Base64 字符串 + +长) +发票信息 +itemList + +发票信息 + +数组 + +否 + +发票信息,为空代表未开票 + +invoiceReqSerialNo + +发票请求流水号 + +String(20) + +否 + +发票请求流水号,可以根据 +该值查询详细的发票信息。 +此值为空时为未开票 + +invoiceItemAmount + +发票金额 + +String(16) + +是 + +小数点后两位,此发票对应 +的金额 + +报文示例: +{ +"taxpayerNum": "11010120181206000", +"qrcodeNo": "DEMO2019121313520912", +"tradeNo": "2019121313520912", +"invoiceUrl": "xxxxx", +"itemList": [{ +"invoiceReqSerialNo": "P1075733838694584320", +"invoiceItemAmount": "20.00" +}] + +票通数电发票接口文档 +} + +2.18.4. + +业务错误码 + +从业务中抽取代码并进行定义: + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +系统内部异常 + +8004 + +找不到对应的开票企业信息,请检查税号 + +5001 + +找不到对应的二维码 + +8995 + +数据校验不通过(对应参考详细信息) + +2.19. 重新发送邮件或短信 +2.19.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/resendEmailOrSMS.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/resendEmailOrSMS.pt +字符编码 + +UTF-8 + +2.19.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(20) + +否 + +发票请求流水号 + +别号 +invoiceReqSerialNo + +发票请求流水号 + +注:发票请求流水号和发票代 +码号码不能同时为空,如果都 +填写以发票请求流水号为准 +invoiceCode + +发票代码 + +String(12) + +否 + +发票代码 + +invoiceNo + +发票号码 + +String(8) + +否 + +发票号码 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +收票人邮箱,不传的话默认使 +用开具时传的收票人邮箱 + +takerTel + +收票人手机号 + +String(11) + +否 + +收票人手机号,无默认,需要 + +票通数电发票接口文档 +校验企业是否开通发送短信。 + +报文示例: +{ +"taxpayerNum": "110105201606160003", +"invoiceReqSerialNo": "TEST2019031919381085", +"invoiceCode": "150003529999", +"invoiceNo": "62761753", +"takerEmail": "8968xxx@qq.com", +"takerTel": "1851100****" +} + +2.19.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识别 + +String(15-20) + +是 + +销售方纳税人识别号 + +号 +invoiceReqSerialNo + +发票请求流水号 + +String(20) + +否 + +发票请求流水号 + +invoiceCode + +发票代码 + +String(12) + +否 + +发票代码 + +invoiceNo + +发票号码 + +String(8) + +否 + +发票号码 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +收票人邮箱 + +takerTel + +收票人手机号 + +String(11) + +否 + +收票人手机号 + +sendEmailResultCod + +邮件发送状态码 + +String(4) + +否 + +邮件发送状态码 + +e + +0000:发送成功 +9999:发送失败 + +sendEmailResultMsg + +邮件发送状态描述 + +不定长 + +否 + +邮件发送状态描述 + +sendSMSResultCode + +短信发送状态码 + +String(4) + +否 + +短信发送状态码 +0000:发送成功 +9999:发送失败 + +sendSMSResultMsg + +短信发送状态描述 + +不定长 + +报文示例: +{ +"taxpayerNum": "110105201606160003", +"invoiceReqSerialNo": "TEST2019031919381085", +"invoiceNo": "", +"invoiceCode": "", +"takerEmail": "89682xxxx@qq.com", +"sendEmailResultCode": "0000", +"sendEmailResultMsg": "邮件已发送,请注意查收!", + +否 + +短信发送状态描述 + +票通数电发票接口文档 +"takerTel": "1851178****", +"sendSMSResultCode": "9999", +"sendSMSResultMsg": "该企业没开通发送短信!" +} + +2.19.4. + +业务错误码 + +从业务中抽取代码并进行定义: + +错误码(code) + +含义说明(msg) + +0000 + +请求成功 + +9999 + +系统内部异常 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8003 + +找不到对应的发票 + +8002 + +企业被禁用 + +8031 + +非电子发票蓝票不可发送邮件和短信 + +8032 + +未开票成功的发票不可发送邮件和短信 + +2.20. 开票统计及授信额度查询 +2.20.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryAllEleBlueInv +Statistics.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/queryAllEleBlueInvStati +stics.pt +字符编码 + +UTF-8 + +2.20.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +票通数电发票接口文档 +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +account + +电子税局登录 + +String(50) + +否 + +电子税局登录账号 + +String(1) + +否 + +是否查询红票信息。 + +账号 +queryRedInv + +是否查询红票 +信息 + +0:否;不传值默认:0 +1:是。 + +报文示例: +{ +"taxpayerNum": "XXXXXXXX" +} + +2.20.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +resultCode + +结果代码 + +String(4) + +是 + +获取结果代码。 +0000:查询成功。 +其他:查询失败,失败原因见 +resultMsg + +resultMsg + +结果描述 + +String(不定长) + +否 + +结果描述 + +blueInvCount + +当月蓝字发票开具数量 + +String(1-20) + +否 + +当月蓝字发票开具数量,成功 +时返回 + +blueInvAmount + +当月蓝字发票累计金额 + +String(1-20) + +否 + +当月蓝字发票累计金额 + +blueInvTaxAmount + +当月蓝字发票累计税额 + +String(1-20) + +否 + +当月蓝字发票累计税额 + +redInvCount + +当月红字发票开具数量 + +String(1-20) + +否 + +当月红字发票开具数量。 +queryRedInv 为 1 时才会返回 +红字发票的数量、金额和税额 + +redInvAmount + +当月红字发票累计金额 + +String(1-20) + +否 + +当月红字发票累计金额 + +redInvTaxAmount + +当月红字发票累计税额 + +String(1-20) + +否 + +当月红字发票累计税额 + +usableCreditLine + +可用授信额度 + +String(1-20) + +否 + +可用授信额度 + +usedCreditLine + +已使用授信额度 + +String(1-20) + +否 + +已使用授信额度 + +creditLine + +总授信额度 + +String(1-20) + +否 + +总授信额度 + +报文示例: +{ +"taxpayerNum": "XXXXXXXX", +"resultCode": "0000", +"resultMsg": "查询成功", +"blueInvCount": "208", + +票通数电发票接口文档 +"blueInvAmount": "3854580.37", +"blueInvTaxAmount": "175827.28", +"redInvCount": "15", +"redInvAmount": "1356.66", +"redInvTaxAmount": "756.58", +"usableCreditLine": "1325368.39", +"usedCreditLine": "3674631.61", +"creditLine": "5000000.00" +} + +2.20.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能开票(企业尚未审核通过),请联系平台客服审核开通 + +2.21. 发票领用及开票数据统计查询 +通过该接口查询发票领用及开票数据统计,只能查询到当天之前的数据统计,此接口比较耗时,请调长超 +时时间。 + +2.21.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryInvStatistics +.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/queryInvStatistics.pt +字符编码 + +UTF-8 + +2.21.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +票通数电发票接口文档 +别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +account + +电子税局登录账 + +String(50) + +否 + +电子税局登录账号 + +String(6) + +是 + +起始月份。yyyyMM。所属期必 + +号 +invoiceMonthStart + +起始月份 + +须按照年/月/季度的范围规则 +invoiceMonthEnd + +终止月份 + +String(6) + +是 + +终止月份。yyyyMM。所属期必 +须按照年/月/季度的范围规 +则。例如起始月份:202212 终 +止月份:202212;或起始月份: +202210 终止月份:202212;或 +起始月份:202201 终止月份: +202212; + +报文示例: +{ +"taxpayerNum": "XXXXXXXXXXXXXX", +"invoiceMonthStart": "202307", +"invoiceMonthEnd": "202307" +} + +2.21.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 + +别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceMonthStart + +起始月份 + +String(6) + +是 + +起始月份。yyyyMM。 + +invoiceMonthEnd + +终止月份 + +String(6) + +是 + +终止月份。yyyyMM。 + +resultCode + +结果代码 + +String(4) + +是 + +获取结果代码。 +0000:查询成功。 +其他:查询失败,失败原因见 +resultMsg + +resultMsg + +结果描述 + +String(不定长) + +否 + +结果描述 + +invReceiptStatisticList 按发票种类统计(领票) +invoiceKindCode + +发票种类代码 + +String(2) + +是 + +发票种类代码 + +invoiceKindName + +发票种类名称 + +String(10-20) + +是 + +发票种类名称 + +beginningInventory + +期初库存份数 + +String + +否 + +期初库存份数 + +endingInventory + +期末库存份数 + +String + +否 + +期末库存份数 + +currentPeriodBuy + +本期新购份数 + +String + +否 + +本期新购份数 + +currentPeriodReturn + +本期退回份数 + +String + +否 + +本期退回份数 + +票通数电发票接口文档 +blankDestroyCount + +空白废票份数 + +String + +否 + +空白废票份数 + +statisticalTime + +统计时间 + +String + +否 + +统计时间。 +格式 yyyy-MM-dd HH:mm:ss + +invIssueStatisticList 按发票种类统计(开票) +invoiceKindCode + +发票种类代码 + +String(2) + +是 + +发票种类代码 + +invoiceKindName + +发票种类名称 + +String(10-20) + +是 + +发票种类名称 + +positiveInvoiceCount + +正数发票份数 + +String + +是 + +正数发票份数 + +positiveCancelledCount + +正数废票份数 + +String + +是 + +正数废票份数 + +negativeInvoiceCount + +负数发票份数 + +String + +是 + +负数发票份数 + +negativeCancelledCount + +负数废票份数 + +String + +是 + +负数废票份数 + +statisticalTime + +统计时间 + +String + +否 + +统计时间。 +格式 yyyy-MM-dd HH:mm:ss + +taxRateStatisticList 按税率/征收率统计 +invoiceKindCode + +发票种类代码 + +String(2) + +否 + +发票种类代码,合计行为空 + +invoiceKindName + +发票种类名称 + +String(10-20) + +是 + +发票种类名称 + +taxRate + +税率/征收率 + +String + +否 + +税率/征收率,合计行为空, +格式:0.01、0.13 等 + +positiveInvoiceAmou + +开具蓝字发票金 + +nt + +额 + +positiveInvoiceTaxA + +开具蓝字发票税 + +mount + +额 + +positiveCancelledIn + +作废蓝字发票金 + +voiceAmount + +额 + +positiveCancelledIn + +作废蓝字发票税 + +voiceTaxAmount + +额 + +negativeInvoiceAmou + +开具红字发票金 + +nt + +额 + +negativeInvoiceTaxA + +开具红字发票税 + +mount + +额 + +negativeCancelledIn + +作废红字发票金 + +voiceAmount + +额 + +negativeCancelledIn + +作废红字发票税 + +voiceTaxAmount + +额 + +actualInvoiceAmount + +实际开具发票金 + +String + +是 + +开具蓝字发票金额 + +String + +是 + +开具蓝字发票税额 + +String + +是 + +作废蓝字发票金额 + +String + +是 + +作废蓝字发票税额 + +String + +是 + +开具红字发票金额 + +String + +是 + +开具红字发票税额 + +String + +是 + +作废红字发票金额 + +String + +是 + +作废红字发票税额 + +String + +是 + +实际开具发票金额 + +String + +是 + +实际开具发票税额 + +String + +否 + +统计时间。 + +额 +actualInvoiceTaxAmo + +实际开具发票税 + +unt + +额 + +statisticalTime + +统计时间 + +格式 yyyy-MM-dd HH:mm:ss + +报文示例: +{ +"taxpayerNum": "XXXXXXXXXXXXXX", + +票通数电发票接口文档 +"resultCode": "0000", +"resultMsg": "查询成功", +"invoiceMonthStart": "202307", +"invoiceMonthEnd": "202307", +"invReceiptStatisticList": [{ +"invoiceKindCode": "82", +"invoiceKindName": "电子发票(普通发票)", +"currentPeriodBuy": "0", +"currentPeriodReturn": "0", +"blankDestroyCount": "0", +"statisticalTime": "2023-07-10 03:00:19" +}, { +"invoiceKindCode": "81", +"invoiceKindName": "电子发票(增值税专用发票)", +"currentPeriodBuy": "0", +"currentPeriodReturn": "0", +"blankDestroyCount": "0", +"statisticalTime": "2023-07-10 03:00:19" +}], +"invIssueStatisticList": [{ +"invoiceKindCode": "82", +"invoiceKindName": "电子发票(普通发票)", +"positiveInvoiceCount": "9", +"positiveCancelledCount": "0", +"negativeInvoiceCount": "5", +"negativeCancelledCount": "0", +"statisticalTime": "2023-07-10 03:00:19" +}, { +"invoiceKindCode": "81", +"invoiceKindName": "电子发票(增值税专用发票)", +"positiveInvoiceCount": "2", +"positiveCancelledCount": "0", +"negativeInvoiceCount": "1", +"negativeCancelledCount": "0", +"statisticalTime": "2023-07-10 03:00:19" +}], +"taxRateStatisticList ": [{ +"invoiceKindCode": "81", +"invoiceKindName": "电子发票(增值税专用发票)", +"taxRate": "0.01", +"positiveInvoiceAmount": "4356.44", +"positiveInvoiceTaxAmount": "43.56", +"positiveCancelledInvoiceAmount": "0.00", +"positiveCancelledInvoiceTaxAmount": "0.00", + +票通数电发票接口文档 +"negativeInvoiceAmount": "-2178.22", +"negativeInvoiceTaxAmount": "-21.78", +"negativeCancelledInvoiceAmount": "0.00", +"negativeCancelledInvoiceTaxAmount": "0.00", +"actualInvoiceAmount": "2178.22", +"actualInvoiceTaxAmount": "21.78", +"statisticalTime": "2023-07-10 03:12:07" +}, { +"invoiceKindCode": "82", +"invoiceKindName": "电子发票(普通发票)", +"taxRate": "0.01", +"positiveInvoiceAmount": "4365.35", +"positiveInvoiceTaxAmount": "43.65", +"positiveCancelledInvoiceAmount": "0.00", +"positiveCancelledInvoiceTaxAmount": "0.00", +"negativeInvoiceAmount": "-4.95", +"negativeInvoiceTaxAmount": "-0.05", +"negativeCancelledInvoiceAmount": "0.00", +"negativeCancelledInvoiceTaxAmount": "0.00", +"actualInvoiceAmount": "4360.40", +"actualInvoiceTaxAmount": "43.60", +"statisticalTime": "2023-07-10 03:12:01" +}, { +"invoiceKindCode": "", +"invoiceKindName": "合计", +"taxRate": "", +"positiveInvoiceAmount": "8721.79", +"positiveInvoiceTaxAmount": "87.21", +"positiveCancelledInvoiceAmount": "0.00", +"positiveCancelledInvoiceTaxAmount": "0.00", +"negativeInvoiceAmount": "-2183.17", +"negativeInvoiceTaxAmount": "-21.83", +"negativeCancelledInvoiceAmount": "0.00", +"negativeCancelledInvoiceTaxAmount": "0.00", +"actualInvoiceAmount": "6538.62", +"actualInvoiceTaxAmount": "65.38", +"statisticalTime": "" +}] +} + +2.21.4. + +业务错误码 + +从业务中抽取代码并进行定义: + +票通数电发票接口文档 + +错误码(code) + +含义说明(msg) + +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8104 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +2.22. 发票数据获取 +通过该接口获取发票信息,此接口比较耗时,请调长超时时间。 + +2.22.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/acquireInvInfo.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/acquireInvInfo.pt +字符编码 + +UTF-8 + +2.22.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +纳税人识别号 + +String(15-20) + +是 + +纳税人识别号,长度校验规则 +为字符长度,只能包括大写英 +文字母或数字 + +queryType + +查询类型 + +String(1) + +是 + +查询类型。 +1:开具发票; +2:取得发票 + +invSource + +发票来源 + +String(1) + +否 + +发票来源。 +0:全部; +1:增值税发票管理系统; +2:电子发票服务平台 + +invoiceKind + +发票种类代码 + +String(2) + +否 + +发票种类代码。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票) +10:增值税电子普通发票 +08:增值税电子专用发票 +04:增值税普通发票 +01:增值税专用发票 + +票通数电发票接口文档 +invoiceState + +发票状态 + +String(2) + +否 + +发票状态。 +"":全部; +"01": "正常"; +"02": "已作废"; +"03":"已红冲-全额"; +"04":"已红冲-部分" + +electronicInvoiceNo + +数电号码 + +String(20) + +否 + +数电号码,如果获取数电发票 +信息,该值必填 + +invoiceCode + +发票代码 + +String(10 或 12) + +否 + +发票代码,获取税控系统发票 +开具的发票必填;数电号码和 +发票代码号码必传一个 + +invoiceNo + +发票号码 + +String(8) + +否 + +发票号码,获取税控系统发票 +开具的发票必填 + +invoiceDate + +开票日期 + +String(8) + +是 + +开票日期。格式 yyyyMMdd + +reciprocalTaxpayerNum + +对方纳税人税号 + +String(15-20) + +否 + +对方纳税人税号 + +reciprocalTaxpayerName + +对方纳税人名称 + +String(4-100) + +否 + +对方纳税人名称 + +account + +电子税局登录账 + +String(50) + +否 + +电子税局登录账号 + +号 + +报文示例: +{ +"taxpayerNum": "XXXXXXXXXXXXXX", +"queryType": "1", +"invSource": "0", +"invoiceState": "0", +"electronicInvoiceNo": "12345678901234567890", +"invoiceDate": "20221123" +} + +2.22.3. + +响应报文 + +响应参数-业务报文部分: +字段 +resultCode + +名称 +结果代码 + +类型 + +必填 + +说明 + +String(4) + +是 + +获取结果代码。 +0000:获取成功。 +其他:获取失败,失败原因见 +resultMsg + +resultMsg + +结果描述 + +String(不定长) + +否 + +结果描述 + +invoiceList 发票列表,兼容后期做批量查询 +buyerName + +购买方名称 + +String(4-100) + +是 + +购买方名称 + +buyerTaxpayerNum + +购买方纳税人识别号 + +String(15-20) + +否 + +购买方纳税人识别号 + +buyerAddress + +购买方地址电话 + +String(1-100) + +否 + +购买方地址电话 + +票通数电发票接口文档 +buyerBankName + +购买方开户行及账号 + +String(1-100) + +否 + +购买方开户行及账号 + +sellerTaxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +sellerName + +销售方名称 + +String(4-100) + +是 + +销售方名称 + +sellerAddress + +销售方地址电话 + +String(1-100) + +否 + +销售方地址电话 + +sellerBankName + +销售方开户行及账号 + +String(1-100) + +否 + +销售方开户行及账号 + +invoiceKind + +发票种类代码 + +String(2) + +是 + +发票种类代码。 +81:电子发票(增值税专用 +发票) +82:电子发票(普通发票) +10:增值税电子普通发票 +08:增值税电子专用发票 +04:增值税普通发票 +01:增值税专用发票 + +invoiceDate + +开票日期 + +String(19) + +是 + +开票日期。 +格式 yyyy-MM-dd HH:mm:ss + +invoiceCode + +发票代码 + +String(12) + +否 + +发票代码 + +invoiceNo + +发票号码 + +String(8) + +否 + +发票号码 + +electronicInvoiceNo + +数电发票号码 + +String(20) + +否 + +数电发票号码 + +noTaxAmount + +不含税金额 + +String(16) + +是 + +不含税金额,保留小数点后 2 +位 + +taxAmount + +税额 + +String(16) + +是 + +税额,保留小数点后 2 位 + +amountWithTax + +价税合计 + +String(16) + +是 + +价税合计,保留小数点后 2 +位 + +invalidFlag + +作废标志 + +String(20) + +是 + +作废标志。 +NOT_DESTROY:未作废; +ALREADY_DESTROY:已作废; +DESTROYING:作废中; +DESTROY_FAIL:作废失败。 + +redFlag + +冲红标志 + +String(20) + +是 + +冲红标志。 +NOT_RED:未冲红; +ALREADY_RED:已冲红; +REDING:冲红中; +RED_FAIL: 冲红失败; +PART_RED:部分冲红。 + +drawerName + +开票人名称 + +String(1-20) + +是 + +开票人名称 + +casherName + +收款人 + +String(1-16) + +否 + +收款人 + +reviewerName + +复核人 + +String(1-16) + +否 + +复核人 + +remark + +备注 + +String(0-240) + +否 + +备注,数电发票为 200 字符, +税控发票 GBK 字长度 240 字 +节 + +项目明细信息(itemList,数组) +goodsName + +货物名称 + +String(1-100) + +是 + +货物名称 + +taxClassificationCode + +税收分类编码 + +String(1-50) + +是 + +税收分类编码 + +specificationModel + +规格型号 + +String(1-20) + +否 + +规格型号 + +票通数电发票接口文档 +meteringUnit + +单位 + +String(1-16) + +否 + +单位 + +quantity + +数量 + +String(1-16) + +否 + +保留小数点后 8 位 + +unitPrice + +单价 + +String(1-16) + +否 + +保留小数点后 8 位 + +taxIncludeFlag + +含税标识 + +String(1) + +是 + +含税标识。 +0:不含税; +1:含税。 + +itemAmount + +项目金额 + +String(10) + +是 + +保留小数点后 2 位 + +taxRate + +税率 + +String(4) + +是 + +保留小数点后 2 位,例如 0.13 + +taxRateAmount + +税额 + +String(10) + +是 + +保留小数点后 2 位 + +deduction + +扣除额 + +String(10) + +否 + +保留小数点后 2 位。差额发 +票有值。 + +preferentialPolicyF + +优惠政策标识 + +String(1-50) + +否 + +优惠政策标识。 +0:不使用; + +lag + +1:使用 +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +空:非零税率, +1:免税, +2:不征税, +3:普通零税率 + +vatSpecialManage + +增值税特殊管理 + +itemProperty + +发票行性质 + +String(0-100) + +否 + +增值税特殊管理 + +String(1) + +是 + +0:正常行 +1:折扣行 +2:被折扣行 + +itemNo + +项目序号 + +String(11) + +报文示例: +{ +"resultCode": "0000", +"resultMsg": "查询成功", +"invoiceList": [{ +"buyerName": "张玲玲", +"buyerTaxpayerNum": "", +"buyerAddress": "", +"buyerBankName": "", +"sellerTaxpayerNum": "XXXXXXXXXXXXXXXXX", +"sellerName": "XXXXXXXX", +"sellerAddress": "XXXXXXXXX 1XXXXXXXX", +"sellerBankName": "XXXXXXXXXX XXXXXXXXX", +"invoiceKind": "82", +"invoiceDate": "2022-11-15 17:13:52", +"electronicInvoiceNo": "12345678901234567890", +"noTaxAmount": "4.6", +"taxAmount": "0", +"amountWithTax": "4.6", +"invalidFlag": "NOT_DESTROY", + +是 + +用来表示项目的先后顺序 + +票通数电发票接口文档 +"redFlag": "ALREADY_RED", +"casherName": "", +"reviewerName": "", +"drawerName": "开票人", +"remark": "9 月水费", +"itemList": [{ +"goodsName": "*水冰雪*自来水", +"taxClassificationCode": "1100301010000000000", +"specificationModel": "", +"quantity": "2.0", +"taxIncludeFlag": "0", +"unitPrice": "2.30000000", +"itemAmount": "4.6", +"taxRate": "0", +"taxRateAmount": "0", +"preferentialPolicyFlag": "1", +"zeroTaxFlag": "1", +"vatSpecialManage": "免税", +"itemProperty": "0", +"itemNo": "1" +}] +}] +} + +2.22.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8104 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能开票(企业尚未审核通过),请联系平台客服审核开通 + +2.23. 开具不动产租赁蓝字数电发票 +2.23.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +票通数电发票接口文档 +http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/invoiceBlue.pt +字符编码 + +2.23.2. + +UTF-8 + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +是 + +销售方纳税人识别号,长度校 + +开票基本信息 +taxpayerNum + +销售方纳税人 + +String(15-20) + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +号 + +4 位平台简称+16 位随机数,长 +度校验规则为字符长度,只能 +包括英文字母或数字,唯一 + +invoiceIssueKindCode + +开具发票种类 + +String(1-2) + +是 + +开具发票种类。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票) + +buyerName + +购买方名称 + +String(1-100) + +是 + +票面信息,发票抬头,无默认 +长度校验规则为 GBK 字节长 +度,不能包含<>字符 + +buyerTaxpayerNum + +购买方纳税人 + +String(15-20) + +否 + +识别号 + +票面信息,无默认,长度校验规 +则为字符长度,只能包括大写 +英文字母或数字 + +naturalPersonFlag + +是否开具给自 + +String(1) + +否 + +然人 + +是否开具给自然人。默认 0。 +0:否; +1:是。电子税局勾选“是”时 +的提示:请您确认受票方为自 +然人,并在纳税人识别号档次 +填入“自然人纳税人识别号” +(自然人受票方可登录个人所 +得税 APP 查看“自然人纳税人 +识别号”),该张发票将在受票 +方自然人个人票夹中展示。 + +buyerAddress + +购买方地址 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度, +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerTel + +购买方电话 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为字符长度,只能是数字、 +中英文括号、中英文横杠。 + +票通数电发票接口文档 +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 +buyerBankName + +购买方开户行 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +buyerBankAccount + +购买方银行账 + +String(1-50) + +否 + +号 + +票面信息,无默认,长度校验 +规则为字符长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +sellerAddress + +销货方地址 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的企业开票 +地址电话信息(如有多条取默 +认的,没有默认取最后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerTel + +销货方电话 + +String(1-20) + +否 + +票面信息,长度校验规则为字 +符长度,只能是数字、中英文 +括号、中英文横杠。如果不传 +值,取平台企业开票设置中的 +企业开票地址电话信息(如有 +多条取默认的,没有默认取最 +后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerBankName + +销货方开户行 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的开户行及 +银行账号信息(如有多条取默 +认的,如有一条则使用该开户 +行及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +sellerBankAccount + +销货方银行账 +号 + +String(1-50) + +否 + +票面信息,长度校验规则为字 +符长度。如果不传值,取平台 +企业开票设置中的开户行及银 +行账号信息(如有多条取默认 + +票通数电发票接口文档 +的,如有一条则使用该开户行 +及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 +showBuyerBank + +是否显示购方 + +String(1) + +否 + +是否显示购方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 + +showSellerBank + +是否显示销方 + +String(1) + +否 + +是否显示销方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 +注:最终版式文件销方银行账 +户取税局维护的开户行及账号 + +showBuyerAddrTel + +是否显示购方 + +String(1) + +否 + +是否显示购方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 + +showSellerAddrTel + +是否显示销方 + +String(1) + +否 + +是否显示销方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 +注:最终版式文件上的销方地 +址电话取税局维护的地址电话 + +account + +开票人税局账 + +String(50) + +否 + +号 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,随机取已在票通平台 +登记的账号。 + +casherName + +收款人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的收款人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该收款人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +reviewerName + +复核人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的复核人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该复核人);如果填入 + +票通数电发票接口文档 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 +takerName + +收票人名称 + +String(1-10) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerTel + +收票人手机号 + +String(11) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +客户信息,填写后,票通会给 +客户发送发票邮件,不填写则 +不发送,长度校验规则为字符 +长度 + +specialInvoiceKind + +特殊票种 + +String(2) + +否 + +特殊票种,默认为空。 +06:不动产经营租赁服务。 +如果不传,会根据税收分类编 +码自动适配特殊票种。 + +reducedTaxType + +减按征税类型 + +String(2) + +否 + +减按征税类型。 +05:住房租赁 + +remark + +备注 + +String(0-200) + +否 + +备注,校验规则字符长度 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 +中会按照定义返回,长度校验 +规则为字符长度 + +tradeNo + +订单号 + +String(0-200) + +否 + +订单号,长度校验规则为字符 +长度。如果没有传值,票通平 +台默认使用发票请求流水号 +invoiceReqSerialNo 赋值 + +shopNum + +门店编号 + +String(6-20) + +否 + +门店编号,取值集团版门店编 +号,没有则不用填写。只允许 +字母、数字 + +开票项目列表信息(itemList),行数要与不动产租赁特定信息的 realEstateRentalList 行数保持一致 +itemList + +开票项目列表 + +goodsName + +货物名称 + +数组或集合 + +是 + +只能一行 + +String(1-100) + +是 + +票面信息,此项不填写时默认 +为 taxClassificationCode 对 +应的名称,长度校验规则为字 +符长度 + +taxClassificationCod + +对应税收分类 + +e + +编码 + +specificationModel + +对应规格型号 + +String(1-50) + +是 + +统一编码表的信息,长度校验 +规则为字符长度 + +String(1-40) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度 + +meteringUnit + +单位 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度 + +quantity + +数量 + +16 位(精确到 8 + +是 + +位小数) +includeTaxFlag + +含税标示 + +String(1) + +票面信息,支持到小数点前 8 +位。 + +否 + +0:不含税,1:含税,默 + +票通数电发票接口文档 + +认为 0 不含税 +unitPrice + +单价 + +16 位(精确到 8 + +是 + +位小数) + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +invoiceAmount + +金额 + +10 位(精确到 2 + +是 + +位小数) + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +taxRateValue + +税率 + +4 位(精确到 2 位 + +是 + +票面信息,例:0.13 + +否 + +票面信息,如果不填写,默认 + +小数) +taxRateAmount + +税额 + +10 位(精确到 2 +位小数) + +会根据税率计算税额,如果填 +写此值会直接使用此税额,可 +通过含税标示 +includeTaxFlag,定义此字段 +为含税 + +discountAmount + +折扣金额 + +10 位(精确到 2 + +否 + +该商品行的折扣金额,传负数 + +否 + +票面信息,使用折扣金额进行 + +位小数) +discountTaxRateAmoun + +折扣税额 + +t +preferentialPolicyFl + +10 位(精确到 2 +位小数) + +优惠政策标识 + +String(1-50) + +计算 +否 + +ag + +空:不使用,1:使用 +零税率标识为 0、1、2 时该值 +必填 1。 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +税率为 0 时该值必填。空:非 +零税率, +1:免税, +2:不征税, +3:普通零税率 + +vatSpecialManage + +增值税特殊管 + +String(0-100) + +否 + +理 + +preferentialPolicyFlag 优惠 +政策标识位 1 时必填,填免税、 +不征税、简易征收等 + +特定业务--不动产经营租赁服务列表(realEstateRentalList),不动产经营租赁服务相关的税收分类 +编码,必填 +realEstateRentalList + +不动产经营租 + +数组或集合 + +是 + +赁服务列表 + +特定业务不动产经营租赁服 +务。行数要与开票项目列表信 +息(itemList)行数保持一致 + +region + +省市(县)区 + +String(4-50) + +是 + +不动产所在省市(县)区。 +例如:上海市黄浦区/四川省绵 +阳市涪城区/广东省河源市和 +平县 + +detailedAddress + +详细地址 + +String(1-100 + +是 + +字符) +areaUnit + +面积单位 + +String + +不动产详细地址。例如:东江 +北路 68 号 + +是 + +面积单位。 + +票通数电发票接口文档 +米(铁路线与管道等使用) +平方千米 +平方米 +公顷 +亩 +h㎡ +k㎡ +㎡ +crossCitySign + +跨地(市)标志 + +String(1) + +是 + +跨地(市)标志。 +0:否; +1:是 + +leaseTerm + +租赁期起止 + +String(21 或 + +是 + +33) + +租赁期起止。 +注: +税收分类编码为 +3040502020200000000(车辆停 +放服务)时格式应为 +yyyy-MM-dd HH:mm yyyy-MM-dd +HH:mm +其他税收分类编码格式应为 +yyyy-MM-dd yyyy-MM-dd + +titleNo +carPlateNum + +产权证书/不动 + +String(0-40 字 + +产权号 + +符) + +车牌号 + +String(0-20 字 +符) + +否 + +产权证书/不动产权号。 +若没有证书填写“无”。 + +否 + +车牌号。注: +税收分类编码为 +3040502020200000000(车辆停 +放服务)时可以填值,其他情 +况不能填值 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceIssueKindCode ": "82", +"buyerName": "北京 XXXXX 技术有限公司", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"buyerAddress": "北京市海淀区 XXXXXX15 号 5 层", +"buyerTel": "010 - 1234567 ", +"buyerBankName": "XXXX 银行", +"buyerBankAccount": "9878XXXXXX45666", +"sellerAddress": "北京海淀区 XXX 路 15 号", +"sellerTel": "010-7654321", +"sellerBankName": "XXXXX 银行", +"sellerBankAccount": "6217XXXXXXX0678", +"remark": "备注", + +票通数电发票接口文档 +"tradeNo": "DEMO1111111111", +"definedData": "自定义数据", +"account": "185XXXXXXXX", +"takerName": "XXX", +"takerTel": "XXXXXXX", +"takerEmail": "XXXXX@qq.com", +"itemList": [{ +"goodsName": "停车费", +"taxClassificationCode": "3040502020200000000", +"specificationModel": "", +"meteringUnit": "", +"quantity": "1.00", +"includeTaxFlag": "1", +"unitPrice": "100", +"invoiceAmount": "100.00", +"taxRateValue": "0.09" +}], +"realEstateRentalList": [{ +"region": "四川省绵阳市涪城区", +"detailedAddress": "东江北路 68 号", +"areaUnit": "㎡", +"crossCitySign": "0", +"leaseTerm": "2022-12-01 12:10 2022-12-12 15:00", +"titleNo": "无" +}] +} + +2.23.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +invoiceReqSerialNo + +发票请求流水 + +类型 + +必填 + +说明 + +String(20) + +是 + +4 位平台简称+16 位随机数 + +String + +否 + +不定长,Base64 字符串,电子 + +号 +qrCodePath + +二维码 url + +发票该值必传 +qrCode + +二维码图片 + +String + +Base64 字符串 + +否 + +扫码查看发票开票状态,二维 +码的内容是 qrCodePath,电子 +发票该值必传 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", + +票通数电发票接口文档 +"qrCodePath": "xxxxxxxxxxxxx", +"qrCode": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} + +2.23.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +9997 + +纳税人识别号无效 + +8993 + +开票请求处理失败(对应参考返回的详细信息) + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8004 + +找不到对应的开票企业信息,请检查税号 + +8005 + +找不到对应的税收分类编码,请参照税收分类编码表,检查编码号 + +8006 + +找不到对应的税率,请输入正确的税率 + +8007 + +折扣金额不可大于对应商品行金额/折扣金额和折扣率不匹配 + +8008 + +优惠政策不为空时,增值税特殊管理不能为空 + +8009 + +企业注册/修改中不能开票 + +8011 + +税额与税率不匹配 + +8012 + +差额开票抵扣金额过大,不能超过价税合计金额 + +8013 + +差额开票只允许单个商品行 + +8014 + +税率为 0 时,零税率标示必须选择 + +8015 + +不存在对应的零税率标示 + +8016 + +单价数量金额不匹配 + +8017 + +折扣税额和税率不匹配 + +8021 + +发票请求流水号已存在,请更换发票请求流水号 + +8040 + +该发票请求流水号正在处理中,请稍后查询开票结果 + +2.23.5. + +适用税收分类编码 + +税收分类编码 + +商品和服务名称 + +商品和服务分类简称 + +增值税税率 + +征收率 + +3040501030000000000 + +不动产融资租赁服务 + +融资租赁 + +9% + +5% + +3040502020101000000 + +公共住房租赁 + +经营租赁 + +9% + +5% + +3040502020102000000 + +个人出租住房 + +经营租赁 + +9% + +5% + +3040502020199000000 + +其他住房租赁服务 + +经营租赁 + +9% + +5% + +3040502020200000000 + +车辆停放服务 + +经营租赁 + +9% + +5% + +3040502020400000000 + +商业营业用房经营租赁服务 + +经营租赁 + +9% + +5% + +3040502029901000000 + +军队空余房产租赁服务 + +经营租赁 + +9% + +5% + +3040502029902000000 + +其他情形不动产经营租赁服务 + +经营租赁 + +9% + +5% + +票通数电发票接口文档 + +2.24. 开具旅客运输服务蓝字数电发票 +2.24.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/invoiceBlue.pt +字符编码 + +2.24.2. + +UTF-8 + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +是 + +销售方纳税人识别号,长度校 + +开票基本信息 +taxpayerNum + +销售方纳税人 + +String(15-20) + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +号 + +4 位平台简称+16 位随机数,长 +度校验规则为字符长度,只能 +包括英文字母或数字,唯一 + +invoiceIssueKindCode + +开具发票种类 + +String(1-2) + +是 + +开具发票种类。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票) + +buyerName + +购买方名称 + +String(1-100) + +是 + +票面信息,发票抬头,无默认 +长度校验规则为 GBK 字节长 +度,不能包含<>字符 + +buyerTaxpayerNum + +购买方纳税人 + +String(15-20) + +否 + +识别号 + +票面信息,无默认,长度校验规 +则为字符长度,只能包括大写 +英文字母或数字 + +naturalPersonFlag + +是否开具给自 +然人 + +String(1) + +否 + +是否开具给自然人。默认 0。 +0:否; +1:是。电子税局勾选“是”时 +的提示:请您确认受票方为自 +然人,并在纳税人识别号档次 +填入“自然人纳税人识别号” +(自然人受票方可登录个人所 +得税 APP 查看“自然人纳税人 + +票通数电发票接口文档 +识别号”),该张发票将在受票 +方自然人个人票夹中展示。 +buyerAddress + +购买方地址 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度, +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerTel + +购买方电话 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为字符长度,只能是数字、 +中英文括号、中英文横杠。 +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerBankName + +购买方开户行 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +buyerBankAccount + +购买方银行账 + +String(1-50) + +否 + +号 + +票面信息,无默认,长度校验 +规则为字符长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +sellerAddress + +销货方地址 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的企业开票 +地址电话信息(如有多条取默 +认的,没有默认取最后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerTel + +销货方电话 + +String(1-20) + +否 + +票面信息,长度校验规则为字 +符长度,只能是数字、中英文 +括号、中英文横杠。如果不传 +值,取平台企业开票设置中的 +企业开票地址电话信息(如有 +多条取默认的,没有默认取最 +后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerBankName + +销货方开户行 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的开户行及 + +票通数电发票接口文档 +银行账号信息(如有多条取默 +认的,如有一条则使用该开户 +行及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 +sellerBankAccount + +销货方银行账 + +String(1-50) + +否 + +号 + +票面信息,长度校验规则为字 +符长度。如果不传值,取平台 +企业开票设置中的开户行及银 +行账号信息(如有多条取默认 +的,如有一条则使用该开户行 +及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +showBuyerBank + +是否显示购方 + +String(1) + +否 + +是否显示购方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 + +showSellerBank + +是否显示销方 + +String(1) + +否 + +是否显示销方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 +注:最终版式文件销方银行账 +户取税局维护的开户行及账号 + +showBuyerAddrTel + +是否显示购方 + +String(1) + +否 + +是否显示购方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 + +showSellerAddrTel + +是否显示销方 + +String(1) + +否 + +是否显示销方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 +注:最终版式文件上的销方地 +址电话取税局维护的地址电话 + +account + +开票人税局账 + +String(50) + +否 + +号 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,随机取已在票通平台 +登记的账号。 + +casherName + +收款人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的收款人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, + +票通数电发票接口文档 +则使用该收款人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 +reviewerName + +复核人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的复核人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该复核人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +takerName + +收票人名称 + +String(1-10) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerTel + +收票人手机号 + +String(11) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +客户信息,填写后,票通会给 +客户发送发票邮件,不填写则 +不发送,长度校验规则为字符 +长度 + +specialInvoiceKind + +特殊票种 + +String(2) + +否 + +特殊票种,默认为空。 +09:旅客运输服务。 +如果不传,会根据税收分类编 +码自动适配特殊票种。 + +remark + +备注 + +String(0-200) + +否 + +备注,校验规则字符长度 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 +中会按照定义返回,长度校验 +规则为字符长度 + +tradeNo + +订单号 + +String(0-200) + +否 + +订单号,长度校验规则为字符 +长度。如果没有传值,票通平 +台默认使用发票请求流水号 +invoiceReqSerialNo 赋值 + +shopNum + +门店编号 + +String(6-20) + +否 + +门店编号,取值集团版门店编 +号,没有则不用填写。只允许 +字母、数字 + +开票项目列表信息(itemList),只能一行 +itemList + +开票项目列表 + +goodsName + +货物名称 + +数组或集合 + +是 + +只能一行 + +String(1-100) + +是 + +票面信息,此项不填写时默认 +为 taxClassificationCode 对 +应的名称,长度校验规则为字 +符长度 + +taxClassificationCo + +对应税收分类 + +String(1-50) + +是 + +统一编码表的信息,长度校验 + +票通数电发票接口文档 +de + +编码 + +规则为字符长度 + +specificationModel + +对应规格型号 + +String(1-40) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度 + +meteringUnit + +单位 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度 + +quantity + +数量 + +16 位(精确到 8 + +否 + +位小数) +includeTaxFlag + +含税标示 + +unitPrice + +单价 + +票面信息,支持到小数点前 8 +位。 + +String(1) + +否 + +0:不含税,1:含税,默 +认为 0 不含税 + +16 位(精确到 8 + +否 + +票面信息,支持到小数点前 8 + +位小数) + +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +invoiceAmount + +金额 + +10 位(精确到 2 + +是 + +位小数) + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +taxRateValue + +税率 + +4 位(精确到 2 位 + +是 + +票面信息,例:0.13 + +否 + +票面信息,如果不填写,默认 + +小数) +taxRateAmount + +税额 + +10 位(精确到 2 +位小数) + +会根据税率计算税额,如果填 +写此值会直接使用此税额,可 +通过含税标示 +includeTaxFlag,定义此字段 +为含税 + +discountAmount + +折扣金额 + +10 位(精确到 2 + +否 + +该商品行的折扣金额,传负数 + +否 + +票面信息,使用折扣金额进行 + +位小数) +discountTaxRateAmou + +折扣税额 + +nt +preferentialPolicyF + +10 位(精确到 2 +位小数) + +优惠政策标识 + +String(1-50) + +计算 +否 + +lag + +空:不使用,1:使用 +零税率标识为 0、1、2 时该值 +必填 1。 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +税率为 0 时该值必填。空:非 +零税率, +1:免税, +2:不征税, +3:普通零税率 + +vatSpecialManage + +增值税特殊管 + +String(0-100) + +否 + +理 + +preferentialPolicyFlag 优惠 +政策标识位 1 时必填,例如填 +免税、不征税、简易征收 + +出行人信息列表 passengerTransportList +passengerTransportList + +出行人信息列 +表 + +数组或集合 + +否 + +出行人信息列表,非必填,如 +填写,列表数据对应必填项必 +填 + +票通数电发票接口文档 +traveler + +出行人 + +String(1-20 字 + +是 + +出行人 + +符) +travelDate + +出行日期 + +String(10) + +是 + +出行日期。格式 yyyy-MM-dd + +travelerIdType + +出行人证件类 + +String(3) + +是 + +出行人证件类型。见码表 3.1 + +型 +travelerIdNo + +出行人证件号 + +证件类型 +String(1-20 字 + +码 + +是 + +符) + +出行人证件号码。提醒:证件 +号码需要符合规则,切勿随意 +填写。 + +departurePlace + +出发地 + +String(1-100) + +是 + +出发地。省市(县)区。例如: +上海市黄浦区/四川省绵阳市 +涪城区/广东省河源市和平县 + +destinationPlace + +到达地 + +String(1-100) + +是 + +到达地。省市(县)区。例如: +上海市黄浦区/四川省绵阳市 +涪城区/广东省河源市和平县 + +vehicleType + +交通工具类型 + +String(1) + +是 + +交通工具类型。 +1:飞机;2:火车;3:长途汽车; +4:公共交通;5:出租车; 6:汽 +车;7:船舶;9:其他 + +seatClass + +等级 + +String + +否 + +等级。 +【飞机】:则值为公务舱、头 +等舱、经济舱的其中一种。 +【火车】:则值为一等座、二 +等座、软席(软座、软卧)、 +硬席(硬座、硬卧)的其中一 +种。 +【船舶】:则值为一等舱、二 +等舱、三等舱的的其中一种。 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceIssueKindCode ": "82", +"buyerName": "北京 XXXXX 技术有限公司", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"buyerAddress": "北京市海淀区 XXXXXX15 号 5 层", +"buyerTel": "010 - 1234567 ", +"buyerBankName": "XXXX 银行", +"buyerBankAccount": "9878XXXXXX45666", +"sellerAddress": "北京海淀区 XXX 路 15 号", +"sellerTel": "010-7654321", +"sellerBankName": "XXXXX 银行", +"sellerBankAccount": "6217XXXXXXX0678", +"remark": "备注", + +票通数电发票接口文档 +"tradeNo": "DEMO1111111111", +"definedData": "自定义数据", +"account": "185XXXXXXXX", +"takerName": "XXX", +"takerTel": "XXXXXXX", +"takerEmail": "XXXXX@qq.com", +"specialInvoiceKind": "09", +"itemList": [{ +"goodsName": "车费", +"taxClassificationCode": "3010101020101010000", +"specificationModel": "", +"meteringUnit": "", +"quantity": "1.00", +"includeTaxFlag": "1", +"unitPrice": "100", +"invoiceAmount": "100.00", +"taxRateValue": "0.09" +}], +"passengerTransportList": [{ +"traveler": "张三", +"travelDate": "2022-12-30", +"travelerIdType": "201", +"travelerIdNo": "101101198811119018", +"departurePlace": "上海市黄浦区", +"destinationPlace": "广东省河源市和平县", +"vehicleType": "3", +}, { +"traveler": "李四", +"travelDate": "2022-12-30", +"travelerIdType": "201", +"travelerIdNo": "101101198811119019", +"departurePlace": "上海市黄浦区", +"destinationPlace": "广东省河源市和平县", +"vehicleType": "3", +}] +} + +2.24.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +invoiceReqSerialNo + +发票请求流水 +号 + +类型 + +必填 + +String(20) + +是 + +说明 +4 位平台简称+16 位随机数 + +票通数电发票接口文档 +qrCodePath + +二维码 url + +String + +否 + +不定长,Base64 字符串,电子 +发票该值必传 + +qrCode + +二维码图片 + +String + +否 + +Base64 字符串 + +扫码查看发票开票状态,二维 +码的内容是 qrCodePath,电子 +发票该值必传 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"qrCodePath": "xxxxxxxxxxxxx", +"qrCode": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} + +2.24.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +9997 + +纳税人识别号无效 + +8993 + +开票请求处理失败(对应参考返回的详细信息) + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8004 + +找不到对应的开票企业信息,请检查税号 + +8005 + +找不到对应的税收分类编码,请参照税收分类编码表,检查编码号 + +8006 + +找不到对应的税率,请输入正确的税率 + +8007 + +折扣金额不可大于对应商品行金额/折扣金额和折扣率不匹配 + +8008 + +优惠政策不为空时,增值税特殊管理不能为空 + +8009 + +企业注册/修改中不能开票 + +8011 + +税额与税率不匹配 + +8012 + +差额开票抵扣金额过大,不能超过价税合计金额 + +8013 + +差额开票只允许单个商品行 + +8014 + +税率为 0 时,零税率标示必须选择 + +8015 + +不存在对应的零税率标示 + +8016 + +单价数量金额不匹配 + +8017 + +折扣税额和税率不匹配 + +8021 + +发票请求流水号已存在,请更换发票请求流水号 + +8040 + +该发票请求流水号正在处理中,请稍后查询开票结果 + +2.24.5. + +适用税收分类编码 + +税收分类编码 + +商品和服务名称 + +商品和服务 +分类简称 + +增值税税率 + +征收率 + +票通数电发票接口文档 +3010101010100000000 + +国内铁路旅客运输服务 + +运输服务 + +9% + +3% + +3010101010200000000 + +国际铁路旅客运输服务 + +运输服务 + +9% + +3% + +3010101010300000000 + +港澳台铁路旅客运输服务 + +运输服务 + +9% + +3% + +3010101020101010000 + +国内长途汽车旅客运输服务 + +运输服务 + +9% + +3% + +3010101020101020000 + +国际长途汽车旅客运输服务 + +运输服务 + +9% + +3% + +3010101020101030000 + +港澳台长途汽车旅客运输服务 + +运输服务 + +9% + +3% + +3010101020102000000 + +其他公路旅客运输服务 + +运输服务 + +9% + +3% + +3010101020201000000 + +公共电汽车客运服务 + +运输服务 + +9% + +3% + +3010101020202000000 + +城市轨道交通服务 + +运输服务 + +9% + +3% + +3010101020203000000 + +出租汽车客运服务 + +运输服务 + +9% + +3% + +3010101020204000000 + +索道客运服务 + +运输服务 + +9% + +3% + +3010101020299000000 + +其他城市旅客公共交通服务 + +运输服务 + +9% + +3% + +3010201010000000000 + +国内水路旅客运输服务 + +运输服务 + +9% + +3% + +3010201020000000000 + +国际水路旅客运输服务 + +运输服务 + +9% + +3% + +3010201030000000000 + +港澳台水路旅客运输服务 + +运输服务 + +9% + +3% + +3010203010000000000 + +水路旅客运输期租业务 + +运输服务 + +9% + +3% + +3010204010000000000 + +水路旅客运输程租业务 + +运输服务 + +9% + +3% + +3010301010100000000 + +国内航空旅客运输服务 + +运输服务 + +9% + +3% + +3010301010200000000 + +国际航空旅客运输服务 + +运输服务 + +9% + +3% + +3010301010300000000 + +港澳台航空旅客运输服务 + +运输服务 + +9% + +3% + +3010301030100000000 + +航空旅客运输湿租业务 + +运输服务 + +9% + +3% + +3010502010100000000 + +无运输工具承运铁路旅客运输服务 + +运输服务 + +9% + +3% + +3010502020100000000 + +无运输工具承运道路旅客运输服务 + +运输服务 + +9% + +3% + +3010503010000000000 + +无运输工具承运水路旅客运输服务 + +运输服务 + +9% + +3% + +3010504010000000000 + +无运输工具承运航空旅客运输服务 + +运输服务 + +9% + +3% + +3010506010000000000 + +无运输工具承运旅客联运运输服务 + +运输服务 + +9% + +3% + +3010599010000000000 + +其他无运输工具承运旅客运输业务 + +运输服务 + +9% + +3% + +3010601000000000000 + +旅客联运服务 + +运输服务 + +9% + +3% + +3019901000000000000 + +其他旅客运输服务 + +运输服务 + +9% + +3% + +2.25. 开具货物运输服务蓝字数电发票 +2.25.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/invoiceBlue.pt + +字符编码 + +UTF-8 + +备注 +POST 方式提交 + +票通数电发票接口文档 + +2.25.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +是 + +销售方纳税人识别号,长度校 + +开票基本信息 +taxpayerNum + +销售方纳税人 + +String(15-20) + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +号 + +4 位平台简称+16 位随机数,长 +度校验规则为字符长度,只能 +包括英文字母或数字,唯一 + +invoiceIssueKindCode + +开具发票种类 + +String(1-2) + +是 + +开具发票种类。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票) + +buyerName + +购买方名称 + +String(1-100) + +是 + +票面信息,发票抬头,无默认 +长度校验规则为 GBK 字节长 +度,不能包含<>字符 + +buyerTaxpayerNum + +购买方纳税人 + +String(15-20) + +否 + +识别号 + +票面信息,无默认,长度校验规 +则为字符长度,只能包括大写 +英文字母或数字 + +naturalPersonFlag + +是否开具给自 + +String(1) + +否 + +然人 + +是否开具给自然人。默认 0。 +0:否; +1:是。电子税局勾选“是”时 +的提示:请您确认受票方为自 +然人,并在纳税人识别号档次 +填入“自然人纳税人识别号” +(自然人受票方可登录个人所 +得税 APP 查看“自然人纳税人 +识别号”),该张发票将在受票 +方自然人个人票夹中展示。 + +buyerAddress + +购买方地址 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度, +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerTel + +购买方电话 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为字符长度,只能是数字、 +中英文括号、中英文横杠。 +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerBankName + +购买方开户行 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度。 + +票通数电发票接口文档 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 +buyerBankAccount + +购买方银行账 + +String(1-50) + +否 + +号 + +票面信息,无默认,长度校验 +规则为字符长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +sellerAddress + +销货方地址 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的企业开票 +地址电话信息(如有多条取默 +认的,没有默认取最后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerTel + +销货方电话 + +String(1-20) + +否 + +票面信息,长度校验规则为字 +符长度,只能是数字、中英文 +括号、中英文横杠。如果不传 +值,取平台企业开票设置中的 +企业开票地址电话信息(如有 +多条取默认的,没有默认取最 +后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerBankName + +销货方开户行 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的开户行及 +银行账号信息(如有多条取默 +认的,如有一条则使用该开户 +行及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +sellerBankAccount + +销货方银行账 +号 + +String(1-50) + +否 + +票面信息,长度校验规则为字 +符长度。如果不传值,取平台 +企业开票设置中的开户行及银 +行账号信息(如有多条取默认 +的,如有一条则使用该开户行 +及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +票通数电发票接口文档 +showBuyerBank + +是否显示购方 + +String(1) + +否 + +是否显示购方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 + +showSellerBank + +是否显示销方 + +String(1) + +否 + +是否显示销方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 +注:最终版式文件销方银行账 +户取税局维护的开户行及账号 + +showBuyerAddrTel + +是否显示购方 + +String(1) + +否 + +是否显示购方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 + +showSellerAddrTel + +是否显示销方 + +String(1) + +否 + +是否显示销方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 +注:最终版式文件上的销方地 +址电话取税局维护的地址电话 + +account + +开票人税局账 + +String(50) + +否 + +号 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,随机取已在票通平台 +登记的账号。 + +casherName + +收款人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的收款人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该收款人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +reviewerName + +复核人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的复核人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该复核人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +takerName + +收票人名称 + +String(1-10) + +否 + +客户信息,长度校验规则为字 + +票通数电发票接口文档 +符长度 +takerTel + +收票人手机号 + +String(11) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +客户信息,填写后,票通会给 +客户发送发票邮件,不填写则 +不发送,长度校验规则为字符 +长度 + +specialInvoiceKind + +特殊票种 + +String(2) + +否 + +特殊票种,默认为空。 +04:货物运输服务。 +如果不传,会根据税收分类编 +码自动适配特殊票种。 + +remark + +备注 + +String(0-200) + +否 + +备注,校验规则字符长度 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 +中会按照定义返回,长度校验 +规则为字符长度 + +tradeNo + +订单号 + +String(0-200) + +否 + +订单号,长度校验规则为字符 +长度。如果没有传值,票通平 +台默认使用发票请求流水号 +invoiceReqSerialNo 赋值 + +shopNum + +门店编号 + +String(6-20) + +否 + +门店编号,取值集团版门店编 +号,没有则不用填写。只允许 +字母、数字 + +开票项目列表信息(itemList) +itemList + +开票项目列表 + +goodsName + +货物名称 + +数组或集合 + +是 + +最大 2000 行 + +String(1-100) + +是 + +票面信息,此项不填写时默认 +为 taxClassificationCode 对 +应的名称,长度校验规则为字 +符长度 + +taxClassificationCo + +对应税收分类 + +de + +编码 + +specificationModel + +对应规格型号 + +String(1-50) + +是 + +统一编码表的信息,长度校验 +规则为字符长度 + +String(1-40) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度 + +meteringUnit + +单位 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度 + +quantity + +数量 + +16 位(精确到 8 + +否 + +位小数) +includeTaxFlag + +含税标示 + +unitPrice + +单价 + +票面信息,支持到小数点前 8 +位。 + +String(1) + +否 + +0:不含税,1:含税,默 +认为 0 不含税 + +16 位(精确到 8 + +否 + +票面信息,支持到小数点前 8 + +位小数) + +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +invoiceAmount + +金额 + +10 位(精确到 2 + +是 + +票面信息,支持到小数点前 8 + +票通数电发票接口文档 +位小数) + +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +taxRateValue + +税率 + +4 位(精确到 2 位 + +是 + +票面信息,例:0.13 + +否 + +票面信息,如果不填写,默认 + +小数) +taxRateAmount + +税额 + +10 位(精确到 2 +位小数) + +会根据税率计算税额,如果填 +写此值会直接使用此税额,可 +通过含税标示 +includeTaxFlag,定义此字段 +为含税 + +discountAmount + +折扣金额 + +10 位(精确到 2 + +否 + +该商品行的折扣金额,传负数 + +否 + +票面信息,使用折扣金额进行 + +位小数) +discountTaxRateAmou + +折扣税额 + +nt +preferentialPolicyF + +10 位(精确到 2 +位小数) + +优惠政策标识 + +String(1-50) + +计算 +否 + +lag + +空:不使用,1:使用 +零税率标识为 0、1、2 时该值 +必填 1。 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +税率为 0 时该值必填。空:非 +零税率, +1:免税, +2:不征税, +3:普通零税率 + +vatSpecialManage + +增值税特殊管 + +String(0-100) + +否 + +理 + +preferentialPolicyFlag 优惠 +政策标识位 1 时必填,例如填 +免税、不征税、简易征收 + +货物运输服务列表 goodsTransportList +goodsTransportList + +货物运输服务 + +数组或集合 + +是 + +货物运输服务列表 + +是 + +运输工具种类。 + +列表 +transportToolType + +运输工具种类 + +String(4) + +铁路运输、公路运输、水路运 +输、航空运输、管道运输、其 +他运输工具 +transportToolBrand + +运输工具牌号 + +String(1-100) + +是 + +运输工具牌号 + +departurePlace + +起运地 + +String(1-100) + +是 + +起运地。省市(县)区。例如: +上海市黄浦区/四川省绵阳市 +涪城区/广东省河源市和平县 + +destinationPlace + +到达地 + +String(1-100) + +是 + +到达地。省市(县)区。例如: +上海市黄浦区/四川省绵阳市 +涪城区/广东省河源市和平县 + +transportGoodsName + +运输货物名称 + +String(1-80 字 +符) + +报文示例: + +是 + +运输货物名称 + +票通数电发票接口文档 +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceIssueKindCode ": "82", +"buyerName": "北京 XXXXX 技术有限公司", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"buyerAddress": "北京市海淀区 XXXXXX15 号 5 层", +"buyerTel": "010 - 1234567 ", +"buyerBankName": "XXXX 银行", +"buyerBankAccount": "9878XXXXXX45666", +"sellerAddress": "北京海淀区 XXX 路 15 号", +"sellerTel": "010-7654321", +"sellerBankName": "XXXXX 银行", +"sellerBankAccount": "6217XXXXXXX0678", +"remark": "备注", +"tradeNo": "DEMO1111111111", +"definedData": "自定义数据", +"account": "185XXXXXXXX", +"takerName": "XXX", +"takerTel": "XXXXXXX", +"takerEmail": "XXXXX@qq.com", +"specialInvoiceKind": "09", +"itemList": [{ +"goodsName": "国内道路货物运输服务", +"taxClassificationCode": "3010102020100000000", +"specificationModel": "", +"meteringUnit": "", +"quantity": "1.00", +"includeTaxFlag": "1", +"unitPrice": "100", +"invoiceAmount": "100.00", +"taxRateValue": "0.09" +}], +"goodsTransportList": [{ +"transportToolType": "公路运输", +"transportToolBrand": "京 A88888", +"departurePlace": "上海市黄浦区", +"destinationPlace": "广东省河源市和平县", +"transportGoodsName": "木材", +}] +} + +票通数电发票接口文档 + +2.25.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +invoiceReqSerialNo + +发票请求流水 + +类型 + +必填 + +说明 + +String(20) + +是 + +4 位平台简称+16 位随机数 + +String + +否 + +不定长,Base64 字符串,电子 + +号 +qrCodePath + +二维码 url + +发票该值必传 +qrCode + +二维码图片 + +String + +否 + +Base64 字符串 + +扫码查看发票开票状态,二维 +码的内容是 qrCodePath,电子 +发票该值必传 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"qrCodePath": "xxxxxxxxxxxxx", +"qrCode": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} + +2.25.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +9997 + +纳税人识别号无效 + +8993 + +开票请求处理失败(对应参考返回的详细信息) + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8004 + +找不到对应的开票企业信息,请检查税号 + +8005 + +找不到对应的税收分类编码,请参照税收分类编码表,检查编码号 + +8006 + +找不到对应的税率,请输入正确的税率 + +8007 + +折扣金额不可大于对应商品行金额/折扣金额和折扣率不匹配 + +8008 + +优惠政策不为空时,增值税特殊管理不能为空 + +8009 + +企业注册/修改中不能开票 + +8011 + +税额与税率不匹配 + +8012 + +差额开票抵扣金额过大,不能超过价税合计金额 + +8013 + +差额开票只允许单个商品行 + +8014 + +税率为 0 时,零税率标示必须选择 + +8015 + +不存在对应的零税率标示 + +8016 + +单价数量金额不匹配 + +8017 + +折扣税额和税率不匹配 + +票通数电发票接口文档 +8021 + +发票请求流水号已存在,请更换发票请求流水号 + +8040 + +该发票请求流水号正在处理中,请稍后查询开票结果 + +2.25.5. + +适用税收分类编码 + +税收分类编码 + +商品和服务名称 + +商品和服务 +分类简称 + +增值税税率 + +征收率 + +3010102010100000000 + +国内铁路货物运输服务 + +运输服务 + +9% + +3% + +3010102010200000000 + +国际铁路货物运输服务 + +运输服务 + +9% + +3% + +3010102010300000000 + +港澳台铁路货物运输服务 + +运输服务 + +9% + +3% + +3010102020100000000 + +国内道路货物运输服务 + +运输服务 + +9% + +3% + +3010102020200000000 + +国际道路货物运输服务 + +运输服务 + +9% + +3% + +3010102020300000000 + +港澳台道路货物运输服务 + +运输服务 + +9% + +3% + +3010102990100000000 + +国内其他陆路货物运输服务 + +运输服务 + +9% + +3% + +3010102990200000000 + +国际其他陆路货物运输服务 + +运输服务 + +9% + +3% + +3010102990300000000 + +港澳台其他陆路货物运输服务 + +运输服务 + +9% + +3% + +3010202010000000000 + +国内水路货物运输服务 + +运输服务 + +9% + +3% + +3010202020000000000 + +国际水路货物运输服务 + +运输服务 + +9% + +3% + +3010202030000000000 + +港澳台水路货物运输服务 + +运输服务 + +9% + +3% + +3010203020000000000 + +水路货物运输期租业务 + +运输服务 + +9% + +3% + +3010204020000000000 + +水路货物运输程租业务 + +运输服务 + +9% + +3% + +3010301020100000000 + +国内航空货物运输服务 + +运输服务 + +9% + +3% + +3010301020200000000 + +国际航空货物运输服务 + +运输服务 + +9% + +3% + +3010301020300000000 + +港澳台航空货物运输服务 + +运输服务 + +9% + +3% + +3010301030200000000 + +航空货物运输湿租业务 + +运输服务 + +9% + +3% + +3010502010200000000 + +无运输工具承运铁路货物运输服务 + +运输服务 + +9% + +3% + +3010502020200000000 + +无运输工具承运道路货物运输服务 + +运输服务 + +9% + +3% + +3010503020000000000 + +无运输工具承运水路货物运输服务 + +运输服务 + +9% + +3% + +3010504020000000000 + +无运输工具承运航空货物运输服务 + +运输服务 + +9% + +3% + +3010506020000000000 + +无运输工具承运货物联运运输服务 + +运输服务 + +9% + +3% + +3010599020000000000 + +其他无运输工具承运货物运输业务 + +运输服务 + +9% + +3% + +3010602000000000000 + +货物联运服务 + +运输服务 + +9% + +3% + +3019902000000000000 + +其他货物运输服务 + +运输服务 + +9% + +3% + +2.26. 开具建筑服务蓝字数电发票 +2.26.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +备注 +POST 方式提交 + +票通数电发票接口文档 +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/invoiceBlue.pt + +字符编码 + +2.26.2. + +UTF-8 + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +是 + +销售方纳税人识别号,长度校 + +开票基本信息 +taxpayerNum + +销售方纳税人 + +String(15-20) + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +号 + +4 位平台简称+16 位随机数,长 +度校验规则为字符长度,只能 +包括英文字母或数字,唯一 + +invoiceIssueKindCode + +开具发票种类 + +String(1-2) + +是 + +开具发票种类。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票) + +buyerName + +购买方名称 + +String(1-100) + +是 + +票面信息,发票抬头,无默认 +长度校验规则为 GBK 字节长 +度,不能包含<>字符 + +buyerTaxpayerNum + +购买方纳税人 + +String(15-20) + +否 + +识别号 + +票面信息,无默认,长度校验规 +则为字符长度,只能包括大写 +英文字母或数字 + +naturalPersonFlag + +是否开具给自 + +String(1) + +否 + +然人 + +是否开具给自然人。默认 0。 +0:否; +1:是。电子税局勾选“是”时 +的提示:请您确认受票方为自 +然人,并在纳税人识别号档次 +填入“自然人纳税人识别号” +(自然人受票方可登录个人所 +得税 APP 查看“自然人纳税人 +识别号”),该张发票将在受票 +方自然人个人票夹中展示。 + +buyerAddress + +购买方地址 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度, +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerTel + +购买方电话 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为字符长度,只能是数字、 + +票通数电发票接口文档 +中英文括号、中英文横杠。 +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 +buyerBankName + +购买方开户行 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +buyerBankAccount + +购买方银行账 + +String(1-50) + +否 + +号 + +票面信息,无默认,长度校验 +规则为字符长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +sellerAddress + +销货方地址 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的企业开票 +地址电话信息(如有多条取默 +认的,没有默认取最后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerTel + +销货方电话 + +String(1-20) + +否 + +票面信息,长度校验规则为字 +符长度,只能是数字、中英文 +括号、中英文横杠。如果不传 +值,取平台企业开票设置中的 +企业开票地址电话信息(如有 +多条取默认的,没有默认取最 +后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerBankName + +销货方开户行 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的开户行及 +银行账号信息(如有多条取默 +认的,如有一条则使用该开户 +行及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +sellerBankAccount + +销货方银行账 +号 + +String(1-50) + +否 + +票面信息,长度校验规则为字 +符长度。如果不传值,取平台 +企业开票设置中的开户行及银 + +票通数电发票接口文档 +行账号信息(如有多条取默认 +的,如有一条则使用该开户行 +及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 +showBuyerBank + +是否显示购方 + +String(1) + +否 + +是否显示购方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 + +showSellerBank + +是否显示销方 + +String(1) + +否 + +是否显示销方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 +注:最终版式文件销方银行账 +户取税局维护的开户行及账号 + +showBuyerAddrTel + +是否显示购方 + +String(1) + +否 + +是否显示购方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 + +showSellerAddrTel + +是否显示销方 + +String(1) + +否 + +是否显示销方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 +注:最终版式文件上的销方地 +址电话取税局维护的地址电话 + +account + +开票人税局账 + +String(50) + +否 + +号 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,随机取已在票通平台 +登记的账号。 + +casherName + +收款人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的收款人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该收款人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +reviewerName + +复核人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的复核人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, + +票通数电发票接口文档 +则使用该复核人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 +takerName + +收票人名称 + +String(1-10) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerTel + +收票人手机号 + +String(11) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +客户信息,填写后,票通会给 +客户发送发票邮件,不填写则 +不发送,长度校验规则为字符 +长度 + +specialInvoiceKind + +特殊票种 + +String(2) + +否 + +特殊票种,默认为空。 +03:建筑服务。 +如果不传,会根据税收分类编 +码自动适配特殊票种。 + +remark + +备注 + +String(0-200) + +否 + +备注,校验规则字符长度 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 +中会按照定义返回,长度校验 +规则为字符长度 + +tradeNo + +订单号 + +String(0-200) + +否 + +订单号,长度校验规则为字符 +长度。如果没有传值,票通平 +台默认使用发票请求流水号 +invoiceReqSerialNo 赋值 + +shopNum + +门店编号 + +String(6-20) + +否 + +门店编号,取值集团版门店编 +号,没有则不用填写。只允许 +字母、数字 + +开票项目列表信息(itemList),只能一行 +itemList + +开票项目列表 + +goodsName + +货物名称 + +数组或集合 + +是 + +只能一行 + +String(1-100) + +是 + +票面信息,此项不填写时默认 +为 taxClassificationCode 对 +应的名称,长度校验规则为字 +符长度 + +taxClassificationCo + +对应税收分类 + +de + +编码 + +quantity + +数量 + +String(1-50) + +是 + +规则为字符长度 +16 位(精确到 8 + +否 + +位小数) +includeTaxFlag + +含税标示 + +unitPrice + +单价 + +统一编码表的信息,长度校验 +票面信息,支持到小数点前 8 +位。 + +String(1) + +否 + +0:不含税,1:含税,默 +认为 0 不含税 + +16 位(精确到 8 + +否 + +票面信息,支持到小数点前 8 + +位小数) + +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +票通数电发票接口文档 +invoiceAmount + +金额 + +10 位(精确到 2 + +是 + +位小数) + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +taxRateValue + +税率 + +4 位(精确到 2 位 + +是 + +票面信息,例:0.13 + +否 + +票面信息,如果不填写,默认 + +小数) +taxRateAmount + +税额 + +10 位(精确到 2 +位小数) + +会根据税率计算税额,如果填 +写此值会直接使用此税额,可 +通过含税标示 +includeTaxFlag,定义此字段 +为含税 + +discountAmount + +折扣金额 + +10 位(精确到 2 + +否 + +该商品行的折扣金额,传负数 + +否 + +票面信息,使用折扣金额进行 + +位小数) +discountTaxRateAmou + +折扣税额 + +nt +preferentialPolicyF + +10 位(精确到 2 +位小数) + +优惠政策标识 + +String(1-50) + +计算 +否 + +lag + +空:不使用,1:使用 +零税率标识为 0、1、2 时该值 +必填 1。 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +税率为 0 时该值必填。空:非 +零税率, +1:免税, +2:不征税, +3:普通零税率 + +vatSpecialManage + +增值税特殊管 + +String(0-100) + +否 + +理 + +preferentialPolicyFlag 优惠 +政策标识位 1 时必填,填免税、 +不征税 + +特定业务--建筑服务(buildService),建筑服务相关的税收分类编码,必填 +buildService + +建筑服务 + +Object + +是 + +特定业务建筑服务必填 + +landTaxItemNo + +土地增值税项 + +String(0-20 字 + +否 + +土地增值税项目编号 + +目编号 + +符) + +建筑服务发生 + +String(4-100) + +是 + +建筑服务发生地,格式:省市 + +buildServicePlace + +地 + +区(县)。 +例如:上海市黄浦区/四川省绵 +阳市涪城区/广东省河源市和 +平县 + +detailedAddress + +详细地址 + +String(1-100 + +否 + +字符) +buildProjectName + +建筑项目名称 + +String(1-80 字 + +不动产详细地址。例如:东江 +北路 68 号 + +是 + +建筑项目名称 + +是 + +跨地(市)标志。 + +符) +crossCitySign + +跨地(市)标志 + +String(1) + +0:否; +1:是 + +票通数电发票接口文档 +taxDeclareManageNum + +跨区域涉税事 + +String(1-50) + +项报验管理编 +号 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceIssueKindCode ": "82", +"buyerName": "北京 XXXXX 技术有限公司", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"buyerAddress": "北京市海淀区 XXXXXX15 号 5 层", +"buyerTel": "010 - 1234567 ", +"buyerBankName": "XXXX 银行", +"buyerBankAccount": "9878XXXXXX45666", +"sellerAddress": "北京海淀区 XXX 路 15 号", +"sellerTel": "010-7654321", +"sellerBankName": "XXXXX 银行", +"sellerBankAccount": "6217XXXXXXX0678", +"remark": "备注", +"tradeNo": "DEMO1111111111", +"definedData": "自定义数据", +"account": "185XXXXXXXX", +"takerName": "XXX", +"takerTel": "XXXXXXX", +"takerEmail": "XXXXX@qq.com", +"itemList": [{ +"goodsName": "安装服务", +"taxClassificationCode": "3050200000000000000", +"quantity": "1.00", +"includeTaxFlag": "1", +"unitPrice": "100", +"invoiceAmount": "100.00", +"taxRateValue": "0.09" +}], +"buildService": { +"landTaxItemNo": "2324567", +"buildServicePlace": "四川省绵阳市涪城区", +"detailedAddress": "东江北路 68 号", +"buildProjectName": "某某大厦", +"crossCitySign": "0" +} +} + +否 + +跨区域涉税事项报验管理编 +号。跨地(市)标志为 1 时必填 + +票通数电发票接口文档 + +2.26.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +invoiceReqSerialNo + +发票请求流水 + +类型 + +必填 + +说明 + +String(20) + +是 + +4 位平台简称+16 位随机数 + +String + +否 + +不定长,Base64 字符串,电子 + +号 +qrCodePath + +二维码 url + +发票该值必传 +qrCode + +二维码图片 + +String + +否 + +Base64 字符串 + +扫码查看发票开票状态,二维 +码的内容是 qrCodePath,电子 +发票该值必传 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"qrCodePath": "xxxxxxxxxxxxx", +"qrCode": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} + +2.26.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +9997 + +纳税人识别号无效 + +8993 + +开票请求处理失败(对应参考返回的详细信息) + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8004 + +找不到对应的开票企业信息,请检查税号 + +8005 + +找不到对应的税收分类编码,请参照税收分类编码表,检查编码号 + +8006 + +找不到对应的税率,请输入正确的税率 + +8007 + +折扣金额不可大于对应商品行金额/折扣金额和折扣率不匹配 + +8008 + +优惠政策不为空时,增值税特殊管理不能为空 + +8009 + +企业注册/修改中不能开票 + +8011 + +税额与税率不匹配 + +8012 + +差额开票抵扣金额过大,不能超过价税合计金额 + +8013 + +差额开票只允许单个商品行 + +8014 + +税率为 0 时,零税率标示必须选择 + +8015 + +不存在对应的零税率标示 + +8016 + +单价数量金额不匹配 + +8017 + +折扣税额和税率不匹配 + +8021 + +发票请求流水号已存在,请更换发票请求流水号 + +票通数电发票接口文档 +8040 + +该发票请求流水号正在处理中,请稍后查询开票结果 + +2.26.5. + +适用税收分类编码 + +税收分类编码 + +商品和服务名称 + +商品和服务分类简称 + +增值税税率 + +征收率 + +3050100000000000000 + +工程服务 + +建筑服务 + +9% + +3% + +3050200000000000000 + +安装服务 + +建筑服务 + +9% + +3% + +3050300000000000000 + +修缮服务 + +建筑服务 + +9% + +3% + +3050400000000000000 + +装饰服务 + +建筑服务 + +9% + +3% + +3059900000000000000 + +其他建筑服务 + +建筑服务 + +9% + +3% + +2.27. 开具不动产销售蓝字数电发票 +2.27.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/invoiceBlue.pt +字符编码 + +2.27.2. + +UTF-8 + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +是 + +销售方纳税人识别号,长度校 + +开票基本信息 +taxpayerNum + +销售方纳税人 + +String(15-20) + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +号 + +4 位平台简称+16 位随机数,长 +度校验规则为字符长度,只能 +包括英文字母或数字,唯一 + +invoiceIssueKindCode + +开具发票种类 + +String(1-2) + +是 + +开具发票种类。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票) + +buyerName + +购买方名称 + +String(1-100) + +是 + +票面信息,发票抬头,无默认 +长度校验规则为 GBK 字节长 + +票通数电发票接口文档 +度,不能包含<>字符 +buyerTaxpayerNum + +购买方纳税人 + +String(15-20) + +否 + +识别号 + +票面信息,无默认,长度校验规 +则为字符长度,只能包括大写 +英文字母或数字 + +naturalPersonFlag + +是否开具给自 + +String(1) + +否 + +然人 + +是否开具给自然人。默认 0。 +0:否; +1:是。电子税局勾选“是”时 +的提示:请您确认受票方为自 +然人,并在纳税人识别号档次 +填入“自然人纳税人识别号” +(自然人受票方可登录个人所 +得税 APP 查看“自然人纳税人 +识别号”),该张发票将在受票 +方自然人个人票夹中展示。 + +buyerAddress + +购买方地址 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度, +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerTel + +购买方电话 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为字符长度,只能是数字、 +中英文括号、中英文横杠。 +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerBankName + +购买方开户行 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +buyerBankAccount + +购买方银行账 + +String(1-50) + +否 + +号 + +票面信息,无默认,长度校验 +规则为字符长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +sellerAddress + +销货方地址 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的企业开票 +地址电话信息(如有多条取默 +认的,没有默认取最后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerTel + +销货方电话 + +String(1-20) + +否 + +票面信息,长度校验规则为字 + +票通数电发票接口文档 +符长度,只能是数字、中英文 +括号、中英文横杠。如果不传 +值,取平台企业开票设置中的 +企业开票地址电话信息(如有 +多条取默认的,没有默认取最 +后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 +sellerBankName + +销货方开户行 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的开户行及 +银行账号信息(如有多条取默 +认的,如有一条则使用该开户 +行及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +sellerBankAccount + +销货方银行账 + +String(1-50) + +否 + +号 + +票面信息,长度校验规则为字 +符长度。如果不传值,取平台 +企业开票设置中的开户行及银 +行账号信息(如有多条取默认 +的,如有一条则使用该开户行 +及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +showBuyerBank + +是否显示购方 + +String(1) + +否 + +是否显示购方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 + +showSellerBank + +是否显示销方 + +String(1) + +否 + +是否显示销方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 +注:最终版式文件销方银行账 +户取税局维护的开户行及账号 + +showBuyerAddrTel + +是否显示购方 + +String(1) + +否 + +是否显示购方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 +1:显示 + +showSellerAddrTel + +是否显示销方 + +String(1) + +否 + +是否显示销方地址电话到发票 + +地址电话到发 + +备注,默认 0 不显示 + +票备注 + +0:不显示 + +票通数电发票接口文档 +1:显示 +注:最终版式文件上的销方地 +址电话取税局维护的地址电话 +account + +开票人税局账 + +String(50) + +否 + +号 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,随机取已在票通平台 +登记的账号。 + +casherName + +收款人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的收款人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该收款人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +reviewerName + +复核人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的复核人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该复核人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +takerName + +收票人名称 + +String(1-10) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerTel + +收票人手机号 + +String(11) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +客户信息,填写后,票通会给 +客户发送发票邮件,不填写则 +不发送,长度校验规则为字符 +长度 + +specialInvoiceKind + +特殊票种 + +String(2) + +否 + +特殊票种,默认为空。 +05:不动产销售服务。 +如果不传,会根据税收分类编 +码自动适配特殊票种。 + +remark + +备注 + +String(0-200) + +否 + +备注,校验规则字符长度 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 +中会按照定义返回,长度校验 +规则为字符长度 + +tradeNo + +订单号 + +String(0-200) + +否 + +订单号,长度校验规则为字符 +长度。如果没有传值,票通平 + +票通数电发票接口文档 +台默认使用发票请求流水号 +invoiceReqSerialNo 赋值 +shopNum + +门店编号 + +String(6-20) + +否 + +门店编号,取值集团版门店编 +号,没有则不用填写。只允许 +字母、数字 + +开票项目列表信息(itemList),行数要与不动产销售特定信息的 realEstateSaleList 行数保持一致 +itemList + +开票项目列表 + +数组或集合 + +是 + +行数要与不动产销售特定信息 +的行数保持一致 + +goodsName + +货物名称 + +String(1-100) + +是 + +票面信息,此项不填写时默认 +为 taxClassificationCode 对 +应的名称,长度校验规则为字 +符长度 + +taxClassificationCo + +对应税收分类 + +de + +编码 + +quantity + +数量 + +String(1-50) + +是 + +规则为字符长度 +16 位(精确到 8 + +是 + +位小数) +includeTaxFlag + +含税标示 + +unitPrice + +单价 + +统一编码表的信息,长度校验 +票面信息,支持到小数点前 8 +位。 + +String(1) + +否 + +0:不含税,1:含税,默 +认为 0 不含税 + +16 位(精确到 8 + +是 + +票面信息,支持到小数点前 8 + +位小数) + +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +invoiceAmount + +金额 + +10 位(精确到 2 + +是 + +位小数) + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +taxRateValue + +税率 + +4 位(精确到 2 位 + +是 + +票面信息,例:0.13 + +否 + +票面信息,如果不填写,默认 + +小数) +taxRateAmount + +税额 + +10 位(精确到 2 +位小数) + +会根据税率计算税额,如果填 +写此值会直接使用此税额,可 +通过含税标示 +includeTaxFlag,定义此字段 +为含税 + +discountAmount + +折扣金额 + +10 位(精确到 2 + +否 + +该商品行的折扣金额,传负数 + +否 + +票面信息,使用折扣金额进行 + +位小数) +discountTaxRateAmou + +折扣税额 + +nt +preferentialPolicyF + +10 位(精确到 2 +位小数) + +优惠政策标识 + +String(1-50) + +计算 +否 + +lag + +空:不使用,1:使用 +零税率标识为 0、1、2 时该值 +必填 1。 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +税率为 0 时该值必填。空:非 +零税率, +1:免税, + +票通数电发票接口文档 +2:不征税, +3:普通零税率 +vatSpecialManage + +增值税特殊管 + +String(0-100) + +否 + +理 + +preferentialPolicyFlag 优惠 +政策标识位 1 时必填,填免税、 +不征税 + +不动产销售特定信息列表 realEstateSaleList +realEstateSaleList + +不动产销售特 + +数组或集合 + +是 + +定信息列表 +realEstateUnitCode + +不动产销售特定信息列表,行 +数要与 itemList 的行数一致 + +不动产单元代 + +String(0-28 字 + +码/网签合同备 + +符) + +否 + +不动产单元代码/网签合同备 +案编号 + +案编号 +region + +不动产地址省 + +String ( 4-100 + +市区(县) + +字符) + +是 + +不动产地址省市区(县),格 +式:省市区(县)。 +例如:上海市黄浦区/四川省绵 +阳市涪城区/广东省河源市和 +平县 + +detailedAddress + +详细地址 + +String(1-100 + +是 + +字符) +crossCitySign + +跨地(市)标志 + +String(1) + +不动产详细地址。例如:东江 +北路 68 号 + +是 + +跨地(市)标志。 +0:否; +1:是 + +landTaxItemNo + +土地增值税项 +目编号 + +String(0-20 字 + +否 + +土地增值税项目编号 + +符) + +assessedTaxPrice + +核定计税价格 + +String(0-20) + +否 + +核定计税价格 + +actualAmountWithTax + +实际成交含税 + +String(0-20) + +否 + +实际成交含税金额。注:核定 + +金额 + +计税价格不为空时实际成交含 +税金额也不能为空 + +titleNo +areaUnit + +房屋产权证书/ + +String(0-40 字 + +不动产权证号 + +符) + +面积单位 + +String + +否 + +房屋产权证书/不动产权证号 + +是 + +面积单位。 +平方千米 +平方米 +孔公里 +公顷 +亩 +h㎡ +k㎡ +㎡ + +共同购买方信息列表 realEstateSaleTogetherBuyerList +realEstateSaleToget + +共同购买方信 + +herBuyerList + +息列表 + +togetherBuyerName + +共同购买方 + +togetherBuyerIdType + +共同购买方证 +件类型 + +数组或集合 + +否 + +共同购买方信息列表 + +String(100) + +是 + +共同购买方 + +String(3) + +是 + +共同购买方证件类型。见码表 +3.1 证件类型 + +票通数电发票接口文档 +togetherBuyerIdNo + +共同购买方证 + +String(20) + +件号码 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceIssueKindCode ": "82", +"buyerName": "北京 XXXXX 技术有限公司", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"buyerAddress": "北京市海淀区 XXXXXX15 号 5 层", +"buyerTel": "010 - 1234567 ", +"buyerBankName": "XXXX 银行", +"buyerBankAccount": "9878XXXXXX45666", +"sellerAddress": "北京海淀区 XXX 路 15 号", +"sellerTel": "010-7654321", +"sellerBankName": "XXXXX 银行", +"sellerBankAccount": "6217XXXXXXX0678", +"remark": "备注", +"tradeNo": "DEMO1111111111", +"definedData": "自定义数据", +"account": "185XXXXXXXX", +"takerName": "XXX", +"takerTel": "XXXXXXX", +"takerEmail": "XXXXX@qq.com", +"itemList": [{ +"goodsName": "房地产开发住宅", +"taxClassificationCode": "5010101000000000000", +"quantity": "100.00", +"includeTaxFlag": "1", +"unitPrice": "100", +"invoiceAmount": "10000.00", +"taxRateValue": "0.09" +}], +"realEstateSaleList": [{ +"realEstateUnitCode": "2324567", +"region": "四川省绵阳市涪城区", +"detailedAddress": "东江北路 68 号", +"crossCitySign": "0", +"landTaxItemNo": "013213", +"assessedTaxPrice": "10000", +"actualAmountWithTax": "10000", +"titleNo": "123456734567", +"areaUnit": "平方米" + +是 + +共同购买方证件号码 + +票通数电发票接口文档 +}], +"realEstateSaleTogetherBuyerList": [{ +"togetherBuyerName": "张三", +"togetherBuyerIdType": "201", +"togetherBuyerIdNo": "110110198912120178" +}] +} + +2.27.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +invoiceReqSerialNo + +发票请求流水 + +类型 + +必填 + +说明 + +String(20) + +是 + +4 位平台简称+16 位随机数 + +String + +否 + +不定长,Base64 字符串,电子 + +号 +qrCodePath + +二维码 url + +发票该值必传 +qrCode + +二维码图片 + +String + +否 + +Base64 字符串 + +扫码查看发票开票状态,二维 +码的内容是 qrCodePath,电子 +发票该值必传 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"qrCodePath": "xxxxxxxxxxxxx", +"qrCode": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} + +2.27.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +9997 + +纳税人识别号无效 + +8993 + +开票请求处理失败(对应参考返回的详细信息) + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8004 + +找不到对应的开票企业信息,请检查税号 + +8005 + +找不到对应的税收分类编码,请参照税收分类编码表,检查编码号 + +8006 + +找不到对应的税率,请输入正确的税率 + +8007 + +折扣金额不可大于对应商品行金额/折扣金额和折扣率不匹配 + +8008 + +优惠政策不为空时,增值税特殊管理不能为空 + +8009 + +企业注册/修改中不能开票 + +票通数电发票接口文档 +8011 + +税额与税率不匹配 + +8012 + +差额开票抵扣金额过大,不能超过价税合计金额 + +8013 + +差额开票只允许单个商品行 + +8014 + +税率为 0 时,零税率标示必须选择 + +8015 + +不存在对应的零税率标示 + +8016 + +单价数量金额不匹配 + +8017 + +折扣税额和税率不匹配 + +8021 + +发票请求流水号已存在,请更换发票请求流水号 + +8040 + +该发票请求流水号正在处理中,请稍后查询开票结果 + +2.27.5. + +适用税收分类编码 + +税收分类编码 + +商品和服务名称 + +商品和服务分类简称 + +增值税税率 + +征收率 + +5010101000000000000 + +房地产开发住宅 + +不动产 + +9% + +5% + +5010102000000000000 + +取得的住宅 + +不动产 + +9% + +5% + +5010103000000000000 + +房改房 + +不动产 + +9% + +5% + +5010199000000000000 + +其他住房 + +不动产 + +9% + +5% + +5010201000000000000 + +房地产开发商业用房 + +不动产 + +9% + +5% + +5010202000000000000 + +取得的商业用房 + +不动产 + +9% + +5% + +5010299000000000000 + +其他商业用房 + +不动产 + +9% + +5% + +5019900000000000000 + +其他建筑物 + +不动产 + +9% + +5% + +5020000000000000000 + +构筑物 + +不动产 + +9% + +5% + +5030000000000000000 + +其他不动产 + +不动产 + +9% + +5% + +2.28. 红字发票确认单申请 +2.28.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/applySpecialInvRed +InfoTable.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/applySpecialInvRedInfoT +able.pt + +字符编码 + +UTF-8 + +备注 +POST 方式提交 + +票通数电发票接口文档 + +2.28.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 +纳税人识别号 + +类型 + +必填 + +说明 + +String(15-20) + +是 + +纳税人识别号,销方申请填销 +方税号,购方申请填购方税号 + +redApplySerialNo + +红字确认单申请 + +String(20) + +是 + +流水号 + +红字确认单申请流水号,4 位 +平台简称+16 位随机数,可以 +用来调用查询接口查询确认单 +信息 + +redReason + +冲红原因 + +String(2) + +是 + +冲红原因。 +01:开票有误; +03:服务中止; +04:销售折让 + +blueInvoiceCode + +原发票代码 + +String(12) + +否 + +原发票代码,冲红增值税发票 +管理系统开具的发票或数电纸 +票时必填 + +blueInvoiceNo + +原发票号码 + +String(8) + +否 + +原发票号码,冲红增值税发票 +管理系统开具的发票或数电纸 +票时必填 + +blueAllEleInvNo + +原数电发票号码 + +String(20) + +否 + +数电发票号码 + +blueInvoiceDate + +原发票开票日期 + +String(19) + +否 + +原发票开票日期。 +格式 yyyyMMdd。 +购方申请必传。销方申请时如 +果需要冲红非票通平台开具的 +发票,此值必传。 + +invoiceKind + +发票种类代码 + +String(2) + +否 + +开具红字确认单的发票种类。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票)。 +默认蓝票的发票种类代码。购 +方申请必填。 + +buyerTaxpayerNum + +购买方税号 + +String(15-20) + +否 + +购买方税号 + +sellerTaxpayerNum + +销方纳税人识别 + +String(15-20) + +否 + +销方纳税人识别号 + +String(1) + +是 + +申请来源。 + +号 +applySource + +申请来源 + +0:销方申请; +1:购方申请。 +account + +确认单录入人员 + +String(50) + +否 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,取蓝票的开票账号。 + +票通数电发票接口文档 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"redApplySerialNo": "XXXX5678901234567891", +"redReason": "01", +"blueInvoiceCode": "123456789012", +"blueInvoiceNo": "12345678", +"blueAllEleInvNo": "12345678901212345678", +"invoiceDate": "2022-09-28 08:15:54", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"sellerTaxpayerNum": "91XXXXXXXXXXXXX31", +"applySource": "0", +"account": "185XXXXXXXX" +} + +2.28.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +redApplySerialNo + +红字信息表申请流 + +String(20) + +是 + +可以用来调用查询接口查询红 + +水号 + +字信息表单号 + +redBillNo + +红字信息表编号 + +String(20) + +否 + +申请成功此值必填 + +processState + +红字确认单状态 + +String(2) + +否 + +红字发票信息确认单确认状。 +0:审核中,调用 2.29 查看红 +字发票确认单接口查询结果; +4:处理失败; +5:已冲红; +6:冲红中; +7:冲红失败; +81:无需确认,可冲红; +82:销方录入待购方确认; +83:购方录入待销方确认; +84:购销双方已确认,可冲红; +85:作废(销方录入购方否认) +; +86:作废(购方录入销方否认) +; +87:作废(超 72 小时未确认); +88:作废(发起方撤销); +89:作废(确认后撤销); +90:作废(异常凭证)。 + +statusMsg + +报文示例: +{ + +状态描述 + +String(500) + +否 + +状态描述 + +票通数电发票接口文档 +"redApplySerialNo": "7bba2c42554a4e74b37ee099a64658c7", +"redBillNo": "31010422081000100081", +"processState": "81" +} + +2.28.4. + +业务错误码 + +错误码(code) + +含义说明(msg) + +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8003 + +找不到对应的发票 + +6006 + +该发票不可冲红 + +6005 + +该蓝票已经冲红,不可冲红 + +6003 + +该蓝票已经作废,不可冲红 + +8025 + +信息表申请流水号重复 + +2.29. 查看红字发票确认单 +2.29.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/querySpecialInvoic +eRedInfo.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/querySpecialInvoiceRedI +nfo.pt +字符编码 + +UTF-8 + +2.29.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +taxpayerNum + +纳税人识别号 + +redApplySerialNo + +红字确认单申请流 +水号 + +类型及长度 + +必填 + +说明 + +String(15-20) + +是 + +当前纳税人识别号 + +String(20) + +是 + +红字确认单申请流水号 + +票通数电发票接口文档 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"redApplySerialNo": "XXXX5678901234567891" +} + +2.29.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +纳税人识别号 + +String(15-20) + +是 + +当前纳税人识别号 + +redApplySerialNo + +红字确认单申请 + +String(20) + +是 + +红字确认单申请流水号 + +流水号 +account + +录入人员账号 + +String(50) + +否 + +录入人员账号 + +applySource + +申请来源 + +String(1) + +是 + +申请来源,确认单的发起方。 +0:销方申请; +1:购方申请。 + +redReason + +冲红原因 + +String(2) + +是 + +冲红原因。 +01:开票有误; +03:服务中止; +04:销售折让 + +processState + +红字发票确认单 + +String(2) + +是 + +状态 + +红字发票确认单状态 +0:审核中; +4:处理失败; +5:已冲红; +6:冲红中; +7:冲红失败; +81:无需确认; +82:销方录入待购方确认; +83:购方录入待销方确认; +84:购销双方已确认; +85:作废(销方录入购方否认) +; +86:作废(购方录入销方否认) +; +87:作废(超 72 小时未确认); +88:作废(发起方撤销); +89:作废(确认后撤销); +90:作废(异常凭证) + +statusMsg + +状态描述 + +String(500) + +否 + +状态描述 + +redInvConfirmationId + +红字确认单 id + +String(50) + +是 + +红字发票信息确认单 id + +redBillNo + +红字通知单编号 + +String(100) + +是 + +红字通知单编号 + +sellerTaxpayerNum + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(2-40) + +是 + +销售方名称 + +别号 +sellerName + +销售方名称 + +票通数电发票接口文档 +buyerTaxpayerNum + +购买方纳税人识 + +String(15-20) + +否 + +购买方纳税人识别号 + +别号 +buyerName + +购买方名称 + +String(2-40) + +是 + +购买方名称 + +invoiceAmount + +合计不含税金额 + +String(16) + +是 + +红 字 确 认 单合 计 不 含 税金 额 +(负数) + +invoiceTaxAmonut + +合计税额 + +String(16) + +是 + +红字确认单合计税额(负数) + +invoiceTotalAmount + +价税合计 + +String(16) + +是 + +红字确认单价税合计(负数) + +invoiceKind + +发票种类代码 + +String(2) + +否 + +发票种类代码。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票) + +blueInvoiceCode + +蓝票代码 + +String(10 或 12) + +否 + +蓝票代码 + +blueInvoiceNo + +蓝票号码 + +String(8) + +否 + +蓝票号码 + +blueAllEleInvNo + +蓝票数电发票号 + +String(20) + +是 + +蓝票数电发票号码 + +String(19) + +是 + +蓝票开票日期 + +码 +blueInvoiceDate + +蓝票开票日期 + +yyyy-MM-dd HH:mm:ss +redInvoiceSerailNo + +红票流水号 + +String(20) + +否 + +红票流水号 + +redInvoiceCode + +红票代码 + +String(10 或 12) + +否 + +红票代码 + +redInvoiceNo + +红票号码 + +String(8) + +否 + +红票号码 + +redAllEleInvNo + +红票数电发票号 + +String(20) + +否 + +红票数电发票号码 + +String(19) + +否 + +红票开票日期 + +码 +redInvoiceDate + +红票开票日期 + +yyyy-MM-dd HH:mm:ss +applyTime + +填开时间 + +String(19) + +是 + +红字信息表填开时间 +yyyy-MM-dd HH:mm:ss + +confirmName + +确认方名称 + +String(100) + +否 + +确认方名称 + +confirmDate + +确认日期 + +String(10) + +否 + +确认日期,格式 yyyy-MM-dd + +itemList + +项目信息 + +数组 + +是 + +itemName + +项目名称 + +String(1-100) + +是 + +项目名称 + +itemCode + +税收分类编码 + +String(1-50) + +是 + +税收分类编码 + +selfItemCode + +自行编码 + +String(1-50) + +否 + +自行编码 + +unitName + +项目单位 + +String(1-32) + +否 + +项目单位 + +specificationModel + +规格型号 + +String(1-40) + +否 + +规格型号 + +itemPrice + +项目单价 + +String(1-16) + +否 + +保留小数点后 8 位 + +taxIncludeFlag + +含税标志 + +String(1) + +否 + +0:不含税; +1:含税。 +默认 0 不含税。 + +itemQuantity + +项目数量 + +String(1-16) + +否 + +保留小数点后 8 位 + +itemAmount + +项目金额 + +String(1-16) + +是 + +保留小数点后 2 位 + +itemTaxRate + +税率 + +String(4) + +是 + +保留小数点后 2 位,例如 0.16 + +itemDeduction + +扣除额 + +String(1-16) + +否 + +保留小数点后 2 位 + +itemTaxAmount + +项目税额 + +String(1-16) + +是 + +保留小数点后 2 位 + +benefitsFlag + +优惠政策标识 + +String(1) + +是 + +0:不享受优惠; + +票通数电发票接口文档 +1:享受优惠 +zeroTaxRateFlag + +零税率标识 + +String(1) + +否 + +空:非零税率, +0:出口零税, +1:免税, +2:不征税, +3:普通零税率 + +addedTaxSpecial + +增值税特殊管理 + +String(50) + +否 + +增值税特殊管理 + +orderNo + +项目序号 + +String(11) + +是 + +用来表示项目的先后顺序 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"redApplySerialNo": "XXXX5678901234567891", +"applySource": "0", +"redReason": "01", +"processState": "84", +"redInvConfirmationId": "7bba2c42554a4e74b37ee099a64658c7", +"redBillNo": "31010422081000100081", +"sellerTaxpayerNum": "91XXXXXXXXXXXXX31", +"sellerName": "北京 XXXXXXXXXX 公司", +"buyerTaxpayerNum": "91XXXXXXXXXXXXX32", +"buyerName": "北京 XXXXXXXXXX 公司", +"invoiceAmount": "-43.26", +"invoiceTaxAmonut": "-1.54", +"invoiceTotalAmount": "-44.80", +"invoiceKind": "81", +"blueAllEleInvNo": "12345678901212345678", +"blueInvoiceDate": "2022-09-28 08:15:54", +"confirmName": "北京 XXXXXXXXXX 公司", +"confirmDate": "2022-10-19", +"itemList": [{ +"itemName": "*油料*喜之郎果冻", +"unitName": "袋", +"specificationModel": "葡萄+苹果 200g", +"itemPrice": "1.72413793", +"taxIncludeFlag": "0", +"itemQuantity": "-1.00000000", +"itemAmount": "-1.72", +"itemTaxRate": "0.16", +"itemTaxAmount": "-0.28", +"itemCode": "1010103020000000000", +"benefitsFlag": "0", +"orderNo": "1" +}, { + +票通数电发票接口文档 +"itemName": "*谷物*天然酵母面包甜橙味", +"unitName": "袋", +"specificationModel": "70g", +"itemPrice": "5.90909091", +"taxIncludeFlag": "0", +"itemQuantity": "-1.00000000", +"itemAmount": "-5.91", +"itemTaxRate": "0.10", +"itemTaxAmount": "-0.59", +"itemCode": "1010101040000000000", +"benefitsFlag": "0", +"orderNo": "2" +}] +} + +2.29.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8026 + +红字确认单不存在 + +2.30. 开具红字数电发票 +2.30.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/issueRedSpecialInvoice. +pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/issueRedSpecialInvoice.pt + +字符编码 + +2.30.2. + +UTF-8 + +请求报文 + +请求参数-业务报文部分: + +备注 +POST 请求 + +票通数电发票接口文档 + +字段 + +名称 + +类型及长度 + +必填 + +说明 + +taxpayerNum + +纳税人识别号 + +String(15-20) + +是 + +开票方纳税人识别号 + +invoiceReqSerialNo + +发票请求流水号 + +String(20) + +是 + +红字发票请求流水号 4 位平台 +简称+16 位随机数,唯一 + +blueInvoiceCode + +蓝票代码 + +String(12) + +否 + +蓝票代码,冲红税控发票或数 +电纸质发票时必填 + +blueInvoiceNo + +蓝票号码 + +String(8) + +否 + +蓝票号码,冲红税控发票或数 +电纸质发票时必填 + +blueAllEleInvNo + +原数电发票号码 + +String(20) + +否 + +数电发票号码 + +redBillNo + +红字信息表编号 + +String(100) + +是 + +红字信息表编号 + +account + +开票人员账号 + +String(50) + +否 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,取确认单录入账号。 + +takerName + +收票人名称 + +String(1-10) + +否 + +收票人名称,长度校验规则为 +字符长度 + +takerTel + +收票人手机号 + +String(11) + +否 + +收票人手机号,长度校验规则 +为字符长度,若在票通平台设 +置了红字发票发送短信且企业 +有可用短信条数,填写该值则 +发送短信 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +收票人邮箱,长度校验规则为 +字符长度,若在票通平台设置 +了红字发票发送邮件,填写该 +值则发送邮件 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 +中会按照定义返回,长度校验 +规则为字符长度 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"blueAllEleInvNo": "12345678901212345678", +"redBillNo": "31010422081000100081", +"account": "185XXXXXXXX" +} + +2.30.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +票通数电发票接口文档 +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +4 位平台简称+16 位随机数 + +String + +否 + +不定长,Base64 字符串,电子 + +号 +qrCodePath + +二维码 url + +发票该值必传 +qrCode + +二维码图片 + +String + +否 + +Base64 字符串 + +扫码查看发票开票状态,二维 +码的内容是 qrCodePath,电子 +发票该值必传 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"qrCodePath": "xxxxxxxxxxxxx", +"qrCode": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} + +2.30.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +8004 + +找不到对应的开票企业信息,请检查税号 + +8005 + +找不到对应的税收分类编码,请参照税收分类编码表,检查编码号 + +8006 + +找不到对应的税率,请输入正确的税率 + +8007 + +折扣金额不可大于对应商品行金额/折扣金额和折扣率不匹配 + +8008 + +优惠政策不为空时,增值税特殊管理不能为空 + +8009 + +企业注册/修改中不能开票 + +8011 + +税额与税率不匹配 + +8012 + +差额开票抵扣金额过大,不能超过价税合计金额 + +8013 + +差额开票只允许单个商品行 + +8014 + +税率为 0 时,零税率标示必须选择 + +8015 + +不存在对应的零税率标示 + +8016 + +单价数量金额不匹配 + +8017 + +折扣税额和税率不匹配 + +8021 + +发票请求流水号已存在,请更换发票请求流水号 + +8040 + +该发票请求流水号正在处理中,请稍后查询开票结果 + +2.31. 红字发票确认单审核 +2.31.1. +项目 + +调用说明 +说明内容 + +备注 + +票通数电发票接口文档 +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +POST 请求 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/confirmRedInvConfirm +ation.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/confirmRedInvConfirmation +.pt +字符编码 + +UTF-8 + +2.31.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型及长度 + +必填 + +说明 + +taxpayerNum + +纳税人识别号 + +String(15-20) + +是 + +开票方纳税人识别号 + +redBillNo + +红字信息表编号 + +String(20) + +是 + +红字信息表编号 + +confirmType + +确认类型 + +String(12) + +是 + +确认类型。 +0:拒绝; +1:确认; + +account + +处理人员账号 + +String(50) + +否 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,取确认单录入账号。 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"redBillNo": "31010422081000100081", +"confirmType": "1", +"account": "185XXXXXXXX" +} + +2.31.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +纳税人识别号 + +String(15-20) + +是 + +开票方纳税人识别号 + +redBillNo + +红字信息表编号 + +String(20) + +否 + +红字信息表编号 + +confirmType + +确认类型 + +String(12) + +是 + +确认类型。 +0:拒绝; +1:确认; + +票通数电发票接口文档 +resultCode + +结果代码 + +String(4) + +是 + +获取结果代码。 +0000:确认成功。 +其他:确认失败,失败原因见 +resultMsg + +resultMsg + +结果描述 + +String(不定 + +是 + +确认结果描述 + +长) + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"redBillNo": "31010422081000100081", +"confirmType": "1", +"resultCode": "0000", +"resultMsg": "确认成功" +} + +2.31.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +2.32. 红字发票确认单撤销 +2.32.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/revokeRedInfoTable.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/revokeRedInfoTable.pt + +字符编码 + +UTF-8 + +2.32.2. + +请求报文 + +请求参数-业务报文部分: + +备注 +POST 请求 + +票通数电发票接口文档 + +字段 + +名称 + +类型及长度 + +必填 + +说明 + +taxpayerNum + +纳税人识别号 + +String(15-20) + +是 + +开票方纳税人识别号 + +redBillNo + +红字信息表编号 + +String(20) + +是 + +红字信息表编号 + +account + +撤销人员账号 + +String(50) + +否 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,取确认单录入账号。 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"redBillNo": "31010422081000100081", +"account": "185XXXXXXXX" +} + +2.32.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +纳税人识别号 + +String(6-20) + +是 + +纳税人识别号 + +redBillNo + +红字信息表编号 + +String(20) + +是 + +红字信息表编号 + +resultCode + +结果代码 + +String(4) + +是 + +结果代码。 +6666:撤销成功; +9999:撤销失败。详情见结果 +描述 + +resultMsg + +结果描述 + +不定长 + +是 + +结果描述 + +报文示例: +{ +"taxpayerNum": "500102192801051381", +"redBillNo": "31010422081000100081", +"resultCode": "6666", +"resultMsg": "撤销成功!" +} + +2.32.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +票通数电发票接口文档 +8022 + +企业已被禁用 + +2.33. 红字发票确认单查询(下载) +2.33.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/downloadSpecialInv +RedInfoTable.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/downloadSpecialInvRedIn +foTable.pt +字符编码 + +UTF-8 + +2.33.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型及长度 + +必填 + +说明 + +taxpayerNum + +纳税人识别号 + +String(15-20) + +是 + +当前纳税人识别号 + +businessSerialNo + +业务流水号 + +String(10-30) + +是 + +业务流水号 + +queryType + +查询类型 + +String(1) + +否 + +查询类型。 +0:增值税专用发票红字信息表 +下载; +1:数电发票确认单下载。 +默认增值税专用发票红字信息 +表。 + +reciprocalName + +对方纳税人名称 + +String(1-100) + +否 + +对方纳税人名称 + +applyDateStart + +填开日期起 + +String(8) + +是 + +填开日期起。格式 yyyyMMdd + +applyDateEnd + +填开日期至 + +String(8) + +是 + +填开日期至。格式 yyyyMMdd + +redBillRange + +下载范围 + +String(1) + +是 + +下载范围。 +0:全部(包括本企业填写和其 +他企业填写的红字确认单); +1:本企业填写的红字信息表 +(包括本企业作为销方和购方 +填写的红字确认单); +2:其他企业填写的红字信息表 +(其他企业作为购方和销方填 +写的红字确认单); + +票通数电发票接口文档 +redBillNo + +红字发票信息确认 + +String(20) + +否 + +红字发票信息确认单编号 + +单编号 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"businessSerialNo": "20210402165800000002", +"reciprocalName": "对方纳税人名称", +"applyDateStart": "20221001", +"applyDateEnd": "20221031", +"redBillRange": "0" +} + +2.33.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +纳税人识别号 + +String(15-20) + +是 + +当前纳税人识别号 + +businessSerialNo + +业务流水号 + +String(10-30) + +是 + +业务流水号 + +resultCode + +结果代码 + +String(4) + +是 + +结果代码。 +0000:发送红字确认单查询指 +令成功,请稍后根据业务流水 +号查询结果; +9999:查询失败,失败原因见 +resultMsg。 + +resultMsg + +结果信息描述 + +不定长 + +是 + +结果信息描述 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"businessSerialNo": "20210402165800000002", +"resultCode": "0000", +"resultMsg": "发送红字确认单查询指令成功,请稍后根据业务流水号查询结果!" +} + +2.33.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +票通数电发票接口文档 + +2.34. 获取红字发票确认单查询(下载)结果 +通过该接口查询专票红字确认单查询下载结果。 +该接口需要与 2.11 红字发票确认单查询 +(下 +载)配合使用。 + +2.34.1. 调用说明 +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/open +api/queryRedInfoTableDownloadResult.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/q +ueryRedInfoTableDownloadResult.pt +字符编码 + +2.34.2. + +UTF-8 + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +纳税人识别号 + +String(6-20) + +是 + +纳税人识别号 + +businessSerialNo + +业务流水号 + +String(10-30) + +是 + +业务流水号 + +报文示例: +{ +"taxpayerNum": "500102201007206608", +"businessSerialNo": "20210402165800000002" +} + +2.34.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +taxpayerNum + +纳税人识别号 + +String(15-20) + +是 + +当前纳税人识别号 + +businessSerialNo + +业务流水号 + +String(10-30) + +是 + +业务流水号 + +resultCode + +结果代码 + +String(4) + +是 + +结果代码。 + +票通数电发票接口文档 +0000:查询成功; +9999:查询失败,失败原因见 +resultMsg。 +resultMsg + +结果信息描述 + +不定长 + +是 + +结果信息描述 + +redInfoTableList + +红字信息表列表 + +数组 + +否 + +红字信息表列表 + +redApplySerialNo + +红字确认单申请 + +String(20) + +是 + +红字确认单申请流水号 + +流水号 +taxpayerNum + +纳税人识别号 + +String(15-20) + +是 + +纳税人识别号 + +account + +录入人员账号 + +String(50) + +否 + +录入人员账号 + +applySource + +申请来源 + +String(1) + +是 + +申请来源,确认单的发起方。 +0:销方申请; +1:购方申请。 + +redReason + +冲红原因 + +String(2) + +是 + +冲红原因。 +01:开票有误; +03:服务中止; +04:销售折让 + +processState + +红字发票确认单 + +String(2) + +是 + +状态 + +红字发票确认单状态 +4:处理失败; +5:已冲红; +6:冲红中; +7:冲红失败; +81:无需确认; +82:销方录入待购方确认; +83:购方录入待销方确认; +84:购销双方已确认; +85:作废(销方录入购方否认) +; +86:作废(购方录入销方否认) +; +87:作废(超 72 小时未确认); +88:作废(发起方撤销); +89:作废(确认后撤销); +90:作废(异常凭证); + +statusMsg + +状态描述 + +String(500) + +否 + +状态描述 + +redInvConfirmationId + +红字确认单 id + +String(50) + +是 + +红字发票信息确认单 id + +redBillNo + +红字通知单编号 + +String(100) + +是 + +红字通知单编号 + +sellerTaxpayerNum + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +别号 +sellerName + +销售方名称 + +String(2-40) + +是 + +销售方名称 + +buyerTaxpayerNum + +购买方纳税人识 + +String(15-20) + +否 + +购买方纳税人识别号 + +别号 +buyerName + +购买方名称 + +String(2-40) + +是 + +购买方名称 + +invoiceAmount + +合计不含税金额 + +String(16) + +是 + +红 字 确 认 单合 计 不 含 税金 额 +(负数) + +invoiceTaxAmonut + +合计税额 + +String(16) + +是 + +红字确认单合计税额(负数) + +invoiceTotalAmount + +价税合计 + +String(16) + +是 + +红字确认单价税合计(负数) + +票通数电发票接口文档 +invoiceKind + +发票种类代码 + +String(2) + +否 + +发票种类代码。 +81:电子发票(增值税专用发 +票) +82:电子发票(普通发票) + +blueInvoiceCode + +蓝票代码 + +String(10 或 12) + +否 + +蓝票代码 + +blueInvoiceNo + +蓝票号码 + +String(8) + +否 + +蓝票号码 + +blueAllEleInvNo + +蓝票数电发票号 + +String(20) + +是 + +蓝票数电发票号码 + +String(19) + +是 + +蓝票开票日期 + +码 +blueInvoiceDate + +蓝票开票日期 + +yyyy-MM-dd HH:mm:ss +redInvoiceSerailNo + +红票流水号 + +String(20) + +否 + +红票流水号 + +redInvoiceCode + +红票代码 + +String(10 或 12) + +否 + +红票代码 + +redInvoiceNo + +红票号码 + +String(8) + +否 + +红票号码 + +redAllEleInvNo + +红票数电发票号 + +String(20) + +否 + +红票数电发票号码 + +String(19) + +否 + +红票开票日期 + +码 +redInvoiceDate + +红票开票日期 + +yyyy-MM-dd HH:mm:ss +applyTime + +填开时间 + +String(19) + +是 + +红字信息表填开时间 +yyyy-MM-dd HH:mm:ss + +confirmName + +确认方名称 + +String(100) + +否 + +确认方名称 + +confirmDate + +确认日期 + +String(10) + +否 + +确认日期,格式 yyyy-MM-dd + +itemList + +项目信息 + +数组 + +是 + +itemName + +项目名称 + +String(1-100) + +是 + +项目名称 + +itemCode + +税收分类编码 + +String(1-50) + +是 + +税收分类编码 + +selfItemCode + +自行编码 + +String(1-50) + +否 + +自行编码 + +unitName + +项目单位 + +String(1-32) + +否 + +项目单位 + +specificationMod + +规格型号 + +String(1-40) + +否 + +规格型号 + +itemPrice + +项目单价 + +String(1-16) + +否 + +保留小数点后 8 位 + +taxIncludeFlag + +含税标志 + +String(1) + +否 + +0:不含税; + +el + +1:含税。 +默认 0 不含税。 +itemQuantity + +项目数量 + +String(1-16) + +否 + +保留小数点后 8 位 + +itemAmount + +项目金额 + +String(1-16) + +是 + +保留小数点后 2 位 + +itemTaxRate + +税率 + +String(4) + +是 + +保留小数点后 2 位,例如 0.16 + +itemDeduction + +扣除额 + +String(1-16) + +否 + +保留小数点后 2 位 + +itemTaxAmount + +项目税额 + +String(1-16) + +是 + +保留小数点后 2 位 + +benefitsFlag + +优惠政策标识 + +String(1) + +是 + +0:不享受优惠; +1:享受优惠 + +zeroTaxRateFlag + +零税率标识 + +String(1) + +否 + +空:非零税率, +0:出口零税, +1:免税, +2:不征税, +3:普通零税率 + +票通数电发票接口文档 +addedTaxSpecial + +增值税特殊管理 + +String(50) + +否 + +增值税特殊管理 + +orderNo + +项目序号 + +String(11) + +是 + +用来表示项目的先后顺序 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"businessSerialNo": "20210402165800000002", +"resultCode": "0000", +"resultMsg": "红字确认单查询成功!", +"redInfoTableList": [{ +"redApplySerialNo": "XXXX5678901234567891", +"taxpayerNum": "91XXXXXXXXXXXXX31", +"applySource": "0", +"redReason": "01", +"processState": "84", +"redInvConfirmationId": "7bba2c42554a4e74b37ee099a64658c7", +"redBillNo": "31010422081000100081", +"sellerTaxpayerNum": "91XXXXXXXXXXXXX31", +"sellerName": "北京 XXXXXXXXXX 公司", +"buyerTaxpayerNum": "91XXXXXXXXXXXXX32", +"buyerName": "北京 XXXXXXXXXX 公司", +"invoiceAmount": "-43.26", +"invoiceTaxAmonut": "-1.54", +"invoiceTotalAmount": "-44.80", +"invoiceKind": "81", +"blueAllEleInvNo": "12345678901212345678", +"blueInvoiceDate": "2022-09-28 08:15:54", +"confirmName": "北京 XXXXXXXXXX 公司", +"confirmDate": "2022-10-19", +"itemList": [{ +"itemName": "*油料*喜之郎果冻", +"unitName": "袋", +"specificationModel": "葡萄+苹果 200g", +"itemPrice": "1.72413793", +"taxIncludeFlag": "0", +"itemQuantity": "-1.00000000", +"itemAmount": "-1.72", +"itemTaxRate": "0.16", +"itemTaxAmount": "-0.28", +"itemCode": "1010103020000000000", +"benefitsFlag": "0", +"orderNo": "1" +}, { +"itemName": "*谷物*天然酵母面包甜橙味", + +票通数电发票接口文档 +"unitName": "袋", +"specificationModel": "70g", +"itemPrice": "5.90909091", +"taxIncludeFlag": "0", +"itemQuantity": "-1.00000000", +"itemAmount": "-5.91", +"itemTaxRate": "0.10", +"itemTaxAmount": "-0.59", +"itemCode": "1010101040000000000", +"benefitsFlag": "0", +"orderNo": "2" +}] +}] +} + +2.34.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能操作 + +8995 + +数据校验不通过(对应参考详细信息) + +2.35. 切换数电企业 +通过该接口切换一个数电账号下面的关联的企业(只支持同一个地区的切换,比如只能从 +四川的企业切换到四川的企业),开票过程中我们会根据当前数电账号绑定的企业的开票情况进 +行自动调度切换企业,广东(除深圳)、天津、浙江(除宁波)、湖北的企业无需使用这个接口 +做切换。调用该接口前建议调用 2.8.查询数电账号认证状态查询当前数电账号是否可以切换到 +当前企业。 +举个例子:目前数电账号 zhangsan 在四川省的企业甲做了登录认证,可以使用该接口切换 +到四川省的企业乙,无需在企业乙做登录认证,可以在企业乙使用数电账号 zhangsan 开票,开 +票过程中可能需要做风险认证。该接口的目的是减少同一个数电账号绑定了同一个地区的多家企 +业,在多个企业之间来回做登录认证。 + +2.35.1. 调用说明 +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/open + +备注 +POST 方式提交 + +票通数电发票接口文档 +api/changeEnterprise.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/c +hangeEnterprise.pt +字符编码 + +2.35.2. + +UTF-8 + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +纳税人识别号 + +String(15-20) + +是 + +纳税人识别号,要切换到的企 +业税号 + +account + +登录账户 + +String(50) + +是 + +电子税局登录账号 + +名称 + +长度 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823" +} + +2.35.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +别号 +account + +登录账户 + +String(50) + +是 + +电子税局登录账号 + +resultCode + +结果代码 + +String(4) + +是 + +结果代码 +0000:切换成功。 +其他:切换失败,失败原因见 +resultMsg + +resultMsg + +结果描述 + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823", +"resultCode": "0000", +"resultMsg": "数电企业切换成功" +} + +String(不定长) + +是 + +切换结果描述 + +票通数电发票接口文档 + +2.35.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能开票(企业尚未审核通过),请联系平台客服审核开 +通 + +8104 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8004 + +找不到对应的开票企业信息,请检查税号 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +8047 + +开票终端已到期,请联系客服续费 + +8052 + +当前终端已暂停开票,请联系销售人员 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +2.36. 初始化红字信息确认单 +通过该接口调用税局接口初始化红字确认单,将返回蓝票的入账状态、增值税用途代码、 +消费税用途代码以及蓝票剩余可冲红的明细和金额。企业可根据返回的初始化红字信息确认单选 +择编辑要冲红的发票明细(前提是该发票支持部分冲红)。如果不调用该接口,开具红票的时候 +票通系统将会自动调用该接口初始化红字信息确认单以校验开票数据。如果是全额冲红,建议使 +用 2.10 快捷冲红数电发票接口。 +该接口将要调用税局接口,需保持数电账号处于登录状态。 + +2.36.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/initRed +InvConfirmation.pt +正式地址: + +备注 +POST 方式提交 + +票通数电发票接口文档 +https://fpkj.vpiaotong.com/tp/openapi/initRedInvCo +nfirmation.pt +字符编码 + +UTF-8 + +2.36.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +taxpayerNum + +纳税人识别号 + +applySource + +申请来源 + +类型 + +必填 + +说明 + +String(15-20) + +是 + +纳税人识别号 + +String(1) + +否 + +申请来源。 +0:销售方; +1:购买方。 +默认 0 销售方。 + +invoiceCode + +税控蓝字发票代 + +String(10 + +码 + +12) + +或 + +否 + +需冲红原发票代码,冲红增值 +税发票管理系统开具的发票或 +数电纸票时必填 + +invoiceNo + +税控蓝字发票号 + +String(8) + +否 + +码 + +需冲红原发票号码,冲红增值 +税发票管理系统开具的发票或 +数电纸票时必填 + +blueAllEleInvNo + +蓝字数电票号码 + +String(20) + +否 + +需冲红蓝字数电票号码,冲红 +数电发票时必填 + +blueInvoiceDate + +蓝字发票开票日 + +String(8) + +否 + +期 + +蓝字发票开票日期,格式 +yyyyMMdd。冲红非票通平台开 +具的发票时必填 + +account + +电子税局登录账 + +String(50) + +号 + +否 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,取蓝票的开票账号, +若蓝票的开票账号不再使用, +取企业现有的。 + +报文示例: +{ +"taxpayerNum": "91511XXXXXXXXXXP0K", +"applySource": "0", +"blueAllEleInvNo": "24512000000013218818", +"account": "185XXXX2888" +} + +2.36.3. + +响应报文 + +票通数电发票接口文档 +响应参数-业务报文部分: +字段 +resultCode + +名称 +结果代码 + +类型 + +必填 + +说明 + +String(4) + +是 + +获取结果代码。 +0000:获取成功。 +其他:获取失败,失败原因 +见 resultMsg +以 下 字 段 只 有 resultCode +为 0000 的情况下必填的字 +段才会有值 + +resultMsg + +结果描述 + +String(不定长) + +是 + +确认结果描述 + +taxpayerNum + +纳税人识别号 + +String(15-20) + +是 + +纳税人识别号 + +applySource + +申请来源 + +String(1) + +否 + +申请来源。 +0:销售方; +1:购买方。 + +sellerTaxpayerNum + +销售方纳税人 + +String(15-20) + +是 + +销售方纳税人识别号 + +识别号 +sellerName + +销售方名称 + +String(2-40) + +是 + +销售方名称 + +buyerTaxpayerNum + +购买方纳税人 + +String(15-20) + +否 + +购买方纳税人识别号 + +识别号 +buyerName + +购买方名称 + +String(2-40) + +是 + +购买方名称 + +invoiceCode + +税控蓝字发票 + +String(10 或 12) + +否 + +需冲红原发票代码,冲红增 + +代码 + +值税发票 管理系统开具 的 +发票时必填 + +invoiceNo + +税控蓝字发票 + +String(8) + +否 + +号码 + +需冲红原发票号码,冲红增 +值税发票 管理系统开具 的 +发票时必填 + +blueAllEleInvNo + +蓝字数电票号 + +String(20) + +否 + +码 +blueInvoiceDate + +蓝字发票开票 + +红数电发票时必填 +String(8) + +是 + +日期 +blueInvoiceKind + +蓝字发票种类 + +需冲红蓝字数电票号码,冲 +蓝字发票开票日期, +格式 yyyy-MM-dd HH:mm:ss + +String(2) + +是 + +蓝字发票种类。 +10:增值税电子普通发票; +04:增值税纸质普通发票; +01:增值税纸质专用发票; +08:增值税电子专用发票; +81:数电票(增值税专用发 +票); +82:数电票(普通发票) + +blueInvAmount + +蓝字发票合计 + +String(20) + +是 + +保留小数点后 2 位 + +String(20) + +是 + +保留小数点后 2 位 + +String(20) + +是 + +保留小数点后 2 位 + +金额 +blueInvTaxAmount + +蓝字发票合计 +税额 + +blueInvTotalAmount + +蓝字发票价税 + +票通数电发票接口文档 +合计 +invoiceAmount + +红字冲销金额 + +String(20) + +是 + +保留小数点后 2 位 + +invoiceTaxAmount + +红字冲销税额 + +String(20) + +是 + +保留小数点后 2 位 + +invoiceTotalAmount + +红字冲销价税 + +String(20) + +是 + +保留小数点后 2 位 + +String(2) + +否 + +特殊票种。 + +合计 +specialInvoiceKind + +特殊票种 + +为空:普票发票; +00:普通发票; +08:成品油发票; +02:农产品收购发票; +03:建筑服务; +04:货物运输服务; +05:不动产销售服务; +06:不动产租赁服务; +09:旅客运输服务; +12:自产农产品销售; +20:农产品 +taxRateFlag + +税率标识 + +String(1) + +是 + +税率标识。 +0:普通征税; +2:差额征税; +3:差额征税-全额开票 + +vatPurposeCode + +增值税用途代 + +增值税用途代码 + +是 + +码 + +增值税用途代码。 +00:已勾选未确认; +01:已确认; +03:未勾选。 + +gstPurposeCode + +消费税用途代 + +消费税用途代码 + +是 + +码 + +消费税用途代码。 +00:未勾选; +01:已勾选。 + +entryStatusCode + +入账状态代码 + +入账状态代码 + +是 + +入账状态代码。 +00:未入账; +01:已入账。 + +itemList 红冲明细 +blueInvOrderNo + +蓝字发票明细 + +String(1-4) + +是 + +序号 + +蓝字发票明细序号,需要冲 +红的蓝字 发票开票项目 的 +序号,从 1 开始, + +goodsName + +项目名称 + +String(1-100) + +是 + +项目名称 + +taxClassificationCo + +税收分类编码 + +String(1-50) + +是 + +税收分类编码 + +specificationModel + +规格型号 + +String(1-40) + +否 + +规格型号 + +meteringUnit + +单位 + +String(1-20) + +否 + +票面信息 + +quantity + +数量 + +String(1-16) + +否 + +保留小数点后 8-13 位,数 + +de + +据来自税局 +includeTaxFlag + +含税标示 + +unitPrice + +单价 + +String(1) + +是 + +含税标示。0:不含税 + +String(1-16) + +否 + +保留小数点后 8-13 位,数 + +票通数电发票接口文档 +据来自税局 +invoiceAmount + +金额 + +String(20) + +是 + +保留小数点后 2 位 + +taxRateValue + +税率 + +String(4) + +是 + +票面信息,例:0.05 + +taxRateAmount + +税额 + +String(20) + +是 + +保留小数点后 2 位 + +preferentialPolicyF + +优惠政策标识 + +String(1-50) + +否 + +优惠政策标识。 + +lag + +0:不使用; +1:使用。 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +零税率标识。 +税率为 0 时该值必填。 +空:非零税率, +1:免税; +2:不征税; +3:普通零税率。 + +vatSpecialManage + +增值税特殊管 + +String(0-100) + +理 + +否 + +增值税特殊管理。 +preferentialPolicyFlag +优惠政策标识位 1 时必填 + +报文示例: +{ +"resultCode": "0000", +"resultMsg": "成功", +"taxpayerNum": "91511XXXXXXXXXXP0K", +"applySource": "0", +"sellerTaxpayerNum": "91511XXXXXXXXXXP0K", +"sellerName": "四川 XXXXXXXXX 分公司", +"buyerTaxpayerNum": "91511XXXXXXXXXXX00H", +"buyerName": "巴中市 XXXX 有限责任公司", +"blueAllEleInvNo": "24512000000013218818", +"blueInvoiceDate": "2024-01-22 09:51:59", +"blueInvoiceKind": "82", +"blueInvAmount": "800", +"blueInvTaxAmount": "0", +"blueInvTotalAmount": "800", +"invoiceAmount": "-800", +"invoiceTaxAmount": "0", +"invoiceTotalAmount": "-800", +"specialInvoiceKind": "00", +"taxRateFlag": "0", +"vatPurposeCode": "03", +"gstPurposeCode": "00", +"entryStatusCode": "00", +"itemList": [{ +"blueInvOrderNo": "1", +"taxClassificationCode": "3040201030000000000", + +票通数电发票接口文档 +"goodsName": "*信息技术服务*电子发票开具平台维护费", +"quantity": "-1.00000000", +"unitPrice": "800.00000000", +"includeTaxFlag": "0", +"invoiceAmount": "-800", +"taxRateValue": "0", +"taxRateAmount": "0", +"preferentialPolicyFlag": "1", +"zeroTaxFlag": "1", +"vatSpecialManage": "免税" +}] +} + +2.36.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8001 + +开票处理失败,开票企业不存在 + +8002 + +找不到对应的税号信息 + +8003 + +找不到对应的发票 + +8004 + +找不到对应的开票企业信息,请检查税号 + +6005 + +该蓝票已经冲红,不可冲红 + +6006 + +原发票不可冲红 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +2.37. 快捷冲红数电发票(全额冲红、部分冲红) +通过该接口快捷冲红数电发票,支持全额冲红和部分冲红,建议调用该接口之前先调用 +2.36 初始化红字信息确认单接口以获取可冲红的发票明细及金额。系统将会缓存红字确认 +单初始化信息 30 分钟。调用该冲红接口时,如果未查询到需冲红发票的红字确认单初始化 +信息,系统将自动初始化红字信息确认单,若初始化失败,将返回红字确认单尚未初始化的 +错误。 +规则: + +票通数电发票接口文档 + +● 对应数电蓝字发票带折扣行时,将折扣金额、税额分摊反映在对应的项目名称行上, +再和红字发票进行比对。 +● 蓝字增值税专用发票只能用增值税专用发票冲红;蓝字普通发票只能用普通发票进 +行冲红。 +● 当蓝字发票对应的“增值税优惠用途标签”为“待农产品全额加计扣除”或“已用 +于农产品全额加计扣除”的,必须全额红冲;“待农产品部分加计扣除”或“已用于农产品 +部分加计扣除”的,第一次红冲只能对未加计部分全额冲红或对这张蓝票全额红冲,第二次 +红冲仅允许对剩余部分(即已加计部分)全额红冲。 +● “冲红原因”为“开票有误”时,必须全额冲红,并且明细单价、金额、数量必须 +和蓝字发票保持一致。 +● “红冲原因”为“销货退回”时,只允许修改数量,不允许修改单价。 +● “红冲原因”为“服务中止”时,允许修改总金额和数量,不允许修改单价。 +● “红冲原因”为“销售折让”时,不能修改单价和数量,单价和数量置空,只允许 +修改金额。 +● 商品服务编码为以 1、2 开头的冲红原因不允许选择“服务中止”,商品服务编码为 +以 3 开头的冲红原因不允许选择“销售退回”,兼营销售则不对红字原因进行控制。 + +2.37.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/issueR +edAllInv.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/issueRedAll +Inv.pt +字符编码 + +UTF-8 + +2.37.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +票通数电发票接口文档 +taxpayerNum + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(20) + +是 + +红票请求流水号 4 位平台简称 + +别号 +invoiceReqSerialNo + +发票请求流水号 + ++16 位随机数 +invoiceCode + +发票代码 + +String(12) + +否 + +需冲红原发票代码,冲红增值 +税发票管理系统开具的发票或 +数电纸票时必填 + +invoiceNo + +发票号码 + +String(8) + +否 + +需冲红原发票号码,冲红增值 +税发票管理系统开具的发票或 +数电纸票时必填 + +blueAllEleInvNo + +原数电发票号码 + +String(20) + +否 + +需冲红原数电发票号码,冲红 +数电发票时必填 + +blueInvoiceDate + +蓝字发票开票日 + +String(8) + +否 + +期 + +蓝字发票开票日期,格式 +yyyyMMdd。冲红非票通平台开 +具的发票时必填 + +redReason + +冲红原因 + +String(1-100) + +是 + +冲红原因,不传默认 01 +01:开票有误 +02:销货退回 +03:服务中止 +04:销售折让 + +account + +电子税局登录账 + +String(50) + +否 + +号 + +电子税局登录账号(手机号或 +身份证号)。不传值取蓝票的, +如果没有查询到对应蓝票的数 +电账号,将优先取企业当前无 +需认证的数电账号 + +invoiceKind + +发票种类代码 + +String(2) + +否 + +开具红字发票种类。 +81:数电票(增值税专用发票) +82:数电票(普通发票)。 +默认蓝票的发票种类代码。 +此字段目的解决数电发票冲红 +增值税发票,只有企业不再使 +用增值税系统时才可以跨票种 +冲红。 +数电票(普通发票)可以冲红 +数电票(普通发票)、增值税 +电子普通发票、增值税纸质普 +通发票。 +数电票(增值税专用发票)可 +以冲红数电票(增值税专用发 +票)、增值税电子专用发票、 +增值税纸质专用发票。 +如果没填,冲红的是税控发票, +将根据普票冲红普票、专票冲 +红专票的规则赋值发票种类。 + +票通数电发票接口文档 +takerName + +收票人名称 + +String(1-10) + +否 + +收票人名称,长度校验规则为 +字符长度 + +takerTel + +收票人手机号 + +String(11) + +否 + +收票人手机号,长度校验规则 +为字符长度,若在票通平台设 +置了红字发票发送短信且企业 +有可用短信条数,填写该值则 +发送短信 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +收票人邮箱,长度校验规则为 +字符长度,若在票通平台设置 +了红字发票发送邮件,填写该 +值则发送邮件 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 +中会按照定义返回,长度校验 +规则为字符长度 + +开票项目列表信息(itemList) +itemList + +开票项目列表 + +数组或集合 + +是 + +blueInvOrderNo + +蓝字发票明细序 + +String(1-4) + +是 + +号 + +蓝字发票明细序号,需要冲红 +的蓝字发票开票项目的序号, +从 1 开始,取 2.36 初始化红字 +确认单返回的 +blueInvOrderNo。将使用该序 +号匹配对应的商品名称、税收 +分类编码、单价和税率,仅数 +量和金额、税额是可以变化的。 + +goodsName + +项目名称 + +String(1-100) + +否 + +票面信息,取蓝票的 + +taxClassificationCod + +税收分类编码 + +String(1-50) + +否 + +税收分类编码,取蓝票的 + +specificationModel + +规格型号 + +String(1-40) + +否 + +规格型号,取蓝票的 + +meteringUnit + +单位 + +String(1-20) + +否 + +票面信息,取蓝票的 + +quantity + +数量 + +16 位(精确到 8 + +否 + +票面信息,支持到小数点前 8 + +e + +位小数) + +位,数量只能为负数。成品油发 +票数量不能为空,数量和单价 +必须同时为空或同时不为空 + +includeTaxFlag + +含税标示 + +String(1) + +否 + +含税标示。 +0:不含税, +1:含税, +默认为 0 不含税 + +unitPrice + +单价 + +16 位(精确到 8 +位小数) + +否 + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税,单价只能为正数。 +成品油发票数量不能为空,数 +量和单价必须同时为空或同时 +不为空。如果蓝字发票含有折 + +票通数电发票接口文档 +扣金额的,单价为(原金额+ +折扣金额)除以数量 +invoiceAmount + +金额 + +10 位(精确到 2 + +是 + +位小数) + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税,金额只能为负数。 + +taxRateValue + +税率 + +4 位(精确到 2 + +是 + +位小数) +taxRateAmount + +税额 + +10 位(精确到 2 + +票面信息,例:0.05。取蓝票 +的 + +否 + +位小数) + +票面信息,如果不填写,默认 +会根据税率计算税额,如果填 +写此值会直接使用此税额。税 +额不能为正数 + +deductionAmount + +差额开票扣除额 + +10 位(精确到 2 + +否 + +位小数) +preferentialPolicyFl + +优惠政策标识 + +String(1-50) + +差额开票扣除额。蓝票为差额 +票此项需要传值。大于等于 0。 + +否 + +ag + +优惠政策标识。 +0:不使用, +1:使用 +零税率标识为 0、1、2 时该值 +必填 1。 +取蓝票的 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +零税率标识。 +税率为 0 时该值必填。 +空:非零税率, +1:免税, +2:不征税, +3:普通零税率。 +取蓝票的 + +vatSpecialManage + +增值税特殊管理 + +String(0-100) + +否 + +增值税特殊管理。 +preferentialPolicyFlag 优惠 +政策标识位 1 时必填,填免税、 +不征税。 +取蓝票的 + +报文示例: +被冲红的蓝票如下: + +票通数电发票接口文档 + +{ +"taxpayerNum": "98699536313822895951", +"invoiceReqSerialNo": "XXXX5678901234567891", +"blueAllEleInvNo": "24699500000006789129", +"redReason": "01", +"definedData": "自定义数据", +"itemList": [{ +"blueInvOrderNo": "1", +"goodsName": "稻谷 1", +"taxClassificationCode": "1070101010100000000", +"specificationModel": "规格 1", +"meteringUnit": "单位 1", +"quantity": "-1.00", +"includeTaxFlag": "0", +"unitPrice": "119.27", +"invoiceAmount": "-119.27", +"taxRateValue": "0.13", +"taxRateAmount": "-10.73" +}, +{ +"blueInvOrderNo": "2", +"goodsName": "稻谷 2", +"taxClassificationCode": "1070101010100000000", +"specificationModel": "规格 2", +"meteringUnit": "单位 2", +"quantity": "-1.00", +"includeTaxFlag": "0", +"unitPrice": "77.98", +"invoiceAmount": "-77.98", +"taxRateValue": "0.13", +"taxRateAmount": "-7.02" +}, + +票通数电发票接口文档 +{ +"blueInvOrderNo": "4", +"goodsName": "稻谷 3", +"taxClassificationCode": "1070101010100000000", +"specificationModel": "规格 3", +"meteringUnit": "单位 3", +"quantity": "-1.00", +"includeTaxFlag": "0", +"unitPrice": "59.63", +"invoiceAmount": "-59.63", +"taxRateValue": "0.13", +"taxRateAmount": "-5.37" +} +] +} + +2.37.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +4 位平台简称+16 位随机数 + +号 +qrCodePath + +二维码 url + +String + +是 + +不定长,Base64 字符串 + +qrCode + +二维码图片 + +String + +否 + +扫码打开查看发票开票状 + +Base64 +redApplySerialNo + +红字确认单申 + +态 +String(20) + +请流水号 + +否 + +红字确认单申请流水号,如 +果受理成功,该值必填。可 +以使用该字段调用 2.29.查 +看红字发票确认单接口查 +看红字确认单信息 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"qrCodePath": "XXXXXXXXXXXXXXXXXXXXXXXX", +"qrCode": "XXXXXXXXXXXXXXXXXXXXXXXX", +"redApplySerialNo": "P1234567891234567890" +} + +2.37.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +票通数电发票接口文档 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8001 + +开票处理失败,开票企业不存在 + +8002 + +找不到对应的税号信息 + +8003 + +找不到对应的发票 + +8004 + +找不到对应的开票企业信息,请检查税号 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +6003 + +对应的发票已作废或作废中 + +6005 + +该蓝票已经冲红,不可冲红 + +6006 + +原发票不可冲红 + +6015 + +红字发票合计金额只能为负数 + +6016 + +红字发票税额应小于等于 0 + +6017 + +“冲红原因”为“开票有误”时,必须全额冲红,并且明细单价、金额、 +数量必须和蓝字发票保持一致 + +6018 + +商品税收分类编码为以 1、2 开头的冲红原因不允许选择“服务中止” + +6019 + +商品税收分类编码为以 3 开头的冲红原因不允许选择“销售退回” + +6020 + +当蓝字发票对应的“增值税用途标签”为空、“消费税用途标签”为“未 +勾选库存”、“入账状态标签”为“未入账”或“已入账撤销”时,红字 +信息只允许销售方发起(除非蓝字发票为收购发票)且只能进行全额冲红 + +6021 + +“冲红原因”为“销货退回”或“服务终止”时,只允许修改数量,不允 +许修改单价 + +6022 + +红字发票开票项目明细金额绝对值不能大于对应蓝字发票明细金额 + +6023 + +红字发票开票项目税率要和对应蓝字发票开票项目税率保持一致 + +6024 + +红字发票开票项目明细税额绝对值不能大于对应蓝字发票明细税额 + +6025 + +对应蓝字发票明细数量不为空时红字发票开票项目明细数量不能为空 + +6026 + +红字发票开票项目明细数量绝对值不能大于对应蓝字发票明细数量 + +6027 + +初始化红字确认单失败(具体失败原因见实际返回的错误描述) + +2.38. 红字确认单申请(支持全额、部分;销方、购方申请) +通过该接口申请红字发票确认单,支持全额申请和部分金额申请,支持销售方申请和购 +买方申请。建议调用该接口之前先调用 2.36 初始化红字信息确认单接口以获取可冲红的发 +票明细及金额。系统将会缓存红字确认单初始化信息 30 分钟。调用该申请接口时,如果未 +查询到需冲红发票的红字确认单初始化信息,系统将自动初始化红字信息确认单,若初始化 + +票通数电发票接口文档 + +失败,将返回红字确认单尚未初始化的错误。 +规则: +● 对应数电蓝字发票带折扣行时,将折扣金额、税额分摊反映在对应的项目名称行上, +再和红字发票进行比对。 +● 蓝字增值税专用发票只能用增值税专用发票冲红;蓝字普通发票只能用普通发票进 +行冲红。 +● 当蓝字发票对应的“增值税优惠用途标签”为“待农产品全额加计扣除”或“已用 +于农产品全额加计扣除”的,必须全额红冲;“待农产品部分加计扣除”或“已用于农产品 +部分加计扣除”的,第一次红冲只能对未加计部分全额冲红或对这张蓝票全额红冲,第二次 +红冲仅允许对剩余部分(即已加计部分)全额红冲。 +● “冲红原因”为“开票有误”时,必须全额冲红,并且明细单价、金额、数量必须 +和蓝字发票保持一致。 +● “红冲原因”为“销货退回”时,只允许修改数量,不允许修改单价。 +● “红冲原因”为“服务中止”时,允许修改总金额和数量,不允许修改单价。 +● “红冲原因”为“销售折让”时,不能修改单价和数量,单价和数量置空,只允许 +修改金额。 +● 商品服务编码为以 1、2 开头的冲红原因不允许选择“服务中止”,商品服务编码为 +以 3 开头的冲红原因不允许选择“销售退回”,兼营销售则不对红字原因进行控制。 +● 购方申请的情况:只有购方做了用途确认,才允许购买方发起红冲。 + +2.38.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/redInv +ConfirmationApply.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/redInvConfi +rmationApply.pt + +字符编码 + +UTF-8 + +备注 +POST 方式提交 + +票通数电发票接口文档 + +2.38.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 +纳税人识别号 + +类型 + +必填 + +说明 + +String(15-20) + +是 + +纳税人识别号,销方申请填销 +方税号,购方申请填购方税号 + +redApplySerialNo + +红字确认单申请 + +String(20) + +是 + +流水号 + +红字确认单申请流水号,4 位 +平台简称+16 位随机数,可以 +用来调用查询接口查询确认单 +信息 + +invoiceCode + +发票代码 + +String(12) + +否 + +需冲红原发票代码,冲红增值 +税发票管理系统开具的发票或 +数电纸票时必填 + +invoiceNo + +发票号码 + +String(8) + +否 + +需冲红原发票号码,冲红增值 +税发票管理系统开具的发票或 +数电纸票时必填 + +blueAllEleInvNo + +原数电发票号码 + +String(20) + +否 + +需冲红原数电发票号码,冲红 +数电发票时必填 + +blueInvoiceDate + +蓝字发票开票日 + +String(8) + +否 + +期 + +蓝字发票开票日期,格式 +yyyyMMdd。冲红非票通平台开 +具的发票时必填,购方申请必 +填 + +redReason + +冲红原因 + +String(1-100) + +是 + +冲红原因,不传默认 01 +01:开票有误 +02:销货退回 +03:服务中止 +04:销售折让 + +applySource + +申请来源 + +String(1) + +是 + +申请来源。 +0:销方申请; +1:购方申请。 + +account + +电子税局登录账 +号 + +String(50) + +否 + +电子税局登录账号(手机号或 +身份证号)。不传值取蓝票的, +如果没有查询到对应蓝票的数 +电账号,将优先取企业当前无 +需认证的数电账号 + +票通数电发票接口文档 +invoiceKind + +发票种类代码 + +String(2) + +否 + +开具红字发票种类。 +81:数电票(增值税专用发票) +82:数电票(普通发票)。 +默认蓝票的发票种类代码。 +此字段目的解决数电发票冲红 +增值税发票,只有企业不再使 +用增值税系统时才可以跨票种 +冲红。 +数电票(普通发票)可以冲红 +数电票(普通发票)、增值税 +电子普通发票、增值税纸质普 +通发票。 +数电票(增值税专用发票)可 +以冲红数电票(增值税专用发 +票)、增值税电子专用发票、 +增值税纸质专用发票。 +如果没填,冲红的是税控发票, +将根据普票冲红普票、专票冲 +红专票的规则赋值发票种类。 + +开票项目列表信息(itemList) +itemList + +开票项目列表 + +数组或集合 + +是 + +blueInvOrderNo + +蓝字发票明细序 + +String(1-4) + +是 + +号 + +蓝字发票明细序号,需要冲红 +的蓝字发票开票项目的序号, +从 1 开始,取 2.36 初始化红字 +确认单返回的 +blueInvOrderNo。将使用该序 +号匹配对应的商品名称、税收 +分类编码、单价和税率,仅数 +量和金额、税额是可以变化的。 + +goodsName + +项目名称 + +String(1-100) + +否 + +票面信息,取蓝票的 + +taxClassificationCod + +税收分类编码 + +String(1-50) + +否 + +税收分类编码,取蓝票的 + +specificationModel + +规格型号 + +String(1-40) + +否 + +规格型号,取蓝票的 + +meteringUnit + +单位 + +String(1-20) + +否 + +票面信息,取蓝票的 + +quantity + +数量 + +16 位(精确到 8 + +否 + +票面信息,支持到小数点前 8 + +e + +位小数) + +位,数量只能为负数。成品油发 +票数量不能为空,数量和单价 +必须同时为空或同时不为空 + +includeTaxFlag + +含税标示 + +String(1) + +否 + +含税标示。 +0:不含税, +1:含税, +默认为 0 不含税 + +unitPrice + +单价 + +16 位(精确到 8 +位小数) + +否 + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 + +票通数电发票接口文档 +标示 includeTaxFlag,定义此 +字段为含税,单价只能为正数。 +成品油发票数量不能为空,数 +量和单价必须同时为空或同时 +不为空。如果蓝字发票含有折 +扣金额的,单价为(原金额+ +折扣金额)除以数量 +invoiceAmount + +金额 + +10 位(精确到 2 + +是 + +位小数) + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税,金额只能为负数。 + +taxRateValue + +税率 + +4 位(精确到 2 + +是 + +位小数) +taxRateAmount + +税额 + +10 位(精确到 2 + +票面信息,例:0.05。取蓝票 +的 + +否 + +位小数) + +票面信息,如果不填写,默认 +会根据税率计算税额,如果填 +写此值会直接使用此税额。税 +额不能为正数 + +deductionAmount + +差额开票扣除额 + +10 位(精确到 2 + +否 + +位小数) +preferentialPolicyFl + +优惠政策标识 + +String(1-50) + +差额开票扣除额。蓝票为差额 +票此项需要传值。大于等于 0。 + +否 + +ag + +优惠政策标识。 +0:不使用, +1:使用 +零税率标识为 0、1、2 时该值 +必填 1。 +取蓝票的 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +零税率标识。 +税率为 0 时该值必填。 +空:非零税率, +1:免税, +2:不征税, +3:普通零税率。 +取蓝票的 + +vatSpecialManage + +增值税特殊管理 + +String(0-100) + +否 + +增值税特殊管理。 +preferentialPolicyFlag 优惠 +政策标识位 1 时必填,填免税、 +不征税。 +取蓝票的 + +报文示例: +被冲红的蓝票如下: + +票通数电发票接口文档 + +{ +"taxpayerNum": "98699536313822895951", +"redApplySerialNo": "XXXX5678901234567891", +"blueAllEleInvNo": "24699500000006789129", +"redReason": "01", +"itemList": [{ +"blueInvOrderNo": "1", +"goodsName": "稻谷 1", +"taxClassificationCode": "1070101010100000000", +"specificationModel": "规格 1", +"meteringUnit": "单位 1", +"quantity": "-1.00", +"includeTaxFlag": "0", +"unitPrice": "119.27", +"invoiceAmount": "-119.27", +"taxRateValue": "0.13", +"taxRateAmount": "-10.73" +}, +{ +"blueInvOrderNo": "2", +"goodsName": "稻谷 2", +"taxClassificationCode": "1070101010100000000", +"specificationModel": "规格 2", +"meteringUnit": "单位 2", +"quantity": "-1.00", +"includeTaxFlag": "0", +"unitPrice": "77.98", +"invoiceAmount": "-77.98", +"taxRateValue": "0.13", +"taxRateAmount": "-7.02" +}, +{ + +票通数电发票接口文档 +"blueInvOrderNo": "4", +"goodsName": "稻谷 3", +"taxClassificationCode": "1070101010100000000", +"specificationModel": "规格 3", +"meteringUnit": "单位 3", +"quantity": "-1.00", +"includeTaxFlag": "0", +"unitPrice": "59.63", +"invoiceAmount": "-59.63", +"taxRateValue": "0.13", +"taxRateAmount": "-5.37" +} +] +} + +2.38.3. + +响应报文 + +响应参数-业务报文部分: + +字段 + +名称 + +类型 + +必填 + +说明 + +redApplySerialNo + +红字信息表申请流 + +String(20) + +是 + +可以用来调用查询接口查询红 + +水号 + +字信息表单号 + +redBillNo + +红字信息表编号 + +String(20) + +否 + +申请成功此值必填 + +processState + +红字确认单状态 + +String(2) + +否 + +红字发票信息确认单确认状。 +0:审核中,调用 2.29 查看红 +字发票确认单接口查询结果; +4:处理失败; +5:已冲红; +6:冲红中; +7:冲红失败; +81:无需确认,可冲红; +82:销方录入待购方确认; +83:购方录入待销方确认; +84:购销双方已确认,可冲红; +85:作废(销方录入购方否认) +; +86:作废(购方录入销方否认) +; +87:作废(超 72 小时未确认); +88:作废(发起方撤销); +89:作废(确认后撤销); +90:作废(异常凭证)。 + +statusMsg + +报文示例: +{ + +状态描述 + +String(500) + +否 + +状态描述 + +票通数电发票接口文档 +"redApplySerialNo": "XXXX5678901234567891", +"redBillNo": "31010422081000100081", +"processState": "81" +} + +2.38.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8001 + +开票处理失败,开票企业不存在 + +8002 + +找不到对应的税号信息 + +8003 + +找不到对应的发票 + +8004 + +找不到对应的开票企业信息,请检查税号 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +6003 + +对应的发票已作废或作废中 + +6005 + +该蓝票已经冲红,不可冲红 + +6006 + +原发票不可冲红 + +6015 + +红字发票合计金额只能为负数 + +6016 + +红字发票税额应小于等于 0 + +6017 + +“冲红原因”为“开票有误”时,必须全额冲红,并且明细单价、金额、 +数量必须和蓝字发票保持一致 + +6018 + +商品税收分类编码为以 1、2 开头的冲红原因不允许选择“服务中止” + +6019 + +商品税收分类编码为以 3 开头的冲红原因不允许选择“销售退回” + +6020 + +当蓝字发票对应的“增值税用途标签”为空、“消费税用途标签”为“未 +勾选库存”、“入账状态标签”为“未入账”或“已入账撤销”时,红字 +信息只允许销售方发起(除非蓝字发票为收购发票)且只能进行全额冲红 + +6021 + +“冲红原因”为“销货退回”或“服务终止”时,只允许修改数量,不允 +许修改单价 + +6022 + +红字发票开票项目明细金额绝对值不能大于对应蓝字发票明细金额 + +6023 + +红字发票开票项目税率要和对应蓝字发票开票项目税率保持一致 + +6024 + +红字发票开票项目明细税额绝对值不能大于对应蓝字发票明细税额 + +6025 + +对应蓝字发票明细数量不为空时红字发票开票项目明细数量不能为空 + +6026 + +红字发票开票项目明细数量绝对值不能大于对应蓝字发票明细数量 + +6027 + +初始化红字确认单失败(具体失败原因见实际返回的错误描述) + +票通数电发票接口文档 + +2.39. 机动车车辆信息查询 +通过该接口查询机车架号是否可以开具发票。在开具机动车销售统一发票前,需要调用 +此接口来补全字段。 + +2.39.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryM +otorVehicleInfo.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/queryMotorV +ehicleInfo.pt +字符编码 + +UTF-8 + +2.39.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +别号 +account + +数电账号 + +String(50) + +否 + +数电账号,电子税局登录账号 + +vehicleIdentificatio + +车辆识别代号/车 + +String(17) + +是 + +车辆识别代号/车架号码 + +nNo + +架号码 + +invoiceKind + +发票种类 + +String(2) + +是 + +发票种类 +81:数电票(增值税专用发票) +87:数电纸质发票(机动车销 +售统一发票) + +报文示例: +{ +"taxpayerNum": "91XX5678901234567891", +"account": "185XXXXXX", +"vehicleIdentificationNo": "LURTAVBA4FA078503", +"invoiceKind": "87" +} + +票通数电发票接口文档 + +2.39.3. + +响应报文 + +响应参数-业务报文部分: + +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识别 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(17) + +是 + +车辆识别代号/车架号码 + +String(4) + +是 + +结果代码。 + +号 +vehicleIdentificati + +车辆识别代号/车 + +onNo + +架号码 + +resultCode + +结果代码 + +0000:查询成功。 +其他:查询失败,失败原因 +见 resultMsg 。 resultCode +为 0000 时以下必填字段才会 +有值。 +resultMsg + +结果描述 + +String(不定长) + +是 + +查询结果描述 + +productionPlace + +产地 + +String(10) + +否 + +产地 + +tonnage + +车辆吨位 + +String(10) + +否 + +车辆吨位 + +vehicleTypeCode + +车辆类型代码 + +String(100) + +是 + +车辆类型代码 + +vehicleSourceCode + +车辆来源代码 + +String(100) + +否 + +车辆来源代码 + +brandAndModel + +厂牌型号 + +String(140) + +是 + +厂牌型号 + +engineNo + +发动机号码 + +String(160) + +是 + +发动机号码 + +qualifiedNo + +合格证号 + +String(50) + +是 + +合格证号 + +ledgerVehicleIdNoUu + +机动车台账车辆识 + +String(100) + +是 + +机动车台账车辆识别代号 + +id + +别代号 uuid + +importCertificateNo + +进口证明书号 + +String(16) + +否 + +进口证明书号 + +manufacturer + +生产企业名称 + +String(100) + +是 + +生产企业名称 + +commercialInspectio + +商检单号 + +String(60) + +否 + +商检单号 + +dutyPaidProofNo + +完税凭证号码 + +String(100) + +否 + +完税凭证号码 + +maxCapacity + +限乘人数 + +String(11) + +是 + +限乘人数 + +taxAuthorityNo + +主管税务机关代码 + +String(20) + +是 + +主管税务机关代码 + +taxAuthorityName + +主管税务机关名称 + +String(100) + +是 + +主管税务机关名称 + +uuid + +nNo + +报文示例: +{ +"taxpayerNum": "91XX5678901234567891", +"vehicleIdentificationNo": "LURTAVBA4FA078503", +"resultCode": "0000", +"resultMsg": "查询成功", +"productionPlace": "", +"tonnage": "0", +"vehicleTypeCode": "乘用车及客车", + +票通数电发票接口文档 +"vehicleSourceCode": "00", +"brandAndModel": "红旗牌 HQ00000001", +"engineNo": "AAPGPGM000588", +"qualifiedNo": "YU633PA000078588", +"ledgerVehicleIdNoUuid": "1702998000745035859", +"importCertificateNo": "", +"manufacturer": "中国第一汽车集团有限公司", +"commercialInspectionNo": "", +"dutyPaidProofNo": "", +"maxCapacity": "5", +"taxAuthorityNo": "144011105", +"taxAuthorityName": "国家税务总局广州市白云区税务局石井税务所" +} + +2.39.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +3001 + +销售方电子税局账号尚未在发票平台完成绑定,请先在发票平台完成绑定 + +2.40. 开具数电专票机动车特定业务 +通过该接口开具数电票(增值税专用发票)机动车特定业务。 + +2.40.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/invoiceBlue.pt + +字符编码 + +UTF-8 + +备注 +POST 方式提交 + +票通数电发票接口文档 + +2.40.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +是 + +销售方纳税人识别号,长度校 + +开票基本信息 +taxpayerNum + +销售方纳税人 + +String(15-20) + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +号 + +4 位平台简称+16 位随机数,长 +度校验规则为字符长度,只能 +包括英文字母或数字,唯一 + +invoiceIssueKindCode + +开具发票种类 + +String(1-2) + +是 + +开具发票种类。 +81:数电票(增值税专用发票) + +buyerName + +购买方名称 + +String(1-100) + +是 + +票面信息,发票抬头,无默认 +长度校验规则为 GBK 字节长 +度,不能包含<>字符 + +buyerTaxpayerNum + +购买方纳税人 + +String(15-20) + +否 + +识别号 + +票面信息,无默认,长度校验规 +则为字符长度,只能包括大写 +英文字母或数字。开具农产品 +收购发票时必填。 + +naturalPersonFlag + +是否开具给自 + +String(1) + +否 + +然人 + +是否开具给自然人。默认 0。 +0:否; +1:是。电子税局勾选“是”时 +的提示:请您确认受票方为自 +然人,并在纳税人识别号档次 +填入“自然人纳税人识别号” +(自然人受票方可登录个人所 +得税 APP 查看“自然人纳税人 +识别号”),该张发票将在受票 +方自然人个人票夹中展示。 + +buyerAddress + +购买方地址 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度, +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerTel + +购买方电话 + +String(1-20) + +否 + +票面信息,无默认,长度校验 +规则为字符长度,只能是数字、 +中英文括号、中英文横杠。 +buyerAddress、buyerTel 两个 +字段总长度不超 100 位 GBK 字 +节。 + +buyerBankName + +购买方开户行 + +String(1-100) + +否 + +票面信息,无默认,长度校验 +规则为 GBK 字节长度。 + +票通数电发票接口文档 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 +buyerBankAccount + +购买方银行账 + +String(1-50) + +否 + +号 + +票面信息,无默认,长度校验 +规则为字符长度。 +buyerBankName、 +buyerBankAccount 两个字段总 +长度不超 100 位 GBK 字节。 + +sellerAddress + +销货方地址 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的企业开票 +地址电话信息(如有多条取默 +认的,没有默认取最后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerTel + +销货方电话 + +String(1-20) + +否 + +票面信息,长度校验规则为字 +符长度,只能是数字、中英文 +括号、中英文横杠。如果不传 +值,取平台企业开票设置中的 +企业开票地址电话信息(如有 +多条取默认的,没有默认取最 +后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerBankName + +销货方开户行 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的开户行及 +银行账号信息(如有多条取默 +认的,如有一条则使用该开户 +行及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +sellerBankAccount + +销货方银行账 +号 + +String(1-50) + +否 + +票面信息,长度校验规则为字 +符长度。如果不传值,取平台 +企业开票设置中的开户行及银 +行账号信息(如有多条取默认 +的,如有一条则使用该开户行 +及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +票通数电发票接口文档 +showBuyerBank + +是否显示购方 + +String(1) + +否 + +是否显示购方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 + +showSellerBank + +是否显示销方 + +String(1) + +否 + +是否显示销方开户行及账号到 + +开户行及账号 + +发票备注,默认 0 不显示 + +到发票备注 + +0:不显示 +1:显示 +注:最终版式文件销方银行账 +户取税局维护的开户行及账号 + +account + +开票人税局账 + +String(50) + +否 + +号 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,随机取已在票通平台 +登记的账号。 + +casherName + +收款人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的收款人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该收款人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +reviewerName + +复核人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的复核人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该复核人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度。若需要显示到备注,需 +要在企业版开票设置进行设置 + +takerName + +收票人名称 + +String(1-10) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerTel + +收票人手机号 + +String(11) + +否 + +客户信息,长度校验规则为字 +符长度 + +takerEmail + +收票人邮箱 + +String(4-50) + +否 + +客户信息,填写后,票通会给 +客户发送发票邮件,不填写则 +不发送,长度校验规则为字符 +长度 + +specialInvoiceKind + +特殊票种 + +String(2) + +是 + +14:机动车 + +remark + +备注 + +String(0-200) + +否 + +备注,校验规则字符长度 + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 + +票通数电发票接口文档 +中会按照定义返回,长度校验 +规则为字符长度 +tradeNo + +订单号 + +String(0-200) + +否 + +订单号,长度校验规则为字符 +长度。如果没有传值,票通平 +台默认使用发票请求流水号 +invoiceReqSerialNo 赋值 + +shopNum + +门店编号 + +String(6-20) + +否 + +门店编号,取值集团版门店编 +号,没有则不用填写。只允许 +字母、数字 + +开票项目列表信息(itemList) +itemList + +开票项目列表 + +goodsName + +货物名称 + +数组或集合 + +是 + +最大 2000 行(包括折扣行)。 + +String(1-100) + +是 + +票面信息,此项不填写时默认 +为 taxClassificationCode 对 +应的名称,长度校验规则为字 +符长度 + +taxClassificationCo + +对应税收分类 + +de + +编码 + +specificationModel + +车辆识别代号/ + +String(1-50) + +是 + +统一编码表的信息,长度校验 +规则为字符长度 + +String(17) + +是 + +车架号码 + +车辆识别代号/车架号码,建议 +使用 2.39 机动车车辆信息查 +询接口查询该车辆识别代号/ +车架号码是否可开票 + +meteringUnit + +单位 + +String(1-20) + +是 + +单位,只能填“辆” + +quantity + +数量 + +1位 + +是 + +只能为 1 + +includeTaxFlag + +含税标示 + +String(1) + +否 + +0:不含税,1:含税,默 +认为 0 不含税 + +unitPrice + +单价 + +16 位(精确到 8 + +是 + +票面信息,支持到小数点前 8 + +位小数) + +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税 + +invoiceAmount + +金额 + +10 位(精确到 2 + +是 + +位小数) + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +taxRateValue + +税率 + +4 位(精确到 2 位 + +是 + +票面信息,例:0.13 + +否 + +票面信息,如果不填写,默认 + +小数) +taxRateAmount + +税额 + +10 位(精确到 2 +位小数) + +会根据税率计算税额,如果填 +写此值会直接使用此税额,可 +通过含税标示 +includeTaxFlag,定义此字段 +为含税 + +discountAmount + +折扣金额 + +10 位(精确到 2 +位小数) + +否 + +该商品行的折扣金额,传负数。 +差额征税-差额开票不支持折 +扣。 + +票通数电发票接口文档 +discountTaxRateAmou + +折扣税额 + +10 位(精确到 2 + +nt + +否 + +位小数) + +deductionAmount + +差额开票扣除 + +10 位(精确到 2 + +金额 + +票面信息,使用折扣金额进行 +计算 + +否 + +位小数) + +差额开票扣除金额,填写时为 +差额开票。如果不填, +variableLevyProofList 有数 +据,将按差额开票。 + +preferentialPolicyF + +优惠政策标识 + +String(1-50) + +否 + +lag + +空:不使用,1:使用 +零税率标识为 1、2 时该值必填 +1。增值税特殊管理有值时该值 +必填 1。 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +税率为 0 时该值必填。空:非 +零税率, +1:免税, +2:不征税, +3:普通零税率 + +vatSpecialManage + +增值税特殊管 + +String(0-100) + +否 + +理 + +preferentialPolicyFlag 优惠 +政策标识位 1 时必填,填免税、 +不征税、简易征收等 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceIssueKindCode ": "81", +"buyerName": "北京 XXXXX 技术有限公司", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"buyerAddress": "北京市海淀区 XXXXXX15 号 5 层", +"buyerTel": ""010-1234567", +"buyerBankName": "XXXX 银行", +"buyerBankAccount": "9878XXXXXX45666", +"sellerAddress": "北京海淀区 XXX 路 15 号", +"sellerTel": "010-7654321", +"sellerBankName": "XXXXX 银行", +"sellerBankAccount": "6217XXXXXXX0678", +"remark": "备注", +"tradeNo": "DEMO1111111111", +"definedData": "自定义数据", +"account": "185XXXXXXXX", +"takerName": "XXX", +"takerTel": "XXXXXXX", +"takerEmail": "XXXXX@qq.com", +"itemList": [{ +"goodsName": "超豪华小汽车", +"taxClassificationCode": "1090305010800000000", + +票通数电发票接口文档 +"specificationModel": "LVA12345678901234", +"meteringUnit": "辆", +"quantity": "1", +"includeTaxFlag": "1", +"unitPrice": "1000000", +"invoiceAmount": "1000000.00", +"taxRateValue": "0.13" +} +] +} + +2.40.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +invoiceReqSerialNo + +发票请求流水 + +类型 + +必填 + +说明 + +String(20) + +是 + +4 位平台简称+16 位随机数 + +String + +否 + +不定长,Base64 字符串,电子 + +号 +qrCodePath + +二维码 url + +发票该值必传 +qrCode + +二维码图片 + +String + +否 + +Base64 字符串 + +扫码查看发票开票状态,二维 +码的内容是 qrCodePath,电子 +发票该值必传 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890", +"qrCodePath": "xxxxxxxxxxxxx", +"qrCode": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} + +2.40.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8993 + +开票请求处理失败(对应参考返回的详细信息) + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +票通数电发票接口文档 +8004 + +找不到对应的开票企业信息,请检查税号 + +8005 + +找不到对应的税收分类编码,请参照税收分类编码表,检查编码号 + +8006 + +找不到对应的税率,请输入正确的税率 + +8007 + +折扣金额不可大于对应商品行金额/折扣金额和折扣率不匹配 + +8008 + +优惠政策不为空时,增值税特殊管理不能为空 + +8009 + +企业注册/修改中不能开票 + +8011 + +税额与税率不匹配 + +8012 + +差额开票抵扣金额过大,不能超过价税合计金额 + +8013 + +差额开票只允许单个商品行 + +8014 + +税率为 0 时,零税率标示必须选择 + +8015 + +不存在对应的零税率标示 + +8016 + +单价数量金额不匹配 + +8017 + +折扣税额和税率不匹配 + +8021 + +发票请求流水号已存在,请更换发票请求流水号 + +8040 + +该发票请求流水号正在处理中,请稍后查询开票结果 + +2.41. 查询纸质未开发票代码号码 +通过该接口查询当前未开具的纸票代码号码。 + +2.41.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryP +aperInvStock.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/queryPaperI +nvStock.pt +字符编码 + +UTF-8 + +2.41.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(50) + +否 + +数电账号,电子税局登录账号 + +别号 +account + +数电账号 + +票通数电发票接口文档 +invoiceKind + +发票种类代码 + +String(2) + +是 + +发票种类代码 +87:数电纸质发票(机动车销 +售统一发票) + +报文示例: +{ +"taxpayerNum": "91XX5678901234567891", +"account": "185XXXXXX", +"invoiceKind": "87" +} + +2.41.3. + +响应报文 + +响应参数-业务报文部分: + +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(4) + +是 + +结果代码。 + +别号 +resultCode + +结果代码 + +0000:查询成功。 +其他:查询失败,失败原因见 +resultMsg 。 resultCode 为 +0000 时以下必填字段才会有 +值。 +resultMsg + +结果描述 + +String(不定长) + +是 + +查询结果描述 + +invoiceType + +发票种类 + +String(12) + +是 + +发票种类 + +invoiceCode + +发票代码 + +String(12) + +是 + +发票代码 + +invoiceNo + +发票号码 + +String(8) + +是 + +发票号码 + +invoiceSurplusNum + +剩余份数 + +String(8) + +是 + +剩余份数 + +报文示例: +{ +"taxpayerNum": "91XX5678901234567891", +"resultCode": "0000", +"resultMsg": "查询成功", +"invoiceType": "03", +"invoiceCode": "123456789012", +"invoiceNo": "10000001", +"invoiceSurplusNum": "99" +} + +票通数电发票接口文档 + +2.41.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +3001 + +销售方电子税局账号尚未在发票平台完成绑定,请先在发票平台完成绑定 + +2.42. 开具数电纸质发票(机动车销售统一发票) +调用该接口之前,需要调用 2.39 机动车车辆信息查询接口根据车辆识别代号/车架号码 +查询车辆相关信息。 + +2.42.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/invoiceBlue.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/invoiceBlue.pt +字符编码 + +2.42.2. + +UTF-8 + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +类型 + +必填 + +说明 + +是 + +销售方纳税人识别号,长度校 + +开票基本信息 +taxpayerNum + +销售方纳税人 + +String(15-20) + +识别号 + +验规则为字符长度,只能包括 +大写英文字母或数字 + +invoiceReqSerialNo + +发票请求流水 + +String(20) + +是 + +号 + +4 位平台简称+16 位随机数,长 +度校验规则为字符长度,只能 +包括英文字母或数字,唯一 + +invoiceIssueKindCode + +开具发票种类 + +String(1-2) + +是 + +开具发票种类。 +87:数电纸质发票(机动车销 + +票通数电发票接口文档 +售统一发票) +buyerName + +购买方名称 + +String(1-100) + +是 + +票面信息,发票抬头,无默认 +长度校验规则为 GBK 字节长 +度,不能包含<>字符 + +buyerTaxpayerNum + +购买方纳税人 + +String(15-20) + +否 + +识别号 + +票面信息,无默认,长度校验规 +则为字符长度,只能包括大写 +英文字母或数字 + +naturalPersonFlag + +是否开具给自 + +String(1) + +否 + +然人 + +是否开具给自然人。默认 0。 +0:否; +1:是。电子税局勾选“是”时 +的提示:请您确认受票方为自 +然人,并在纳税人识别号档次 +填入“自然人纳税人识别号” +(自然人受票方可登录个人所 +得税 APP 查看“自然人纳税人 +识别号”),该张发票将在受票 +方自然人个人票夹中展示。 + +sellerAddress + +销货方地址 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的企业开票 +地址电话信息(如有多条取默 +认的,没有默认取最后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerTel + +销货方电话 + +String(1-20) + +否 + +票面信息,长度校验规则为字 +符长度,只能是数字、中英文 +括号、中英文横杠。如果不传 +值,取平台企业开票设置中的 +企业开票地址电话信息(如有 +多条取默认的,没有默认取最 +后一条)。 +sellerAddress、sellerTel 两 +个字段总长度不超 100 位 GBK +字节。 + +sellerBankName + +销货方开户行 + +String(1-100) + +否 + +票面信息, +长度校验规则为 GBK +字节长度。如果不传值,取平 +台企业开票设置中的开户行及 +银行账号信息(如有多条取默 +认的,如有一条则使用该开户 +行及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +票通数电发票接口文档 +sellerBankAccount + +销货方银行账 + +String(1-50) + +否 + +号 + +票面信息,长度校验规则为字 +符长度。如果不传值,取平台 +企业开票设置中的开户行及银 +行账号信息(如有多条取默认 +的,如有一条则使用该开户行 +及银行账号信息)。 +sellerBankName、 +sellerBankAccount 两个字段 +总长度不超 100 位 GBK 字节。 + +account + +开票人税局账 + +String(50) + +否 + +号 + +电子税局登录账号(手机号或 +身份证号),必须是通过 2.1 +接口进行用户登记的账号。如 +果不填,随机取已在票通平台 +登记的账号。 + +casherName + +收款人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的收款人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该收款人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度 + +reviewerName + +复核人名称 + +String(1-16) + +否 + +票面信息,如果不填写,则默 +认开票设置中的复核人(如果 +设置了多条,取默认的,没有 +默认的则为空;如果有一条, +则使用该复核人);如果填入 +则根据填入信息填入票面信 +息,长度校验规则为 GBK 字节 +长度 + +remark + +备注 + +String(0-200) + +否 + +备注,固定值“一车一票” + +definedData + +自定义数据 + +String(0-200) + +否 + +自定义数据,在发票推送接口 +中会按照定义返回,长度校验 +规则为字符长度 + +tradeNo + +订单号 + +String(0-200) + +否 + +订单号,长度校验规则为字符 +长度。如果没有传值,票通平 +台默认使用发票请求流水号 +invoiceReqSerialNo 赋值 + +shopNum + +门店编号 + +String(6-20) + +否 + +门店编号,取值集团版门店编 +号,没有则不用填写。只允许 +字母、数字 + +开票项目列表信息(itemList),只能一行 +itemList + +开票项目列表 + +数组或集合 + +是 + +只能一行 + +票通数电发票接口文档 +goodsName + +货物名称 + +String(1-100) + +是 + +票面信息,此项不填写时默认 +为 taxClassificationCode 对 +应的名称,长度校验规则为字 +符长度 + +taxClassificationCo + +对应税收分类 + +de + +编码 + +quantity + +数量 + +String(1-50) + +是 + +统一编码表的信息,长度校验 +规则为字符长度 + +16 位(精确到 8 + +否 + +位小数) + +票面信息,支持到小数点前 8 +位。可以不填写,填写的话必 +须是 1 + +includeTaxFlag + +含税标示 + +unitPrice + +单价 + +String(1) + +否 + +0:不含税,1:含税,默 +认为 0 不含税 + +16 位(精确到 8 + +否 + +票面信息,支持到小数点前 8 + +位小数) + +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +invoiceAmount + +金额 + +10 位(精确到 2 + +是 + +位小数) + +票面信息,支持到小数点前 8 +位,默认为不含税,可通过含税 +标示 includeTaxFlag,定义此 +字段为含税。 + +taxRateValue + +税率 + +4 位(精确到 2 位 + +是 + +票面信息,例:0.13 + +否 + +票面信息,如果不填写,默认 + +小数) +taxRateAmount + +税额 + +10 位(精确到 2 +位小数) + +会根据税率计算税额,如果填 +写此值会直接使用此税额,可 +通过含税标示 +includeTaxFlag,定义此字段 +为含税 + +preferentialPolicyF + +优惠政策标识 + +String(1-50) + +否 + +lag + +空:不使用,1:使用 +零税率标识为 0、1、2 时该值 +必填 1。 + +zeroTaxFlag + +零税率标识 + +String(1) + +否 + +税率为 0 时该值必填。空:非 +零税率, +1:免税, +2:不征税, +3:普通零税率 + +vatSpecialManage + +增值税特殊管 + +String(0-100) + +否 + +理 + +preferentialPolicyFlag 优惠 +政策标识位 1 时必填,填免税、 +不征税 + +机动车信息(motorVehicle)开具机动车销售统一发票时必填 +motorVehicle + +机动车信息 + +Object + +是 + +机动车销售统一发票时必填 + +brandAndModel + +厂牌型号 + +String(140) + +是 + +厂牌型号,校验 GBK 字节长度 + +productionPlace + +产地 + +String(10) + +否 + +产地,校验 GBK 字节长度 + +qualifiedNo + +合格证号 + +String(50) + +否 + +合格证号,校验 GBK 字节长度 + +importCertificateNo + +进口证明书号 + +String(16) + +否 + +进口证明书号,校验 GBK 字节 + +票通数电发票接口文档 +长度 +commercialInspectio + +商检单号 + +String(60) + +否 + +商检单号,校验 GBK 字节长度 + +发动机号码 + +String(160) + +否 + +发动机号码,校验 GBK 字节长 + +nNo +engineNo + +度 +vehicleIdentificati + +车辆识别代号/ + +onNo + +车架号码 + +taxAuthorityNo + +主管税务机关 + +String(17) + +是 + +车辆识别代号/车架号码 + +String(20) + +是 + +主管税务机关代码 + +String(100) + +是 + +主管税务机关名称 + +String(100) + +否 + +完税凭证号码,校验 GBK 字节 + +代码 +taxAuthorityName + +主管税务机关 +名称 + +dutyPaidProofNo + +完税凭证号码 + +长度 +tonnage + +车辆吨位 + +String(10) + +否 + +车辆吨位,校验 GBK 字节长度 + +maxCapacity + +限乘人数 + +String(11) + +否 + +限乘人数,校验 GBK 字节长度 + +报文示例: +{ +"taxpayerNum": "91XXXXXXXXXXXXX31", +"invoiceReqSerialNo": "XXXX5678901234567890", +"invoiceIssueKindCode ": "82", +"buyerName": "北京 XXXXX 技术有限公司", +"buyerTaxpayerNum": "9211XXXXXXX365M", +"sellerAddress": "北京海淀区 XXX 路 15 号", +"sellerTel": "010-7654321", +"sellerBankName": "XXXXX 银行", +"sellerBankAccount": "6217XXXXXXX0678", +"remark": "一车一票", +"tradeNo": "DEMO1111111111", +"definedData": "自定义数据", +"account": "185XXXXXXXX", +"itemList": [{ +"goodsName": "电动汽车", +"taxClassificationCode": "1090309000000000000", +"includeTaxFlag": "1", +"invoiceAmount": "300000.00", +"taxRateValue": "0.13" +}], +"motorVehicle": { +"brandAndModel": "红旗牌 HQ00000001", +"productionPlace": "", +"qualifiedNo": "YU633PA000078588", +"importCertificateNo": "", +"commercialInspectionNo": "", + +票通数电发票接口文档 +"engineNo": "AAPGPGM000588", +"vehicleIdentificationNo": "LURTAVBA4FA078503", +"taxAuthorityNo": "144011105", +"taxAuthorityName": "国家税务总局广州市白云区税务局石井税务所", +"dutyPaidProofNo": "", +"tonnage": "", +"maxCapacity": "5" +} +} + +2.42.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +invoiceReqSerialNo + +发票请求流水 + +类型 + +必填 + +String(20) + +是 + +说明 +4 位平台简称+16 位随机数 + +号 + +报文示例: +{ +"invoiceReqSerialNo": "XXXX5678901234567890" +} + +2.42.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +9997 + +纳税人识别号无效 + +8993 + +开票请求处理失败(对应参考返回的详细信息) + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8004 + +找不到对应的开票企业信息,请检查税号 + +8005 + +找不到对应的税收分类编码,请参照税收分类编码表,检查编码号 + +8006 + +找不到对应的税率,请输入正确的税率 + +8007 + +折扣金额不可大于对应商品行金额/折扣金额和折扣率不匹配 + +8008 + +优惠政策不为空时,增值税特殊管理不能为空 + +8009 + +企业注册/修改中不能开票 + +8011 + +税额与税率不匹配 + +8012 + +差额开票抵扣金额过大,不能超过价税合计金额 + +8013 + +差额开票只允许单个商品行 + +8014 + +税率为 0 时,零税率标示必须选择 + +8015 + +不存在对应的零税率标示 + +8016 + +单价数量金额不匹配 + +票通数电发票接口文档 +8021 + +发票请求流水号已存在,请更换发票请求流水号 + +8040 + +该发票请求流水号正在处理中,请稍后查询开票结果 + +3001 + +销售方电子税局账号尚未在发票平台完成绑定,请先在发票平台完成绑定 + +2.43. 获取数电账号绑定微信二维码 +通过该接口获取数电账号绑定微信的二维码,扫描二维码可以将数电账号和微信号绑 +定,该数电账号开票时若触发登录预警或风险预警,将会通过微信公众号消息通知绑定该数 +电账号的微信用户,用户可以通过微信公众号完成登录或风险认证。一个数电账号只可以使 +用一个微信号绑定,最后扫描绑定二维码的微信用户将绑定该数电账号,之前绑定的将会自 +动解绑。 + +2.43.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/getTax +BureauAccountBindQrcode.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/getTaxBurea +uAccountBindQrcode.pt +字符编码 + +UTF-8 + +2.43.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(50) + +是 + +数电账号,电子税局登录账号 + +别号 +account + +数电账号 + +报文示例: +{ +"taxpayerNum": "91XX5678901234567891", +"account": "185XXXXXX" +} + +票通数电发票接口文档 + +2.43.3. + +响应报文 + +响应参数-业务报文部分: + +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(50) + +是 + +电子税局登录账号(手机号或 + +别号 +account + +登录账户 + +身份证号) +resultCode + +结果代码 + +String(4) + +是 + +获取结果代码。 +0000:获取成功。 +其他:获取失败,失败原因见 +resultMsg + +resultMsg + +结果描述 + +String(不定长) + +是 + +获取结果描述 + +qrcodeImgUrl + +二维码图片 url + +String(不定长) + +否 + +二维码图片 url,base64 字符 +串,访问该 url 直接可以返回 +二维码图片 + +qrcodePath + +二维码内容 + +String(不定长) + +否 + +二维码内容,base64 字符串, +需要自行转为二维码 + +failureTime + +失效时间 + +String(不定长) + +否 + +失效时间。 +格式 yyyy-MM-dd HH:mm:ss + +报文示例: +{ +"taxpayerNum": "500102192801051381", +"account": "185XXXXXX", +"resultCode": "0000", +"resultMsg": "获取成功", +"qrcodePath": "aHR0cDovL3dlaXhpbi5xcS5jb20vcS8wMnNMRTB0enY0YjYtMWQzQXUxQWNI", +"qrcodeImgUrl": +"aHR0cHM6Ly9tcC53ZWl4aW4ucXEuY29tL2NnaS1iaW4vc2hvd3FyY29kZT90aWNrZXQ9Z1FGVjhEd0FBQUFBQUFB +QUFTNW9kSFJ3T2k4dmQyVnBlR2x1TG5GeExtTnZiUzl4THpBeWMweEZNSFI2ZGpSaU5pMHhaRE5CZFRGQlkwZ0FBZ +1JEVi1kakF3UUFqU2NB", +"failureTime": "2023-03-25 20:11:04" +} + +2.43.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +票通数电发票接口文档 +9997 + +纳税人识别号无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +3001 + +销售方电子税局账号尚未在发票平台完成绑定,请先在发票平台完成绑定 + +2.44. 查询建筑服务跨区域涉税事项报验管理编号信息 +如果开具建筑服务发票时,跨地市标识为“是”时,需要填写跨区域涉税事项报验管理 +编号,通过该接口可以查询跨区域涉税事项报验管理编号信息。 + +2.44.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 方式提交 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryC +rossRegionTaxRelatedManageInfo.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/queryCrossR +egionTaxRelatedManageInfo.pt +字符编码 + +UTF-8 + +2.44.2. + +请求报文 + +请求参数-业务报文部分: +必 +字段 + +名称 + +类型 + +说明 +填 + +taxpayerNum + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +别号 +account + +数电账号 + +String(50) + +否 + +数电账号,电子税局登录账号 + +taxDeclareManageNumPrefix + +跨区域涉税事项 + +String(0-50) + +否 + +跨区域涉税事项报验管理编号 + +报验管理编号前 + +前缀 + +缀 +taxDeclareManageNumYear + +跨区域涉税事项 + +String(0-50) + +否 + +报验管理编号年 + +跨区域涉税事项报验管理编号 +年份 + +份 +taxDeclareManageNumNo + +跨区域涉税事项 +报验管理编号具 +体的号 + +String(0-50) + +否 + +跨区域涉税事项报验管理编号 +具体的号 + +票通数电发票接口文档 +counterpartyTaxpayerNum + +合同对方纳税人 + +String(6-20) + +否 + +合同对方纳税人识别号 + +String(0-100) + +否 + +合同对方纳税人名称 + +String(6) + +否 + +跨区域经营地行政区划代码。 + +识别号 +counterpartyTaxpayerName + +合同对方纳税人 +名称 + +crossRegionCode + +跨区域经营地行 +政区划代码 + +taxRelatedEffectiveStart + +例:北京市东城区:110101 + +跨区域涉税事项 + +String(19) + +否 + +有效期起 +taxRelatedEffectiveEnd + +跨区域涉税事项有效期起。 +格式 yyyy-MM-dd HH:mm:ss + +跨区域涉税事项 + +String(19) + +否 + +有效期至 + +跨区域涉税事项有效期至。 +格式 yyyy-MM-dd HH:mm:ss + +projectName + +工程项目名称 + +String(0-100) + +否 + +工程项目名称 + +entryDateStart + +报告开具期限起 + +String(19) + +是 + +报告开具期限起。 +格式 yyyy-MM-dd HH:mm:ss + +entryDateEnd + +报告开具期限止 + +String(19) + +是 + +报告开具期限止。 +格式 yyyy-MM-dd HH:mm:ss + +pageNum + +页码 + +int + +是 + +页码 + +pageSize + +每页数据条数 + +int + +是 + +每页数据条数。数值:10、20、 +50 + +税局截图: + +报文示例: +{ +"taxpayerNum": "98699536313822895951", +"account": "185XXXXXX", +"taxRelatedEffectiveStart": "2024-08-27 00:00:00", +"taxRelatedEffectiveEnd": "2024-08-27 23:59:59", +"entryDateStart": "2024-08-27 00:00:00", +"entryDateEnd": "2024-08-27 23:59:59", +"pageNum": "1", +"pageSize": "10" +} + +2.44.3. + +响应报文 + +响应参数-业务报文部分: + +字段 +taxpayerNum + +名称 + +类型 + +必填 + +销售方纳税人识 + +String(15-20) + +是 + +说明 +销售方纳税人识别号 + +票通数电发票接口文档 +别号 +resultCode + +结果代码 + +String(4) + +是 + +结果代码。 +0000:查询成功。 +其他:查询失败,失败原因见 +resultMsg 。 resultCode 为 +0000 时以下必填字段才会有 +值。 + +resultMsg + +结果描述 + +String(不定长) + +是 + +查询结果描述 + +total + +总条数 + +int + +否 + +总条数 + +pageNum + +页码 + +int + +否 + +页码 + +pageSize + +每页数据条数 + +int + +否 + +每页数据条数 + +taxRelatedManageInfoList 跨区域涉税事项报验管理信息明细 +taxRelatedManageInfoL + +跨区域涉税事项 + +ist + +报验管理信息明 + +数组 + +否 + +跨区域涉税事项报验管理信 +息明细 + +细 +taxDeclareManageNum + +跨区域涉税事项 + +String(0-50) + +否 + +报验管理编号 + +跨区域涉税事项报验管理编 +号 + +registrationNum + +登记序号 + +String(0-50) + +否 + +登记序号 + +streetAdminCode + +街道行政代码 + +String(0-50) + +否 + +街道行政代码 + +streetAdminName + +街道行政名称 + +String(0-50) + +否 + +街道行政名称 + +crossRegionCode + +跨区域经营地行 + +String(6) + +否 + +跨区域经营地行政区划代码 + +政区划代码 +crossRegionBusiAddr + +跨区域经营地址 + +String(0-200) + +否 + +跨区域经营地址 + +projectName + +工程项目名称 + +String(0-100) + +否 + +工程项目名称 + +updateTime + +修改日期 + +String(19) + +否 + +修改日期。 +格式 yyyy-MM-dd HH:mm:ss + +validFlag + +有效标志 + +String(1) + +否 + +有效标志 +Y:有效; +N:无效 + +destroyFlag + +作废标志 + +String(1) + +否 + +作废标志 +N:未作废; +Y:已作废 + +taxRelatedEffectiveStart + +跨区域涉税事项 + +String(19) + +否 + +有效期起 +taxRelatedEffectiveEnd + +跨区域涉税事项 + +格式 yyyy-MM-dd HH:mm:ss +String(19) + +有效期止 + +报文示例: +{ +"taxpayerNum": "98699536313822895951", +"resultCode": "0000", +"resultMsg": "查询成功", +"total": "1", +"pageNum": "1", + +跨区域涉税事项有效期起。 + +否 + +跨区域涉税事项有效期止。 +格式 yyyy-MM-dd HH:mm:ss + +票通数电发票接口文档 +"pageSize": "10", +"taxRelatedManageInfoList": [{ +"taxDeclareManageNum": "郑税一分 税跨报 〔2023〕 31000 号", +"registrationNum": "10114101000241901001", +"crossRegionCode": "410491", +"crossRegionBusiAddr": "平顶山市 XXXX 处理厂", +"updateTime": "2023-05-06 11:56:21", +"validFlag": "Y", +"destroyFlag": "N", +"taxRelatedEffectiveStart": "2025-04-28 00:00:00", +"taxRelatedEffectiveEnd": "2025-06-30 00:00:00" +}] +} + +2.44.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +3001 + +销售方电子税局账号尚未在发票平台完成绑定,请先在发票平台完成绑定 + +2.45. 查询数电账号列表 +根据该接口查询某个税号绑定的数电账号信息。 + +2.45.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/listTaxBureauAccou +nt.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/listTaxBureauAccount.pt + +字符编码 + +UTF-8 + +备注 +POST 方式提交 + +票通数电发票接口文档 + +2.45.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +taxpayerNum + +纳税人识别号 + +类型 + +必填 + +说明 + +String(15-20) + +是 + +销售方纳税人识别号,长度校 +验规则为字符长度,只能包括 +大写英文字母或数字 + +account + +数电账号 + +String(50) + +否 + +电子税局登录账号 + +类型 + +必填 + +说明 + +报文示例: +{ +"taxpayerNum": "9151XXXXXXXX652" +} + +2.45.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +数组或列表 +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +account + +登录账户 + +String(50) + +是 + +电子税局登录账号 + +name + +姓名 + +String(20) + +是 + +姓名 + +identityType + +登录身份类型 + +String(2) + +是 + +登录身份类型。 +01:法定代表人 +02:财务负责人 +03:办税员 +04:涉税服务人员 +05:管理员 +07:领票人 +09:开票员 +99:其他人员 + +operationProposed + +操作建议 + +String(1) + +是 + +操作建议,注意:该操作建议是 +根据账号状态和不同地区的登 +录方式区分出来的,并非和账 +号状态一一对应。 +0:无需认证; +1:需扫码认证; +2:需扫码或短信认证; +3:需短信认证 + +authStatus + +账号状态 + +String(1) + +是 + +账号状态 +0:无需认证; + +票通数电发票接口文档 +1:风险认证; +2:登录认证; +3:风险+登录认证 +注:目前是根据调用税局返回 +的结果判断的账号状态,风险 +认证只有开具发票才能得知。 +switchable + +可切换状态 + +String(1) + +是 + +当前企业是否可切换。 +0:不可切换; +1:可切换,代表该数电账号在 +该地区的其他企业有登录状 +态,当前企业不需要做登录认 + +证,如果有开票,会自动调度 +切换到当前企业开票。 +wechatUserBindSta + +是否绑定微信公众号 + +String(1) + +是 + +是否绑定票通云服务微信公众 +号。 + +tus + +0:否; +1:是。 +lastAuthSuccTime + +最新认证成功(登录认 + +String(19) + +否 + +证或风险认证)时间 + +最新认证成功(登录认证或风 +险认证)时间 +格式 yyyy-MM-dd HH:mm:ss + +loginAuthStatus + +登录认证状态 + +String(1) + +是 + +登录认证状态。 +0:未登录; +1:已登录 + +lastLoginAuthTime + +最新登录认证时间 + +String(19) + +否 + +最新登录认证时间。 +格式 yyyy-MM-dd HH:mm:ss + +riskAuthStatus + +风险认证状态 + +String(1) + +是 + +风险认证状态。 +0:未认证; +1:已认证 + +lastRiskAuthTime + +最新风险认证时间 + +String(19) + +否 + +最新风险认证时间。 +格式 yyyy-MM-dd HH:mm:ss + +报文示例: +[{ +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6823", +"name": "张三", +"identityType": "09", +"authStatus": "0", +"operationProposed": "0", +"switchable": "0", +"wechatUserBindStatus": "1", +"lastAuthSuccTime": "2024-08-26 18:12:18", +"loginAuthStatus": "1", +"lastLoginAuthTime": "2024-07-25 19:18:32", + +票通数电发票接口文档 +"riskAuthStatus": "1", +"lastRiskAuthTime": "2024-08-26 18:12:18" +}, { +"taxpayerNum": "9151XXXXXXXX652", +"account": "189XXXX6828", +"name": "李四", +"identityType": "09", +"authStatus": "2", +"operationProposed": "2", +"switchable": "0", +"wechatUserBindStatus": "0", +"lastAuthSuccTime": "2024-08-26 18:12:18", +"loginAuthStatus": "0", +"lastLoginAuthTime": "2024-07-25 19:18:32", +"riskAuthStatus": "1", +"lastRiskAuthTime": "2024-08-26 18:12:18" +}] + +2.45.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8004 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +8022 + +企业已被禁用 + +8009 + +企业注册/修改中不能开票(企业尚未审核通过),请联系平台客服审核开通 + +3001 + +电子税局账号尚未在平台登记,请先在平台登记 + +2.46. 退出电子税局登录 +通过该接口退出发票平台的电子税局登录,解决短信登录后使用电子税局时被挤掉线的 +问题。登录电子税局前,需要使用该接口先将平台的电子税局登录状态退出。 + +2.46.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/logout +Etax.pt +正式地址: + +备注 +POST 方式提交 + +票通数电发票接口文档 +https://fpkj.vpiaotong.com/tp/openapi/logoutEtax. +pt +字符编码 + +UTF-8 + +2.46.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(50) + +是 + +数电账号,电子税局登录账号 + +类型 + +必填 + +说明 + +String(15-20) + +是 + +纳税人识别号 + +String(50) + +是 + +电子税局登录账号(手机号或 + +别号 +account + +数电账号 + +报文示例: +{ +"taxpayerNum": "91XX5678901234567891", +"account": "185XXXXXX" +} + +2.46.3. + +响应报文 + +响应参数-业务报文部分: + +字段 + +名称 + +taxpayerNum + +纳税人识别号 + +account + +数电账号 + +身份证号) +resultCode + +结果代码 + +String(4) + +是 + +退出结果代码。 +0000:退出成功。 +其他:退出失败,失败原因见 +resultMsg + +resultMsg + +结果描述 + +String(不定长) + +报文示例: +{ +"taxpayerNum": "500102192801051381", +"account": "185XXXXXX", +"resultCode": "0000", +"resultMsg": "退出成功" +} + +是 + +退出结果描述 + +票通数电发票接口文档 + +2.46.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +9997 + +纳税人识别号无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +3001 + +销售方电子税局账号尚未在发票平台完成绑定,请先在发票平台完成绑定 + +2.47. 查询企业信息 +2.47.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: + +备注 +POST 请求 + +http://fpkj.testnw.vpiaotong.cn/tp/openapi/getEnterpriseInfo.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/getEnterpriseInfo.pt +字符编码 + +UTF-8 + +接口描述 + +通过该接口查询企业信息 + +2.47.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型及长度 + +必填 + +销售方纳税人识 + +String(15-20) + +是 + +别号 + +报文示例: +{ +"taxpayerNum": "91400108MA0043365M15" +} + +说明 +销售方纳税人识别号 + +票通数电发票接口文档 + +2.47.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +String(4-100) + +是 + +销售方企业名称,长度校验规 + +别号 +enterpriseName + +销售方企业名称 + +则为 GBK 字节长度 +regionCode + +地区编码 + +cityName + +市(地区)名称 + +String(2) + +是 + +省级编码,编码表见下 + +String(1-20) + +是 + +市(地区)名称(参照提供的地 +区表),直辖市为地区名 + +enterpriseAddress + +详细地址 + +String(1-85) + +是 + +公司地址,长度校验规则为 GBK +字节长度 + +invitationCode + +邀请码 + +String(6-10) + +否 + +代理商邀请码 + +reviewStatus + +审核状态 + +String(1) + +是 + +审核状态。 +0:待审核; +1:审核通过; +2:审核不通过; +3:审核中 + +reviewOpinion + +审核意见 + +String(0-200) + +否 + +审核意见 + +invoiceKind + +开通的发票种类 + +String(不定 + +否 + +开通的发票种类。多个用|分 + +长) + +割。 +10:电子普票; +04:纸质普票; +01:纸质专票; +12:机动车销售统一发票; +9:区块链电子发票; +08:电子专票 +81:数电票(增值税专用发票) +82:数电票(普通发票) + +invoiceLayoutFileType + +电子发票版式文 + +String(3) + +否 + +件类型 + +电子发票版式文件类型。 +pdf:pdf 格式; +ofd:ofd 格式。 + +blockchainInvSingleQuota + +区块链单张发票限 +额 + +blockchainInvDailyQuota + +区块链日开票限 +额 + +blockchainInvMonthlyQuota + +serviceStatus + +区块链月开票限 + +20 位(精确到 2 + +否 + +位小数) +20 位(精确到 2 + +块链发票必传。 +否 + +位小数) +20 位(精确到 2 + +额 + +位小数) + +服务状态 + +String(1) + +区块链单张发票限额。开通区 +区块链日开票限额。开通区块 +链发票必传。 + +否 + +区块链月开票限额。开通区块 +链发票必传。 + +是 + +服务状态。 +0:已禁用; +1:正常 + +票通数电发票接口文档 +terminalList 设备列表 +diskType + +税控设备类型 + +String(1) + +是 + +1:金税盘 +2:税控盘 +3:UKEY +4:区块链电票 +5:数电发票 + +extensionNum + +分机号 + +String(1-3) + +否 + +分机号,区块链电票、全电发 +票为空 + +machineCode + +税盘编号 + +String(12) + +否 + +税盘编号,区块链电票、全电 +发票为空 + +available + +是否可用 + +String(1) + +否 + +设备是否可用。1:可用;0: +过期禁用 + +serviceStartTime + +服务起始时间 + +String(14) + +否 + +服务起始时间。 +格式:yyyyMMddHHmmss + +serviceEndTime + +服务截止时间 + +String(14) + +否 + +服务截止时间。 +格式:yyyyMMddHHmmss + +报文示例: +{ +"taxpayerNum": "91400108MA0043365M15", +"enterpriseName": "北京票通信息技术有限公司深圳分公司 15", +"regionCode": "44", +"cityName": "深圳市", +"enterpriseAddress": "深圳市南山区桃源街道福光社区学苑大道 1088 号", +"invitationCode": "W31FP4", +"reviewStatus": "1", +"reviewOpinion": "就是通过", +"invoiceKind": "10", +"invoiceLayoutFileType": "pdf", +"serviceStatus": "1", +"terminalList": [{ +"diskType": "1", +"machineCode": "661565723941", +"extensionNum": "0", +"serviceStartTime": "20210125142641", +"serviceEndTime": "20210125142641", +"available": "1" +}] +} + +2.47.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +票通数电发票接口文档 + +错误(code) 含义说明(msg) +0000 + +处理成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8104 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +2.48. 企业审核结果推送 +2.48.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +票通平台调用第三方平台 + +调用方式 + +https + +接口地址 + +第三方平台提供 + +字符编码 + +UTF-8 + +备注 +POST 方式提交 + +接口描述 + +2.48.2. + +请求报文 + +请求参数-业务报文部分: +字段 + +名称 + +长度 + +必填 + +说明 + +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +enterpriseName + +销售方企业名称 + +String(4-40) + +是 + +销售方企业名称 + +code + +注册状态 + +String(4) + +是 + +注册状态: +0000 审核通过 +9991 审核不通过 + +msg + +注册状态描述 + +String(1-100) + +报文示例: +{ +"taxpayerNum": "9120931023801231", +"enterpriseName": "北京票通信息技术有限公司", +"code": "0000", +"msg": "审核通过" +} + +是 + +注册状态描述 + +票通数电发票接口文档 + +2.48.3. + +响应报文 + +响应参数-业务报文部分: +字段 + +名称 + +长度 + +必填 + +说明 + +taxpayerNum + +销售方纳税人识别号 + +String(15-20) + +是 + +销售方纳税人识别号 + +enterpriseName + +销售方企业名称 + +String(2-40) + +是 + +销售方企业名称 + +示例报文: +{ +"taxpayerNum": "9120931023801231", +"enterpriseName": "北京票通信息技术有限公司" +} + +2.48.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误码(code) + +含义说明(msg) + +0000 + +成功 + +9999 + +系统内部异常 + +2.49. 查询企业开户行及账号 +通过该接口查询企业开户行及账号信息,优先查询企业在票通平台维护的开户行及账 +号;如果没有维护,则查询在电子税局维护的开户行及账号,查询在电子税局维护的开户行 +及账号信息时需要数电账号处于已登录认证的状态。该接口用于解决企业没有维护开户行及 +账号信息导致开票失败的问题,可以先通过该接口查询,若票通平台和电子税局都没有维护 +开户行及账号,需要提醒企业维护。 + +2.49.1. + +调用说明 + +项目 + +说明内容 + +调用关系 + +第三方平台调用票通平台 + +调用方式 + +https + +接口地址 + +测试地址: +http://fpkj.testnw.vpiaotong.cn/tp/openapi/queryEnterpriseBankInfo +.pt +正式地址: +https://fpkj.vpiaotong.com/tp/openapi/queryEnterpriseBankInfo.pt + +字符编码 + +UTF-8 + +备注 +POST 请求 + +票通数电发票接口文档 +接口描述 + +通过该接口查询企业信息 + +2.49.2. + +请求报文 + +请求参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型及长度 + +必填 + +销售方纳税人识 + +String(15-20) + +是 + +说明 +销售方纳税人识别号 + +别号 + +报文示例: +{ +"taxpayerNum": "91400108MA0043365M15" +} + +2.49.3. + +响应报文 + +响应参数-业务报文部分: +字段 +taxpayerNum + +名称 + +类型 + +必填 + +说明 + +销售方纳税人识 + +String(15-20) + +是 + +销售方纳税人识别号 + +别号 +bankList 开户行列表 +bankName + +企业开户行 + +String(1-100) + +是 + +企业开户行 + +bankAccount + +银行账号 + +String(1-50) + +否 + +银行账号 + +source + +来源 + +String(1) + +是 + +来源。 +1:票通平台; +2:电子税局 + +报文示例: +{ +"taxpayerNum": "98699536313822895951", +"bankList": [{ +"bankName": "XXXXXXX 银行 XX 支行", +"bankAccount": "6111888888888888", +"source": "1" +}] +} + +2.49.4. + +业务错误码 + +从业务中抽取代码并进行定义 + +错误(code) 含义说明(msg) + +票通数电发票接口文档 +0000 + +处理成功 + +9999 + +验签失败 + +9998 + +平台编码无效 + +8996 + +业务异常,请联系运维 + +8995 + +数据校验不通过(对应参考详细信息) + +8002 + +找不到对应的税号信息(企业尚未注册),请检查税号是否正确 + +8104 + +企业尚未绑定该第三方平台,请联系平台客服绑定 + +3. 码表 +3.1. 证件类型 +证件类型代码 + +证件类型名称 + +101 + +组织机构代码证 + +102 + +营业执照 + +103 + +税务登记证 + +199 + +其他单位证件 + +201 + +居民身份证 + +202 + +军官证 + +203 + +武警警官证 + +204 + +士兵证 + +205 + +军队离退休干部证 + +206 + +残疾人证 + +207 + +残疾军人证(1-8 级) + +208 + +外国护照 + +210 + +港澳居民来往内地通行证 + +212 + +中华人民共和国往来港澳通行证 + +213 + +台湾居民来往大陆通行证 + +214 + +大陆居民往来台湾通行证 + +215 + +外国人居留证 + +216 + +外交官证 + +217 + +使(领事)馆证 + +218 + +海员证 + +219 + +香港永久性居民身份证 + +220 + +台湾身份证 + +221 + +澳门特别行政区永久性居民身份证 + +222 + +外国人身份证件 + +224 + +就业失业登记证 + +225 + +退休证 + +226 + +离休证 + +票通数电发票接口文档 +227 + +中国护照 + +228 + +城镇退役士兵自谋职业证 + +229 + +随军家属身份证明 + +230 + +中国人民解放军军官转业证书 + +231 + +中国人民解放军义务兵退出现役证 + +232 + +中国人民解放军士官退出现役证 + +233 + +外国人永久居留身份证(外国人永久居留证) + +234 + +就业创业证 + +235 + +香港特别行政区护照 + +236 + +澳门特别行政区护照 + +237 + +中华人民共和国港澳居民居住证 + +238 + +中华人民共和国台湾居民居住证 + +239 + +《中华人民共和国外国人工作许可证》(A 类) + +240 + +《中华人民共和国外国人工作许可证》(B 类) + +241 + +《中华人民共和国外国人工作许可证》(C 类) + +291 + +出生医学证明 + +299 + +其他个人证件 + +3.2. 农产品收购发票销方证件类型 +证件类型代码 + +证件类型名称 + +201 + +居民身份证 + +208 + +外国护照 + +210 + +港澳居民来往内地通行证 + +213 + +台湾居民来往大陆通行证 + +215 + +外国人居留证 + +219 + +香港永久性居民身份证 + +220 + +台湾身份证 + +221 + +澳门特别行政区永久性居民身份证 + +233 + +外国人永久居留身份证(外国人永久居留证) + +103 + +税务登记证 + +299 + +其他个人证件 + + \ No newline at end of file diff --git a/doc/票通数电平台对接指引v1.0(2).pdf b/doc/票通数电平台对接指引v1.0(2).pdf new file mode 100644 index 0000000..eb34077 Binary files /dev/null and b/doc/票通数电平台对接指引v1.0(2).pdf differ diff --git a/doc/票通数电平台对接指引v1.0(2).txt b/doc/票通数电平台对接指引v1.0(2).txt new file mode 100644 index 0000000..5ab60fa --- /dev/null +++ b/doc/票通数电平台对接指引v1.0(2).txt @@ -0,0 +1,206 @@ +票通电子发票平台对接指引 +V1.0 + +北京票通信息技术有限公司 +2024 年 10 月 16 日 + +修订文档历史记录 +日期 + +版本 + +2024-10-16 + +<1.0> + +说明 +编写初稿,包括对接文档及 sdk 获取,企业注册开通说明, +开票场景,冲红场景,数电账号管理、补充场景说明等内容 + +作者 +饶森林 + +本文档基于票通电子发票平台提供的接口能力进行说明,为合作伙伴对接票通系统时提 +供简要指引和参考。 +一、对接前事项 +票通提供对接服务,随时可启动对接。对接前合作伙伴或客户需提供一些简单的信息。 +接入方名称: +接入方联系人及电话: +接入方产品类型:企业定制开发产品、企业私有化部署系统、SaaS 产品 +接入方产品所属行业:餐饮收银、物业收银、停车收费、供热收费等等 +接入方需要的开票场景:扫码开票、订单开票、小程序开票、支付开票等 +接入方预计服务的企业数量:用于评估需要接入的接口范围 +该信息可发给票通商务人员,或直接发到对接沟通群。 +二、如何启动对接 +票通商务人员确认需要提供对接服务时,建立微信群,拉入相关的人员,票通侧需拉入 +产品人员、对接人员、服务或运营人员。客户侧视客户情况拉入相关人员,一般建议有商务、 +产品和研发人员。 +在微信群,票通对接人员发送标准接口文档《票通数电发票接口文档 3.X.X.pdf》和 SDK +工具包,目前 SDK 工具包支持 Java 和 C#两种开发语言。 +对接所需的测试环境及参数信息:如平台编码,RSA 签名验签的证书,3des 加密秘钥, +可用于测试开票的税号等由票通对接人员提供。接入方系统开发完成,正式上线前,联系票 +通对接人员提供正式环境的参数信息。 +正式对接前,可先参考该文档,如有疑问可在微信群组织会议沟通。 +三、企业注册开通 +要通过接口开具发票,需要开票的企业先在票通平台完成入驻流程,完成入驻有以下几 +种方法。(联调测试阶段,可使用票通对接人员提供测试税号) +(1)客户在票通平台直接注册 +客户可以在票通企业版平台申请注册(注册地址:https://fpkj.vpiaotong.com/register) +, +也可以在票通集团版(票通集团版账号可联系票通商务人员获取)的机构管理功能中添加需 +要入驻的企业。申请提交后,需要票通平台运营人员或有权限的代理商进行审核。该过程如 +有问题可联系商务人员沟通。 +(2)接口注册 +针对企业数量较多的平台,可通过票通接口完成注册,使用上述接口文档中的“2.2.注 +册企业”提交注册信息。接口注册的企业,无登录密码,用户如需使用票通平台,可在票通 +平台,通过注册时预留的手机号,获取短信验证码完成密码设置,使用设置后的密码即可登 +录票通平台。 +基础文档仅提供了提交注册接口,如需获取票通的审核状态,可通过查询接口或推送接 +口获取审核状态,如需接口可联系票通对接人员提供。 +(3)可由票通代理商协助完成注册 +联系票通代理商进行操作。 +通过接口注册的企业,平台和企业的绑定关系自动生成,如果是使用票通产品功能完成 +的注册或已经在票通完成入驻的企业,需要联系票通商务人员或运营人员完成接入方平台和 +开票企业的绑定关系。 +四、主要的开票场景 +一般情况下,仅完成基础开票,对接少量的一至两个接口即可。根据开票场景不同,我 + +们分别介绍。 +(1)扫码开票 +扫码开票是票通提供的特色能力,针对一些当面交易场景,企业的顾客消费完成后,现 +场获取交易小票,可在小票后追加打印开票二维码,顾客可自行扫码,填写完成开票。 +顾客侧操作如下图示意: + +该过程中,二维码信息生成,可调用票通接口文档中提供的“2.16.获取开票二维码” +, +接入方系统传入交易相关信息,如商品、单价、数量、税率等,票通为该笔交易生成对应的 +开票链接和二维码并同步返回给接入方,接入方系统展示或打印二维码到小票上。 +用户扫码后,看到的开票界面是由票通提供,该界面集成了发票抬头模糊检索,获取微 +信或支付宝抬头,默认记录上次开票的发票抬头等功能,可方便顾客快速填充抬头信息,并 +支持填写邮箱地址和手机号,票通会自动发送邮件,已开通短信服务的企业票通也会自动发 +送短信推送发票。票通开票 H5 支持扫描多个二维码合并开票,或将一个二维码拆分开票, +商户自行设置即可。 +提交发票后,还可授权将发票插入微信发票卡包或支付宝发票管家,发票开具成功后, +票通自动推到发票到顾客的微信发票卡包或支付宝发票管家。 +平台发票开具成功,会调用“2.13.推送发票主要信息”接口推送开具成功的发票,接入 +方也可调用“2.18.查询二维码开票信息”主动查询二维码开票状态。 +如果用户侧产生退货,未开票情况下可调用“2.17.批量作废开票二维码”直接作废开票 +二维码。如果二维码已开票,可调用“2.10.快捷冲红数电发票(全额冲红)”冲红已开具发 +票。 +注意:2.10、2.13、2.17、2.18 为可选接口,如不对接接口,相关操作也可以通过票通 +产品功能完成。 +(2)直接开票 +直接开票接口是通用的开票能力,系统接入方组织好待开票信息后,直接调用“2.9.开 +具蓝字数电发票”接口完成开票申请提交,票通服务会实时返回结果,并附带一个链接,通 +过该链接可打开 H5 界面实时查看发票开具状态。 +票通接收开票申请后,处理开票过程,开票成功或开票异常,都会通过“2.13.推送发票 +主要信息”接口,推送相关的信息。 +目前数电票开具,需要开票人保持电子税务局账号的登录状态,如果需要开票人员进行 +登录认证或风险认证时,票通将会 2.13 接口返回 3999 的特定错误状态,该状态表明需要提 +供开票人完成相关认证。数电账号的认证问题,会在后续第六章节详细说明。 +注意:提交开票时,发票请求流水号字段,唯一代表一张发票,同一个发票请求流水号 +不会重复开票,接入方系统遇到一些场景需重试开票时,如果为同一张发票,请不要变更发 + +票请求流水号,否则可能有导致重开发票的风险。 +(3)单据开票 +票通集团版提供的单据管理的能力,支持拆分开票、合并开票、支持直接传入订单信息 +后续补充发票抬头等进行开票。该接口并非基础能力接口,如有需要财务人员介入进行审核 +或拆分合并开票的场景,可使用该套接口能力,联系票通商务人员,安排提供相关的接口文 +档,该功能的使用需依赖票通集团版系统。 +五、冲红场景 +当顾客产生退货或发票开具错误时,票通提供了多种冲红操作能力,票通企业版、集团 +版均提供有手工冲红和一键冲红的能力,接口能力也提供了快捷冲红(1 个接口)和全场景 +冲红(需 4-8 个接口组合使用) +。 +数电票冲红需要发起红字确认单,确认单申请通过后才可发起真正的冲红操作,流程相 +对较长,接口较多,一般建议接入方系统使用快捷冲红接口即可,快捷冲红接口服务对冲红 +逻辑进行了封装,由票通整合红字信单申请管理和冲红功能,有效减轻接入方系统的开发工 +作量。 +(1)快捷冲红 +快捷冲红时,如仅需全部冲红,可对接“2.10.快捷冲红数电发票(全额冲红)”,该接口 +参数简单;如需支持部分冲红,可对接“2.37.快捷冲红数电发票(全额冲红、部分冲红)”, +该接口参数比较完整,支持场景更多,部分冲红和全额冲红都支持,如果需要进行部分冲红, +建议调用“2.36.初始化红字信息确认单”完成初始化,该初始化的目的是加载剩余可冲红的 +发票信息,无需接入方系统自行管理剩余可冲红的商品信息。 +(2)全场景冲红管理 +全场景冲红需对接“2.28.红字发票确认单申请”、“2.29.查看红字发票确认单”、“2.30. +开具红字数电发票”、“2.31.红字发票确认单审核”、“2.32.红字发票确认单撤销”、“2.33.红 +字发票确认单查询(下载)”、“2.34.获取红字发票确认单查询(下载)结果”、 +“2.36.初始化 +红字信息确认单”接口,用于精细化管理红字发票确认单及冲红。 +销方申请冲红管理流程如下: +发票冲红流程及事项: +数电发票冲红,均需发起红字发票确认单申请, +普通发票申请,如果对方未进行入账操作,则申请后无需确认,调用红字信息表查询接 +口获取到已确认(或无需确认)状态后,可进行冲红。 +专用发票申请,如果对方未入账未勾选,则申请后无需确认,其他情况需对方确认,对 +方确认通过后,调用红字信息表查询接口获取已确认(或无需确认)状态,接下来可进行冲 +红。 +对方企业发起的红字信息表,可通过红字信息表查询(下载)接口获取红字信息表,对 +需要审核的红字信息表,可通过红字信息表审核接口,进行拒绝或者通过。 +数电发票(普通发票)可冲红对应的增值税普通发票,包括普通电子发票。 +数电发票(增值税专用发票),可冲红对应的增值税专用发票,包括专用电子发票。 + +接口同时支持购方申请红字确认单,冲红动作需销方执行。 +注:冲红接口为非必须对接的接口,有些开发资源紧张或财务人员可人工处理冲红的情 +况下,可以不对接冲红接口。或者将冲红功能放入后续的迭代开发。 +六、数电账号管理 +数电发票的开具,需要用户先在票通平台完成电子税务局账号的登录和风险认证。对于 +接入方系统期望在系统内完成认证过程的,可使用票通提供的接口能力。如接入方系统仅需 +完成开票,不关注认证过程,可使用票通成熟的认证方式。可通过票通企业版、集团版、微 +票通 APP、票通云小程序、票通云服务公众号都可完成认证。推荐使用票通云公众号,该方 +式认证无需登录票通账号,可直接在公众号进行数电账号的绑定、认证和对未认证导致开 +票失败的发票进行重开。 +(1)接口能力 +票通提供了“2.3.数电账号登记”、“2.4.获取登录短信验证码”、“2.5.短信登录”、“2.6. +获取实名认证二维码”、 +“2.7.查询实名认证二维码扫码状态” +、 +“2.8.查询数电账号认证状态”、 +“2.45.查询数电账号列表” +、“2.46.退出电子税局登录”等接口完成数电账号的认证。 +这里支持几种场景。 +1. 接入方系统仅需要数电账号信息,用于开票时指定开票人,可使用 2.45 接口获取 +在票通维护好的数电账号及对应状态即可。在开票时传入开票人信息。 +2. 接入方系统需进行是数电账号维护的,可调用 2.3 数电账号登记接口,该接口为实 +时接口,可验证用户输入的账号密码是否正确。 +3. 接入方系统如需完成认证,需首先通过 2.3 或 2.45 获取登录信息,然后调用 2.4 和 +2.5 完成短信登录认证,使用 2.6、2.7 完成风险码扫码认证。如需查询各状态的登录或认证 +状态,可通过 2.8 接口完成认证。 + +(2)票通云公众号 +票通云服务公众号提供了您和您的数电账号关联的两种方式,一种是通过在企业版或集 +团版对应数电账号后的关注二维码,扫码关注后,建立用户和数电账号的绑定关系。一种是 +直接在票通云公众号-数电认证功能,通过输入手机号或电子税务局的登录账号密码完成验 +证,获取属于用户的数电账号列表进行绑定。 +“票通云服务”公众号提供的能力: +1. 用户绑定的账号未登录或未认证,导致发票开票失败时,票通将会通过消息通知进 +行提醒,用户可直接点击通知,进入到认证界面,完成相应的认证,认证完成后可直接选择 +是否重开发票及重开发票的范围(当前失败的发票或 30 天内因为该错误导致失败的发票) +。 + +2. 可通过票通云服务-数电认证功能,主动完成登录认证或风险认证,该场景适用于 +一些企业为提高顾客体验,要求企业员工按时认证的情况,数电认证功能,无论当前账号处 +于何种状态,都可以随时进行再次认证。 + +3.支持退出登录,目前通过短信方式完成登录,可保持很长的登录状态。 +注意:由于目前短信登录方式保持登录的有效期很长,在 PC 端登录进行审核或其他操 + +作时,可能会被票通平台挤掉,可提醒用户先在票通平台退出登录,然后在电子税务局 PC +端进行操作。退出登录操作可使用票通产品或公众号,也可以通过接口 2.46 完成。 +七、补充场景 +(1)发票抬头获取接口 +接入方系统如需自行开发 H5 或需要帮助用户补充抬头信息时,可使用发票抬头获取接 +口,该接口非基础接口,请联系商务人员获取。 +(2)开票项目智能赋码接口 +接入方系统如需管理大量的商品品目时,可通过智能赋码接口完成对商品的税收分类编 +码设置。该接口非基础接口,请联系商务人员获取。 +(3)SaaS 平台类对接工单接口 +接入方系统如果服务的企业较多,需要完善的线上原因流程支持,可联系票通商务人员 +和对接人员,提供工单的接口支持,工单接口包括套餐(订单)创建、企业绑定、套餐续费 +等能力。 +八、更多支持 +票通平台提供了多种场景组合的接口能力,如需交流沟通,可在微信群直接沟通或组织 +会议进行沟通。 + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ed5af7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + postgres: + image: postgres:18.3 + container_name: ticket-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ticket + POSTGRES_USER: ticket + POSTGRES_PASSWORD: ticket_password + TZ: Asia/Shanghai + ports: + - "5432:5432" + volumes: + - platform_a_postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U platform -d platform"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:8 + container_name: ticket-redis + restart: unless-stopped + command: ["redis-server", "--requirepass", "ticket_password", "--appendonly", "yes"] + ports: + - "6379:6379" + volumes: + - platform_a_redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "ticket_password", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + platform_a_postgres_data: + platform_a_redis_data: diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..c426c32 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,36 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 0000000..c5af1da --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + kotlin("jvm") version "2.3.20" + id("io.ktor.plugin") version "3.4.1" + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" +} + +group = "com.bbit.platform" +version = "0.0.1" + +application { + mainClass = "io.ktor.server.netty.EngineMain" +} + +kotlin { + jvmToolchain(21) +} + + +dependencies { + val kotlinVersion = "2.3.20" + implementation("io.ktor:ktor-server-core") + implementation("io.ktor:ktor-serialization-kotlinx-json") + implementation("io.ktor:ktor-server-content-negotiation") + implementation("io.ktor:ktor-server-cors") + implementation("io.ktor:ktor-server-host-common") + implementation("io.ktor:ktor-server-status-pages") + implementation("io.ktor:ktor-server-auth") + implementation("io.ktor:ktor-server-auth-jwt") + implementation("io.ktor:ktor-server-netty") + implementation("io.ktor:ktor-server-call-logging") + implementation("ch.qos.logback:logback-classic:1.5.13") + implementation("io.ktor:ktor-server-config-yaml") + testImplementation("io.ktor:ktor-server-test-host") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion") + + // 数据库 + val exposedVersion = "1.1.1" + implementation("org.postgresql:postgresql:42.7.10") + implementation("org.jetbrains.exposed:exposed-core:${exposedVersion}") + implementation("org.jetbrains.exposed:exposed-jdbc:${exposedVersion}") + implementation("org.jetbrains.exposed:exposed-java-time:${exposedVersion}") + implementation("org.jetbrains.exposed:exposed-migration-jdbc:$exposedVersion") + implementation("com.zaxxer:HikariCP:7.0.2") + implementation("org.mindrot:jbcrypt:0.4") + + // Redis + implementation("org.redisson:redisson:3.38.1") +} diff --git a/server/gradle/wrapper/gradle-wrapper.jar b/server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/server/gradle/wrapper/gradle-wrapper.properties b/server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4312465 --- /dev/null +++ b/server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.3.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/server/gradlew b/server/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/server/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/server/gradlew.bat b/server/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/server/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/server/settings.gradle.kts b/server/settings.gradle.kts new file mode 100644 index 0000000..f4eb229 --- /dev/null +++ b/server/settings.gradle.kts @@ -0,0 +1,7 @@ +rootProject.name = "platform-a-server" + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/Application.kt b/server/src/main/kotlin/com/bbit/ticket/Application.kt new file mode 100644 index 0000000..4595536 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/Application.kt @@ -0,0 +1,63 @@ +package com.bbit.ticket + +import com.bbit.ticket.bootstrap.DatabaseInitializer +import com.bbit.ticket.bootstrap.SeedData +import com.bbit.ticket.common.ok +import com.bbit.ticket.config.AppConfig +import com.bbit.ticket.modules.auth.registerAuthRoutes +import com.bbit.ticket.modules.logs.registerLogsQueryRoutes +import com.bbit.ticket.modules.system.dict.registerDictRoutes +import com.bbit.ticket.modules.system.menu.registerMenuRoutes +import com.bbit.ticket.modules.system.org.registerOrgRoutes +import com.bbit.ticket.modules.system.role.registerRoleRoutes +import com.bbit.ticket.modules.system.user.registerUserRoutes +import com.bbit.ticket.plugins.configureCors +import com.bbit.ticket.plugins.configureDatabase +import com.bbit.ticket.plugins.configureLogging +import com.bbit.ticket.plugins.configureApiAccessLog +import com.bbit.ticket.plugins.configureRedis +import com.bbit.ticket.plugins.configureSecurity +import com.bbit.ticket.plugins.configureSerialization +import com.bbit.ticket.plugins.configureStatusPages +import com.bbit.ticket.plugins.configureTrace +import kotlinx.coroutines.runBlocking +import io.ktor.server.application.Application +import io.ktor.server.netty.EngineMain +import io.ktor.server.response.respond +import io.ktor.server.routing.get +import io.ktor.server.routing.routing + +fun main(args: Array) { + EngineMain.main(args) +} + +fun Application.module() { + AppConfig.init(environment) + + configureTrace() + configureSerialization() + configureStatusPages() + configureLogging() + configureApiAccessLog() + configureCors() + configureSecurity() + configureDatabase() + configureRedis() + runBlocking { + DatabaseInitializer.initialize() + SeedData.seed() + } + + routing { + get("/health") { + call.respond(ok(mapOf("status" to "UP", "service" to AppConfig.app.name))) + } + registerAuthRoutes() + registerUserRoutes() + registerOrgRoutes() + registerRoleRoutes() + registerMenuRoutes() + registerDictRoutes() + registerLogsQueryRoutes() + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/bootstrap/DatabaseInitializer.kt b/server/src/main/kotlin/com/bbit/ticket/bootstrap/DatabaseInitializer.kt new file mode 100644 index 0000000..3ef460b --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/bootstrap/DatabaseInitializer.kt @@ -0,0 +1,53 @@ +package com.bbit.ticket.bootstrap + +import com.bbit.ticket.database.system.SysApiAccessLogTable +import com.bbit.ticket.database.system.SysDictItemTable +import com.bbit.ticket.database.system.SysDictTypeTable +import com.bbit.ticket.database.system.SysMenuTable +import com.bbit.ticket.database.system.SysOperationLogTable +import com.bbit.ticket.database.system.SysOrgTable +import com.bbit.ticket.database.system.SysRoleMenuTable +import com.bbit.ticket.database.system.SysRoleTable +import com.bbit.ticket.database.system.SysUserRoleTable +import com.bbit.ticket.database.system.SysUserTable +import com.bbit.ticket.plugins.dbQuery +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils +import org.slf4j.LoggerFactory + +object DatabaseInitializer { + private val logger = LoggerFactory.getLogger(DatabaseInitializer::class.java) + + suspend fun initialize() { + val tables = arrayOf( + SysOrgTable, + SysUserTable, + SysRoleTable, + SysMenuTable, + SysUserRoleTable, + SysRoleMenuTable, + SysDictTypeTable, + SysDictItemTable, + SysOperationLogTable, + SysApiAccessLogTable, + ) + // 先通过 Exposed 生成迁移 SQL,再逐条执行,避免启动时静默跳过缺失表或字段。 + dbQuery { + MigrationUtils.statementsRequiredForDatabaseMigration(*tables, withLogs = true) + } + transaction { + val statements = MigrationUtils.statementsRequiredForDatabaseMigration( + *tables, + withLogs = false + ) + if (statements.isNotEmpty()) { + logger.info("Migrating database schema, statement count={}", statements.size) + statements.forEach { + logger.debug("Executing migration SQL: {};", it) + exec(it) + } + } + } + logger.info("Database schema initialized") + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/bootstrap/SeedData.kt b/server/src/main/kotlin/com/bbit/ticket/bootstrap/SeedData.kt new file mode 100644 index 0000000..bb814e4 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/bootstrap/SeedData.kt @@ -0,0 +1,357 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package com.bbit.ticket.bootstrap + +import com.bbit.ticket.database.system.SysDictItemTable +import com.bbit.ticket.database.system.SysDictTypeTable +import com.bbit.ticket.database.system.SysMenuTable +import com.bbit.ticket.database.system.SysOrgTable +import com.bbit.ticket.database.system.SysRoleMenuTable +import com.bbit.ticket.database.system.SysRoleTable +import com.bbit.ticket.database.system.SysUserRoleTable +import com.bbit.ticket.database.system.SysUserTable +import com.bbit.ticket.plugins.dbQuery +import com.bbit.ticket.security.PasswordService +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.core.statements.UpdateBuilder +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import org.slf4j.LoggerFactory +import java.time.OffsetDateTime +import kotlin.uuid.Uuid + +object SeedData { + private val logger = LoggerFactory.getLogger(SeedData::class.java) + + const val ADMIN_USERNAME = "admin" + const val ADMIN_INIT_PASSWORD = "Admin@123456" + + private const val DEFAULT_ORG_CODE = "DEFAULT_ORG" + private const val SUPER_ADMIN_ROLE_CODE = "SUPER_ADMIN" + + suspend fun seed() { + val now = OffsetDateTime.now() + val orgId = upsertDefaultOrg(now) + val roleId = upsertSuperAdminRole(now) + val adminId = upsertAdminUser(orgId, now) + upsertUserRole(adminId, roleId) + val menuIds = upsertMenus(now) + bindRoleMenus(roleId, menuIds) + seedDicts(now) + logger.info("Seed data initialized, default admin username: {}", ADMIN_USERNAME) + } + + private suspend fun upsertDefaultOrg(now: OffsetDateTime): Uuid = dbQuery { + val existing = SysOrgTable.selectAll() + .where { (SysOrgTable.code eq DEFAULT_ORG_CODE) and SysOrgTable.deletedAt.isNull() } + .singleOrNull() + + if (existing != null) { + val id = existing[SysOrgTable.id] + SysOrgTable.update({ SysOrgTable.id eq id }) { + it[name] = "默认组织" + it[sort] = 0 + it[status] = "ENABLED" + it[updatedAt] = now + } + return@dbQuery id + } + + val inserted = SysOrgTable.insert { + it[parentId] = null + it[name] = "默认组织" + it[code] = DEFAULT_ORG_CODE + it[sort] = 0 + it[status] = "ENABLED" + it[createdAt] = now + } + inserted[SysOrgTable.id] + } + + private suspend fun upsertSuperAdminRole(now: OffsetDateTime): Uuid = dbQuery { + val existing = SysRoleTable.selectAll() + .where { (SysRoleTable.code eq SUPER_ADMIN_ROLE_CODE) and SysRoleTable.deletedAt.isNull() } + .singleOrNull() + + if (existing != null) { + val id = existing[SysRoleTable.id] + SysRoleTable.update({ SysRoleTable.id eq id }) { + it[name] = "超级管理员" + it[description] = "系统内置超级管理员角色" + it[status] = "ENABLED" + it[dataScope] = "ALL" + it[updatedAt] = now + } + return@dbQuery id + } + + val inserted = SysRoleTable.insert { + it[name] = "超级管理员" + it[code] = SUPER_ADMIN_ROLE_CODE + it[description] = "系统内置超级管理员角色" + it[status] = "ENABLED" + it[dataScope] = "ALL" + it[createdAt] = now + } + inserted[SysRoleTable.id] + } + + private suspend fun upsertAdminUser(orgId: Uuid, now: OffsetDateTime): Uuid = dbQuery { + val existing = SysUserTable.selectAll() + .where { (SysUserTable.username eq ADMIN_USERNAME) and SysUserTable.deletedAt.isNull() } + .singleOrNull() + + if (existing != null) { + val id = existing[SysUserTable.id] + SysUserTable.update({ SysUserTable.id eq id }) { + it[nickname] = "管理员" + it[realName] = "系统管理员" + it[SysUserTable.orgId] = orgId + it[status] = "ENABLED" + it[updatedAt] = now + } + return@dbQuery id + } + + val inserted = SysUserTable.insert { + it[username] = ADMIN_USERNAME + it[passwordHash] = PasswordService.hash(ADMIN_INIT_PASSWORD) + it[nickname] = "管理员" + it[realName] = "系统管理员" + it[SysUserTable.orgId] = orgId + it[status] = "ENABLED" + it[tokenVersion] = 1 + it[createdAt] = now + } + inserted[SysUserTable.id] + } + + private suspend fun upsertUserRole(userId: Uuid, roleId: Uuid) = dbQuery { + val exists = SysUserRoleTable.selectAll() + .where { (SysUserRoleTable.userId eq userId) and (SysUserRoleTable.roleId eq roleId) } + .any() + if (!exists) { + SysUserRoleTable.insert { + it[SysUserRoleTable.userId] = userId + it[SysUserRoleTable.roleId] = roleId + } + } + } + + private suspend fun upsertMenus(now: OffsetDateTime): List { + val seedMenus = listOf( + SeedMenu("dashboard", null, "MENU", "工作台", "Dashboard", "/dashboard", "dashboard/index", "LayoutDashboard", null, 10, true, true), + SeedMenu("system", null, "CATALOG", "系统管理", "SystemRoot", "/system", null, "Settings", null, 20, true, false), + SeedMenu("system_user", "system", "MENU", "用户管理", "SystemUsers", "/system/users", "system/users/index", "Users", "system:user:view", 10, true, true), + SeedMenu("system_user_create", "system_user", "BUTTON", "新增用户", "SystemUserCreate", null, null, null, "system:user:create", 1, true, false), + SeedMenu("system_user_update", "system_user", "BUTTON", "修改用户", "SystemUserUpdate", null, null, null, "system:user:update", 2, true, false), + SeedMenu("system_user_delete", "system_user", "BUTTON", "删除用户", "SystemUserDelete", null, null, null, "system:user:delete", 3, true, false), + SeedMenu("system_org", "system", "MENU", "组织管理", "SystemOrgs", "/system/orgs", "system/orgs/index", "Building2", "system:org:view", 20, true, true), + SeedMenu("system_org_create", "system_org", "BUTTON", "新增组织", "SystemOrgCreate", null, null, null, "system:org:create", 1, true, false), + SeedMenu("system_org_update", "system_org", "BUTTON", "更新组织", "SystemOrgUpdate", null, null, null, "system:org:update", 2, true, false), + SeedMenu("system_org_delete", "system_org", "BUTTON", "删除组织", "SystemOrgDelete", null, null, null, "system:org:delete", 3, true, false), + SeedMenu("system_role", "system", "MENU", "角色管理", "SystemRoles", "/system/roles", "system/roles/index", "Shield", "system:role:view", 30, true, true), + SeedMenu("system_role_create", "system_role", "BUTTON", "新增角色", "SystemRoleCreate", null, null, null, "system:role:create", 1, true, false), + SeedMenu("system_role_update", "system_role", "BUTTON", "更新角色", "SystemRoleUpdate", null, null, null, "system:role:update", 2, true, false), + SeedMenu("system_role_delete", "system_role", "BUTTON", "删除角色", "SystemRoleDelete", null, null, null, "system:role:delete", 3, true, false), + SeedMenu("system_role_assign", "system_role", "BUTTON", "分配角色权限", "SystemRoleAssign", null, null, null, "system:role:assign", 4, true, false), + SeedMenu("system_menu", "system", "MENU", "菜单管理", "SystemMenus", "/system/menus", "system/menus/index", "PanelLeft", "system:menu:view", 40, true, true), + SeedMenu("system_menu_create", "system_menu", "BUTTON", "新增菜单", "SystemMenuCreate", null, null, null, "system:menu:create", 1, true, false), + SeedMenu("system_menu_update", "system_menu", "BUTTON", "更新菜单", "SystemMenuUpdate", null, null, null, "system:menu:update", 2, true, false), + SeedMenu("system_menu_delete", "system_menu", "BUTTON", "删除菜单", "SystemMenuDelete", null, null, null, "system:menu:delete", 3, true, false), + SeedMenu("system_dict", "system", "MENU", "字典管理", "SystemDict", "/system/dicts", "system/dicts/index", "BookType", "system:dict:view", 50, true, true), + SeedMenu("system_dict_create", "system_dict", "BUTTON", "新增字典", "SystemDictCreate", null, null, null, "system:dict:create", 1, true, false), + SeedMenu("system_dict_update", "system_dict", "BUTTON", "更新字典", "SystemDictUpdate", null, null, null, "system:dict:update", 2, true, false), + SeedMenu("system_dict_delete", "system_dict", "BUTTON", "删除字典", "SystemDictDelete", null, null, null, "system:dict:delete", 3, true, false), + SeedMenu("logs", null, "CATALOG", "日志管理", "LogsRoot", "/logs", null, "Logs", null, 30, true, false), + SeedMenu("logs_operation", "logs", "MENU", "操作日志", "LogsOperation", "/logs/operation", "logs/operation/index", "ScrollText", "log:operation:view", 10, true, true), + SeedMenu("logs_api_access", "logs", "MENU", "接口日志", "LogsApiAccess", "/logs/api-access", "logs/api-access/index", "Waypoints", "log:api-access:view", 20, true, true), + ) + + val idMap = mutableMapOf() + for (menu in seedMenus) { + val parentId = menu.parentKey?.let { idMap[it] } + val menuId = upsertMenu(menu, parentId, now) + idMap[menu.key] = menuId + } + + return idMap.values.toList() + } + + private suspend fun upsertMenu(seedMenu: SeedMenu, parentId: Uuid?, now: OffsetDateTime): Uuid = dbQuery { + val existing = SysMenuTable.selectAll() + .where { (SysMenuTable.name eq seedMenu.name) and SysMenuTable.deletedAt.isNull() } + .singleOrNull() + + if (existing != null) { + val id = existing[SysMenuTable.id] + SysMenuTable.update({ SysMenuTable.id eq id }) { + fillMenuColumns(it, seedMenu, parentId, now, isCreate = false) + } + return@dbQuery id + } + + val inserted = SysMenuTable.insert { + fillMenuColumns(it, seedMenu, parentId, now, isCreate = true) + } + inserted[SysMenuTable.id] + } + + private fun fillMenuColumns( + statement: UpdateBuilder<*>, + seedMenu: SeedMenu, + parentId: Uuid?, + now: OffsetDateTime, + isCreate: Boolean, + ) { + statement[SysMenuTable.parentId] = parentId + statement[SysMenuTable.type] = seedMenu.type + statement[SysMenuTable.title] = seedMenu.title + statement[SysMenuTable.name] = seedMenu.name + statement[SysMenuTable.path] = seedMenu.path + statement[SysMenuTable.component] = seedMenu.component + statement[SysMenuTable.icon] = seedMenu.icon + statement[SysMenuTable.permission] = seedMenu.permission + statement[SysMenuTable.sort] = seedMenu.sort + statement[SysMenuTable.visible] = seedMenu.visible + statement[SysMenuTable.keepAlive] = seedMenu.keepAlive + statement[SysMenuTable.builtIn] = seedMenu.builtIn + statement[SysMenuTable.status] = "ENABLED" + if (isCreate) { + statement[SysMenuTable.createdAt] = now + } else { + statement[SysMenuTable.updatedAt] = now + } + } + + private suspend fun bindRoleMenus(roleId: Uuid, menuIds: List) = dbQuery { + if (menuIds.isEmpty()) { + return@dbQuery + } + + val existing = SysRoleMenuTable.selectAll() + .where { SysRoleMenuTable.roleId eq roleId } + .map { it[SysRoleMenuTable.menuId] } + .toSet() + + val toAdd = menuIds.filter { !existing.contains(it) } + toAdd.forEach { menuId -> + SysRoleMenuTable.insert { + it[SysRoleMenuTable.roleId] = roleId + it[SysRoleMenuTable.menuId] = menuId + } + } + + val toRemove = existing.filter { !menuIds.contains(it) } + if (toRemove.isNotEmpty()) { + SysRoleMenuTable.deleteWhere { (SysRoleMenuTable.roleId eq roleId) and (SysRoleMenuTable.menuId inList toRemove) } + } + } + + private suspend fun seedDicts(now: OffsetDateTime) { + val userStatusTypeId = upsertDictType("user_status", "用户状态", now) + upsertDictItem(userStatusTypeId, "启用", "ENABLED", "green", 1, now) + upsertDictItem(userStatusTypeId, "禁用", "DISABLED", "red", 2, now) + + val orgStatusTypeId = upsertDictType("org_status", "组织状态", now) + upsertDictItem(orgStatusTypeId, "启用", "ENABLED", "green", 1, now) + upsertDictItem(orgStatusTypeId, "禁用", "DISABLED", "red", 2, now) + + val roleStatusTypeId = upsertDictType("role_status", "角色状态", now) + upsertDictItem(roleStatusTypeId, "启用", "ENABLED", "green", 1, now) + upsertDictItem(roleStatusTypeId, "禁用", "DISABLED", "red", 2, now) + + val menuTypeId = upsertDictType("menu_type", "菜单类型", now) + upsertDictItem(menuTypeId, "目录", "CATALOG", "default", 1, now) + upsertDictItem(menuTypeId, "菜单", "MENU", "blue", 2, now) + upsertDictItem(menuTypeId, "按钮", "BUTTON", "orange", 3, now) + + val logStatusTypeId = upsertDictType("log_status", "日志状态", now) + upsertDictItem(logStatusTypeId, "成功", "SUCCESS", "green", 1, now) + upsertDictItem(logStatusTypeId, "失败", "FAIL", "red", 2, now) + } + + private suspend fun upsertDictType(code: String, name: String, now: OffsetDateTime): Uuid = dbQuery { + val existing = SysDictTypeTable.selectAll() + .where { (SysDictTypeTable.code eq code) and SysDictTypeTable.deletedAt.isNull() } + .singleOrNull() + + if (existing != null) { + val id = existing[SysDictTypeTable.id] + SysDictTypeTable.update({ SysDictTypeTable.id eq id }) { + it[SysDictTypeTable.name] = name + it[status] = "ENABLED" + it[updatedAt] = now + } + return@dbQuery id + } + + val inserted = SysDictTypeTable.insert { + it[SysDictTypeTable.code] = code + it[SysDictTypeTable.name] = name + it[status] = "ENABLED" + it[createdAt] = now + } + inserted[SysDictTypeTable.id] + } + + private suspend fun upsertDictItem( + typeId: Uuid, + label: String, + value: String, + color: String?, + sort: Int, + now: OffsetDateTime, + ) = dbQuery { + val existing = SysDictItemTable.selectAll() + .where { + (SysDictItemTable.typeId eq typeId) and + (SysDictItemTable.value eq value) and + SysDictItemTable.deletedAt.isNull() + } + .singleOrNull() + + if (existing != null) { + val id = existing[SysDictItemTable.id] + SysDictItemTable.update({ SysDictItemTable.id eq id }) { + it[SysDictItemTable.label] = label + it[SysDictItemTable.color] = color + it[SysDictItemTable.sort] = sort + it[status] = "ENABLED" + it[updatedAt] = now + } + return@dbQuery + } + + SysDictItemTable.insert { + it[SysDictItemTable.typeId] = typeId + it[SysDictItemTable.label] = label + it[SysDictItemTable.value] = value + it[SysDictItemTable.color] = color + it[SysDictItemTable.sort] = sort + it[status] = "ENABLED" + it[createdAt] = now + } + } +} + +private data class SeedMenu( + val key: String, + val parentKey: String?, + val type: String, + val title: String, + val name: String, + val path: String?, + val component: String?, + val icon: String?, + val permission: String?, + val sort: Int, + val visible: Boolean, + val keepAlive: Boolean, + val builtIn: Boolean = true, +) diff --git a/server/src/main/kotlin/com/bbit/ticket/common/ApiResult.kt b/server/src/main/kotlin/com/bbit/ticket/common/ApiResult.kt new file mode 100644 index 0000000..a70ab45 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/common/ApiResult.kt @@ -0,0 +1,17 @@ +package com.bbit.ticket.common + +import kotlinx.serialization.Serializable + +@Serializable +data class ApiResult( + val code: String, + val message: String, + val data: T? = null, + val traceId: String? = null, +) + +fun ok(data: T? = null, message: String = "成功"): ApiResult = + ApiResult(code = "0", message = message, data = data) + +fun fail(code: String, message: String, traceId: String? = null): ApiResult = + ApiResult(code = code, message = message, traceId = traceId) diff --git a/server/src/main/kotlin/com/bbit/ticket/common/BizException.kt b/server/src/main/kotlin/com/bbit/ticket/common/BizException.kt new file mode 100644 index 0000000..980a24e --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/common/BizException.kt @@ -0,0 +1,9 @@ +package com.bbit.ticket.common + +import io.ktor.http.HttpStatusCode + +class BizException( + val errorCode: String, + override val message: String, + val status: HttpStatusCode = HttpStatusCode.BadRequest, +) : RuntimeException(message) diff --git a/server/src/main/kotlin/com/bbit/ticket/common/DateTimeFormats.kt b/server/src/main/kotlin/com/bbit/ticket/common/DateTimeFormats.kt new file mode 100644 index 0000000..de2b4e3 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/common/DateTimeFormats.kt @@ -0,0 +1,13 @@ +package com.bbit.ticket.common + +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val defaultZone: ZoneId = ZoneId.systemDefault() +private val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + +fun formatDateTime(value: OffsetDateTime?): String? { + if (value == null) return null + return value.atZoneSameInstant(defaultZone).format(dateTimeFormatter) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/common/DisplayLabels.kt b/server/src/main/kotlin/com/bbit/ticket/common/DisplayLabels.kt new file mode 100644 index 0000000..5419dee --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/common/DisplayLabels.kt @@ -0,0 +1,24 @@ +package com.bbit.ticket.common + +fun statusLabel(status: String): String = when (status) { + "ENABLED" -> "启用" + "DISABLED" -> "禁用" + "SUCCESS" -> "成功" + "FAIL" -> "失败" + else -> status +} + +fun menuTypeLabel(type: String): String = when (type) { + "CATALOG" -> "目录" + "MENU" -> "菜单" + "BUTTON" -> "按钮" + else -> type +} + +fun dataScopeLabel(scope: String): String = when (scope) { + "ALL" -> "全部数据" + "DEPT" -> "本组织及下级" + "DEPT_ONLY" -> "本组织" + "SELF" -> "仅本人" + else -> scope +} diff --git a/server/src/main/kotlin/com/bbit/ticket/common/ErrorCode.kt b/server/src/main/kotlin/com/bbit/ticket/common/ErrorCode.kt new file mode 100644 index 0000000..966050d --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/common/ErrorCode.kt @@ -0,0 +1,18 @@ +package com.bbit.ticket.common + +enum class ErrorCode(val code: String, val message: String) { + BAD_REQUEST("COMMON.BAD_REQUEST", "请求参数错误"), + DATA_CONFLICT("COMMON.DATA_CONFLICT", "数据冲突"), + UNAUTHORIZED("AUTH.UNAUTHORIZED", "未登录或登录已失效"), + FORBIDDEN("AUTH.FORBIDDEN", "无权限访问"), + USERNAME_OR_PASSWORD_INVALID("AUTH.USERNAME_OR_PASSWORD_INVALID", "用户名或密码错误"), + USER_DISABLED("AUTH.USER_DISABLED", "用户已禁用"), + USER_NOT_FOUND("SYSTEM.USER_NOT_FOUND", "用户不存在"), + ORG_NOT_FOUND("SYSTEM.ORG_NOT_FOUND", "组织不存在"), + ROLE_NOT_FOUND("SYSTEM.ROLE_NOT_FOUND", "角色不存在"), + MENU_NOT_FOUND("SYSTEM.MENU_NOT_FOUND", "菜单不存在"), + DICT_TYPE_NOT_FOUND("SYSTEM.DICT_TYPE_NOT_FOUND", "字典类型不存在"), + DICT_ITEM_NOT_FOUND("SYSTEM.DICT_ITEM_NOT_FOUND", "字典项不存在"), + TOKEN_VERSION_INVALID("AUTH.TOKEN_VERSION_INVALID", "登录状态已失效,请重新登录"), + INTERNAL_SERVER_ERROR("COMMON.INTERNAL_SERVER_ERROR", "服务器内部错误"), +} diff --git a/server/src/main/kotlin/com/bbit/ticket/common/PageQuery.kt b/server/src/main/kotlin/com/bbit/ticket/common/PageQuery.kt new file mode 100644 index 0000000..08f071a --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/common/PageQuery.kt @@ -0,0 +1,18 @@ +package com.bbit.ticket.common + +import kotlinx.serialization.Serializable + +@Serializable +data class PageQuery( + val page: Int = 1, + val pageSize: Int = 20, +) { + init { + require(page >= 1) { "page 必须大于等于 1" } + require(pageSize in 1..200) { "pageSize 必须在 1 到 200 之间" } + } + + val offset: Long + get() = ((page - 1) * pageSize).toLong() +} + diff --git a/server/src/main/kotlin/com/bbit/ticket/common/PageResult.kt b/server/src/main/kotlin/com/bbit/ticket/common/PageResult.kt new file mode 100644 index 0000000..286c386 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/common/PageResult.kt @@ -0,0 +1,12 @@ +package com.bbit.ticket.common + +import kotlinx.serialization.Serializable + +@Serializable +data class PageResult( + val items: List, + val page: Int, + val pageSize: Int, + val total: Long, +) + diff --git a/server/src/main/kotlin/com/bbit/ticket/common/RequestUtils.kt b/server/src/main/kotlin/com/bbit/ticket/common/RequestUtils.kt new file mode 100644 index 0000000..2e0d756 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/common/RequestUtils.kt @@ -0,0 +1,30 @@ +package com.bbit.ticket.common + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +fun ApplicationCall.queryString(name: String): String? = request.queryParameters[name]?.trim()?.takeIf { it.isNotEmpty() } + +fun ApplicationCall.queryInt(name: String, default: Int): Int { + val raw = request.queryParameters[name] ?: return default + val value = raw.toIntOrNull() ?: throw BizException( + ErrorCode.BAD_REQUEST.code, + "$name 必须是整数", + HttpStatusCode.BadRequest, + ) + if (name == "page" && value < 1) { + throw BizException(ErrorCode.BAD_REQUEST.code, "page 必须大于等于 1", HttpStatusCode.BadRequest) + } + if (name == "pageSize" && value !in 1..200) { + throw BizException(ErrorCode.BAD_REQUEST.code, "pageSize 必须在 1 到 200 之间", HttpStatusCode.BadRequest) + } + return value +} + +@OptIn(ExperimentalUuidApi::class) +fun parseUuid(value: String, fieldName: String): Uuid = + runCatching { Uuid.parse(value) }.getOrElse { + throw BizException(ErrorCode.BAD_REQUEST.code, "$fieldName 格式非法", HttpStatusCode.BadRequest) + } diff --git a/server/src/main/kotlin/com/bbit/ticket/common/TraceContext.kt b/server/src/main/kotlin/com/bbit/ticket/common/TraceContext.kt new file mode 100644 index 0000000..dd3965f --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/common/TraceContext.kt @@ -0,0 +1,10 @@ +package com.bbit.ticket.common + +import io.ktor.server.application.ApplicationCall +import io.ktor.util.AttributeKey + +val TraceIdKey = AttributeKey("traceId") + +fun ApplicationCall.traceIdOrNull(): String? = + attributes.getOrNull(TraceIdKey) + diff --git a/server/src/main/kotlin/com/bbit/ticket/config/AppConfig.kt b/server/src/main/kotlin/com/bbit/ticket/config/AppConfig.kt new file mode 100644 index 0000000..6f5b579 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/config/AppConfig.kt @@ -0,0 +1,94 @@ +package com.bbit.ticket.config + +import io.ktor.server.application.ApplicationEnvironment + +object AppConfig { + data class App( + val name: String, + val env: String, + ) + + data class Database( + val url: String, + val user: String, + val password: String, + val maximumPoolSize: Int, + val minimumIdle: Int, + ) + + data class Redis( + val url: String, + val password: String?, + ) + + data class Jwt( + val issuer: String, + val audience: String, + val realm: String, + val secret: String, + val accessTokenTtlMinutes: Long, + ) + + data class Cors( + val allowedHosts: List, + ) + + lateinit var app: App + private set + + lateinit var database: Database + private set + + lateinit var redis: Redis + private set + + lateinit var jwt: Jwt + private set + + lateinit var cors: Cors + private set + + fun init(environment: ApplicationEnvironment) { + app = App( + name = string(environment, "app.name", "Platform A"), + env = string(environment, "app.env", "local"), + ) + + database = Database( + url = string(environment, "database.url", "jdbc:postgresql://localhost:5432/platform_a"), + user = string(environment, "database.user", "platform_a"), + password = string(environment, "database.password", "platform_a_password"), + maximumPoolSize = int(environment, "database.maximumPoolSize", 16), + minimumIdle = int(environment, "database.minimumIdle", 4), + ) + + redis = Redis( + url = string(environment, "redis.url", "redis://127.0.0.1:6379"), + password = string(environment, "redis.password", "").ifBlank { null }, + ) + + jwt = Jwt( + issuer = string(environment, "security.jwt.issuer", "platform-a"), + audience = string(environment, "security.jwt.audience", "platform-a-admin"), + realm = string(environment, "security.jwt.realm", "Platform A"), + secret = string(environment, "security.jwt.secret", "change-me-to-a-strong-secret"), + accessTokenTtlMinutes = long(environment, "security.jwt.accessTokenTtlMinutes", 120), + ) + + cors = Cors( + allowedHosts = string(environment, "cors.allowedHosts", "localhost:5173,127.0.0.1:5173") + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() }, + ) + } + + private fun string(environment: ApplicationEnvironment, path: String, default: String): String = + environment.config.propertyOrNull(path)?.getString() ?: default + + private fun int(environment: ApplicationEnvironment, path: String, default: Int): Int = + string(environment, path, default.toString()).toInt() + + private fun long(environment: ApplicationEnvironment, path: String, default: Long): Long = + string(environment, path, default.toString()).toLong() +} diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysApiAccessLogTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysApiAccessLogTable.kt new file mode 100644 index 0000000..a8ae394 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysApiAccessLogTable.kt @@ -0,0 +1,26 @@ +package com.bbit.ticket.database.system + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) +object SysApiAccessLogTable : Table("sys_api_access_log") { + val id = uuid("id").clientDefault { Uuid.random() } + val traceId = varchar("trace_id", 64).nullable() + val appKey = varchar("app_key", 100).nullable() + val appName = varchar("app_name", 100).nullable() + val httpMethod = varchar("http_method", 20) + val requestPath = varchar("request_path", 255) + val requestHeaders = text("request_headers").nullable() + val requestBody = text("request_body").nullable() + val responseCode = varchar("response_code", 50).nullable() + val responseBody = text("response_body").nullable() + val ip = varchar("ip", 64).nullable() + val status = varchar("status", 20) + val errorMessage = text("error_message").nullable() + val costMs = long("cost_ms") + val createdAt = timestampWithTimeZone("created_at") + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysDictItemTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysDictItemTable.kt new file mode 100644 index 0000000..a4047a3 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysDictItemTable.kt @@ -0,0 +1,28 @@ +package com.bbit.ticket.database.system + + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +object SysDictItemTable : Table("sys_dict_item") { + val id = uuid("id").clientDefault { Uuid.random() } + val typeId = uuid("type_id").references(SysDictTypeTable.id) + val label = varchar("label", 100) + val value = varchar("value", 100) + val color = varchar("color", 30).nullable() + val sort = integer("sort").default(0) + val status = varchar("status", 20).default("ENABLED") + val remark = varchar("remark", 255).nullable() + val createdAt = timestampWithTimeZone("created_at") + val createdBy = uuid("created_by").nullable() + val updatedAt = timestampWithTimeZone("updated_at").nullable() + val updatedBy = uuid("updated_by").nullable() + val deletedAt = timestampWithTimeZone("deleted_at").nullable() + val deletedBy = uuid("deleted_by").nullable() + val version = integer("version").default(1) + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysDictTypeTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysDictTypeTable.kt new file mode 100644 index 0000000..a71ca5b --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysDictTypeTable.kt @@ -0,0 +1,24 @@ +package com.bbit.ticket.database.system + + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) +object SysDictTypeTable : Table("sys_dict_type") { + val id = uuid("id").clientDefault { Uuid.random() } + val code = varchar("code", 80).uniqueIndex() + val name = varchar("name", 100) + val status = varchar("status", 20).default("ENABLED") + val remark = varchar("remark", 255).nullable() + val createdAt = timestampWithTimeZone("created_at") + val createdBy = uuid("created_by").nullable() + val updatedAt = timestampWithTimeZone("updated_at").nullable() + val updatedBy = uuid("updated_by").nullable() + val deletedAt = timestampWithTimeZone("deleted_at").nullable() + val deletedBy = uuid("deleted_by").nullable() + val version = integer("version").default(1) + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysMenuTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysMenuTable.kt new file mode 100644 index 0000000..6809831 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysMenuTable.kt @@ -0,0 +1,34 @@ +package com.bbit.ticket.database.system + + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +object SysMenuTable : Table("sys_menu") { + val id = uuid("id").clientDefault { Uuid.random() } + val parentId = uuid("parent_id").nullable() + val type = varchar("type", 20) + val title = varchar("title", 100) + val name = varchar("name", 100).nullable() + val path = varchar("path", 255).nullable() + val component = varchar("component", 255).nullable() + val icon = varchar("icon", 100).nullable() + val permission = varchar("permission", 120).nullable() + val sort = integer("sort").default(0) + val visible = bool("visible").default(true) + val keepAlive = bool("keep_alive").default(false) + val builtIn = bool("built_in").default(false) + val status = varchar("status", 20).default("ENABLED") + val createdAt = timestampWithTimeZone("created_at") + val createdBy = uuid("created_by").nullable() + val updatedAt = timestampWithTimeZone("updated_at").nullable() + val updatedBy = uuid("updated_by").nullable() + val deletedAt = timestampWithTimeZone("deleted_at").nullable() + val deletedBy = uuid("deleted_by").nullable() + val version = integer("version").default(1) + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysOperationLogTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysOperationLogTable.kt new file mode 100644 index 0000000..ea33043 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysOperationLogTable.kt @@ -0,0 +1,28 @@ +package com.bbit.ticket.database.system + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +object SysOperationLogTable : Table("sys_operation_log") { + val id = uuid("id").clientDefault { Uuid.random() } + val traceId = varchar("trace_id", 64).nullable() + val userId = uuid("user_id").nullable() + val username = varchar("username", 50).nullable() + val orgId = uuid("org_id").nullable() + val operationType = varchar("operation_type", 50) + val operationName = varchar("operation_name", 100) + val httpMethod = varchar("http_method", 20) + val requestPath = varchar("request_path", 255) + val requestParams = text("request_params").nullable() + val ip = varchar("ip", 64).nullable() + val userAgent = varchar("user_agent", 255).nullable() + val status = varchar("status", 20) + val errorMessage = text("error_message").nullable() + val costMs = long("cost_ms") + val createdAt = timestampWithTimeZone("created_at") + + override val primaryKey = PrimaryKey(id) +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysOrgTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysOrgTable.kt new file mode 100644 index 0000000..9c79398 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysOrgTable.kt @@ -0,0 +1,25 @@ +package com.bbit.ticket.database.system + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +object SysOrgTable : Table("sys_org") { + val id = uuid("id").clientDefault { Uuid.random() } + val parentId = uuid("parent_id").nullable() + val name = varchar("name", 100) + val code = varchar("code", 50).uniqueIndex() + val sort = integer("sort").default(0) + val status = varchar("status", 20).default("ENABLED") + val createdAt = timestampWithTimeZone("created_at") + val createdBy = uuid("created_by").nullable() + val updatedAt = timestampWithTimeZone("updated_at").nullable() + val updatedBy = uuid("updated_by").nullable() + val deletedAt = timestampWithTimeZone("deleted_at").nullable() + val deletedBy = uuid("deleted_by").nullable() + val version = integer("version").default(1) + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysRoleMenuTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysRoleMenuTable.kt new file mode 100644 index 0000000..2507a8e --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysRoleMenuTable.kt @@ -0,0 +1,12 @@ +package com.bbit.ticket.database.system + +import org.jetbrains.exposed.v1.core.Table +import kotlin.uuid.ExperimentalUuidApi + +@OptIn(ExperimentalUuidApi::class) +object SysRoleMenuTable : Table("sys_role_menu") { + val roleId = uuid("role_id").references(SysRoleTable.id) + val menuId = uuid("menu_id").references(SysMenuTable.id) + + override val primaryKey = PrimaryKey(roleId, menuId) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysRoleTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysRoleTable.kt new file mode 100644 index 0000000..b994943 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysRoleTable.kt @@ -0,0 +1,25 @@ +package com.bbit.ticket.database.system + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +object SysRoleTable : Table("sys_role") { + val id = uuid("id").clientDefault { Uuid.random() } + val name = varchar("name", 100) + val code = varchar("code", 50).uniqueIndex() + val description = varchar("description", 255).nullable() + val status = varchar("status", 20).default("ENABLED") + val dataScope = varchar("data_scope", 30).default("SELF") + val createdAt = timestampWithTimeZone("created_at") + val createdBy = uuid("created_by").nullable() + val updatedAt = timestampWithTimeZone("updated_at").nullable() + val updatedBy = uuid("updated_by").nullable() + val deletedAt = timestampWithTimeZone("deleted_at").nullable() + val deletedBy = uuid("deleted_by").nullable() + val version = integer("version").default(1) + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysUserRoleTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysUserRoleTable.kt new file mode 100644 index 0000000..1aa9609 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysUserRoleTable.kt @@ -0,0 +1,12 @@ +package com.bbit.ticket.database.system + +import org.jetbrains.exposed.v1.core.Table +import kotlin.uuid.ExperimentalUuidApi + +@OptIn(ExperimentalUuidApi::class) +object SysUserRoleTable : Table("sys_user_role") { + val userId = uuid("user_id").references(SysUserTable.id) + val roleId = uuid("role_id").references(SysRoleTable.id) + + override val primaryKey = PrimaryKey(userId, roleId) +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysUserTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysUserTable.kt new file mode 100644 index 0000000..0fe49bf --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysUserTable.kt @@ -0,0 +1,32 @@ +package com.bbit.ticket.database.system + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +object SysUserTable : Table("sys_user") { + val id = uuid("id").clientDefault { Uuid.random() } + val username = varchar("username", 50).uniqueIndex() + val passwordHash = varchar("password_hash", 255) + val nickname = varchar("nickname", 50).nullable() + val realName = varchar("real_name", 50).nullable() + val phone = varchar("phone", 32).nullable() + val email = varchar("email", 100).nullable() + val avatar = text("avatar").nullable() + val orgId = uuid("org_id").nullable() + val status = varchar("status", 20).default("ENABLED") + val tokenVersion = integer("token_version").default(1) + val lastLoginAt = timestampWithTimeZone("last_login_at").nullable() + val lastLoginIp = varchar("last_login_ip", 64).nullable() + val createdAt = timestampWithTimeZone("created_at") + val createdBy = uuid("created_by").nullable() + val updatedAt = timestampWithTimeZone("updated_at").nullable() + val updatedBy = uuid("updated_by").nullable() + val deletedAt = timestampWithTimeZone("deleted_at").nullable() + val deletedBy = uuid("deleted_by").nullable() + val version = integer("version").default(1) + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/modules/auth/AuthDtos.kt b/server/src/main/kotlin/com/bbit/ticket/modules/auth/AuthDtos.kt new file mode 100644 index 0000000..7810815 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/modules/auth/AuthDtos.kt @@ -0,0 +1,51 @@ +package com.bbit.ticket.modules.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val username: String, + val password: String, +) + +@Serializable +data class LoginResponse( + val accessToken: String, + val tokenType: String = "Bearer", + val expiresIn: Long, +) + +@Serializable +data class MeResponse( + val user: CurrentUserProfile, + val menus: List, + val permissions: Set, +) + +@Serializable +data class CurrentUserProfile( + val id: String, + val username: String, + val nickname: String? = null, + val realName: String? = null, + val orgId: String? = null, + val status: String, +) + +@Serializable +data class MenuNode( + val id: String, + val parentId: String? = null, + val type: String, + val title: String, + val name: String? = null, + val path: String? = null, + val component: String? = null, + val icon: String? = null, + val permission: String? = null, + val sort: Int, + val visible: Boolean, + val keepAlive: Boolean, + val children: List = emptyList(), +) + diff --git a/server/src/main/kotlin/com/bbit/ticket/modules/auth/AuthRoutes.kt b/server/src/main/kotlin/com/bbit/ticket/modules/auth/AuthRoutes.kt new file mode 100644 index 0000000..cb29a6c --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/modules/auth/AuthRoutes.kt @@ -0,0 +1,44 @@ +package com.bbit.ticket.modules.auth + +import com.bbit.ticket.common.ok +import com.bbit.ticket.modules.logs.OperationLogService +import com.bbit.ticket.security.requireCurrentUser +import io.ktor.server.auth.authenticate +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import kotlin.time.TimeSource + +fun Route.registerAuthRoutes() { + route("/api/auth") { + post("/login") { + val start = TimeSource.Monotonic.markNow() + val request = call.receive() + runCatching { + val response = AuthService.login(request, call.request.local.remoteHost) + call.respond(ok(response)) + OperationLogService.success(call, null, "LOGIN", "登录成功", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, null, "LOGIN", "登录失败", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + + authenticate("auth-jwt") { + post("/logout") { + val currentUser = call.requireCurrentUser() + call.respond(ok(message = "退出成功")) + OperationLogService.success(call, currentUser, "LOGOUT", "退出登录", 0) + } + + get("/me") { + val currentUser = call.requireCurrentUser() + val response = AuthService.me(currentUser) + call.respond(ok(response)) + } + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/modules/auth/AuthService.kt b/server/src/main/kotlin/com/bbit/ticket/modules/auth/AuthService.kt new file mode 100644 index 0000000..586e662 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/modules/auth/AuthService.kt @@ -0,0 +1,204 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package com.bbit.ticket.modules.auth + +import com.bbit.ticket.common.BizException +import com.bbit.ticket.common.ErrorCode +import com.bbit.ticket.database.system.SysMenuTable +import com.bbit.ticket.database.system.SysRoleMenuTable +import com.bbit.ticket.database.system.SysRoleTable +import com.bbit.ticket.database.system.SysUserRoleTable +import com.bbit.ticket.database.system.SysUserTable +import com.bbit.ticket.plugins.dbQuery +import com.bbit.ticket.security.CurrentUser +import com.bbit.ticket.security.JwtService +import com.bbit.ticket.security.PasswordService +import io.ktor.http.HttpStatusCode +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import java.time.OffsetDateTime +import kotlin.uuid.Uuid + +object AuthService { + suspend fun login(request: LoginRequest, loginIp: String?): LoginResponse { + val username = request.username.trim() + if (username.isBlank() || request.password.isBlank()) { + throw BizException(ErrorCode.BAD_REQUEST.code, "用户名和密码不能为空", HttpStatusCode.BadRequest) + } + + val user = dbQuery { + SysUserTable.selectAll() + .where { (SysUserTable.username eq username) and SysUserTable.deletedAt.isNull() } + .singleOrNull() + } ?: throw BizException( + ErrorCode.USERNAME_OR_PASSWORD_INVALID.code, + ErrorCode.USERNAME_OR_PASSWORD_INVALID.message, + HttpStatusCode.BadRequest, + ) + + if (!PasswordService.matches(request.password, user[SysUserTable.passwordHash])) { + throw BizException( + ErrorCode.USERNAME_OR_PASSWORD_INVALID.code, + ErrorCode.USERNAME_OR_PASSWORD_INVALID.message, + HttpStatusCode.BadRequest, + ) + } + + if (user[SysUserTable.status] != "ENABLED") { + throw BizException(ErrorCode.USER_DISABLED.code, ErrorCode.USER_DISABLED.message, HttpStatusCode.BadRequest) + } + + val userId = user[SysUserTable.id] + val roleCodes = dbQuery { + (SysUserRoleTable innerJoin SysRoleTable) + .selectAll() + .where { + (SysUserRoleTable.userId eq userId) and + SysRoleTable.deletedAt.isNull() and + (SysRoleTable.status eq "ENABLED") + } + .map { it[SysRoleTable.code] } + } + + val (accessToken, expiresIn) = JwtService.issueAccessToken( + userId = userId.toString(), + username = user[SysUserTable.username], + orgId = user[SysUserTable.orgId]?.toString(), + roles = roleCodes, + tokenVersion = user[SysUserTable.tokenVersion], + ) + + dbQuery { + SysUserTable.update({ SysUserTable.id eq userId }) { + it[lastLoginAt] = OffsetDateTime.now() + it[lastLoginIp] = loginIp + it[updatedAt] = OffsetDateTime.now() + } + } + + return LoginResponse(accessToken = accessToken, expiresIn = expiresIn) + } + + suspend fun me(currentUser: CurrentUser): MeResponse { + val userRow = dbQuery { + SysUserTable.selectAll() + .where { (SysUserTable.id eq currentUser.id) and SysUserTable.deletedAt.isNull() } + .single() + } + + val allMenus = loadMenusForUser(currentUser) + val menuTree = buildMenuTree(allMenus) + val permissions = allMenus.mapNotNull { it.permission }.toSet() + + return MeResponse( + user = CurrentUserProfile( + id = currentUser.id.toString(), + username = userRow[SysUserTable.username], + nickname = userRow[SysUserTable.nickname], + realName = userRow[SysUserTable.realName], + orgId = userRow[SysUserTable.orgId]?.toString(), + status = userRow[SysUserTable.status], + ), + menus = menuTree, + permissions = permissions, + ) + } + + private suspend fun loadMenusForUser(currentUser: CurrentUser): List { + val rows = if (currentUser.isSuperAdmin) { + dbQuery { + SysMenuTable.selectAll() + .where { SysMenuTable.deletedAt.isNull() and (SysMenuTable.status eq "ENABLED") } + .toList() + } + } else { + val roleIds = dbQuery { + (SysUserRoleTable innerJoin SysRoleTable) + .selectAll() + .where { + (SysUserRoleTable.userId eq currentUser.id) and + SysRoleTable.deletedAt.isNull() and + (SysRoleTable.status eq "ENABLED") + } + .map { it[SysRoleTable.id] } + } + + if (roleIds.isEmpty()) { + emptyList() + } else { + dbQuery { + (SysRoleMenuTable innerJoin SysMenuTable) + .selectAll() + .where { + (SysRoleMenuTable.roleId inList roleIds) and + SysMenuTable.deletedAt.isNull() and + (SysMenuTable.status eq "ENABLED") + } + .distinct() + .toList() + } + } + } + + return rows.map { row -> + MenuFlat( + id = row[SysMenuTable.id], + parentId = row[SysMenuTable.parentId], + type = row[SysMenuTable.type], + title = row[SysMenuTable.title], + name = row[SysMenuTable.name], + path = row[SysMenuTable.path], + component = row[SysMenuTable.component], + icon = row[SysMenuTable.icon], + permission = row[SysMenuTable.permission], + sort = row[SysMenuTable.sort], + visible = row[SysMenuTable.visible], + keepAlive = row[SysMenuTable.keepAlive], + ) + }.sortedWith(compareBy { it.sort }.thenBy { it.id.toString() }) + } + + private fun buildMenuTree(flatMenus: List): List { + val parentMap = flatMenus.groupBy { it.parentId } + + fun build(parentId: Uuid?): List = + (parentMap[parentId] ?: emptyList()).map { menu -> + MenuNode( + id = menu.id.toString(), + parentId = menu.parentId?.toString(), + type = menu.type, + title = menu.title, + name = menu.name, + path = menu.path, + component = menu.component, + icon = menu.icon, + permission = menu.permission, + sort = menu.sort, + visible = menu.visible, + keepAlive = menu.keepAlive, + children = build(menu.id), + ) + } + + return build(null) + } +} + +private data class MenuFlat( + val id: Uuid, + val parentId: Uuid?, + val type: String, + val title: String, + val name: String?, + val path: String?, + val component: String?, + val icon: String?, + val permission: String?, + val sort: Int, + val visible: Boolean, + val keepAlive: Boolean, +) diff --git a/server/src/main/kotlin/com/bbit/ticket/modules/logs/LogsQueryModule.kt b/server/src/main/kotlin/com/bbit/ticket/modules/logs/LogsQueryModule.kt new file mode 100644 index 0000000..4f55de0 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/modules/logs/LogsQueryModule.kt @@ -0,0 +1,145 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package com.bbit.ticket.modules.logs + +import com.bbit.ticket.common.PageResult +import com.bbit.ticket.common.formatDateTime +import com.bbit.ticket.common.ok +import com.bbit.ticket.common.queryInt +import com.bbit.ticket.common.queryString +import com.bbit.ticket.database.system.SysApiAccessLogTable +import com.bbit.ticket.database.system.SysOperationLogTable +import com.bbit.ticket.plugins.dbQuery +import com.bbit.ticket.security.requirePermission +import io.ktor.server.auth.authenticate +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.jdbc.selectAll + +@Serializable +data class OperationLogItem( + val id: String, + val traceId: String? = null, + val username: String? = null, + val operationType: String, + val operationName: String, + val httpMethod: String, + val requestPath: String, + val status: String, + val errorMessage: String? = null, + val costMs: Long, + val createdAt: String, +) + +@Serializable +data class ApiAccessLogItem( + val id: String, + val traceId: String? = null, + val appKey: String? = null, + val appName: String? = null, + val httpMethod: String, + val requestPath: String, + val responseCode: String? = null, + val status: String, + val errorMessage: String? = null, + val costMs: Long, + val createdAt: String, +) + +object LogsQueryService { + suspend fun operationLogs(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult = dbQuery { + var where: Op = Op.TRUE + if (!keyword.isNullOrBlank()) { + where = where and ((SysOperationLogTable.username like "%$keyword%") or (SysOperationLogTable.requestPath like "%$keyword%")) + } + if (!status.isNullOrBlank()) where = where and (SysOperationLogTable.status eq status) + val total = SysOperationLogTable.selectAll().where { where }.count() + val rows = SysOperationLogTable.selectAll().where { where } + .orderBy(SysOperationLogTable.createdAt, SortOrder.DESC) + .limit(pageSize) + .offset(((page - 1) * pageSize).toLong()) + .toList() + PageResult( + items = rows.map { + OperationLogItem( + id = it[SysOperationLogTable.id].toString(), + traceId = it[SysOperationLogTable.traceId], + username = it[SysOperationLogTable.username], + operationType = it[SysOperationLogTable.operationType], + operationName = it[SysOperationLogTable.operationName], + httpMethod = it[SysOperationLogTable.httpMethod], + requestPath = it[SysOperationLogTable.requestPath], + status = it[SysOperationLogTable.status], + errorMessage = it[SysOperationLogTable.errorMessage], + costMs = it[SysOperationLogTable.costMs], + createdAt = formatDateTime(it[SysOperationLogTable.createdAt]) ?: "", + ) + }, + page = page, + pageSize = pageSize, + total = total, + ) + } + + suspend fun apiAccessLogs(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult = dbQuery { + var where: Op = Op.TRUE + if (!keyword.isNullOrBlank()) { + where = where and ((SysApiAccessLogTable.appName like "%$keyword%") or (SysApiAccessLogTable.requestPath like "%$keyword%")) + } + if (!status.isNullOrBlank()) where = where and (SysApiAccessLogTable.status eq status) + val total = SysApiAccessLogTable.selectAll().where { where }.count() + val rows = SysApiAccessLogTable.selectAll().where { where } + .orderBy(SysApiAccessLogTable.createdAt, SortOrder.DESC) + .limit(pageSize) + .offset(((page - 1) * pageSize).toLong()) + .toList() + PageResult( + items = rows.map { + ApiAccessLogItem( + id = it[SysApiAccessLogTable.id].toString(), + traceId = it[SysApiAccessLogTable.traceId], + appKey = it[SysApiAccessLogTable.appKey], + appName = it[SysApiAccessLogTable.appName], + httpMethod = it[SysApiAccessLogTable.httpMethod], + requestPath = it[SysApiAccessLogTable.requestPath], + responseCode = it[SysApiAccessLogTable.responseCode], + status = it[SysApiAccessLogTable.status], + errorMessage = it[SysApiAccessLogTable.errorMessage], + costMs = it[SysApiAccessLogTable.costMs], + createdAt = formatDateTime(it[SysApiAccessLogTable.createdAt]) ?: "", + ) + }, + page = page, + pageSize = pageSize, + total = total, + ) + } +} + +fun Route.registerLogsQueryRoutes() { + authenticate("auth-jwt") { + route("/api/logs") { + get("/operation") { + call.requirePermission("log:operation:view") + val page = call.queryInt("page", 1) + val pageSize = call.queryInt("pageSize", 20) + call.respond(ok(LogsQueryService.operationLogs(page, pageSize, call.queryString("keyword"), call.queryString("status")))) + } + get("/api-access") { + call.requirePermission("log:api-access:view") + val page = call.queryInt("page", 1) + val pageSize = call.queryInt("pageSize", 20) + call.respond(ok(LogsQueryService.apiAccessLogs(page, pageSize, call.queryString("keyword"), call.queryString("status")))) + } + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/modules/logs/OperationLogService.kt b/server/src/main/kotlin/com/bbit/ticket/modules/logs/OperationLogService.kt new file mode 100644 index 0000000..f9c6176 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/modules/logs/OperationLogService.kt @@ -0,0 +1,59 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package com.bbit.ticket.modules.logs + +import com.bbit.ticket.common.traceIdOrNull +import com.bbit.ticket.database.system.SysOperationLogTable +import com.bbit.ticket.plugins.dbQuery +import com.bbit.ticket.security.CurrentUser +import io.ktor.http.formUrlEncode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.httpMethod +import io.ktor.server.request.path +import org.jetbrains.exposed.v1.jdbc.insert +import java.time.OffsetDateTime + +object OperationLogService { + suspend fun success(call: ApplicationCall, currentUser: CurrentUser?, operationType: String, operationName: String, costMs: Long) { + save(call, currentUser, operationType, operationName, "SUCCESS", null, costMs) + } + + suspend fun fail( + call: ApplicationCall, + currentUser: CurrentUser?, + operationType: String, + operationName: String, + errorMessage: String?, + costMs: Long, + ) { + save(call, currentUser, operationType, operationName, "FAIL", errorMessage?.take(500), costMs) + } + + private suspend fun save( + call: ApplicationCall, + currentUser: CurrentUser?, + operationType: String, + operationName: String, + status: String, + errorMessage: String?, + costMs: Long, + ) = dbQuery { + SysOperationLogTable.insert { + it[traceId] = call.traceIdOrNull() + it[userId] = currentUser?.id + it[username] = currentUser?.username + it[orgId] = currentUser?.orgId + it[SysOperationLogTable.operationType] = operationType + it[SysOperationLogTable.operationName] = operationName + it[httpMethod] = call.request.httpMethod.value + it[requestPath] = call.request.path().take(255) + it[requestParams] = call.request.queryParameters.formUrlEncode().take(1000) + it[ip] = call.request.local.remoteHost.take(64) + it[userAgent] = call.request.headers["User-Agent"]?.take(255) + it[SysOperationLogTable.status] = status + it[SysOperationLogTable.errorMessage] = errorMessage + it[SysOperationLogTable.costMs] = costMs + it[createdAt] = OffsetDateTime.now() + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/modules/system/dict/DictModule.kt b/server/src/main/kotlin/com/bbit/ticket/modules/system/dict/DictModule.kt new file mode 100644 index 0000000..636f3d0 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/modules/system/dict/DictModule.kt @@ -0,0 +1,338 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package com.bbit.ticket.modules.system.dict + +import com.bbit.ticket.common.BizException +import com.bbit.ticket.common.ErrorCode +import com.bbit.ticket.common.PageResult +import com.bbit.ticket.common.ok +import com.bbit.ticket.common.parseUuid +import com.bbit.ticket.common.queryInt +import com.bbit.ticket.common.queryString +import com.bbit.ticket.common.statusLabel +import com.bbit.ticket.database.system.SysDictItemTable +import com.bbit.ticket.database.system.SysDictTypeTable +import com.bbit.ticket.modules.logs.OperationLogService +import com.bbit.ticket.plugins.dbQuery +import com.bbit.ticket.security.requirePermission +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.authenticate +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import java.time.OffsetDateTime +import kotlin.time.TimeSource +import kotlin.uuid.Uuid + +@Serializable +data class DictTypeItem( + val id: String, + val code: String, + val name: String, + val status: String, + val statusLabel: String, + val remark: String? = null, +) + +@Serializable +data class DictItem( + val id: String, + val typeId: String, + val label: String, + val value: String, + val color: String? = null, + val sort: Int, + val status: String, + val statusLabel: String, + val remark: String? = null, +) + +@Serializable +data class CreateDictTypeRequest(val code: String, val name: String, val status: String = "ENABLED", val remark: String? = null) + +@Serializable +data class UpdateDictTypeRequest(val name: String, val status: String = "ENABLED", val remark: String? = null) + +@Serializable +data class CreateDictItemRequest( + val typeId: String, + val label: String, + val value: String, + val color: String? = null, + val sort: Int = 0, + val status: String = "ENABLED", + val remark: String? = null, +) + +@Serializable +data class UpdateDictItemRequest( + val typeId: String, + val label: String, + val value: String, + val color: String? = null, + val sort: Int = 0, + val status: String = "ENABLED", + val remark: String? = null, +) + +object DictService { + suspend fun listTypes(page: Int, pageSize: Int, keyword: String?): PageResult = dbQuery { + var where = SysDictTypeTable.deletedAt.isNull() + if (!keyword.isNullOrBlank()) { + where = where and ((SysDictTypeTable.code like "%$keyword%") or (SysDictTypeTable.name like "%$keyword%")) + } + val total = SysDictTypeTable.selectAll().where { where }.count() + val rows = SysDictTypeTable.selectAll().where { where } + .orderBy(SysDictTypeTable.createdAt) + .limit(pageSize) + .offset(((page - 1) * pageSize).toLong()) + .toList() + PageResult( + items = rows.map { + DictTypeItem( + id = it[SysDictTypeTable.id].toString(), + code = it[SysDictTypeTable.code], + name = it[SysDictTypeTable.name], + status = it[SysDictTypeTable.status], + statusLabel = statusLabel(it[SysDictTypeTable.status]), + remark = it[SysDictTypeTable.remark], + ) + }, + page = page, + pageSize = pageSize, + total = total, + ) + } + + suspend fun createType(request: CreateDictTypeRequest): String = dbQuery { + if (request.code.trim().isBlank() || request.name.trim().isBlank()) { + throw BizException(ErrorCode.BAD_REQUEST.code, "字典类型编码和名称不能为空") + } + val exists = SysDictTypeTable.selectAll().where { + (SysDictTypeTable.code eq request.code.trim()) and SysDictTypeTable.deletedAt.isNull() + }.any() + if (exists) throw BizException(ErrorCode.DATA_CONFLICT.code, "字典类型编码已存在") + val inserted = SysDictTypeTable.insert { + it[code] = request.code.trim() + it[name] = request.name.trim() + it[status] = request.status + it[remark] = request.remark?.trim() + it[createdAt] = OffsetDateTime.now() + } + inserted[SysDictTypeTable.id].toString() + } + + suspend fun updateType(id: Uuid, request: UpdateDictTypeRequest) = dbQuery { + requireType(id) + SysDictTypeTable.update({ SysDictTypeTable.id eq id }) { + it[name] = request.name.trim() + it[status] = request.status + it[remark] = request.remark?.trim() + it[updatedAt] = OffsetDateTime.now() + } + } + + suspend fun deleteType(id: Uuid) = dbQuery { + requireType(id) + val hasItems = SysDictItemTable.selectAll().where { + (SysDictItemTable.typeId eq id) and SysDictItemTable.deletedAt.isNull() + }.any() + if (hasItems) throw BizException(ErrorCode.BAD_REQUEST.code, "字典类型下存在字典项,不能删除") + SysDictTypeTable.update({ SysDictTypeTable.id eq id }) { + it[deletedAt] = OffsetDateTime.now() + } + } + + suspend fun listItems(page: Int, pageSize: Int, typeId: Uuid?): PageResult = dbQuery { + var where = SysDictItemTable.deletedAt.isNull() + if (typeId != null) where = where and (SysDictItemTable.typeId eq typeId) + val total = SysDictItemTable.selectAll().where { where }.count() + val rows = SysDictItemTable.selectAll().where { where } + .orderBy(SysDictItemTable.sort) + .limit(pageSize) + .offset(((page - 1) * pageSize).toLong()) + .toList() + PageResult( + items = rows.map { + DictItem( + id = it[SysDictItemTable.id].toString(), + typeId = it[SysDictItemTable.typeId].toString(), + label = it[SysDictItemTable.label], + value = it[SysDictItemTable.value], + color = it[SysDictItemTable.color], + sort = it[SysDictItemTable.sort], + status = it[SysDictItemTable.status], + statusLabel = statusLabel(it[SysDictItemTable.status]), + remark = it[SysDictItemTable.remark], + ) + }, + page = page, + pageSize = pageSize, + total = total, + ) + } + + suspend fun createItem(request: CreateDictItemRequest): String = dbQuery { + val typeId = parseUuid(request.typeId, "typeId") + requireType(typeId) + val inserted = SysDictItemTable.insert { + it[SysDictItemTable.typeId] = typeId + it[label] = request.label.trim() + it[value] = request.value.trim() + it[color] = request.color?.trim() + it[sort] = request.sort + it[status] = request.status + it[remark] = request.remark?.trim() + it[createdAt] = OffsetDateTime.now() + } + inserted[SysDictItemTable.id].toString() + } + + suspend fun updateItem(id: Uuid, request: UpdateDictItemRequest) = dbQuery { + requireItem(id) + val typeId = parseUuid(request.typeId, "typeId") + requireType(typeId) + SysDictItemTable.update({ SysDictItemTable.id eq id }) { + it[SysDictItemTable.typeId] = typeId + it[label] = request.label.trim() + it[value] = request.value.trim() + it[color] = request.color?.trim() + it[sort] = request.sort + it[status] = request.status + it[remark] = request.remark?.trim() + it[updatedAt] = OffsetDateTime.now() + } + } + + suspend fun deleteItem(id: Uuid) = dbQuery { + requireItem(id) + SysDictItemTable.update({ SysDictItemTable.id eq id }) { + it[deletedAt] = OffsetDateTime.now() + } + } + + private fun requireType(id: Uuid): ResultRow = + SysDictTypeTable.selectAll().where { (SysDictTypeTable.id eq id) and SysDictTypeTable.deletedAt.isNull() }.singleOrNull() + ?: throw BizException(ErrorCode.DICT_TYPE_NOT_FOUND.code, ErrorCode.DICT_TYPE_NOT_FOUND.message, HttpStatusCode.NotFound) + + private fun requireItem(id: Uuid): ResultRow = + SysDictItemTable.selectAll().where { (SysDictItemTable.id eq id) and SysDictItemTable.deletedAt.isNull() }.singleOrNull() + ?: throw BizException(ErrorCode.DICT_ITEM_NOT_FOUND.code, ErrorCode.DICT_ITEM_NOT_FOUND.message, HttpStatusCode.NotFound) +} + +fun Route.registerDictRoutes() { + authenticate("auth-jwt") { + route("/api/system/dict-types") { + get { + call.requirePermission("system:dict:view") + val page = call.queryInt("page", 1) + val pageSize = call.queryInt("pageSize", 20) + call.respond(ok(DictService.listTypes(page, pageSize, call.queryString("keyword")))) + } + post { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:dict:create") + val request = call.receive() + runCatching { + val id = DictService.createType(request) + call.respond(ok(mapOf("id" to id))) + OperationLogService.success(call, currentUser, "CREATE", "新增字典类型", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "CREATE", "新增字典类型", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + put("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:dict:update") + val id = parseUuid(call.parameters["id"] ?: "", "id") + val request = call.receive() + runCatching { + DictService.updateType(id, request) + call.respond(ok(message = "更新成功")) + OperationLogService.success(call, currentUser, "UPDATE", "更新字典类型", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "UPDATE", "更新字典类型", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + delete("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:dict:delete") + val id = parseUuid(call.parameters["id"] ?: "", "id") + runCatching { + DictService.deleteType(id) + call.respond(ok(message = "删除成功")) + OperationLogService.success(call, currentUser, "DELETE", "删除字典类型", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "DELETE", "删除字典类型", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + } + route("/api/system/dict-items") { + get { + call.requirePermission("system:dict:view") + val page = call.queryInt("page", 1) + val pageSize = call.queryInt("pageSize", 20) + val typeId = call.queryString("typeId")?.let { parseUuid(it, "typeId") } + call.respond(ok(DictService.listItems(page, pageSize, typeId))) + } + post { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:dict:create") + val request = call.receive() + runCatching { + val id = DictService.createItem(request) + call.respond(ok(mapOf("id" to id))) + OperationLogService.success(call, currentUser, "CREATE", "新增字典项", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "CREATE", "新增字典项", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + put("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:dict:update") + val id = parseUuid(call.parameters["id"] ?: "", "id") + val request = call.receive() + runCatching { + DictService.updateItem(id, request) + call.respond(ok(message = "更新成功")) + OperationLogService.success(call, currentUser, "UPDATE", "更新字典项", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "UPDATE", "更新字典项", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + delete("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:dict:delete") + val id = parseUuid(call.parameters["id"] ?: "", "id") + runCatching { + DictService.deleteItem(id) + call.respond(ok(message = "删除成功")) + OperationLogService.success(call, currentUser, "DELETE", "删除字典项", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "DELETE", "删除字典项", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/modules/system/menu/MenuModule.kt b/server/src/main/kotlin/com/bbit/ticket/modules/system/menu/MenuModule.kt new file mode 100644 index 0000000..b95897c --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/modules/system/menu/MenuModule.kt @@ -0,0 +1,280 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package com.bbit.ticket.modules.system.menu + +import com.bbit.ticket.common.BizException +import com.bbit.ticket.common.ErrorCode +import com.bbit.ticket.common.ok +import com.bbit.ticket.common.parseUuid +import com.bbit.ticket.common.menuTypeLabel +import com.bbit.ticket.common.statusLabel +import com.bbit.ticket.database.system.SysMenuTable +import com.bbit.ticket.database.system.SysRoleMenuTable +import com.bbit.ticket.modules.logs.OperationLogService +import com.bbit.ticket.plugins.dbQuery +import com.bbit.ticket.security.requirePermission +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.authenticate +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import java.time.OffsetDateTime +import kotlin.time.TimeSource +import kotlin.uuid.Uuid + +@Serializable +data class MenuTreeNode( + val id: String, + val parentId: String? = null, + val type: String, + val typeLabel: String, + val title: String, + val name: String? = null, + val path: String? = null, + val component: String? = null, + val icon: String? = null, + val permission: String? = null, + val sort: Int, + val visible: Boolean, + val keepAlive: Boolean, + val builtIn: Boolean = false, + val status: String, + val statusLabel: String, + val children: List = emptyList(), +) + +@Serializable +data class CreateMenuRequest( + val parentId: String? = null, + val type: String, + val title: String, + val name: String? = null, + val path: String? = null, + val component: String? = null, + val icon: String? = null, + val permission: String? = null, + val sort: Int = 0, + val visible: Boolean = true, + val keepAlive: Boolean = false, + val status: String = "ENABLED", +) + +@Serializable +data class UpdateMenuRequest( + val parentId: String? = null, + val type: String, + val title: String, + val name: String? = null, + val path: String? = null, + val component: String? = null, + val icon: String? = null, + val permission: String? = null, + val sort: Int = 0, + val visible: Boolean = true, + val keepAlive: Boolean = false, + val status: String = "ENABLED", +) + +object MenuService { + suspend fun tree(): List = dbQuery { + val rows = SysMenuTable.selectAll().where { SysMenuTable.deletedAt.isNull() }.toList() + val flat = rows.map { + MenuFlat( + id = it[SysMenuTable.id], + parentId = it[SysMenuTable.parentId], + type = it[SysMenuTable.type], + title = it[SysMenuTable.title], + name = it[SysMenuTable.name], + path = it[SysMenuTable.path], + component = it[SysMenuTable.component], + icon = it[SysMenuTable.icon], + permission = it[SysMenuTable.permission], + sort = it[SysMenuTable.sort], + visible = it[SysMenuTable.visible], + keepAlive = it[SysMenuTable.keepAlive], + builtIn = it[SysMenuTable.builtIn], + status = it[SysMenuTable.status], + ) + } + buildTree(flat) + } + + suspend fun create(request: CreateMenuRequest): String = dbQuery { + validateMenuType(request.type) + val parentId = request.parentId?.let { parseUuid(it, "parentId") } + if (parentId != null) requireMenu(parentId) + val inserted = SysMenuTable.insert { + it[SysMenuTable.parentId] = parentId + it[SysMenuTable.type] = request.type + it[title] = request.title.trim() + it[name] = request.name?.trim() + it[path] = request.path?.trim() + it[component] = request.component?.trim() + it[icon] = request.icon?.trim() + it[permission] = request.permission?.trim() + it[sort] = request.sort + it[visible] = request.visible + it[keepAlive] = request.keepAlive + it[builtIn] = false + it[status] = request.status + it[createdAt] = OffsetDateTime.now() + } + inserted[SysMenuTable.id].toString() + } + + suspend fun update(id: Uuid, request: UpdateMenuRequest) = dbQuery { + requireMenu(id) + validateMenuType(request.type) + val parentId = request.parentId?.let { parseUuid(it, "parentId") } + if (parentId == id) throw BizException(ErrorCode.BAD_REQUEST.code, "上级菜单不能选择自身") + if (parentId != null) requireMenu(parentId) + SysMenuTable.update({ SysMenuTable.id eq id }) { + it[SysMenuTable.parentId] = parentId + it[SysMenuTable.type] = request.type + it[title] = request.title.trim() + it[name] = request.name?.trim() + it[path] = request.path?.trim() + it[component] = request.component?.trim() + it[icon] = request.icon?.trim() + it[permission] = request.permission?.trim() + it[sort] = request.sort + it[visible] = request.visible + it[keepAlive] = request.keepAlive + it[status] = request.status + it[updatedAt] = OffsetDateTime.now() + } + } + + suspend fun delete(id: Uuid) = dbQuery { + requireMenu(id) + val hasChildren = SysMenuTable.selectAll().where { + (SysMenuTable.parentId eq id) and SysMenuTable.deletedAt.isNull() + }.any() + if (hasChildren) throw BizException(ErrorCode.BAD_REQUEST.code, "存在子菜单,不能删除") + val referenced = SysRoleMenuTable.selectAll().where { SysRoleMenuTable.menuId eq id }.any() + if (referenced) throw BizException(ErrorCode.BAD_REQUEST.code, "菜单已被角色引用,不能删除") + val row = requireMenu(id) + if (row[SysMenuTable.builtIn]) throw BizException(ErrorCode.BAD_REQUEST.code, "基础框架内置菜单不可删除") + SysMenuTable.update({ SysMenuTable.id eq id }) { + it[deletedAt] = OffsetDateTime.now() + } + } + + private fun requireMenu(id: Uuid): ResultRow = + SysMenuTable.selectAll().where { (SysMenuTable.id eq id) and SysMenuTable.deletedAt.isNull() }.singleOrNull() + ?: throw BizException(ErrorCode.MENU_NOT_FOUND.code, ErrorCode.MENU_NOT_FOUND.message, HttpStatusCode.NotFound) + + private fun validateMenuType(type: String) { + if (type !in setOf("CATALOG", "MENU", "BUTTON")) { + throw BizException(ErrorCode.BAD_REQUEST.code, "菜单类型必须是目录、菜单或按钮") + } + } + + private fun buildTree(items: List): List { + val grouped = items.groupBy { it.parentId } + fun children(parentId: Uuid?): List = + (grouped[parentId] ?: emptyList()).sortedBy { it.sort }.map { menu -> + MenuTreeNode( + id = menu.id.toString(), + parentId = menu.parentId?.toString(), + type = menu.type, + typeLabel = menuTypeLabel(menu.type), + title = menu.title, + name = menu.name, + path = menu.path, + component = menu.component, + icon = menu.icon, + permission = menu.permission, + sort = menu.sort, + visible = menu.visible, + keepAlive = menu.keepAlive, + builtIn = menu.builtIn, + status = menu.status, + statusLabel = statusLabel(menu.status), + children = children(menu.id), + ) + } + return children(null) + } +} + +private data class MenuFlat( + val id: Uuid, + val parentId: Uuid?, + val type: String, + val title: String, + val name: String?, + val path: String?, + val component: String?, + val icon: String?, + val permission: String?, + val sort: Int, + val visible: Boolean, + val keepAlive: Boolean, + val builtIn: Boolean, + val status: String, +) + +fun Route.registerMenuRoutes() { + authenticate("auth-jwt") { + route("/api/system/menus") { + get { + call.requirePermission("system:menu:view") + call.respond(ok(MenuService.tree())) + } + post { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:menu:create") + val request = call.receive() + runCatching { + val id = MenuService.create(request) + call.respond(ok(mapOf("id" to id))) + OperationLogService.success(call, currentUser, "CREATE", "新增菜单", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "CREATE", "新增菜单", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + put("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:menu:update") + val id = parseUuid(call.parameters["id"] ?: "", "id") + val request = call.receive() + runCatching { + MenuService.update(id, request) + call.respond(ok(message = "更新成功")) + OperationLogService.success(call, currentUser, "UPDATE", "更新菜单", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "UPDATE", "更新菜单", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + delete("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:menu:delete") + val id = parseUuid(call.parameters["id"] ?: "", "id") + runCatching { + MenuService.delete(id) + call.respond(ok(message = "删除成功")) + OperationLogService.success(call, currentUser, "DELETE", "删除菜单", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "DELETE", "删除菜单", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/modules/system/org/OrgModule.kt b/server/src/main/kotlin/com/bbit/ticket/modules/system/org/OrgModule.kt new file mode 100644 index 0000000..c3cfeb3 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/modules/system/org/OrgModule.kt @@ -0,0 +1,228 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package com.bbit.ticket.modules.system.org + +import com.bbit.ticket.common.BizException +import com.bbit.ticket.common.ErrorCode +import com.bbit.ticket.common.ok +import com.bbit.ticket.common.parseUuid +import com.bbit.ticket.common.statusLabel +import com.bbit.ticket.database.system.SysOrgTable +import com.bbit.ticket.database.system.SysUserTable +import com.bbit.ticket.modules.logs.OperationLogService +import com.bbit.ticket.plugins.dbQuery +import com.bbit.ticket.security.requirePermission +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.authenticate +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import java.time.OffsetDateTime +import kotlin.time.TimeSource +import kotlin.uuid.Uuid + +@Serializable +data class OrgTreeNode( + val id: String, + val parentId: String? = null, + val name: String, + val code: String, + val sort: Int, + val status: String, + val statusLabel: String, + val children: List = emptyList(), +) + +@Serializable +data class CreateOrgRequest( + val parentId: String? = null, + val name: String, + val code: String, + val sort: Int = 0, + val status: String = "ENABLED", +) + +@Serializable +data class UpdateOrgRequest( + val parentId: String? = null, + val name: String, + val sort: Int = 0, + val status: String = "ENABLED", +) + +object OrgService { + suspend fun tree(): List = dbQuery { + val rows = SysOrgTable.selectAll() + .where { SysOrgTable.deletedAt.isNull() } + .orderBy(SysOrgTable.sort) + .toList() + val nodes = rows.map(::toNode) + buildTree(nodes) + } + + suspend fun create(request: CreateOrgRequest): String = dbQuery { + val code = request.code.trim() + if (code.isBlank() || request.name.trim().isBlank()) { + throw BizException(ErrorCode.BAD_REQUEST.code, "组织名称和编码不能为空") + } + val exists = SysOrgTable.selectAll().where { + (SysOrgTable.code eq code) and SysOrgTable.deletedAt.isNull() + }.any() + if (exists) { + throw BizException(ErrorCode.DATA_CONFLICT.code, "组织编码已存在") + } + val parentId = request.parentId?.let { parseUuid(it, "parentId") } + if (parentId != null) requireOrg(parentId) + val inserted = SysOrgTable.insert { + it[SysOrgTable.parentId] = parentId + it[name] = request.name.trim() + it[SysOrgTable.code] = code + it[sort] = request.sort + it[status] = request.status + it[createdAt] = OffsetDateTime.now() + } + inserted[SysOrgTable.id].toString() + } + + suspend fun update(id: Uuid, request: UpdateOrgRequest) = dbQuery { + requireOrg(id) + val parentId = request.parentId?.let { parseUuid(it, "parentId") } + if (parentId == id) { + throw BizException(ErrorCode.BAD_REQUEST.code, "上级组织不能选择自身") + } + if (parentId != null) requireOrg(parentId) + SysOrgTable.update({ SysOrgTable.id eq id }) { + it[SysOrgTable.parentId] = parentId + it[name] = request.name.trim() + it[sort] = request.sort + it[status] = request.status + it[updatedAt] = OffsetDateTime.now() + } + } + + suspend fun delete(id: Uuid) = dbQuery { + val org = requireOrg(id) + if (org[SysOrgTable.code] == "DEFAULT_ORG") { + throw BizException(ErrorCode.BAD_REQUEST.code, "默认组织不可删除") + } + val hasChildren = SysOrgTable.selectAll() + .where { (SysOrgTable.parentId eq id) and SysOrgTable.deletedAt.isNull() } + .any() + if (hasChildren) { + throw BizException(ErrorCode.BAD_REQUEST.code, "当前组织存在子组织,不能删除") + } + val hasUsers = SysUserTable.selectAll() + .where { (SysUserTable.orgId eq id) and SysUserTable.deletedAt.isNull() } + .any() + if (hasUsers) { + throw BizException(ErrorCode.BAD_REQUEST.code, "当前组织存在用户,不能删除") + } + SysOrgTable.update({ SysOrgTable.id eq id }) { + it[deletedAt] = OffsetDateTime.now() + } + } + + private fun requireOrg(id: Uuid): ResultRow = + SysOrgTable.selectAll().where { (SysOrgTable.id eq id) and SysOrgTable.deletedAt.isNull() }.singleOrNull() + ?: throw BizException(ErrorCode.ORG_NOT_FOUND.code, ErrorCode.ORG_NOT_FOUND.message, HttpStatusCode.NotFound) + + private fun toNode(row: ResultRow): OrgNodeFlat = OrgNodeFlat( + id = row[SysOrgTable.id], + parentId = row[SysOrgTable.parentId], + name = row[SysOrgTable.name], + code = row[SysOrgTable.code], + sort = row[SysOrgTable.sort], + status = row[SysOrgTable.status], + ) + + private fun buildTree(nodes: List): List { + val byParent = nodes.groupBy { it.parentId } + fun children(parentId: Uuid?): List = + (byParent[parentId] ?: emptyList()).sortedBy { it.sort }.map { item -> + OrgTreeNode( + id = item.id.toString(), + parentId = item.parentId?.toString(), + name = item.name, + code = item.code, + sort = item.sort, + status = item.status, + statusLabel = statusLabel(item.status), + children = children(item.id), + ) + } + return children(null) + } +} + +private data class OrgNodeFlat( + val id: Uuid, + val parentId: Uuid?, + val name: String, + val code: String, + val sort: Int, + val status: String, +) + +fun Route.registerOrgRoutes() { + authenticate("auth-jwt") { + route("/api/system/orgs") { + get { + call.requirePermission("system:org:view") + call.respond(ok(OrgService.tree())) + } + post { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:org:create") + val request = call.receive() + runCatching { + val id = OrgService.create(request) + call.respond(ok(mapOf("id" to id))) + OperationLogService.success(call, currentUser, "CREATE", "新增组织", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "CREATE", "新增组织", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + put("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:org:update") + val id = parseUuid(call.parameters["id"] ?: "", "id") + val request = call.receive() + runCatching { + OrgService.update(id, request) + call.respond(ok(message = "更新成功")) + OperationLogService.success(call, currentUser, "UPDATE", "更新组织", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "UPDATE", "更新组织", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + delete("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:org:delete") + val id = parseUuid(call.parameters["id"] ?: "", "id") + runCatching { + OrgService.delete(id) + call.respond(ok(message = "删除成功")) + OperationLogService.success(call, currentUser, "DELETE", "删除组织", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "DELETE", "删除组织", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/modules/system/role/RoleModule.kt b/server/src/main/kotlin/com/bbit/ticket/modules/system/role/RoleModule.kt new file mode 100644 index 0000000..9836c8c --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/modules/system/role/RoleModule.kt @@ -0,0 +1,285 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package com.bbit.ticket.modules.system.role + +import com.bbit.ticket.common.BizException +import com.bbit.ticket.common.ErrorCode +import com.bbit.ticket.common.PageResult +import com.bbit.ticket.common.ok +import com.bbit.ticket.common.parseUuid +import com.bbit.ticket.common.queryInt +import com.bbit.ticket.common.queryString +import com.bbit.ticket.common.dataScopeLabel +import com.bbit.ticket.common.statusLabel +import com.bbit.ticket.database.system.SysMenuTable +import com.bbit.ticket.database.system.SysRoleMenuTable +import com.bbit.ticket.database.system.SysRoleTable +import com.bbit.ticket.database.system.SysUserRoleTable +import com.bbit.ticket.modules.logs.OperationLogService +import com.bbit.ticket.plugins.dbQuery +import com.bbit.ticket.security.requirePermission +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.authenticate +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.insertIgnore +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import java.time.OffsetDateTime +import kotlin.time.TimeSource +import kotlin.uuid.Uuid + +@Serializable +data class RoleItem( + val id: String, + val name: String, + val code: String, + val description: String? = null, + val status: String, + val statusLabel: String, + val dataScope: String, + val dataScopeLabel: String, +) + +@Serializable +data class RoleDetail( + val id: String, + val name: String, + val code: String, + val description: String? = null, + val status: String, + val statusLabel: String, + val dataScope: String, + val dataScopeLabel: String, + val menuIds: List, +) + +@Serializable +data class CreateRoleRequest( + val name: String, + val code: String, + val description: String? = null, + val status: String = "ENABLED", + val dataScope: String = "SELF", +) + +@Serializable +data class UpdateRoleRequest( + val name: String, + val description: String? = null, + val status: String = "ENABLED", + val dataScope: String = "SELF", +) + +@Serializable +data class UpdateRoleMenusRequest(val menuIds: List) + +object RoleService { + suspend fun list(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult = dbQuery { + var where = SysRoleTable.deletedAt.isNull() + if (!keyword.isNullOrBlank()) { + where = where and ((SysRoleTable.name like "%$keyword%") or (SysRoleTable.code like "%$keyword%")) + } + if (!status.isNullOrBlank()) { + where = where and (SysRoleTable.status eq status) + } + val total = SysRoleTable.selectAll().where { where }.count() + val rows = SysRoleTable.selectAll().where { where } + .orderBy(SysRoleTable.createdAt) + .limit(pageSize) + .offset(((page - 1) * pageSize).toLong()) + .toList() + PageResult( + items = rows.map { + RoleItem( + id = it[SysRoleTable.id].toString(), + name = it[SysRoleTable.name], + code = it[SysRoleTable.code], + description = it[SysRoleTable.description], + status = it[SysRoleTable.status], + statusLabel = statusLabel(it[SysRoleTable.status]), + dataScope = it[SysRoleTable.dataScope], + dataScopeLabel = dataScopeLabel(it[SysRoleTable.dataScope]), + ) + }, + page = page, + pageSize = pageSize, + total = total, + ) + } + + suspend fun create(request: CreateRoleRequest): String = dbQuery { + if (request.name.trim().isBlank() || request.code.trim().isBlank()) { + throw BizException(ErrorCode.BAD_REQUEST.code, "角色名称和编码不能为空") + } + val exists = SysRoleTable.selectAll().where { + (SysRoleTable.code eq request.code.trim()) and SysRoleTable.deletedAt.isNull() + }.any() + if (exists) throw BizException(ErrorCode.DATA_CONFLICT.code, "角色编码已存在") + val inserted = SysRoleTable.insert { + it[name] = request.name.trim() + it[code] = request.code.trim() + it[description] = request.description?.trim() + it[status] = request.status + it[dataScope] = request.dataScope + it[createdAt] = OffsetDateTime.now() + } + inserted[SysRoleTable.id].toString() + } + + suspend fun detail(id: Uuid): RoleDetail = dbQuery { + val role = requireRole(id) + val menuIds = SysRoleMenuTable.selectAll().where { SysRoleMenuTable.roleId eq id }.map { it[SysRoleMenuTable.menuId].toString() } + RoleDetail( + id = role[SysRoleTable.id].toString(), + name = role[SysRoleTable.name], + code = role[SysRoleTable.code], + description = role[SysRoleTable.description], + status = role[SysRoleTable.status], + statusLabel = statusLabel(role[SysRoleTable.status]), + dataScope = role[SysRoleTable.dataScope], + dataScopeLabel = dataScopeLabel(role[SysRoleTable.dataScope]), + menuIds = menuIds, + ) + } + + suspend fun update(id: Uuid, request: UpdateRoleRequest) = dbQuery { + requireRole(id) + SysRoleTable.update({ SysRoleTable.id eq id }) { + it[name] = request.name.trim() + it[description] = request.description?.trim() + it[status] = request.status + it[dataScope] = request.dataScope + it[updatedAt] = OffsetDateTime.now() + } + } + + suspend fun delete(id: Uuid) = dbQuery { + val role = requireRole(id) + if (role[SysRoleTable.code] == "SUPER_ADMIN") { + throw BizException(ErrorCode.BAD_REQUEST.code, "超级管理员角色不可删除") + } + val inUse = SysUserRoleTable.selectAll().where { SysUserRoleTable.roleId eq id }.any() + if (inUse) { + throw BizException(ErrorCode.BAD_REQUEST.code, "角色已被用户使用,不能删除") + } + SysRoleTable.update({ SysRoleTable.id eq id }) { + it[deletedAt] = OffsetDateTime.now() + } + SysRoleMenuTable.deleteWhere { SysRoleMenuTable.roleId eq id } + } + + suspend fun updateMenus(id: Uuid, request: UpdateRoleMenusRequest) = dbQuery { + requireRole(id) + val menuIds = request.menuIds.distinct().map { parseUuid(it, "menuId") } + if (menuIds.isNotEmpty()) { + val validCount = SysMenuTable.selectAll().where { + (SysMenuTable.id inList menuIds) and + SysMenuTable.deletedAt.isNull() and + (SysMenuTable.status eq "ENABLED") + }.count() + if (validCount != menuIds.size.toLong()) { + throw BizException(ErrorCode.BAD_REQUEST.code, "包含不存在或禁用菜单") + } + } + SysRoleMenuTable.deleteWhere { SysRoleMenuTable.roleId eq id } + menuIds.forEach { menuId -> + SysRoleMenuTable.insertIgnore { + it[roleId] = id + it[SysRoleMenuTable.menuId] = menuId + } + } + } + + private fun requireRole(id: Uuid): ResultRow = + SysRoleTable.selectAll().where { (SysRoleTable.id eq id) and SysRoleTable.deletedAt.isNull() }.singleOrNull() + ?: throw BizException(ErrorCode.ROLE_NOT_FOUND.code, ErrorCode.ROLE_NOT_FOUND.message, HttpStatusCode.NotFound) +} + +fun Route.registerRoleRoutes() { + authenticate("auth-jwt") { + route("/api/system/roles") { + get { + call.requirePermission("system:role:view") + val page = call.queryInt("page", 1) + val pageSize = call.queryInt("pageSize", 20) + call.respond(ok(RoleService.list(page, pageSize, call.queryString("keyword"), call.queryString("status")))) + } + post { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:role:create") + val request = call.receive() + runCatching { + val id = RoleService.create(request) + call.respond(ok(mapOf("id" to id))) + OperationLogService.success(call, currentUser, "CREATE", "新增角色", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "CREATE", "新增角色", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + get("/{id}") { + call.requirePermission("system:role:view") + val id = parseUuid(call.parameters["id"] ?: "", "id") + call.respond(ok(RoleService.detail(id))) + } + put("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:role:update") + val id = parseUuid(call.parameters["id"] ?: "", "id") + val request = call.receive() + runCatching { + RoleService.update(id, request) + call.respond(ok(message = "更新成功")) + OperationLogService.success(call, currentUser, "UPDATE", "更新角色", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "UPDATE", "更新角色", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + delete("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:role:delete") + val id = parseUuid(call.parameters["id"] ?: "", "id") + runCatching { + RoleService.delete(id) + call.respond(ok(message = "删除成功")) + OperationLogService.success(call, currentUser, "DELETE", "删除角色", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "DELETE", "删除角色", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + put("/{id}/menus") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:role:assign") + val id = parseUuid(call.parameters["id"] ?: "", "id") + val request = call.receive() + runCatching { + RoleService.updateMenus(id, request) + call.respond(ok(message = "菜单分配成功")) + OperationLogService.success(call, currentUser, "ASSIGN_MENU", "分配角色菜单", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "ASSIGN_MENU", "分配角色菜单", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/modules/system/user/UserModule.kt b/server/src/main/kotlin/com/bbit/ticket/modules/system/user/UserModule.kt new file mode 100644 index 0000000..c96de46 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/modules/system/user/UserModule.kt @@ -0,0 +1,421 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package com.bbit.ticket.modules.system.user + +import com.bbit.ticket.common.BizException +import com.bbit.ticket.common.ErrorCode +import com.bbit.ticket.common.PageResult +import com.bbit.ticket.common.ok +import com.bbit.ticket.common.parseUuid +import com.bbit.ticket.common.queryInt +import com.bbit.ticket.common.queryString +import com.bbit.ticket.common.statusLabel +import com.bbit.ticket.database.system.SysOrgTable +import com.bbit.ticket.database.system.SysRoleTable +import com.bbit.ticket.database.system.SysUserRoleTable +import com.bbit.ticket.database.system.SysUserTable +import com.bbit.ticket.modules.logs.OperationLogService +import com.bbit.ticket.plugins.dbQuery +import com.bbit.ticket.security.PasswordService +import com.bbit.ticket.security.requirePermission +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.authenticate +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.route +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.insertIgnore +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import java.time.OffsetDateTime +import kotlin.time.TimeSource +import kotlin.uuid.Uuid + +@Serializable +data class UserListItem( + val id: String, + val username: String, + val nickname: String? = null, + val realName: String? = null, + val orgId: String? = null, + val status: String, + val statusLabel: String, + val roleCodes: List, +) + +@Serializable +data class UserDetailResponse( + val id: String, + val username: String, + val nickname: String? = null, + val realName: String? = null, + val phone: String? = null, + val email: String? = null, + val avatar: String? = null, + val orgId: String? = null, + val status: String, + val statusLabel: String, + val roleIds: List, +) + +@Serializable +data class CreateUserRequest( + val username: String, + val password: String, + val nickname: String? = null, + val realName: String? = null, + val phone: String? = null, + val email: String? = null, + val avatar: String? = null, + val orgId: String? = null, + val status: String = "ENABLED", +) + +@Serializable +data class UpdateUserRequest( + val nickname: String? = null, + val realName: String? = null, + val phone: String? = null, + val email: String? = null, + val avatar: String? = null, + val orgId: String? = null, +) + +@Serializable +data class UpdateUserStatusRequest(val status: String) + +@Serializable +data class UpdateUserPasswordRequest(val password: String) + +@Serializable +data class UpdateUserRolesRequest(val roleIds: List) + +object UserService { + suspend fun list( + page: Int, + pageSize: Int, + username: String?, + nickname: String?, + status: String?, + orgId: Uuid?, + ): PageResult = dbQuery { + val where = buildWhere(username, nickname, status, orgId) + val total = SysUserTable.selectAll().where { where }.count() + val rows = SysUserTable.selectAll() + .where { where } + .orderBy(SysUserTable.createdAt) + .limit(pageSize) + .offset(((page - 1) * pageSize).toLong()) + .toList() + + val userIds = rows.map { it[SysUserTable.id] } + val roleMap = if (userIds.isEmpty()) { + emptyMap() + } else { + (SysUserRoleTable innerJoin SysRoleTable).selectAll() + .where { + (SysUserRoleTable.userId inList userIds) and + SysRoleTable.deletedAt.isNull() + } + .groupBy { it[SysUserRoleTable.userId] } + .mapValues { entry -> entry.value.map { row -> row[SysRoleTable.code] }.distinct() } + } + + PageResult( + items = rows.map { row -> + UserListItem( + id = row[SysUserTable.id].toString(), + username = row[SysUserTable.username], + nickname = row[SysUserTable.nickname], + realName = row[SysUserTable.realName], + orgId = row[SysUserTable.orgId]?.toString(), + status = row[SysUserTable.status], + statusLabel = statusLabel(row[SysUserTable.status]), + roleCodes = roleMap[row[SysUserTable.id]] ?: emptyList(), + ) + }, + page = page, + pageSize = pageSize, + total = total, + ) + } + + suspend fun create(request: CreateUserRequest): String = dbQuery { + val username = request.username.trim() + if (username.isBlank() || request.password.isBlank()) { + throw BizException(ErrorCode.BAD_REQUEST.code, "用户名和密码不能为空") + } + val existed = SysUserTable.selectAll().where { + (SysUserTable.username eq username) and SysUserTable.deletedAt.isNull() + }.any() + if (existed) { + throw BizException(ErrorCode.DATA_CONFLICT.code, "用户名已存在") + } + + val orgUuid = request.orgId?.let { parseUuid(it, "orgId") } + if (orgUuid != null) { + ensureOrgExists(orgUuid) + } + val now = OffsetDateTime.now() + val row = SysUserTable.insert { + it[SysUserTable.username] = username + it[passwordHash] = PasswordService.hash(request.password) + it[nickname] = request.nickname?.trim() + it[realName] = request.realName?.trim() + it[phone] = request.phone?.trim() + it[email] = request.email?.trim() + it[avatar] = request.avatar?.trim() + it[orgId] = orgUuid + it[status] = request.status + it[tokenVersion] = 1 + it[createdAt] = now + } + row[SysUserTable.id].toString() + } + + suspend fun detail(id: Uuid): UserDetailResponse = dbQuery { + val user = requireUser(id) + val roleIds = SysUserRoleTable.selectAll().where { SysUserRoleTable.userId eq id } + .map { it[SysUserRoleTable.roleId].toString() } + UserDetailResponse( + id = user[SysUserTable.id].toString(), + username = user[SysUserTable.username], + nickname = user[SysUserTable.nickname], + realName = user[SysUserTable.realName], + phone = user[SysUserTable.phone], + email = user[SysUserTable.email], + avatar = user[SysUserTable.avatar], + orgId = user[SysUserTable.orgId]?.toString(), + status = user[SysUserTable.status], + statusLabel = statusLabel(user[SysUserTable.status]), + roleIds = roleIds, + ) + } + + suspend fun update(id: Uuid, request: UpdateUserRequest) = dbQuery { + requireUser(id) + val orgUuid = request.orgId?.let { parseUuid(it, "orgId") } + if (orgUuid != null) { + ensureOrgExists(orgUuid) + } + SysUserTable.update({ SysUserTable.id eq id }) { + it[nickname] = request.nickname?.trim() + it[realName] = request.realName?.trim() + it[phone] = request.phone?.trim() + it[email] = request.email?.trim() + it[avatar] = request.avatar?.trim() + it[orgId] = orgUuid + it[updatedAt] = OffsetDateTime.now() + } + } + + suspend fun softDelete(id: Uuid) = dbQuery { + if (id.toString() == "00000000-0000-0000-0000-000000000000") { + throw BizException(ErrorCode.BAD_REQUEST.code, "系统保留用户不可删除") + } + requireUser(id) + SysUserTable.update({ SysUserTable.id eq id }) { + it[deletedAt] = OffsetDateTime.now() + } + SysUserRoleTable.deleteWhere { SysUserRoleTable.userId eq id } + } + + suspend fun updateStatus(id: Uuid, request: UpdateUserStatusRequest) = dbQuery { + requireUser(id) + SysUserTable.update({ SysUserTable.id eq id }) { + it[status] = request.status + it[updatedAt] = OffsetDateTime.now() + } + } + + suspend fun updatePassword(id: Uuid, request: UpdateUserPasswordRequest) = dbQuery { + val user = requireUser(id) + if (request.password.isBlank()) { + throw BizException(ErrorCode.BAD_REQUEST.code, "密码不能为空") + } + val nextTokenVersion = user[SysUserTable.tokenVersion] + 1 + SysUserTable.update({ SysUserTable.id eq id }) { + it[passwordHash] = PasswordService.hash(request.password) + it[tokenVersion] = nextTokenVersion + it[updatedAt] = OffsetDateTime.now() + } + } + + suspend fun updateRoles(id: Uuid, request: UpdateUserRolesRequest) = dbQuery { + requireUser(id) + val roleIds = request.roleIds.distinct().map { parseUuid(it, "roleId") } + if (roleIds.isNotEmpty()) { + val validCount = SysRoleTable.selectAll().where { + (SysRoleTable.id inList roleIds) and + (SysRoleTable.status eq "ENABLED") and + SysRoleTable.deletedAt.isNull() + }.count() + if (validCount != roleIds.size.toLong()) { + throw BizException(ErrorCode.BAD_REQUEST.code, "包含不存在或已禁用角色") + } + } + SysUserRoleTable.deleteWhere { SysUserRoleTable.userId eq id } + roleIds.forEach { roleId -> + SysUserRoleTable.insertIgnore { + it[userId] = id + it[SysUserRoleTable.roleId] = roleId + } + } + } + + private fun buildWhere(username: String?, nickname: String?, status: String?, orgId: Uuid?): Op { + var where: Op = SysUserTable.deletedAt.isNull() + if (!username.isNullOrBlank()) { + where = where and (SysUserTable.username like "%$username%") + } + if (!nickname.isNullOrBlank()) { + where = where and (SysUserTable.nickname like "%$nickname%") + } + if (!status.isNullOrBlank()) { + where = where and (SysUserTable.status eq status) + } + if (orgId != null) { + where = where and (SysUserTable.orgId eq orgId) + } + return where + } + + private fun requireUser(id: Uuid) = + SysUserTable.selectAll().where { (SysUserTable.id eq id) and SysUserTable.deletedAt.isNull() }.singleOrNull() + ?: throw BizException(ErrorCode.USER_NOT_FOUND.code, ErrorCode.USER_NOT_FOUND.message, HttpStatusCode.NotFound) + + private fun ensureOrgExists(orgId: Uuid) { + val exists = SysOrgTable.selectAll().where { (SysOrgTable.id eq orgId) and SysOrgTable.deletedAt.isNull() }.any() + if (!exists) { + throw BizException(ErrorCode.ORG_NOT_FOUND.code, ErrorCode.ORG_NOT_FOUND.message, HttpStatusCode.BadRequest) + } + } +} + +fun Route.registerUserRoutes() { + authenticate("auth-jwt") { + route("/api/system/users") { + get { + call.requirePermission("system:user:view") + val page = call.queryInt("page", 1) + val pageSize = call.queryInt("pageSize", 20) + val result = UserService.list( + page = page, + pageSize = pageSize, + username = call.queryString("username"), + nickname = call.queryString("nickname"), + status = call.queryString("status"), + orgId = call.queryString("orgId")?.let { parseUuid(it, "orgId") }, + ) + call.respond(ok(result)) + } + + post { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:user:create") + val request = call.receive() + runCatching { + val id = UserService.create(request) + call.respond(ok(mapOf("id" to id))) + OperationLogService.success(call, currentUser, "CREATE", "新增用户", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "CREATE", "新增用户", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + + get("/{id}") { + call.requirePermission("system:user:view") + val id = parseUuid(call.parameters["id"] ?: "", "id") + call.respond(ok(UserService.detail(id))) + } + + put("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:user:update") + val id = parseUuid(call.parameters["id"] ?: "", "id") + val request = call.receive() + runCatching { + UserService.update(id, request) + call.respond(ok(message = "更新成功")) + OperationLogService.success(call, currentUser, "UPDATE", "更新用户", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "UPDATE", "更新用户", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + + delete("/{id}") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:user:delete") + val id = parseUuid(call.parameters["id"] ?: "", "id") + runCatching { + UserService.softDelete(id) + call.respond(ok(message = "删除成功")) + OperationLogService.success(call, currentUser, "DELETE", "删除用户", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "DELETE", "删除用户", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + + put("/{id}/status") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:user:update") + val id = parseUuid(call.parameters["id"] ?: "", "id") + val request = call.receive() + runCatching { + UserService.updateStatus(id, request) + call.respond(ok(message = "状态更新成功")) + OperationLogService.success(call, currentUser, "UPDATE_STATUS", "更新用户状态", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "UPDATE_STATUS", "更新用户状态", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + + put("/{id}/password") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:user:update") + val id = parseUuid(call.parameters["id"] ?: "", "id") + val request = call.receive() + runCatching { + UserService.updatePassword(id, request) + call.respond(ok(message = "密码更新成功")) + OperationLogService.success(call, currentUser, "RESET_PASSWORD", "重置用户密码", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "RESET_PASSWORD", "重置用户密码", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + + put("/{id}/roles") { + val start = TimeSource.Monotonic.markNow() + val currentUser = call.requirePermission("system:role:assign") + val id = parseUuid(call.parameters["id"] ?: "", "id") + val request = call.receive() + runCatching { + UserService.updateRoles(id, request) + call.respond(ok(message = "角色分配成功")) + OperationLogService.success(call, currentUser, "ASSIGN_ROLE", "分配用户角色", start.elapsedNow().inWholeMilliseconds) + }.onFailure { + OperationLogService.fail(call, currentUser, "ASSIGN_ROLE", "分配用户角色", it.message, start.elapsedNow().inWholeMilliseconds) + throw it + } + } + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/plugins/ApiAccessLogPlugin.kt b/server/src/main/kotlin/com/bbit/ticket/plugins/ApiAccessLogPlugin.kt new file mode 100644 index 0000000..be65cf5 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/plugins/ApiAccessLogPlugin.kt @@ -0,0 +1,79 @@ +package com.bbit.ticket.plugins + +import com.bbit.ticket.common.TraceIdKey +import com.bbit.ticket.database.system.SysApiAccessLogTable +import io.ktor.server.application.Application +import io.ktor.server.application.createApplicationPlugin +import io.ktor.server.application.install +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.principal +import io.ktor.server.request.httpMethod +import io.ktor.server.request.path +import org.jetbrains.exposed.v1.jdbc.insert +import java.time.OffsetDateTime +import kotlin.time.TimeSource + +private val accessLogStartMarkKey = io.ktor.util.AttributeKey("api-access-start") +private val accessLogWrittenKey = io.ktor.util.AttributeKey("api-access-written") + +fun Application.configureApiAccessLog() { + install( + createApplicationPlugin("ApiAccessLogPlugin") { + onCall { call -> + if (!call.request.path().startsWith("/api/")) return@onCall + call.attributes.put(accessLogStartMarkKey, TimeSource.Monotonic.markNow()) + } + + // 统一记录 API 访问日志,业务操作日志由各模块在写操作成功/失败后补充。 + onCallRespond { call, _ -> + if (!call.request.path().startsWith("/api/")) return@onCallRespond + if (call.attributes.getOrNull(accessLogWrittenKey) == true) return@onCallRespond + writeAccessLog(call, null) + call.attributes.put(accessLogWrittenKey, true) + } + }, + ) +} + +private suspend fun writeAccessLog( + call: io.ktor.server.application.ApplicationCall, + errorMessage: String?, +) = dbQuery { + val startedAt = call.attributes.getOrNull(accessLogStartMarkKey) + val costMs = startedAt?.elapsedNow()?.inWholeMilliseconds ?: 0L + val traceId = call.attributes.getOrNull(TraceIdKey) + val requestPath = call.request.path().take(255) + val principal = call.principal() + val appKeyFromHeader = call.request.headers["X-App-Key"]?.take(100) + val appNameFromHeader = call.request.headers["X-App-Name"]?.take(100) + val appNameFromUser = principal?.payload?.getClaim("username")?.asString()?.take(100) + val responseCode = call.response.status()?.value?.toString() + val statusCode = call.response.status()?.value ?: 200 + val statusForStore = if (statusCode >= 400) "FAIL" else "SUCCESS" + + SysApiAccessLogTable.insert { + it[SysApiAccessLogTable.traceId] = traceId?.take(64) + it[appKey] = appKeyFromHeader + it[appName] = appNameFromHeader ?: appNameFromUser + it[httpMethod] = call.request.httpMethod.value.take(20) + it[SysApiAccessLogTable.requestPath] = requestPath + it[requestHeaders] = maskedHeaders(call.request.headers.entries()) + it[requestBody] = null + it[SysApiAccessLogTable.responseCode] = responseCode?.take(50) + it[responseBody] = null + it[ip] = call.request.local.remoteHost.take(64) + it[SysApiAccessLogTable.status] = statusForStore + it[SysApiAccessLogTable.errorMessage] = errorMessage?.take(500) + it[SysApiAccessLogTable.costMs] = costMs + it[createdAt] = OffsetDateTime.now() + } +} + +private fun maskedHeaders(entries: Set>>): String { + if (entries.isEmpty()) return "" + val content = entries.joinToString("&") { (key, values) -> + val value = if (key.equals("Authorization", ignoreCase = true)) "***" else values.joinToString(",") + "${key.lowercase()}=${value.take(120)}" + } + return content.take(2000) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/plugins/CorsPlugin.kt b/server/src/main/kotlin/com/bbit/ticket/plugins/CorsPlugin.kt new file mode 100644 index 0000000..ca2f2f5 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/plugins/CorsPlugin.kt @@ -0,0 +1,29 @@ +package com.bbit.ticket.plugins + +import com.bbit.ticket.config.AppConfig +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.cors.routing.CORS + +fun Application.configureCors() { + install(CORS) { + if (AppConfig.cors.allowedHosts.contains("*")) { + anyHost() + } else { + AppConfig.cors.allowedHosts.forEach { allowedHost -> + allowHost(allowedHost, schemes = listOf("http", "https")) + } + } + + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Put) + allowMethod(HttpMethod.Delete) + allowMethod(HttpMethod.Patch) + allowMethod(HttpMethod.Options) + allowHeader(HttpHeaders.Authorization) + allowHeader(HttpHeaders.ContentType) + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/plugins/DatabasePlugin.kt b/server/src/main/kotlin/com/bbit/ticket/plugins/DatabasePlugin.kt new file mode 100644 index 0000000..9d44827 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/plugins/DatabasePlugin.kt @@ -0,0 +1,49 @@ +package com.bbit.ticket.plugins + +import com.bbit.ticket.config.AppConfig +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import io.ktor.server.application.Application +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("DatabasePlugin") + +fun Application.configureDatabase() { + connectDatabase() + logger.info("PostgreSQL datasource initialized") +} + +lateinit var platformDatabase: Database + private set + +fun connectDatabase(): Database { + val hikariConfig = HikariConfig().apply { + jdbcUrl = AppConfig.database.url + username = AppConfig.database.user + password = AppConfig.database.password + driverClassName = "org.postgresql.Driver" + maximumPoolSize = AppConfig.database.maximumPoolSize + minimumIdle = AppConfig.database.minimumIdle + idleTimeout = 60_000 + maxLifetime = 600_000 + keepaliveTime = 120_000 + connectionTimeout = 10_000 + validationTimeout = 5_000 + transactionIsolation = "TRANSACTION_READ_COMMITTED" + poolName = "platform-a-hikari" + } + + platformDatabase = Database.connect(HikariDataSource(hikariConfig)) + return platformDatabase +} + +suspend fun dbQuery(block: suspend () -> T): T = + withContext(Dispatchers.IO) { + suspendTransaction(platformDatabase) { + block() + } + } diff --git a/server/src/main/kotlin/com/bbit/ticket/plugins/LoggingPlugin.kt b/server/src/main/kotlin/com/bbit/ticket/plugins/LoggingPlugin.kt new file mode 100644 index 0000000..371e7c8 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/plugins/LoggingPlugin.kt @@ -0,0 +1,22 @@ +package com.bbit.ticket.plugins + +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.calllogging.CallLogging +import io.ktor.server.request.httpMethod +import io.ktor.server.request.path +import org.slf4j.event.Level + +fun Application.configureLogging() { + install(CallLogging) { + level = Level.INFO + mdc("traceId") { call -> call.attributes.getOrNull(com.bbit.ticket.common.TraceIdKey) } + filter { call -> !call.request.path().startsWith("/health") } + format { call -> + val status = call.response.status() + val method = call.request.httpMethod.value + val path = call.request.path() + "HTTP $method $path -> $status" + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/plugins/RedisPlugin.kt b/server/src/main/kotlin/com/bbit/ticket/plugins/RedisPlugin.kt new file mode 100644 index 0000000..fcd6c72 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/plugins/RedisPlugin.kt @@ -0,0 +1,21 @@ +package com.bbit.ticket.plugins + +import com.bbit.ticket.config.AppConfig +import io.ktor.server.application.Application +import org.redisson.Redisson +import org.redisson.api.RedissonClient +import org.redisson.config.Config +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("RedisPlugin") + +lateinit var redisClient: RedissonClient + private set + +fun Application.configureRedis() { + val config = Config() + val server = config.useSingleServer().setAddress(AppConfig.redis.url) + AppConfig.redis.password?.let { server.password = it } + redisClient = Redisson.create(config) + logger.info("Redis client initialized") +} diff --git a/server/src/main/kotlin/com/bbit/ticket/plugins/SecurityPlugin.kt b/server/src/main/kotlin/com/bbit/ticket/plugins/SecurityPlugin.kt new file mode 100644 index 0000000..bb499cb --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/plugins/SecurityPlugin.kt @@ -0,0 +1,44 @@ +package com.bbit.ticket.plugins + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.bbit.ticket.common.ErrorCode +import com.bbit.ticket.common.fail +import com.bbit.ticket.common.traceIdOrNull +import com.bbit.ticket.config.AppConfig +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.jwt.jwt +import io.ktor.server.response.respond + +fun Application.configureSecurity() { + install(Authentication) { + jwt("auth-jwt") { + realm = AppConfig.jwt.realm + verifier( + JWT.require(Algorithm.HMAC256(AppConfig.jwt.secret)) + .withIssuer(AppConfig.jwt.issuer) + .withAudience(AppConfig.jwt.audience) + .build(), + ) + validate { credential -> + val userId = credential.payload.subject + val tokenType = credential.payload.getClaim("token_type").asString() + if (userId.isNullOrBlank() || tokenType != "access_token") { + null + } else { + JWTPrincipal(credential.payload) + } + } + challenge { _, _ -> + call.respond( + HttpStatusCode.Unauthorized, + fail(ErrorCode.UNAUTHORIZED.code, ErrorCode.UNAUTHORIZED.message, call.traceIdOrNull()), + ) + } + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/plugins/SerializationPlugin.kt b/server/src/main/kotlin/com/bbit/ticket/plugins/SerializationPlugin.kt new file mode 100644 index 0000000..95f2c9e --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/plugins/SerializationPlugin.kt @@ -0,0 +1,20 @@ +package com.bbit.ticket.plugins + +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import kotlinx.serialization.json.Json + +val appJson = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + explicitNulls = false +} + +fun Application.configureSerialization() { + install(ContentNegotiation) { + json(appJson) + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/plugins/StatusPagesPlugin.kt b/server/src/main/kotlin/com/bbit/ticket/plugins/StatusPagesPlugin.kt new file mode 100644 index 0000000..7684e22 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/plugins/StatusPagesPlugin.kt @@ -0,0 +1,37 @@ +package com.bbit.ticket.plugins + +import com.bbit.ticket.common.BizException +import com.bbit.ticket.common.ErrorCode +import com.bbit.ticket.common.fail +import com.bbit.ticket.common.traceIdOrNull +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.response.respond +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("StatusPagesPlugin") + +fun Application.configureStatusPages() { + install(StatusPages) { + exception { call, cause -> + call.respond(cause.status, fail(cause.errorCode, cause.message, call.traceIdOrNull())) + } + + exception { call, cause -> + call.respond( + HttpStatusCode.BadRequest, + fail(ErrorCode.BAD_REQUEST.code, cause.message ?: ErrorCode.BAD_REQUEST.message, call.traceIdOrNull()), + ) + } + + exception { call, cause -> + logger.error("Unhandled server error", cause) + call.respond( + HttpStatusCode.InternalServerError, + fail(ErrorCode.INTERNAL_SERVER_ERROR.code, ErrorCode.INTERNAL_SERVER_ERROR.message, call.traceIdOrNull()), + ) + } + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/plugins/TracePlugin.kt b/server/src/main/kotlin/com/bbit/ticket/plugins/TracePlugin.kt new file mode 100644 index 0000000..4828551 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/plugins/TracePlugin.kt @@ -0,0 +1,24 @@ +package com.bbit.ticket.plugins + +import com.bbit.ticket.common.TraceIdKey +import io.ktor.server.application.Application +import io.ktor.server.application.createApplicationPlugin +import io.ktor.server.application.install +import java.util.UUID + +private const val TRACE_HEADER = "X-Trace-Id" + +fun Application.configureTrace() { + install( + createApplicationPlugin("TracePlugin") { + onCall { call -> + val traceId = call.request.headers[TRACE_HEADER] ?: UUID.randomUUID().toString().replace("-", "") + call.attributes.put(TraceIdKey, traceId) + } + + onCallRespond { call, _ -> + call.response.headers.append(TRACE_HEADER, call.attributes[TraceIdKey], safeOnly = false) + } + }, + ) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/security/JwtService.kt b/server/src/main/kotlin/com/bbit/ticket/security/JwtService.kt new file mode 100644 index 0000000..11c3c78 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/security/JwtService.kt @@ -0,0 +1,36 @@ +package com.bbit.ticket.security + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.bbit.ticket.config.AppConfig +import java.time.Instant +import java.time.temporal.ChronoUnit + +object JwtService { + fun issueAccessToken( + userId: String, + username: String, + orgId: String?, + roles: List, + tokenVersion: Int, + ): Pair { + val now = Instant.now() + val expiresAt = now.plus(AppConfig.jwt.accessTokenTtlMinutes, ChronoUnit.MINUTES) + + val token = JWT.create() + .withIssuer(AppConfig.jwt.issuer) + .withAudience(AppConfig.jwt.audience) + .withIssuedAt(now) + .withExpiresAt(expiresAt) + .withSubject(userId) + .withClaim("username", username) + .withClaim("orgId", orgId) + .withArrayClaim("roles", roles.toTypedArray()) + .withClaim("tokenVersion", tokenVersion) + .withClaim("token_type", "access_token") + .sign(Algorithm.HMAC256(AppConfig.jwt.secret)) + + return token to AppConfig.jwt.accessTokenTtlMinutes * 60 + } +} + diff --git a/server/src/main/kotlin/com/bbit/ticket/security/PasswordService.kt b/server/src/main/kotlin/com/bbit/ticket/security/PasswordService.kt new file mode 100644 index 0000000..8af082f --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/security/PasswordService.kt @@ -0,0 +1,10 @@ +package com.bbit.ticket.security + +import org.mindrot.jbcrypt.BCrypt + +object PasswordService { + fun hash(rawPassword: String): String = BCrypt.hashpw(rawPassword, BCrypt.gensalt()) + + fun matches(rawPassword: String, passwordHash: String): Boolean = BCrypt.checkpw(rawPassword, passwordHash) +} + diff --git a/server/src/main/kotlin/com/bbit/ticket/security/RequirePermission.kt b/server/src/main/kotlin/com/bbit/ticket/security/RequirePermission.kt new file mode 100644 index 0000000..41470a8 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/security/RequirePermission.kt @@ -0,0 +1,16 @@ +package com.bbit.ticket.security + +import com.bbit.ticket.common.BizException +import com.bbit.ticket.common.ErrorCode +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall + +suspend fun ApplicationCall.requirePermission(permission: String): CurrentUser { + val currentUser = requireCurrentUser() + if (currentUser.isSuperAdmin || currentUser.permissions.contains(permission)) { + return currentUser + } + + throw BizException(ErrorCode.FORBIDDEN.code, ErrorCode.FORBIDDEN.message, HttpStatusCode.Forbidden) +} + diff --git a/server/src/main/kotlin/com/bbit/ticket/security/SecurityPrincipal.kt b/server/src/main/kotlin/com/bbit/ticket/security/SecurityPrincipal.kt new file mode 100644 index 0000000..5ecc31c --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/security/SecurityPrincipal.kt @@ -0,0 +1,141 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package com.bbit.ticket.security + +import com.bbit.ticket.common.BizException +import com.bbit.ticket.common.ErrorCode +import com.bbit.ticket.database.system.SysMenuTable +import com.bbit.ticket.database.system.SysRoleMenuTable +import com.bbit.ticket.database.system.SysRoleTable +import com.bbit.ticket.database.system.SysUserRoleTable +import com.bbit.ticket.database.system.SysUserTable +import com.bbit.ticket.plugins.dbQuery +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.auth.principal +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.util.AttributeKey +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.isNotNull +import org.jetbrains.exposed.v1.core.isNull +import org.jetbrains.exposed.v1.jdbc.selectAll +import kotlin.uuid.Uuid + +data class CurrentUser( + val id: Uuid, + val username: String, + val orgId: Uuid?, + val tokenVersion: Int, + val roleCodes: Set, + val permissions: Set, +) { + val isSuperAdmin: Boolean + get() = roleCodes.contains("SUPER_ADMIN") +} + +private val CurrentUserKey = AttributeKey("currentUser") + +suspend fun ApplicationCall.requireCurrentUser(): CurrentUser { + attributes.getOrNull(CurrentUserKey)?.let { return it } + + val principal = principal() ?: throw BizException( + ErrorCode.UNAUTHORIZED.code, + ErrorCode.UNAUTHORIZED.message, + HttpStatusCode.Unauthorized, + ) + + val userId = principal.payload.subject ?: throw BizException( + ErrorCode.UNAUTHORIZED.code, + ErrorCode.UNAUTHORIZED.message, + HttpStatusCode.Unauthorized, + ) + val userUuid = runCatching { Uuid.parse(userId) }.getOrElse { + throw BizException(ErrorCode.UNAUTHORIZED.code, ErrorCode.UNAUTHORIZED.message, HttpStatusCode.Unauthorized) + } + + val tokenVersion = principal.payload.getClaim("tokenVersion").asInt() + ?: throw BizException(ErrorCode.UNAUTHORIZED.code, ErrorCode.UNAUTHORIZED.message, HttpStatusCode.Unauthorized) + + val userRow = dbQuery { + SysUserTable.selectAll() + .where { (SysUserTable.id eq userUuid) and SysUserTable.deletedAt.isNull() } + .singleOrNull() + } ?: throw BizException(ErrorCode.USER_NOT_FOUND.code, ErrorCode.USER_NOT_FOUND.message, HttpStatusCode.Unauthorized) + + if (userRow[SysUserTable.status] != "ENABLED") { + throw BizException(ErrorCode.USER_DISABLED.code, ErrorCode.USER_DISABLED.message, HttpStatusCode.Unauthorized) + } + if (userRow[SysUserTable.tokenVersion] != tokenVersion) { + throw BizException( + ErrorCode.TOKEN_VERSION_INVALID.code, + ErrorCode.TOKEN_VERSION_INVALID.message, + HttpStatusCode.Unauthorized, + ) + } + + val roleCodes = dbQuery { + (SysUserRoleTable innerJoin SysRoleTable) + .selectAll() + .where { + (SysUserRoleTable.userId eq userUuid) and + SysRoleTable.deletedAt.isNull() and + (SysRoleTable.status eq "ENABLED") + } + .map { it[SysRoleTable.code] } + .toSet() + } + + val permissions = if (roleCodes.contains("SUPER_ADMIN")) { + dbQuery { + SysMenuTable.selectAll() + .where { + SysMenuTable.deletedAt.isNull() and + (SysMenuTable.status eq "ENABLED") and + SysMenuTable.permission.isNotNull() + } + .mapNotNull { it[SysMenuTable.permission] } + .toSet() + } + } else { + val roleIds = dbQuery { + (SysUserRoleTable innerJoin SysRoleTable) + .selectAll() + .where { + (SysUserRoleTable.userId eq userUuid) and + SysRoleTable.deletedAt.isNull() and + (SysRoleTable.status eq "ENABLED") + } + .map { it[SysRoleTable.id] } + } + if (roleIds.isEmpty()) { + emptySet() + } else { + dbQuery { + (SysRoleMenuTable innerJoin SysMenuTable) + .selectAll() + .where { + (SysRoleMenuTable.roleId inList roleIds) and + SysMenuTable.deletedAt.isNull() and + (SysMenuTable.status eq "ENABLED") and + SysMenuTable.permission.isNotNull() + } + .mapNotNull { it[SysMenuTable.permission] } + .toSet() + } + } + } + + val currentUser = CurrentUser( + id = userRow[SysUserTable.id], + username = userRow[SysUserTable.username], + orgId = userRow[SysUserTable.orgId], + tokenVersion = userRow[SysUserTable.tokenVersion], + roleCodes = roleCodes, + permissions = permissions, + ) + + attributes.put(CurrentUserKey, currentUser) + return currentUser +} diff --git a/server/src/main/resources/application.yaml b/server/src/main/resources/application.yaml new file mode 100644 index 0000000..75099cf --- /dev/null +++ b/server/src/main/resources/application.yaml @@ -0,0 +1,32 @@ +ktor: + application: + modules: + - com.bbit.ticket.ApplicationKt.module + deployment: + port: 8070 + +app: + name: "Ticket" + env: "local" + +database: + url: "jdbc:postgresql://localhost:5432/ticket" + user: "ticket" + password: "ticket_password" + maximumPoolSize: 16 + minimumIdle: 4 + +redis: + url: "redis://127.0.0.1:6379" + password: "ticket_password" + +security: + jwt: + issuer: "platform-a" + audience: "platform-a-admin" + realm: "Platform A" + secret: "change-me-to-a-strong-secret" + accessTokenTtlMinutes: 120 + +cors: + allowedHosts: "localhost:5173,127.0.0.1:5173" diff --git a/server/src/main/resources/logback.xml b/server/src/main/resources/logback.xml new file mode 100644 index 0000000..4cbf84d --- /dev/null +++ b/server/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{40} - %msg%n + + + + + + + + + + + + diff --git a/web/.env.development b/web/.env.development new file mode 100644 index 0000000..201889a --- /dev/null +++ b/web/.env.development @@ -0,0 +1,2 @@ +VITE_API_BASE_URL=/api +VITE_API_PROXY_TARGET=http://localhost:8070 diff --git a/web/.env.production b/web/.env.production new file mode 100644 index 0000000..14ea4ad --- /dev/null +++ b/web/.env.production @@ -0,0 +1 @@ +VITE_API_BASE_URL=/api diff --git a/web/.idea/.gitignore b/web/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/web/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/web/.idea/codeStyles/Project.xml b/web/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..a576872 --- /dev/null +++ b/web/.idea/codeStyles/Project.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/.idea/codeStyles/codeStyleConfig.xml b/web/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/web/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/.idea/inspectionProfiles/Project_Default.xml b/web/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/web/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/.idea/modules.xml b/web/.idea/modules.xml new file mode 100644 index 0000000..f589ca3 --- /dev/null +++ b/web/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/web/.idea/prettier.xml b/web/.idea/prettier.xml new file mode 100644 index 0000000..b0c1c68 --- /dev/null +++ b/web/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/.idea/vcs.xml b/web/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/web/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/.idea/web.iml b/web/.idea/web.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/web/.idea/web.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..2499163 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100 +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..c427715 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,21 @@ +import pluginVue from 'eslint-plugin-vue' +import vueTsEslintConfig from '@vue/eslint-config-typescript' + +export default [ + { + ignores: ['dist/**', 'node_modules/**'] + }, + ...pluginVue.configs['flat/recommended'], + ...vueTsEslintConfig(), + { + rules: { + 'vue/multi-word-component-names': 'off', + 'vue/max-attributes-per-line': 'off', + 'vue/html-indent': 'off', + 'vue/html-closing-bracket-newline': 'off', + 'vue/html-self-closing': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/multiline-html-element-content-newline': 'off' + } + } +] diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..2c86786 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + 农产品收购发票开票平台 + + + + + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..b8fa462 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,5154 @@ +{ + "name": "platform-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "platform-web", + "version": "0.1.0", + "dependencies": { + "@tanstack/vue-query": "^5.76.0", + "axios": "^1.9.0", + "lucide-vue-next": "^0.511.0", + "naive-ui": "^2.42.0", + "pinia": "^2.3.1", + "vue": "^3.5.13", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@vitejs/plugin-vue": "^5.2.4", + "@vue/eslint-config-typescript": "^14.6.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.26.0", + "eslint-plugin-vue": "^9.33.0", + "postcss": "^8.5.3", + "prettier": "^3.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.3", + "vite": "^5.4.19", + "vitest": "^2.1.9", + "vue-tsc": "^2.2.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", + "license": "MIT", + "dependencies": { + "remove-accents": "0.5.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.5.tgz", + "integrity": "sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-query": { + "version": "5.100.5", + "resolved": "https://registry.npmjs.org/@tanstack/vue-query/-/vue-query-5.100.5.tgz", + "integrity": "sha512-+V7R5JhyavCzUE8BCs9fhtir7QgIL6FAeu1kpqUSpmH2Z9Vo9CLtVougVdqUp+Xx7cAC0NBQ7nGzlsYdHTUUdQ==", + "license": "MIT", + "dependencies": { + "@tanstack/match-sorter-utils": "^8.19.4", + "@tanstack/query-core": "5.100.5", + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@vue/composition-api": "^1.1.2", + "vue": "^2.6.0 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/eslint-config-typescript": { + "version": "14.7.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.7.0.tgz", + "integrity": "sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.56.0", + "fast-glob": "^3.3.3", + "typescript-eslint": "^8.56.0", + "vue-eslint-parser": "^10.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.10.0 || ^10.0.0", + "eslint-plugin-vue": "^9.28.0 || ^10.0.0", + "typescript": ">=4.8.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-render": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", + "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "globals": "^13.24.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.15", + "semver": "^7.6.3", + "vue-eslint-parser": "^9.4.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-vue/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-vue/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-vue/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-vue/node_modules/vue-eslint-parser": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", + "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "eslint-scope": "^7.1.1", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.1", + "esquery": "^1.4.0", + "lodash": "^4.17.21", + "semver": "^7.3.6" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/evtd": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz", + "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lucide-vue-next": { + "version": "0.511.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.511.0.tgz", + "integrity": "sha512-VSv0F3pHniGN7JMMzDcLFNMQbl8381+shNnHwV8hi+El7xl2ZL8qdNuzPoiBViKk8mTKK5K3ZDfmE/wEcTZVIQ==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/naive-ui": { + "version": "2.44.1", + "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.44.1.tgz", + "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.65" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/seemly": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz", + "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vdirs": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz", + "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vooks": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", + "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz", + "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vueuc": { + "version": "0.4.65", + "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz", + "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8481a6a --- /dev/null +++ b/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "platform-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --mode development", + "build": "vue-tsc --noEmit && vite build --mode production", + "preview": "vite preview", + "lint": "eslint . --ext .ts,.vue", + "test": "vitest run" + }, + "dependencies": { + "@tanstack/vue-query": "^5.76.0", + "axios": "^1.9.0", + "lucide-vue-next": "^0.511.0", + "naive-ui": "^2.42.0", + "pinia": "^2.3.1", + "vue": "^3.5.13", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@vitejs/plugin-vue": "^5.2.4", + "@vue/eslint-config-typescript": "^14.6.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.26.0", + "eslint-plugin-vue": "^9.33.0", + "postcss": "^8.5.3", + "prettier": "^3.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.3", + "vite": "^5.4.19", + "vitest": "^2.1.9", + "vue-tsc": "^2.2.10" + } +} diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs new file mode 100644 index 0000000..85f717c --- /dev/null +++ b/web/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..4c22b46 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 0000000..fe98582 --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,14 @@ +import http from '@/api/http' +import type { LoginRequest, LoginResponse, MeResponse } from '@/types/auth' + +export function loginApi(payload: LoginRequest) { + return http.post('/auth/login', payload) +} + +export function logoutApi() { + return http.post('/auth/logout') +} + +export function meApi() { + return http.get('/auth/me') +} diff --git a/web/src/api/http.ts b/web/src/api/http.ts new file mode 100644 index 0000000..2718523 --- /dev/null +++ b/web/src/api/http.ts @@ -0,0 +1,71 @@ +import axios, { AxiosError } from 'axios' +import { createDiscreteApi } from 'naive-ui' +import type { ApiResult } from '@/types/http' +import { BizError } from '@/types/http' +import { useAuthStore } from '@/stores/auth' +import { router } from '@/router' +import { appEnv } from '@/config/env' + +const { message } = createDiscreteApi(['message']) + +const http = axios.create({ + baseURL: appEnv.apiBaseUrl, + timeout: 15000 +}) + +http.interceptors.request.use((config) => { + const authStore = useAuthStore() + const token = authStore.token + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +http.interceptors.response.use( + (response) => { + const payload = response.data as ApiResult + const traceId = response.headers['x-trace-id'] as string | undefined + if (!payload || typeof payload.code !== 'string') { + return response.data + } + if (payload.code !== '0') { + throw new BizError(payload.code, payload.message || '请求失败', payload.traceId ?? traceId) + } + return payload.data + }, + async (error: AxiosError>) => { + const authStore = useAuthStore() + const status = error.response?.status + const traceId = + (error.response?.headers?.['x-trace-id'] as string | undefined) ?? + error.response?.data?.traceId + const backendMessage = error.response?.data?.message + + if (status === 401) { + authStore.clearAuth() + if (router.currentRoute.value.path !== '/login') { + await router.replace({ + path: '/login', + query: { redirect: router.currentRoute.value.fullPath } + }) + } + message.warning(backendMessage ?? '登录已失效,请重新登录') + return Promise.reject(new BizError('401', backendMessage ?? '未登录', traceId)) + } + + if (status === 403) { + if (router.currentRoute.value.path !== '/403') { + await router.replace('/403') + } + message.error(backendMessage ?? '无权限访问该资源') + return Promise.reject(new BizError('403', backendMessage ?? '无权限', traceId)) + } + + const messageText = backendMessage ?? error.message ?? '网络异常,请稍后重试' + message.error(traceId ? `${messageText}(追踪ID:${traceId})` : messageText) + return Promise.reject(new BizError(String(status ?? 'HTTP_ERROR'), messageText, traceId)) + } +) + +export default http diff --git a/web/src/api/logs.ts b/web/src/api/logs.ts new file mode 100644 index 0000000..b9ece48 --- /dev/null +++ b/web/src/api/logs.ts @@ -0,0 +1,20 @@ +import http from '@/api/http' +import type { ApiAccessLogPage, OperationLogPage } from '@/types/logs' + +export function listOperationLogsApi(params: { + page: number + pageSize: number + keyword?: string + status?: string +}) { + return http.get('/logs/operation', { params }) +} + +export function listApiAccessLogsApi(params: { + page: number + pageSize: number + keyword?: string + status?: string +}) { + return http.get('/logs/api-access', { params }) +} diff --git a/web/src/api/system/dict.ts b/web/src/api/system/dict.ts new file mode 100644 index 0000000..6d256a0 --- /dev/null +++ b/web/src/api/system/dict.ts @@ -0,0 +1,61 @@ +import http from '@/api/http' +import type { DictItemPage, DictTypePage } from '@/types/system/dict' + +export function listDictTypesApi(params: { page: number; pageSize: number; keyword?: string }) { + return http.get('/system/dict-types', { params }) +} + +export function createDictTypeApi(payload: { + code: string + name: string + status: string + remark?: string +}) { + return http.post('/system/dict-types', payload) +} + +export function updateDictTypeApi( + id: string, + payload: { name: string; status: string; remark?: string } +) { + return http.put(`/system/dict-types/${id}`, payload) +} + +export function deleteDictTypeApi(id: string) { + return http.delete(`/system/dict-types/${id}`) +} + +export function listDictItemsApi(params: { page: number; pageSize: number; typeId?: string }) { + return http.get('/system/dict-items', { params }) +} + +export function createDictItemApi(payload: { + typeId: string + label: string + value: string + color?: string + sort: number + status: string + remark?: string +}) { + return http.post('/system/dict-items', payload) +} + +export function updateDictItemApi( + id: string, + payload: { + typeId: string + label: string + value: string + color?: string + sort: number + status: string + remark?: string + } +) { + return http.put(`/system/dict-items/${id}`, payload) +} + +export function deleteDictItemApi(id: string) { + return http.delete(`/system/dict-items/${id}`) +} diff --git a/web/src/api/system/menu.ts b/web/src/api/system/menu.ts new file mode 100644 index 0000000..6cdf2b0 --- /dev/null +++ b/web/src/api/system/menu.ts @@ -0,0 +1,18 @@ +import http from '@/api/http' +import type { CreateMenuRequest, MenuTreeNode, UpdateMenuRequest } from '@/types/system/menu' + +export function listMenusApi() { + return http.get('/system/menus') +} + +export function createMenuApi(payload: CreateMenuRequest) { + return http.post('/system/menus', payload) +} + +export function updateMenuApi(id: string, payload: UpdateMenuRequest) { + return http.put(`/system/menus/${id}`, payload) +} + +export function deleteMenuApi(id: string) { + return http.delete(`/system/menus/${id}`) +} diff --git a/web/src/api/system/org.ts b/web/src/api/system/org.ts new file mode 100644 index 0000000..35e5c4b --- /dev/null +++ b/web/src/api/system/org.ts @@ -0,0 +1,18 @@ +import http from '@/api/http' +import type { CreateOrgRequest, OrgTreeNode, UpdateOrgRequest } from '@/types/system/org' + +export function listOrgsApi() { + return http.get('/system/orgs') +} + +export function createOrgApi(payload: CreateOrgRequest) { + return http.post('/system/orgs', payload) +} + +export function updateOrgApi(id: string, payload: UpdateOrgRequest) { + return http.put(`/system/orgs/${id}`, payload) +} + +export function deleteOrgApi(id: string) { + return http.delete(`/system/orgs/${id}`) +} diff --git a/web/src/api/system/role.ts b/web/src/api/system/role.ts new file mode 100644 index 0000000..80fc032 --- /dev/null +++ b/web/src/api/system/role.ts @@ -0,0 +1,32 @@ +import http from '@/api/http' +import type { + CreateRoleRequest, + RoleDetail, + RolePage, + RoleQuery, + UpdateRoleRequest +} from '@/types/system/role' + +export function listRolesApi(params: RoleQuery) { + return http.get('/system/roles', { params }) +} + +export function createRoleApi(payload: CreateRoleRequest) { + return http.post('/system/roles', payload) +} + +export function getRoleDetailApi(id: string) { + return http.get(`/system/roles/${id}`) +} + +export function updateRoleApi(id: string, payload: UpdateRoleRequest) { + return http.put(`/system/roles/${id}`, payload) +} + +export function deleteRoleApi(id: string) { + return http.delete(`/system/roles/${id}`) +} + +export function updateRoleMenusApi(id: string, menuIds: string[]) { + return http.put(`/system/roles/${id}/menus`, { menuIds }) +} diff --git a/web/src/api/system/user.ts b/web/src/api/system/user.ts new file mode 100644 index 0000000..208034d --- /dev/null +++ b/web/src/api/system/user.ts @@ -0,0 +1,43 @@ +import http from '@/api/http' +import type { + CreateUserRequest, + UpdateUserPasswordRequest, + UpdateUserRequest, + UpdateUserRolesRequest, + UpdateUserStatusRequest, + UserDetail, + UserListPage, + UserQuery +} from '@/types/system/user' + +export function listUsersApi(params: UserQuery) { + return http.get('/system/users', { params }) +} + +export function createUserApi(payload: CreateUserRequest) { + return http.post('/system/users', payload) +} + +export function getUserDetailApi(id: string) { + return http.get(`/system/users/${id}`) +} + +export function updateUserApi(id: string, payload: UpdateUserRequest) { + return http.put(`/system/users/${id}`, payload) +} + +export function deleteUserApi(id: string) { + return http.delete(`/system/users/${id}`) +} + +export function updateUserStatusApi(id: string, payload: UpdateUserStatusRequest) { + return http.put(`/system/users/${id}/status`, payload) +} + +export function updateUserPasswordApi(id: string, payload: UpdateUserPasswordRequest) { + return http.put(`/system/users/${id}/password`, payload) +} + +export function updateUserRolesApi(id: string, payload: UpdateUserRolesRequest) { + return http.put(`/system/users/${id}/roles`, payload) +} diff --git a/web/src/components/AppBreadcrumb.vue b/web/src/components/AppBreadcrumb.vue new file mode 100644 index 0000000..d24602e --- /dev/null +++ b/web/src/components/AppBreadcrumb.vue @@ -0,0 +1,23 @@ + + + + {{ item.title }} + + + + + diff --git a/web/src/components/AppMenu.vue b/web/src/components/AppMenu.vue new file mode 100644 index 0000000..c1ab35c --- /dev/null +++ b/web/src/components/AppMenu.vue @@ -0,0 +1,96 @@ + + + + + + + diff --git a/web/src/components/AppTabs.vue b/web/src/components/AppTabs.vue new file mode 100644 index 0000000..0a8bfd2 --- /dev/null +++ b/web/src/components/AppTabs.vue @@ -0,0 +1,107 @@ + + + + + {{ tab.title }} + × + + + + + + + + diff --git a/web/src/components/PermissionButton.vue b/web/src/components/PermissionButton.vue new file mode 100644 index 0000000..4207b6d --- /dev/null +++ b/web/src/components/PermissionButton.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/web/src/config/brand.ts b/web/src/config/brand.ts new file mode 100644 index 0000000..72340ff --- /dev/null +++ b/web/src/config/brand.ts @@ -0,0 +1,4 @@ +export const appBrand = { + title: '农产品收购发票开票平台', + logo: '' +} diff --git a/web/src/config/env.ts b/web/src/config/env.ts new file mode 100644 index 0000000..b9b5d76 --- /dev/null +++ b/web/src/config/env.ts @@ -0,0 +1,12 @@ +import { appBrand } from '@/config/brand' + +function normalizeUrl(url: string | undefined, fallback: string) { + const value = (url || '').trim() + return value.length > 0 ? value : fallback +} + +export const appEnv = { + appTitle: normalizeUrl(import.meta.env.VITE_APP_TITLE, appBrand.title), + appLogo: normalizeUrl(import.meta.env.VITE_APP_LOGO, appBrand.logo), + apiBaseUrl: normalizeUrl(import.meta.env.VITE_API_BASE_URL, '/api') +} diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..dadde31 --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,18 @@ +/// + +interface ImportMetaEnv { + readonly VITE_APP_TITLE: string + readonly VITE_APP_LOGO?: string + readonly VITE_API_BASE_URL: string + readonly VITE_API_PROXY_TARGET?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/web/src/features/dashboard/index.vue b/web/src/features/dashboard/index.vue new file mode 100644 index 0000000..d4f010d --- /dev/null +++ b/web/src/features/dashboard/index.vue @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/web/src/features/logs/api-access/index.vue b/web/src/features/logs/api-access/index.vue new file mode 100644 index 0000000..f249540 --- /dev/null +++ b/web/src/features/logs/api-access/index.vue @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + 查询 + 重置 + + + + + + + + + + + + diff --git a/web/src/features/logs/operation/index.vue b/web/src/features/logs/operation/index.vue new file mode 100644 index 0000000..f596b31 --- /dev/null +++ b/web/src/features/logs/operation/index.vue @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + 查询 + 重置 + + + + + + + + + + + + diff --git a/web/src/features/system/dicts/index.vue b/web/src/features/system/dicts/index.vue new file mode 100644 index 0000000..d675610 --- /dev/null +++ b/web/src/features/system/dicts/index.vue @@ -0,0 +1,235 @@ + + + + + + 字典类型 + 新增类型 + + + + + + + + 字典项 + 新增字典项 + + + + + + + + + + diff --git a/web/src/features/system/menus/index.vue b/web/src/features/system/menus/index.vue new file mode 100644 index 0000000..f25d153 --- /dev/null +++ b/web/src/features/system/menus/index.vue @@ -0,0 +1,393 @@ + + + + + 菜单结构 + 新增菜单 + + + + 菜单名称 + 类型 + 状态 + 属性 + 操作 + + + + + + + + + + + + + + + + + + + + + + + + + + 取消保存 + + + + + + + + diff --git a/web/src/features/system/orgs/index.vue b/web/src/features/system/orgs/index.vue new file mode 100644 index 0000000..553c75c --- /dev/null +++ b/web/src/features/system/orgs/index.vue @@ -0,0 +1,238 @@ + + + + + 组织架构 + 新增组织 + + + + + + + + + + + + + + + + + 启用 + 禁用 + + + + + + 取消 + 保存 + + + + + + + + + diff --git a/web/src/features/system/roles/index.vue b/web/src/features/system/roles/index.vue new file mode 100644 index 0000000..d96a920 --- /dev/null +++ b/web/src/features/system/roles/index.vue @@ -0,0 +1,297 @@ + + + + + + + + 查询 + + 新增角色 + + + + + + + + + + + 启用禁用 + + + + + 取消保存 + + + + + + + 取消保存 + + + + + + diff --git a/web/src/features/system/users/index.vue b/web/src/features/system/users/index.vue new file mode 100644 index 0000000..744c456 --- /dev/null +++ b/web/src/features/system/users/index.vue @@ -0,0 +1,549 @@ + + + + + + + + + + + + + + + + + 查询 + 重置 + + + + + 新增用户 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 启用 + 禁用 + + + + + + 取消 + 保存 + + + + + + + + + + + + + 取消 + 确认 + + + + + + + + + + + + + 取消 + 确认 + + + + + + + diff --git a/web/src/features/workbench/index.vue b/web/src/features/workbench/index.vue new file mode 100644 index 0000000..01d6653 --- /dev/null +++ b/web/src/features/workbench/index.vue @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue new file mode 100644 index 0000000..3ef18fc --- /dev/null +++ b/web/src/layouts/MainLayout.vue @@ -0,0 +1,376 @@ + + + + + + + {{ appTitle }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ userDisplayName }} + 退出 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..d44671e --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,18 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query' +import naive from 'naive-ui' +import App from '@/App.vue' +import { router } from '@/router' +import '@/style.css' + +const app = createApp(App) +const pinia = createPinia() +const queryClient = new QueryClient() + +app.use(pinia) +app.use(router) +app.use(naive) +app.use(VueQueryPlugin, { queryClient }) + +app.mount('#app') diff --git a/web/src/router/dynamic-routes.ts b/web/src/router/dynamic-routes.ts new file mode 100644 index 0000000..dfdd4d2 --- /dev/null +++ b/web/src/router/dynamic-routes.ts @@ -0,0 +1,71 @@ +import type { Component } from 'vue' +import type { RouteRecordRaw } from 'vue-router' +import type { MenuNode } from '@/types/auth' + +const viewModules = import.meta.glob('../features/**/index.vue') + +const dynamicRouteNames = new Set() + +function normalizePath(path: string) { + if (!path.startsWith('/')) return `/${path}` + return path +} + +function routeNameByMenu(menu: MenuNode) { + if (menu.name && menu.name.trim().length > 0) { + return menu.name.trim() + } + return `menu-${menu.id}` +} + +function resolveViewComponent(componentPath?: string | null): Component { + if (!componentPath) { + return () => import('@/views/errors/NotFoundView.vue') + } + const normalized = componentPath.endsWith('.vue') ? componentPath : `${componentPath}.vue` + const fullPath = `../features/${normalized}` + const component = viewModules[fullPath] + if (!component) { + return () => import('@/views/errors/NotFoundView.vue') + } + return component as unknown as Component +} + +function toRoute(menu: MenuNode): RouteRecordRaw | null { + if (menu.type !== 'MENU') return null + if (!menu.path || !menu.component || !menu.visible) return null + + const name = routeNameByMenu(menu) + dynamicRouteNames.add(name) + + return { + path: normalizePath(menu.path), + name, + component: resolveViewComponent(menu.component), + meta: { + title: menu.title, + permission: menu.permission ?? undefined, + keepAlive: menu.keepAlive, + requiresAuth: true, + dynamic: true + } + } +} + +function flattenMenus(menus: MenuNode[]): MenuNode[] { + return menus.flatMap((item) => [item, ...flattenMenus(item.children ?? [])]) +} + +export function buildDynamicRoutes(menus: MenuNode[]) { + return flattenMenus(menus) + .map(toRoute) + .filter((item): item is RouteRecordRaw => Boolean(item)) +} + +export function consumeDynamicRouteNames() { + return [...dynamicRouteNames] +} + +export function resetDynamicRouteNames() { + dynamicRouteNames.clear() +} diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..beedce0 --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,79 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' +import { + buildDynamicRoutes, + consumeDynamicRouteNames, + resetDynamicRouteNames +} from '@/router/dynamic-routes' +import { staticRoutes } from '@/router/static-routes' +import { appEnv } from '@/config/env' + +export const router = createRouter({ + history: createWebHistory(), + routes: staticRoutes +}) + +async function ensureDynamicRoutes() { + const authStore = useAuthStore() + if (!authStore.isAuthed) return + + const profile = await authStore.loadProfile() + if (!profile) return + + const existingNames = new Set( + router + .getRoutes() + .map((route) => route.name) + .filter(Boolean) + ) + buildDynamicRoutes(profile.menus).forEach((route) => { + if (route.name && !existingNames.has(route.name)) { + router.addRoute('root', route) + } + }) +} + +export function resetDynamicRoutes() { + consumeDynamicRouteNames().forEach((name) => { + if (router.hasRoute(name)) { + router.removeRoute(name) + } + }) + resetDynamicRouteNames() +} + +router.beforeEach(async (to) => { + const authStore = useAuthStore() + + if (to.path === '/login' && authStore.isAuthed) { + return '/dashboard' + } + + if (to.meta.requiresAuth && !authStore.isAuthed) { + return { path: '/login', query: { redirect: to.fullPath } } + } + + if (authStore.isAuthed) { + await ensureDynamicRoutes() + + if (to.name === 'not-found' && to.path !== '/404') { + const resolved = router.resolve(to.fullPath) + const hasRealMatch = resolved.matched.some((route) => route.name !== 'not-found') + if (hasRealMatch) { + return { path: to.fullPath, replace: true } + } + } + + if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) { + return '/403' + } + } + + return true +}) + +router.afterEach((to) => { + if (to.meta.title) { + document.title = `${to.meta.title} - ${appEnv.appTitle}` + } +}) diff --git a/web/src/router/static-routes.ts b/web/src/router/static-routes.ts new file mode 100644 index 0000000..1ffe955 --- /dev/null +++ b/web/src/router/static-routes.ts @@ -0,0 +1,42 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const staticRoutes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'login', + component: () => import('@/views/auth/LoginView.vue'), + meta: { title: '登录' } + }, + { + path: '/403', + name: 'forbidden', + component: () => import('@/views/errors/ForbiddenView.vue'), + meta: { title: '无权限' } + }, + { + path: '/', + name: 'root', + component: () => import('@/layouts/MainLayout.vue'), + redirect: '/dashboard', + meta: { requiresAuth: true }, + children: [ + { + path: '/dashboard', + alias: ['/workbench'], + name: 'workbench-home', + component: () => import('@/features/dashboard/index.vue'), + meta: { + title: '工作台', + requiresAuth: true, + keepAlive: true + } + } + ] + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: () => import('@/views/errors/NotFoundView.vue'), + meta: { title: '页面不存在' } + } +] diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts new file mode 100644 index 0000000..93a9991 --- /dev/null +++ b/web/src/stores/auth.ts @@ -0,0 +1,181 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { loginApi, logoutApi, meApi } from '@/api/auth' +import { listDictItemsApi } from '@/api/system/dict' +import type { CurrentUserProfile, LoginRequest, MeResponse, MenuNode } from '@/types/auth' +import type { DictItem } from '@/types/system/dict' + +const TOKEN_KEY = 'platform.token' + +export interface TabItem { + name: string + title: string + path: string + closable: boolean +} + +function normalizeTabPath(path: string) { + if (path === '/workbench') return '/dashboard' + return path +} + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem(TOKEN_KEY) ?? '') + const user = ref(null) + const menus = ref([]) + const permissions = ref([]) + const layoutCollapsed = ref(false) + const dictCache = ref>({}) + const tabs = ref([ + { name: 'workbench-home', title: '工作台', path: '/dashboard', closable: false } + ]) + const tabRefreshMarks = ref>({}) + const loaded = ref(false) + + const isAuthed = computed(() => Boolean(token.value)) + + function setToken(nextToken: string) { + token.value = nextToken + localStorage.setItem(TOKEN_KEY, nextToken) + } + + function clearAuth() { + token.value = '' + user.value = null + menus.value = [] + permissions.value = [] + dictCache.value = {} + loaded.value = false + tabs.value = [{ name: 'workbench-home', title: '工作台', path: '/dashboard', closable: false }] + tabRefreshMarks.value = {} + localStorage.removeItem(TOKEN_KEY) + } + + async function login(payload: LoginRequest) { + const result = await loginApi(payload) + setToken(result.accessToken) + await loadProfile() + } + + async function logout() { + try { + if (token.value) { + await logoutApi() + } + } finally { + clearAuth() + } + } + + async function loadProfile(force = false): Promise { + if (!token.value) return null + if (loaded.value && !force) { + return { + user: user.value!, + menus: menus.value, + permissions: permissions.value + } + } + const profile = await meApi() + user.value = profile.user + menus.value = profile.menus + permissions.value = [...profile.permissions] + loaded.value = true + return profile + } + + function hasPermission(permission?: string | null) { + if (!permission) return true + return permissions.value.includes(permission) + } + + async function loadDictByTypeId(typeId: string, force = false) { + const cached = dictCache.value[typeId] + if (cached && !force) return cached + const result = await listDictItemsApi({ typeId, page: 1, pageSize: 200 }) + dictCache.value[typeId] = result.items.filter((item) => item.status === 'ENABLED') + return dictCache.value[typeId] + } + + function getDictLabel(typeId: string, value: string) { + const item = dictCache.value[typeId]?.find((entry) => entry.value === value) + return item?.label ?? value + } + + function getDictColor(typeId: string, value: string) { + const item = dictCache.value[typeId]?.find((entry) => entry.value === value) + return item?.color ?? undefined + } + + function addTab(tab: TabItem) { + const normalizedPath = normalizeTabPath(tab.path) + if (tabs.value.some((item) => item.path === normalizedPath)) return + tabs.value.push({ + ...tab, + path: normalizedPath + }) + } + + function closeTab(path: string) { + const targetPath = normalizeTabPath(path) + const fixed = tabs.value.filter((item) => !item.closable) + tabs.value = [ + ...fixed, + ...tabs.value.filter((item) => item.closable && item.path !== targetPath) + ] + delete tabRefreshMarks.value[targetPath] + } + + function closeOtherTabs(path: string) { + const targetPath = normalizeTabPath(path) + tabs.value = tabs.value.filter((item) => !item.closable || item.path === targetPath) + Object.keys(tabRefreshMarks.value).forEach((tabPath) => { + if (!tabs.value.some((item) => item.path === tabPath)) { + delete tabRefreshMarks.value[tabPath] + } + }) + } + + function closeAllTabs() { + const fixedTabs = tabs.value.filter((item) => !item.closable) + const fixedPaths = new Set(fixedTabs.map((item) => item.path)) + Object.keys(tabRefreshMarks.value).forEach((path) => { + if (!fixedPaths.has(path)) { + delete tabRefreshMarks.value[path] + } + }) + tabs.value = fixedTabs + } + + function refreshTab(path: string) { + const targetPath = normalizeTabPath(path) + tabRefreshMarks.value[targetPath] = Date.now() + } + + return { + token, + user, + menus, + permissions, + layoutCollapsed, + dictCache, + tabs, + tabRefreshMarks, + loaded, + isAuthed, + setToken, + clearAuth, + login, + logout, + loadProfile, + hasPermission, + addTab, + closeTab, + closeOtherTabs, + closeAllTabs, + refreshTab, + loadDictByTypeId, + getDictLabel, + getDictColor + } +}) diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..955a64c --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,265 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color: #0f172a; + background: #f5f7fb; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + overflow: hidden; + font-family: Inter, 'Avenir Next', 'PingFang SC', 'Microsoft YaHei', sans-serif; + background: #f5f7fb; + @apply text-slate-900 antialiased; +} + +#app { + height: 100vh; + overflow: hidden; +} + +.app-panel { + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 14px 32px rgba(15, 23, 42, 0.05); +} + +.page-title { + @apply text-lg font-semibold tracking-tight text-slate-900; +} + +.subtle-grid { + background-image: + linear-gradient(to right, rgba(148, 163, 184, 0.06) 1px, transparent 1px), + linear-gradient(to bottom, rgba(148, 163, 184, 0.06) 1px, transparent 1px); + background-size: 28px 28px; +} + +.list-page { + height: 100%; + display: flex; + flex-direction: column; + gap: 16px; + overflow: hidden; +} + +.page-shell { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + gap: 0; + overflow: hidden; +} + +.page-hero { + display: none; + position: relative; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.76); + border-radius: 26px; + background: + radial-gradient(circle at 12% 12%, rgba(45, 212, 191, 0.24), transparent 28%), + radial-gradient(circle at 92% 18%, rgba(125, 211, 252, 0.24), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(240, 253, 250, 0.56)); + box-shadow: + 0 18px 36px rgba(14, 116, 144, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.9); + padding: 18px 22px; +} + +.page-hero::after { + content: ''; + position: absolute; + right: -36px; + top: -52px; + width: 160px; + height: 160px; + border-radius: 999px; + border: 28px solid rgba(14, 165, 233, 0.08); +} + +.page-hero-content { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.page-kicker { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + color: #0f8f86; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.page-title-xl { + margin: 0; + color: #0f172a; + font-size: 22px; + font-weight: 800; + letter-spacing: -0.02em; +} + +.page-desc { + display: none; +} + +.page-card { + @apply app-panel; + flex: 1; + min-height: 0; + overflow: hidden; + border-radius: 8px; +} + +.page-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid #e2e8f0; + background: #ffffff; + padding: 12px 14px; +} + +.toolbar-form { + flex: 1; +} + +.toolbar-form .n-form-item { + margin-bottom: 0; +} + +.card-body { + padding: 14px; +} + +.card-body-fill { + flex: 1; + min-height: 0; + padding: 0; +} + +.table-fill { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.table-fill .n-data-table, +.table-fill .n-data-table-wrapper, +.table-fill .n-data-table-base-table { + height: 100%; +} + +.table-fill .n-data-table { + display: flex; + flex-direction: column; + min-height: 0; +} + +.table-fill .n-data-table-wrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.table-fill .n-data-table-base-table { + flex: 1; + min-height: 0; +} + +.table-fill .n-data-table-base-table-body { + height: 100%; + overflow: auto; +} + +.table-fill .n-data-table .n-data-table__pagination { + flex: 0 0 auto; + justify-content: flex-end; + margin-top: auto; + padding: 12px 2px 0; +} + +.app-vxe-table-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.app-vxe-table-body > .vxe-table { + flex: 1; + min-height: 0; +} + +.page-card .n-data-table { + background: transparent; +} + +.page-card .n-data-table-wrapper { + background: transparent; +} + +.page-card .n-data-table-base-table { + background: transparent; +} + +.page-card .n-data-table-th { + font-weight: 600; +} + +.page-card .n-data-table-td { + color: #334155; +} + +.action-btn { + min-width: 64px; +} + +.soft-stat { + position: relative; + overflow: hidden; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: #ffffff; + box-shadow: 0 14px 32px rgba(15, 23, 42, 0.05); + padding: 18px 18px 16px; +} + +.soft-stat::after { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 3px; + height: 100%; + background: linear-gradient(180deg, #2563eb, #14b8a6); +} + +.n-button { + font-weight: 500; +} + +.n-input, +.n-base-selection { + font-size: 13px; +} diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts new file mode 100644 index 0000000..03ca33c --- /dev/null +++ b/web/src/types/auth.ts @@ -0,0 +1,42 @@ +export interface LoginRequest { + username: string + password: string +} + +export interface LoginResponse { + accessToken: string + tokenType: string + expiresIn: number +} + +export interface CurrentUserProfile { + id: string + username: string + nickname?: string | null + realName?: string | null + orgId?: string | null + status: string +} + +export interface MenuNode { + id: string + parentId?: string | null + type: 'CATALOG' | 'MENU' | 'BUTTON' | string + title: string + name?: string | null + path?: string | null + component?: string | null + icon?: string | null + permission?: string | null + sort: number + visible: boolean + keepAlive: boolean + builtIn?: boolean + children: MenuNode[] +} + +export interface MeResponse { + user: CurrentUserProfile + menus: MenuNode[] + permissions: string[] +} diff --git a/web/src/types/http.ts b/web/src/types/http.ts new file mode 100644 index 0000000..9be058b --- /dev/null +++ b/web/src/types/http.ts @@ -0,0 +1,25 @@ +export interface ApiResult { + code: string + message: string + data: T + traceId?: string +} + +export interface PageResult { + items: T[] + page: number + pageSize: number + total: number +} + +export class BizError extends Error { + code: string + traceId?: string + + constructor(code: string, message: string, traceId?: string) { + super(message) + this.code = code + this.traceId = traceId + this.name = 'BizError' + } +} diff --git a/web/src/types/logs.ts b/web/src/types/logs.ts new file mode 100644 index 0000000..b3c234a --- /dev/null +++ b/web/src/types/logs.ts @@ -0,0 +1,32 @@ +import type { PageResult } from '@/types/http' + +export interface OperationLogItem { + id: string + traceId?: string | null + username?: string | null + operationType: string + operationName: string + httpMethod: string + requestPath: string + status: string + errorMessage?: string | null + costMs: number + createdAt: string +} + +export interface ApiAccessLogItem { + id: string + traceId?: string | null + appKey?: string | null + appName?: string | null + httpMethod: string + requestPath: string + responseCode?: string | null + status: string + errorMessage?: string | null + costMs: number + createdAt: string +} + +export type OperationLogPage = PageResult +export type ApiAccessLogPage = PageResult diff --git a/web/src/types/router.d.ts b/web/src/types/router.d.ts new file mode 100644 index 0000000..c868bea --- /dev/null +++ b/web/src/types/router.d.ts @@ -0,0 +1,11 @@ +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta { + title?: string + requiresAuth?: boolean + permission?: string + keepAlive?: boolean + dynamic?: boolean + } +} diff --git a/web/src/types/system/dict.ts b/web/src/types/system/dict.ts new file mode 100644 index 0000000..5910e63 --- /dev/null +++ b/web/src/types/system/dict.ts @@ -0,0 +1,25 @@ +import type { PageResult } from '@/types/http' + +export interface DictTypeItem { + id: string + code: string + name: string + status: string + statusLabel?: string + remark?: string | null +} + +export interface DictItem { + id: string + typeId: string + label: string + value: string + color?: string | null + sort: number + status: string + statusLabel?: string + remark?: string | null +} + +export type DictTypePage = PageResult +export type DictItemPage = PageResult diff --git a/web/src/types/system/menu.ts b/web/src/types/system/menu.ts new file mode 100644 index 0000000..c6742d9 --- /dev/null +++ b/web/src/types/system/menu.ts @@ -0,0 +1,36 @@ +export interface MenuTreeNode { + id: string + parentId?: string | null + type: 'CATALOG' | 'MENU' | 'BUTTON' | string + typeLabel?: string + title: string + name?: string | null + path?: string | null + component?: string | null + icon?: string | null + permission?: string | null + sort: number + visible: boolean + keepAlive: boolean + builtIn?: boolean + status: string + statusLabel?: string + children: MenuTreeNode[] +} + +export interface CreateMenuRequest { + parentId?: string + type: string + title: string + name?: string + path?: string + component?: string + icon?: string + permission?: string + sort: number + visible: boolean + keepAlive: boolean + status: string +} + +export type UpdateMenuRequest = CreateMenuRequest diff --git a/web/src/types/system/org.ts b/web/src/types/system/org.ts new file mode 100644 index 0000000..82dc0e8 --- /dev/null +++ b/web/src/types/system/org.ts @@ -0,0 +1,25 @@ +export interface OrgTreeNode { + id: string + parentId?: string | null + name: string + code: string + sort: number + status: string + statusLabel?: string + children: OrgTreeNode[] +} + +export interface CreateOrgRequest { + parentId?: string + name: string + code: string + sort: number + status: string +} + +export interface UpdateOrgRequest { + parentId?: string + name: string + sort: number + status: string +} diff --git a/web/src/types/system/role.ts b/web/src/types/system/role.ts new file mode 100644 index 0000000..e7c4bc2 --- /dev/null +++ b/web/src/types/system/role.ts @@ -0,0 +1,40 @@ +import type { PageResult } from '@/types/http' + +export interface RoleItem { + id: string + name: string + code: string + description?: string | null + status: string + statusLabel?: string + dataScope: string + dataScopeLabel?: string +} + +export interface RoleDetail extends RoleItem { + menuIds: string[] +} + +export type RolePage = PageResult + +export interface RoleQuery { + page: number + pageSize: number + keyword?: string + status?: string +} + +export interface CreateRoleRequest { + name: string + code: string + description?: string + status: string + dataScope: string +} + +export interface UpdateRoleRequest { + name: string + description?: string + status: string + dataScope: string +} diff --git a/web/src/types/system/user.ts b/web/src/types/system/user.ts new file mode 100644 index 0000000..3b11d2a --- /dev/null +++ b/web/src/types/system/user.ts @@ -0,0 +1,70 @@ +import type { PageResult } from '@/types/http' + +export interface UserListItem { + id: string + username: string + nickname?: string | null + realName?: string | null + orgId?: string | null + status: string + statusLabel?: string + roleCodes: string[] +} + +export interface UserDetail { + id: string + username: string + nickname?: string | null + realName?: string | null + phone?: string | null + email?: string | null + avatar?: string | null + orgId?: string | null + status: string + statusLabel?: string + roleIds: string[] +} + +export interface UserQuery { + username?: string + nickname?: string + status?: string + orgId?: string + page: number + pageSize: number +} + +export type UserListPage = PageResult + +export interface CreateUserRequest { + username: string + password: string + nickname?: string + realName?: string + phone?: string + email?: string + avatar?: string + orgId?: string + status: string +} + +export interface UpdateUserRequest { + nickname?: string + realName?: string + phone?: string + email?: string + avatar?: string + orgId?: string +} + +export interface UpdateUserStatusRequest { + status: string +} + +export interface UpdateUserPasswordRequest { + password: string +} + +export interface UpdateUserRolesRequest { + roleIds: string[] +} diff --git a/web/src/utils/display.ts b/web/src/utils/display.ts new file mode 100644 index 0000000..dcfa2ee --- /dev/null +++ b/web/src/utils/display.ts @@ -0,0 +1,61 @@ +import type { TagProps } from 'naive-ui' + +export function statusLabel(status?: string | null) { + const map: Record = { + ENABLED: '启用', + DISABLED: '禁用', + SUCCESS: '成功', + FAIL: '失败' + } + return status ? (map[status] ?? status) : '-' +} + +export function statusTagType(status?: string | null): TagProps['type'] { + if (status === 'ENABLED' || status === 'SUCCESS') return 'success' + if (status === 'DISABLED') return 'warning' + if (status === 'FAIL') return 'error' + return 'default' +} + +export function menuTypeLabel(type?: string | null) { + const map: Record = { + CATALOG: '目录', + MENU: '菜单', + BUTTON: '按钮' + } + return type ? (map[type] ?? type) : '-' +} + +export function dataScopeLabel(scope?: string | null) { + const map: Record = { + ALL: '全部数据', + DEPT: '本组织及下级', + DEPT_ONLY: '本组织', + SELF: '仅本人' + } + return scope ? (map[scope] ?? scope) : '-' +} + +export function httpMethodLabel(method?: string | null) { + const map: Record = { + GET: '查询', + POST: '新增', + PUT: '更新', + PATCH: '局部更新', + DELETE: '删除' + } + return method ? (map[method] ?? method) : '-' +} + +export function operationTypeLabel(type?: string | null) { + const map: Record = { + CREATE: '新增', + UPDATE: '更新', + DELETE: '删除', + UPDATE_STATUS: '更新状态', + RESET_PASSWORD: '重置密码', + ASSIGN_ROLE: '分配角色', + ASSIGN_MENU: '分配菜单' + } + return type ? (map[type] ?? type) : '-' +} diff --git a/web/src/utils/pagination.ts b/web/src/utils/pagination.ts new file mode 100644 index 0000000..b3ce85f --- /dev/null +++ b/web/src/utils/pagination.ts @@ -0,0 +1,11 @@ +type PagePrefixInfo = { + page: number + pageCount: number + itemCount?: number +} + +export function renderPagePrefix(info: PagePrefixInfo) { + const pageCount = Math.max(info.pageCount, 1) + const itemCount = info.itemCount ?? 0 + return `第 ${info.page} / ${pageCount} 页,共 ${itemCount} 条` +} diff --git a/web/src/views/auth/LoginView.vue b/web/src/views/auth/LoginView.vue new file mode 100644 index 0000000..c50d521 --- /dev/null +++ b/web/src/views/auth/LoginView.vue @@ -0,0 +1,147 @@ + + + + + + + 通用管理平台 + + + + + + + + + + + 登录 + + + + + + + + diff --git a/web/src/views/errors/ForbiddenView.vue b/web/src/views/errors/ForbiddenView.vue new file mode 100644 index 0000000..91b2919 --- /dev/null +++ b/web/src/views/errors/ForbiddenView.vue @@ -0,0 +1,31 @@ + + + + + + 返回工作台 + 退出登录 + + + + + + + diff --git a/web/src/views/errors/NotFoundView.vue b/web/src/views/errors/NotFoundView.vue new file mode 100644 index 0000000..190a92b --- /dev/null +++ b/web/src/views/errors/NotFoundView.vue @@ -0,0 +1,15 @@ + + + + + 返回工作台 + + + + + + diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts new file mode 100644 index 0000000..31b46da --- /dev/null +++ b/web/tailwind.config.ts @@ -0,0 +1,24 @@ +import type { Config } from 'tailwindcss' + +export default { + content: ['./index.html', './src/**/*.{vue,ts,tsx}'], + theme: { + extend: { + colors: { + brand: { + 50: '#eef5ff', + 100: '#dce9ff', + 500: '#356dff', + 600: '#2a5af0' + } + }, + borderRadius: { + panel: '14px' + }, + boxShadow: { + panel: '0 6px 24px rgba(15, 23, 42, 0.06)' + } + } + }, + plugins: [] +} satisfies Config diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d29810b --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "strict": true, + "jsx": "preserve", + "types": ["vite/client", "node"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..863e963 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts", "vitest.config.ts", "tailwind.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..dd17b71 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'node:path' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const proxyTarget = env.VITE_API_PROXY_TARGET + + return { + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, + server: { + host: '0.0.0.0', + port: 5173, + open: false, + proxy: proxyTarget + ? { + '/api': { + target: proxyTarget, + changeOrigin: true + } + } + : undefined + }, + preview: { + host: '0.0.0.0', + port: 4173 + } + } +}) diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..b840eab --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + test: { + environment: 'node', + include: ['src/**/*.spec.ts'] + } +}) diff --git a/三期工程实施文档.md b/三期工程实施文档.md new file mode 100644 index 0000000..9e03280 --- /dev/null +++ b/三期工程实施文档.md @@ -0,0 +1,257 @@ +# Codex 三期工程实施文档(通用票通优先) + +## 1. 文档用途 + +这份文档不是详细设计书,而是一份可以直接拿去给 Codex 执行的实施文档。 + +默认路线: + +- 先做 `通用票通开票模块` +- 暂不接你现有业务系统的待开票数据 +- 后续再把你的业务系统接到这套票通能力上 + +适用前提: + +- 当前项目前后端已经有通用后台底座 +- 已有登录、权限、动态菜单、系统管理、日志管理 +- 新增功能应尽量复用现有结构,不做大重构 + +## 2. 当前项目可直接复用的基础 + +Codex 在执行时,默认直接复用这些已有能力: + +- 后端 Ktor 启动、鉴权、异常处理、日志、数据库初始化 +- 后端 `modules/*` 的路由组织方式 +- 后端 `SeedData` 的菜单、权限、字典初始化方式 +- 前端登录、主布局、动态路由、权限按钮、页签体系 +- 前端 `features/*` 页面组织方式 +- 前端现有表格、表单、弹窗、查询页风格 + +本次新增内容尽量放到以下高层目录: + +- `server/src/main/kotlin/com/bbit/platform/integration/piaotong` +- `server/src/main/kotlin/com/bbit/platform/modules/piaotong` +- `server/src/main/kotlin/com/bbit/platform/database/piaotong` +- `web/src/api/piaotong` +- `web/src/types/piaotong` +- `web/src/features/piaotong` +- `web/src/components/piaotong` + +## 3. 全局实施原则 + +Codex 执行时,统一遵循下面原则: + +- 不改现有系统管理模块的交互风格 +- 不做全局重命名和大规模目录重构 +- 每次只处理一个任务包,不跨太多模块 +- 单个任务包尽量做到“改完即可本地验证一个页面或一组接口” +- 先打通票通主链路,再考虑业务系统适配 +- 农产品收购发票从一开始就要纳入支持范围 + +新增菜单统一按这套来: + +- 一级菜单:`票通开票` +- 二级菜单: + - `票通企业` + - `数电账号` + - `认证中心` + - `手工开票` + - `开票任务` + +## 4. 三期工程总览 + +### 第一期目标 + +把票通模块骨架和基础查询能力搭起来,做到“能看企业、能看账号、能看认证状态、页面和接口结构稳定”。 + +### 第二期目标 + +把认证链路和手工开票主链路打通,做到“可以在系统里完成认证并发起真实开票”。 + +### 第三期目标 + +把推送、补偿、重试、稳定性补齐,做到“开票结果能稳定落定,模块具备持续使用能力”。 + +## 5. 第一期:票通底座与基础查询 + +### 5.1 本期目标 + +先把“票通开票”模块作为一个独立业务域立起来,完成最小骨架和只读能力,不急着一开始就打开发票提交。 + +### 5.2 本期应完成的内容 + +- 新增票通开票菜单和页面骨架 +- 新增票通相关后端模块骨架 +- 落票通配置、加密签名、HTTP 客户端基础能力 +- 支持查询企业信息 +- 支持查询企业开户行及账号信息 +- 支持查询数电账号列表 +- 支持查询数电账号认证状态 +- 建立本地票通账号表和基础任务表 + +### 5.3 本期不要做的内容 + +- 不做真实开票提交 +- 不做认证短信/扫码交互闭环 +- 不做票通推送回调 +- 不接你自己的业务系统 + +### 5.4 可直接给 Codex 的任务包 + +#### 任务包 1 + +新增 `票通开票` 一级菜单和 5 个二级菜单,补齐前端路由和页面骨架,页面先只保留基础查询区和占位内容,不接真实接口。 + +#### 任务包 2 + +在后端新增 `piaotong` 业务域目录和模块注册,补齐配置读取、公共报文封装、RSA/3DES 基础能力、票通 HTTP 客户端基础封装,先不接具体业务页面。 + +#### 任务包 3 + +实现 `票通企业` 相关接口和页面,支持按税号查询企业信息、查询企业开户行及账号信息,并把查询结果在前端页面展示出来。 + +#### 任务包 4 + +实现 `数电账号` 和 `认证状态` 的基础查询能力,支持查询数电账号列表、查看默认账号、查看认证建议和认证状态,并补齐本地账号表的基础读写。 + +### 5.5 本期验收标准 + +- 菜单、路由、页面已经可访问 +- 后端已经形成独立的 `piaotong` 业务域 +- 可以查询企业信息 +- 可以查询企业开户行及账号信息 +- 可以查询数电账号列表和认证状态 +- 代码结构已经为后续认证和开票留出扩展位置 + +## 6. 第二期:认证中心与手工开票主链路 + +### 6.1 本期目标 + +把“账号认证 -> 手工录入 -> 发起开票 -> 查看任务状态”这条主链路打通。 + +### 6.2 本期应完成的内容 + +- 认证中心后端接口 +- 短信验证码发送与短信登录 +- 二维码认证获取与扫码状态查询 +- 手工开票页面 +- 普通蓝字数电票开票 +- 农产品收购发票开票 +- 本地开票任务记录 +- 开票任务列表和详情页 +- 手动查询开票结果 + +### 6.3 本期不要做的内容 + +- 不做票通推送回调 +- 不做定时补偿任务 +- 不接你的业务系统回写 + +### 6.4 可直接给 Codex 的任务包 + +#### 任务包 1 + +实现 `认证中心` 后端接口,支持查询认证状态、发送短信验证码、提交短信登录、获取实名认证二维码、查询二维码扫码状态、退出电子税局登录。 + +#### 任务包 2 + +实现 `认证中心` 前端页面和交互,支持短信认证流程和扫码认证流程,页面能清晰展示当前认证建议、二维码状态和短信状态。 + +#### 任务包 3 + +实现 `手工开票` 页面和后端开票接口,支持手工录入发票基础信息、购买方/销方信息、商品明细,并支持普通开票模式和农产品收购发票模式切换。 + +#### 任务包 4 + +实现本地开票任务创建、开票提交、手动状态查询、任务列表和详情页,让用户能看到发票请求流水号、状态、失败原因、票号等核心结果。 + +### 6.5 本期验收标准 + +- 用户可以在系统里完成短信认证或扫码认证 +- 用户可以手工录入一张票并发起开票 +- 农产品收购发票模式可用 +- 能看到本地任务状态和票通返回结果 +- 遇到 `3999` 时能进入认证流程,并可继续原任务 + +## 7. 第三期:推送、补偿与稳定性收口 + +### 7.1 本期目标 + +把结果同步和异常处理补齐,让模块从“可演示”进入“可稳定联调和可持续使用”状态。 + +### 7.2 本期应完成的内容 + +- 票通推送回调接收 +- 验签、解密、幂等处理 +- 主动查询补偿 +- 失败任务重试 +- 状态机收口 +- 日志和异常信息收口 +- 联调说明和部署说明 + +### 7.3 本期不要做的内容 + +- 仍然不接你的待开票业务系统 +- 不把整套系统改造成完整业务平台 + +### 7.4 可直接给 Codex 的任务包 + +#### 任务包 1 + +实现票通发票推送回调接口,支持接收推送主要信息和全票面信息,完成验签、解密、幂等判断和本地任务状态更新。 + +#### 任务包 2 + +实现主动查询补偿机制,对处理中和待认证后的任务进行补偿查询,避免只依赖回调导致状态长期不落定。 + +#### 任务包 3 + +实现失败任务重试和状态机收口,保证同一张发票重试时复用原 `invoiceReqSerialNo`,并把任务页上的错误提示、状态标签、操作入口整理完整。 + +#### 任务包 4 + +整理联调说明、环境配置说明和回调部署说明,补齐必要的日志与调试信息,让后续对接你自己的业务系统时可以直接复用这套票通能力。 + +### 7.5 本期验收标准 + +- 开票结果可以通过推送或主动查询最终落定 +- 重复推送不会造成重复更新 +- 失败任务可以重试 +- 任务状态流转稳定 +- 模块已经具备后续接业务系统的基础 + +## 8. 给 Codex 的执行边界 + +后续给 Codex 发任务时,建议直接按“任务包”发,不要一次跨两期。 + +推荐下发方式: + +- `按第一期任务包 1 做,只做菜单、路由和 5 个页面骨架,不接真实接口` +- `按第一期任务包 3 做,只做票通企业查询前后端,不动认证和开票` +- `按第二期任务包 2 做,只做认证中心前端交互,复用现有后端接口` +- `按第二期任务包 4 做,只做本地开票任务和任务列表详情` +- `按第三期任务包 2 做,只做主动查询补偿和状态落定` + +不建议下发方式: + +- 一次要求 Codex 同时做菜单、认证、开票、推送、补偿 +- 一次要求 Codex 同时接票通和你的业务系统 +- 一次要求 Codex 重构现有后台结构后再开发功能 + +## 9. 这份文档的最终建议 + +这三期的节奏,核心是先把“票通能力底座”做出来,再考虑“业务系统接入层”。 + +如果按当前项目状态来看,这样分期最合理: + +- 第一期把结构立稳,先查得到 +- 第二期把主链路跑通,先开得出 +- 第三期把稳定性补齐,先用得住 + +后续如果你要再接自己的待开票业务系统,就在这三期完成后再新增第四阶段: + +- 读取业务待开票数据 +- 自动创建票通任务 +- 结果回写业务系统 + +这样不会推翻前面的实现,Codex 也更容易持续推进。 diff --git a/待开发功能梳理.md b/待开发功能梳理.md new file mode 100644 index 0000000..d1f1b35 --- /dev/null +++ b/待开发功能梳理.md @@ -0,0 +1,219 @@ +# 农产品收购发票开票平台待开发功能梳理 + +## 1. 梳理结论 + +结合当前前端、后端代码和原 [系统设计.md](C:\Users\BBIT\Desktop\农产品收购发票开票平台\系统设计.md),目前项目已经完成的是“通用后台底座”,尚未进入“农产品收购发票开票业务闭环”开发阶段。 + +当前可以从后续范围中移除的内容,主要是: + +- 平台账号登录、JWT 鉴权、当前用户信息获取 +- 动态菜单、按钮权限、路由权限控制 +- 用户、组织、角色、菜单、字典管理 +- 操作日志、接口访问日志 +- 基础工作台、后台布局、通用表格表单交互 + +因此,新文档不再把上述能力作为一期建设重点,后续应聚焦“业务系统对接 + 票通对接 + 开票状态闭环 + 业务页面”。 + +## 2. 当前已完成能力 + +### 2.1 后端已完成 + +- 认证基础:`/api/auth/login`、`/api/auth/logout`、`/api/auth/me` +- 权限体系:JWT、权限码校验、角色菜单绑定 +- 系统管理接口: + - `/api/system/users` + - `/api/system/orgs` + - `/api/system/roles` + - `/api/system/menus` + - `/api/system/dicts` +- 日志查询接口: + - `/api/logs/operation` + - `/api/logs/api-access` +- 初始化能力:默认组织、管理员、菜单、字典种子数据 + +### 2.2 前端已完成 + +- 登录页、主布局、动态路由、页签导航 +- 工作台首页 +- 用户管理 +- 组织管理 +- 角色管理 +- 菜单管理 +- 字典管理 +- 操作日志、接口日志 + +## 3. 当前未完成的核心业务能力 + +## 3.1 业务主线能力 + +以下是当前最需要补齐的一期闭环: + +1. 获取当前用户所属公司上下文 +2. 对接我方业务系统,查询待开票农产品收购数据 +3. 选择待开票数据并发起蓝字数电发票开具 +4. 对接票通认证流程,处理短信认证、扫码认证 +5. 查询开票结果并回写我方业务系统 +6. 接收票通回调,驱动本地状态更新 +7. 支持失败重试、处理中补偿查询、审计留痕 + +目前以上 7 项在现有前后端代码中都还没有真正落地。 + +## 3.2 后端待开发清单 + +### 3.2.1 我方业务系统对接 + +需要新增业务系统集成模块,至少包括: + +- 登录后获取当前用户 `companyId`、公司名称、纳税人识别号 +- 根据 `companyId` 查询待开票列表 +- 开票前写库/锁单 +- 开票结果回写 +- 回写失败后的补偿重试 + +### 3.2.2 票通集成能力 + +需要新增票通专用模块,至少包括: + +- 票通基础配置管理 +- 3DES 加密、RSA 签名、验签、解密 +- `invoiceBlue.pt` 蓝字开票接口封装 +- `getTaxBureauAccountAuthStatus.pt` 认证状态查询 +- `sendLoginSmsCode.pt` 短信验证码发送 +- `smsLogin.pt` 短信登录 +- `getAuthenticationQrcode.pt` 实名认证二维码获取 +- `queryAuthQrcodeScanStatus.pt` 二维码扫码状态查询 +- `queryInvoice.pt` 发票结果主动查询 +- 电子税局退出登录接口封装 + +### 3.2.3 开票任务与状态机 + +需要新增开票任务中心,而不是直接同步开票: + +- `invoice_job`、`invoice_job_item`、`tax_account`、`audit_log` 等业务表 +- `invoiceReqSerialNo` 幂等号生成与复用 +- 本地状态流转: + - `PENDING` + - `PROCESSING` + - `NEED_AUTH` + - `SUCCESS` + - `FAILED` + - `BIZ_SYNC_FAILED` +- 认证后继续原任务,而不是重新生成任务 +- 调用超时后转处理中,并通过主动查询补状态 + +### 3.2.4 对外业务接口 + +当前还缺少原设计中的业务接口: + +- `GET /api/invoice-candidates` +- `POST /api/invoice-jobs` +- `GET /api/invoice-jobs/{jobId}` +- `POST /api/invoice-jobs/{jobId}/retry` +- `GET /api/piaotong/accounts/{account}/auth-status` +- `POST /api/piaotong/accounts/{account}/sms-code` +- `POST /api/piaotong/accounts/{account}/sms-login` +- `POST /api/piaotong/accounts/{account}/auth-qrcode` +- `GET /api/piaotong/accounts/{account}/auth-qrcode/{authId}` +- `POST /api/piaotong/accounts/{account}/logout` +- `POST /api/callbacks/piaotong/invoice` + +### 3.2.5 后台任务与可靠性 + +还需要补齐后台可靠性能力: + +- 定时查询 `PROCESSING` 任务 +- 回调去重与乱序保护 +- 开票结果与业务回写失败补偿 +- 关键字段脱敏存档 +- 面向票通/业务系统的错误码映射 + +## 3.3 前端待开发清单 + +### 3.3.1 业务页面 + +当前前端没有任何开票业务页面,需要新增: + +- 待开票列表页 +- 开票确认弹窗 +- 票通认证弹窗 +- 开票结果页/任务详情页 +- 票通账号配置页 +- 开票日志或任务追踪页 + +### 3.3.2 页面交互能力 + +需要补齐以下交互: + +- 按公司查看待开票数据 +- 勾选待开票单据并显示金额汇总 +- 发起开票前的参数确认 +- 根据 `operationProposed` 展示不同认证方式 +- 认证完成后继续原开票任务 +- 展示开票状态、失败原因、票号、数电发票号码 +- 支持手动刷新、重试、查看明细 + +### 3.3.3 菜单与品牌调整 + +当前前端仍偏“通用管理平台”,还需要调整为业务平台形态: + +- 新增发票业务菜单,而不是只有系统管理菜单 +- 工作台改为展示开票业务统计,而不是菜单/权限数量 +- 登录页、工作台、导航标题统一到“农产品收购发票开票平台”业务语境 + +## 4. 建议保留的一期范围 + +结合现状,建议把一期范围压缩成“最小可用闭环”: + +1. 登录后拿到公司上下文 +2. 查询待开票列表 +3. 发起蓝字数电普票开具 +4. 票通认证:短信 + 扫码 +5. 开票状态查询 +6. 开票结果回写我方业务系统 +7. 票通回调接收 +8. 失败重试与日志留痕 + +## 5. 建议暂缓到二期的内容 + +以下内容建议暂不纳入当前主线: + +- 冲红流程 +- 红字确认单申请/审核 +- 发票文件下载 +- 二维码开票扩展能力 +- 微信/支付宝卡包 +- 企业注册入驻 +- 更复杂的经营分析报表 + +## 6. 推荐实施顺序 + +### 第一阶段:打通后端主链路 + +- 建业务系统客户端 +- 建票通客户端与加密签名能力 +- 落业务表结构 +- 完成开票任务、状态机、回调、主动查询 + +### 第二阶段:补前端业务页面 + +- 待开票列表 +- 发起开票 +- 认证弹窗 +- 结果页与重试 + +### 第三阶段:做稳定性和运维补齐 + +- 补偿任务 +- 日志审计 +- 配置维护页 +- 错误提示和异常兜底 + +## 7. 最终结论 + +当前项目“底座已具备,业务未开工”的特征非常明确。接下来不需要继续投入系统管理模块,而应集中资源完成以下三块: + +- 我方业务系统对接 +- 票通开票与认证对接 +- 开票任务闭环与前端业务页面 + +只要这三块完成,项目才会从“通用后台”真正进入“农产品收购发票开票平台”的可用状态。 diff --git a/票通对接业务流程说明.md b/票通对接业务流程说明.md new file mode 100644 index 0000000..afd9659 --- /dev/null +++ b/票通对接业务流程说明.md @@ -0,0 +1,513 @@ +# 票通对接业务流程说明 + +本文基于以下资料整理: + +- [系统设计.md](C:\Users\BBIT\Desktop\农产品收购发票开票平台\系统设计.md) +- [doc\票通数电平台对接指引v1.0(2).txt](C:\Users\BBIT\Desktop\农产品收购发票开票平台\doc\票通数电平台对接指引v1.0(2).txt) +- [doc\票通数电发票接口文档3.3.5.txt](C:\Users\BBIT\Desktop\农产品收购发票开票平台\doc\票通数电发票接口文档3.3.5.txt) + +## 1. 你的角色本质是什么 + +结论:是的,你本质上是“接入方平台 / 第三方平台 / 中台”,帮助甲方企业对接票通,以甲方企业的名义完成开票。 + +但这里不是简单的 HTTP 转发,还包括以下职责: + +- 维护“你的平台”和“开票企业”的绑定关系 +- 维护或选择甲方企业可用的数电账号 +- 组织开票报文并调用票通接口 +- 处理登录认证、风险认证 +- 接收票通推送或主动查询开票结果 +- 再把开票结果回写给你自己的业务系统 + +从票通文档看,这个角色被明确定义为: + +- `第三方平台` / `集团企业` +- `接入方系统` + +关键依据: + +- 对接指引说明“接入方系统开发完成后,由票通提供正式环境参数” +- 直接开票场景中,`系统接入方组织好待开票信息后,直接调用 2.9 开具蓝字数电发票` +- 企业通过接口注册后,会自动生成“平台和企业的绑定关系”;如果企业已在票通完成入驻,则需要联系票通侧建立“接入方平台和开票企业的绑定关系” + +所以业务归属上: + +- 开票主体是甲方企业 +- 票通是开票服务平台 +- 你是中间接入平台 + +## 2. 票通是否提供测试接口 + +结论:提供。 + +### 2.1 测试环境说明 + +票通对接指引明确写了: + +- 测试环境和参数信息由票通对接人员提供 +- 包括平台编码、RSA 证书、3DES 密钥、测试税号 +- 正式上线前,再由票通提供正式环境参数 + +文档里的核心说明: + +- 测试环境参数:平台编码、RSA 签名验签证书、3DES 密钥、测试税号 +- 联调测试阶段,可直接使用票通对接人员提供的测试税号 + +### 2.2 测试地址风格 + +核心测试地址统一是: + +```text +http://fpkj.testnw.vpiaotong.cn/tp/openapi/... +``` + +正式地址统一是: + +```text +https://fpkj.vpiaotong.com/tp/openapi/... +``` + +### 2.3 你当前真正会用到的测试接口 + +- `register.pt` +- `registerUser.pt` +- `sendLoginSmsCode.pt` +- `smsLogin.pt` +- `getAuthenticationQrcode.pt` +- `queryAuthQrcodeScanStatus.pt` +- `getTaxBureauAccountAuthStatus.pt` +- `listTaxBureauAccount.pt` +- `invoiceBlue.pt` +- `queryInvoice.pt` +- `queryInvoiceInfo.pt` +- `logoutEtax.pt` +- `getEnterpriseInfo.pt` +- `queryEnterpriseBankInfo.pt` + +注意: + +- 推送接口 `2.13 / 2.14 / 2.48` 没有票通固定测试地址,因为这是“票通调用你提供的接口地址” +- 也就是说,回调 URL 不是你调用票通时传进去的,而是你提供给票通侧配置使用的 + +## 3. 开票结果“回写”到底分几层 + +这里要分清楚两层,不要混在一起。 + +### 3.1 第一层:票通把结果返回给你的平台 + +这是票通体系内的结果同步,主要有两种方式: + +#### 方式 A:票通推送 + +票通文档定义了: + +- `2.13 推送发票主要信息` +- `2.14 推送发票全票面信息` + +调用关系写得很明确: + +- `票通平台调用第三方平台` +- `接口地址:第三方平台提供` + +这就是我们说的“回调”。 + +#### 方式 B:你的平台主动查询 + +票通文档定义了: + +- `2.11 查询发票主要信息` +- `2.12 查询发票全票面信息` + +也就是说,开票结果可以: + +- 等票通推送过来 +- 或者你主动轮询查询 + +最佳实践不是二选一,而是: + +- 主用推送 +- 查询作为补偿和兜底 + +### 3.2 第二层:你的平台再把结果写回你自己的业务系统 + +这个“开票结果回写接口”不是给票通用的。 + +它是你自己的业务系统接口,用来做这些事: + +- 把原始业务单据状态改成“开票成功/失败/处理中” +- 回写发票号码、数电发票号码、开票日期 +- 回写失败原因 +- 记录票通流水号或发票请求流水号 + +所以: + +- `票通 -> 你的平台`:票通文档里有 +- `你的平台 -> 你的业务系统`:票通文档里没有,需要你自己定义 + +## 4. 回调是怎么和票通绑定的 + +从文档能确认两件事: + +1. `2.13 / 2.14 / 2.48` 都是“票通平台调用第三方平台” +2. 接口地址不是你在开票请求里传递,而是“第三方平台提供” + +因此可以得出一个明确结论: + +- 回调地址不是按每张发票动态传的 +- 而是你先提供给票通,由票通侧事先配置 + +文档没有给出“通过某个接口动态设置回调地址”的描述,所以这里应按“线下配置 / 对接阶段配置”理解。 + +实际落地时通常就是: + +1. 你先准备一个公网可访问的回调地址 +2. 提供给票通对接人员 +3. 票通侧把这个地址配置成推送接收地址 +4. 票通后续在开票成功、失败、认证异常等场景调用你的回调接口 + +对你来说要注意: + +- 回调接口必须能公网访问 +- 必须按票通公共报文做验签和解密 +- 要做幂等,避免重复推送重复入库 +- 不能只靠回调,最好仍保留主动查询兜底 + +## 5. 开票结果样例怎么理解 + +这里也分两种。 + +### 5.1 票通推送/查询给你的样例 + +这个在票通文档里是有的。 + +最有代表性的就是: + +- `2.13 推送发票主要信息` 样例 +- `2.11 查询发票主要信息` 样例 +- `2.12 查询发票全票面信息` 样例 + +例如 `2.13` 的成功推送报文会包含: + +- `taxpayerNum` +- `invoiceReqSerialNo` +- `invoiceType` +- `invoiceKind` +- `code` +- `msg` +- `tradeNo` +- `definedData` +- `invoiceCode` +- `invoiceNo` +- `electronicInvoiceNo` +- `invoiceDate` +- `noTaxAmount` +- `taxAmount` +- `invoicePdf` +- `invoiceXml` +- `downloadUrl` + +其中: + +- `code=0000` 表示成功 +- `code=3999` 表示需要扫码或短信认证 +- `code=9999` 表示开票失败 + +### 5.2 你写回甲方业务系统的样例 + +这个在票通文档里没有,因为这是你自己的内部系统接口。 + +你至少需要自己约定一份这样的回写报文: + +```json +{ + "bizOrderNo": "CG202604290001", + "companyId": "COMP_001", + "invoiceReqSerialNo": "DEMO202604290001234567", + "status": "SUCCESS", + "ptCode": "0000", + "ptMsg": "开具成功", + "invoiceCode": "123456789012", + "invoiceNo": "12345678", + "electronicInvoiceNo": "12345678901212345678", + "invoiceDate": "2026-04-29 14:20:31", + "noTaxAmount": "90.50", + "taxAmount": "11.50", + "downloadUrl": "https://...", + "definedData": "自定义数据" +} +``` + +失败样例建议至少保留: + +```json +{ + "bizOrderNo": "CG202604290001", + "companyId": "COMP_001", + "invoiceReqSerialNo": "DEMO202604290001234567", + "status": "FAILED", + "ptCode": "9999", + "ptMsg": "开票失败,失败原因见票通返回", + "failReason": "票通返回的失败描述" +} +``` + +## 6. 按业务流程顺序,你需要知道和会用到的接口 + +下面按实际开发顺序来列,不按文档章节号罗列。 + +### 6.1 安全机制和公共报文 + +所有接口调用前都要先满足: + +- 公共报文使用 RSA 签名 +- 业务报文使用 3DES 加密 +- 编码统一 UTF-8 +- 公共字段包括: + - `platformCode` + - `signType` + - `sign` + - `format` + - `timestamp` + - `version` + - `serialNo` + - `content` + +注意事项: + +- `content` 里放的是除公共参数外的全部业务参数 +- RSA 签名结果要做 Base64 编码 +- 业务 JSON 编码统一 UTF-8 + +### 6.2 企业是否已入驻票通 + +你需要先确认开票企业是否已经具备票通开票资格。 + +可用接口: + +- `2.2 注册企业` +- `2.47 查询企业信息` +- `2.48 企业审核结果推送` + +使用顺序建议: + +1. 如果甲方企业尚未入驻,走 `2.2 注册企业` +2. 注册后用 `2.47 查询企业信息` 看审核状态 +3. 如果票通侧支持,可接 `2.48 企业审核结果推送` + +注意事项: + +- 企业必须先完成入驻才能开票 +- 如果企业不是通过接口注册,而是在票通侧已存在,需要和票通做“平台与企业绑定” +- `reviewStatus` 要关注: + - `0` 待审核 + - `1` 审核通过 + - `2` 审核不通过 + - `3` 审核中 + +### 6.3 数电账号准备 + +如果甲方企业已经入驻,下一步是准备可以用于开票的数电账号。 + +可用接口: + +- `2.3 数电账号登记` +- `2.45 查询数电账号列表` +- `2.8 查询数电账号认证状态` +- `2.46 退出电子税局登录` + +使用顺序建议: + +1. 先用 `2.45` 查询某税号下已经维护好的数电账号 +2. 如果你需要平台内维护账号,再用 `2.3` 登记 +3. 开票前用 `2.8` 检查认证状态 +4. 如存在异常登录状态,再视情况用 `2.46` 退出后重登 + +注意事项: + +- `2.3` 的密码和身份密码要用票通要求的 3DES 加密 +- `2.45 / 2.8` 会返回: + - `operationProposed` + - `authStatus` + - `switchable` + - `wechatUserBindStatus` +- `operationProposed` 最重要: + - `0` 无需认证 + - `1` 需扫码认证 + - `2` 需扫码或短信认证 + - `3` 需短信认证 + +### 6.4 开票前置检查 + +正式开票前,建议先做企业和票面数据检查。 + +建议用到: + +- `2.47 查询企业信息` +- `2.49 查询企业开户行及账号` +- `2.45 查询数电账号列表` +- `2.8 查询数电账号认证状态` + +注意事项: + +- `2.49` 文档明确说明:可用于避免“企业没有维护开户行及账号导致开票失败” +- 如果票通平台和电子税局都没有维护开户行及账号,需要先提醒企业维护 +- 查询电子税局开户行及账号时,要求数电账号已登录认证 + +### 6.5 发起开票 + +核心接口: + +- `2.9 开具蓝字数电发票` + +你的业务场景重点注意以下字段: + +- `invoiceIssueKindCode` + - 农产品收购发票只能开数电普通票,建议按 `82` +- `specialInvoiceKind` + - 农产品收购发票必须传 `02` +- `invoiceReqSerialNo` + - 一张发票的唯一幂等号 +- `account` + - 明确指定开票使用的数电账号 +- `definedData` + - 建议传你自己的业务单号或关联标识,后续推送会原样带回 +- `tradeNo` + - 可传业务订单号,不传时票通默认使用 `invoiceReqSerialNo` + +农产品收购发票特别注意: + +- 文档写明:`specialInvoiceKind=02` 时,只能开具数电票(普通发票) +- 文档还写明:`购方信息代表实际的销方信息,销方信息是实际的购方` +- `purchaseInvSellerIdType` 开具农产品收购发票时必填 +- `buyerTaxpayerNum` 开具农产品收购发票时必填 + +注意事项: + +- `invoiceReqSerialNo` 对同一张票重试时不能变 +- 文档明确提示:如果变更同一张票的请求流水号,可能造成重开发票 +- 返回里的 `qrCodePath` / `qrCode` 可用于打开 H5 实时查看开票状态 + +### 6.6 如果开票遇到认证 + +如果开票返回 `3999`,不要直接判成普通失败。 + +可用接口: + +- `2.8 查询数电账号认证状态` +- `2.4 获取登录短信验证码` +- `2.5 短信登录` +- `2.6 获取实名认证二维码` +- `2.7 查询实名认证二维码扫码状态` + +短信认证路径: + +1. 调 `2.8` 看建议 +2. 如果允许短信认证,调 `2.4` +3. 再调 `2.5` 完成登录 + +扫码认证路径: + +1. 调 `2.6` 获取二维码 +2. 展示二维码给用户 +3. 轮询 `2.7` 查询扫码状态 +4. 认证完成后,再继续原开票流程 + +注意事项: + +- `2.4` 返回 `6666` 时,文档说“无需真正发送短信验证码,系统会自动登录成功” +- `2.6` 二维码一般 5 分钟左右过期 +- `2.7` 文档特别提示:接口比较耗时,请调长超时时间 +- `2.7` 的 `scanStatus`: + - `1` 未扫码 + - `2` 已扫码 + - `3` 二维码已过期 + +### 6.7 获取开票结果 + +结果同步建议两条线都做。 + +#### 第一条:票通推送 + +- `2.13 推送发票主要信息` +- `2.14 推送发票全票面信息` + +作用: + +- 票通开票成功、失败、需要认证时,主动通知你 + +注意事项: + +- 接口地址由你提供给票通配置 +- 你返回的业务结果码: + - `0000` 接收成功 + - `9999` 接收失败 + +#### 第二条:主动查询 + +- `2.11 查询发票主要信息` +- `2.12 查询发票全票面信息` + +作用: + +- 回调没到时兜底 +- 开票中状态补偿查询 +- 手动刷新状态 + +关键状态码: + +- `0000` 开票成功 +- `6666` 未开票 +- `7777` 开票中 +- `9999` 开票失败 +- `3999` 需要扫码或短信认证 + +### 6.8 结果落库并回写你自己的业务系统 + +这一段票通不会帮你做,需要你自己做。 + +建议你的平台在收到 `2.13` 推送或 `2.11 / 2.12` 查询结果后: + +1. 更新本地开票任务状态 +2. 保存发票号码、数电发票号码、开票时间、失败原因 +3. 再调用你自己的业务系统回写接口 + +建议你自己的回写接口至少要包含: + +- 业务单号 +- 公司标识 +- 发票请求流水号 +- 开票状态 +- 票通状态码 +- 票通状态描述 +- 发票号码 / 数电发票号码 +- 开票日期 +- 金额 / 税额 +- 下载地址或文件标识 + +## 7. 这份文档给你的最终结论 + +你现在最需要抓住的不是“票通有多少接口”,而是下面这条最小闭环: + +1. 确认企业已入驻且已与平台绑定 +2. 确认企业下有可用数电账号 +3. 开票前检查认证状态和企业基础信息 +4. 调 `2.9` 发起农产品收购发票开具 +5. 如遇 `3999`,走短信认证或扫码认证 +6. 用 `2.13 / 2.14` 收推送,同时用 `2.11 / 2.12` 做补偿查询 +7. 最后把结果回写到你自己的业务系统 + +如果只按开发优先级排序,你最先应该落的接口就是: + +- `2.45 查询数电账号列表` +- `2.8 查询数电账号认证状态` +- `2.9 开具蓝字数电发票` +- `2.11 查询发票主要信息` +- `2.13 推送发票主要信息` +- `2.4 / 2.5 / 2.6 / 2.7` 认证链路 + +对你这个项目最容易踩坑的点有 4 个: + +- 农产品收购发票字段语义和普通蓝票不同,购销方字段有反转 +- `invoiceReqSerialNo` 必须稳定复用,不能重试就换 +- 回调地址不是请求里传的,而是提前配置给票通的 +- “开票结果回写接口”不是票通接口,而是你自己的业务系统接口 diff --git a/系统设计.md b/系统设计.md new file mode 100644 index 0000000..e859150 --- /dev/null +++ b/系统设计.md @@ -0,0 +1,315 @@ +# 农产品收购发票开票平台系统设计 + +## 1. 系统定位 + +本平台作为“业务系统”和“票通数电发票平台”之间的开票中台: + +- 前端 Web:登录、查看待开票农产品收购数据、选择数据、发起开票、处理票通认证、查看开票结果。 +- Ktor 后端:统一封装我方业务接口、票通接口、票通加密签名、开票状态机、结果回写、重试与审计。 +- 我方业务系统:提供公司、待开票列表、开票前写库、开票结果回写等接口。 +- 票通系统:提供数电账号认证、短信登录、实名认证二维码、蓝字发票开具、发票查询、结果推送等接口。 + +当前文档资料中,本业务主要对应票通“直接开票”场景:调用 `invoiceBlue.pt` 开具蓝字数电发票;开票结果通过票通推送或主动查询同步。票通要求业务报文使用 UTF-8,业务 JSON 先 3DES 加密,外层报文再按字段排序后用 RSA 签名。 + +## 2. 总体架构 + +```mermaid +flowchart LR + Web["Web 前端"] + Ktor["Ktor 后端"] + Biz["我方业务系统"] + DB["平台数据库"] + PT["票通 OpenAPI"] + + Web -->|"登录/查询/开票/认证"| Ktor + Ktor -->|"公司、待开票、预写库、状态回写"| Biz + Ktor -->|"开票任务、状态、票通账号、审计日志"| DB + Ktor -->|"加密签名请求"| PT + PT -->|"推送结果 callback"| Ktor + Ktor -->|"状态更新"| Web +``` + +建议 Ktor 后端承担“防重复、状态机、签名加密、票通异常兼容”的职责,前端不要直接调用票通,也不要持有票通密钥。 + +## 3. 核心业务流程 + +### 3.1 平台登录与公司上下文 + +1. 用户登录 Web。 +2. Ktor 调用我方登录或用户信息接口,获取 `userId`、`companyId`、公司名称、纳税人识别号 `taxpayerNum`。 +3. Ktor 建立平台会话/JWT,前端后续请求都带当前公司上下文。 + +### 3.2 查询待开票列表 + +1. 前端调用 `GET /api/invoice-candidates`。 +2. Ktor 按当前 `companyId` 调用我方“根据公司 id 查询待开票列表”接口。 +3. Ktor 对列表做轻量规范化:金额、税率、商品名称、收购方/销售方信息、是否可开票、已锁定状态。 +4. 前端展示并支持勾选。 + +### 3.3 发起开票 + +1. 前端选中待开票数据,调用 `POST /api/invoice-jobs`。 +2. Ktor 校验当前公司票通配置和数电账号状态。 +3. 如果 `getTaxBureauAccountAuthStatus.pt` 返回需要认证: + - `operationProposed=1`:提示扫码认证; + - `operationProposed=2`:展示扫码和短信两种方式; + - `operationProposed=3`:提示短信登录; + - `operationProposed=0`:继续开票。 +4. Ktor 调用我方“开票前写库”接口,锁定这些业务数据,并取得我方开票批次号或业务流水号。 +5. Ktor 生成稳定且唯一的 `invoiceReqSerialNo`。同一张发票重试必须复用同一个流水号,避免重复开票。 +6. Ktor 按票通字段组装 `invoiceBlue.pt` 业务报文,调用票通。 +7. Ktor 记录票通同步返回结果: + - 成功受理或开票中:本地状态置为 `PROCESSING`; + - 立即成功:置为 `SUCCESS`,回写我方系统; + - 失败:置为 `FAILED` 或 `NEED_AUTH`,回写我方系统。 + +### 3.4 认证流程 + +票通文档中认证不是简单登录态,包含登录认证和风险认证。推荐做成统一认证面板。 + +- 查询认证状态:`getTaxBureauAccountAuthStatus.pt` +- 获取短信验证码:`sendLoginSmsCode.pt` +- 短信登录:`smsLogin.pt` +- 获取实名认证二维码:`getAuthenticationQrcode.pt` +- 查询二维码扫码状态:`queryAuthQrcodeScanStatus.pt` +- 退出电子税局登录:`logout` 类接口,对应文档 2.46 + +前端行为: + +- `operationProposed=1`:调用后端获取二维码,轮询扫码状态。 +- `operationProposed=2`:同时展示“扫码认证”和“短信认证”。 +- `operationProposed=3`:展示手机号/账号、验证码输入、发送验证码按钮。 +- 认证完成后,用户点击“继续开票”,后端用原 `invoiceReqSerialNo` 继续或重试。 + +### 3.5 开票结果同步 + +建议同时支持两条链路: + +1. 票通推送:实现 `POST /api/callbacks/piaotong/invoice`,接收票通推送发票主要信息或全票面信息。 +2. 主动查询:后台任务定时调用 `queryInvoice.pt` 查询 `PROCESSING`、`NEED_AUTH_RETRYING` 的发票。 + +票通状态码映射: + +| 票通 code | 含义 | 本地状态 | +| --- | --- | --- | +| `0000` | 开票成功 | `SUCCESS` | +| `6666` | 未开票 | `PENDING` 或 `PROCESSING` | +| `7777` | 开票中 | `PROCESSING` | +| `9999` | 开票失败 | `FAILED` | +| `3999` | 需要扫码或短信认证 | `NEED_AUTH` | +| `4999` | 红字确认单申请中 | 后续冲红扩展 | +| `5999` | 红字确认单审核中 | 后续冲红扩展 | + +结果落库后,Ktor 调用我方“写状态”接口,把成功/失败、票号、数电发票号码、失败原因、票通流水号等回写。 + +## 4. 后端模块设计 + +建议 Ktor 后端按以下模块拆分: + +```text +server/ + src/main/kotlin/com/bbit/agriinvoice/ + Application.kt + config/ + AppConfig.kt + PiaotongConfig.kt + BizSystemConfig.kt + routes/ + AuthRoutes.kt + InvoiceCandidateRoutes.kt + InvoiceJobRoutes.kt + PiaotongAuthRoutes.kt + PiaotongCallbackRoutes.kt + domain/ + Company.kt + InvoiceCandidate.kt + InvoiceJob.kt + InvoiceStatus.kt + TaxAccount.kt + service/ + LoginService.kt + InvoiceCandidateService.kt + InvoiceIssueService.kt + InvoiceStatusService.kt + PiaotongAuthService.kt + PiaotongCallbackService.kt + integration/ + biz/ + BizSystemClient.kt + BizDtos.kt + piaotong/ + PiaotongClient.kt + PiaotongCrypto.kt + PiaotongDtos.kt + PiaotongEndpoints.kt + repository/ + InvoiceJobRepository.kt + TaxAccountRepository.kt + AuditLogRepository.kt +``` + +关键服务职责: + +- `PiaotongCrypto`:3DES 加解密、RSA 签名验签、外层报文构造、响应解密。 +- `PiaotongClient`:封装票通 endpoint,所有票通接口只接受/返回业务 DTO。 +- `InvoiceIssueService`:开票主流程、幂等控制、状态推进。 +- `PiaotongAuthService`:数电账号状态查询、短信登录、二维码认证。 +- `InvoiceStatusService`:票通状态映射、本地落库、我方业务系统状态回写。 + +## 5. Web 页面设计 + +### 5.1 页面 + +- 登录页:登录平台并进入公司上下文。 +- 待开票列表页:筛选、勾选、查看金额合计、发起开票。 +- 开票确认弹窗:展示本次开票单据、购买方/销售方、商品行、价税合计。 +- 票通认证弹窗:根据后端返回展示扫码或短信认证。 +- 开票结果页:展示处理中、成功、失败、需要认证,支持刷新和重试。 +- 系统配置页:维护票通数电账号、开票员、税号、默认商品编码等。 + +### 5.2 前端状态 + +列表项建议展示: + +- `未开票` +- `已锁定` +- `开票中` +- `待认证` +- `开票成功` +- `开票失败` + +前端不要自己判断票通接口细节,只消费后端聚合后的 `status`、`actionRequired`、`authOptions`、`message`。 + +## 6. Ktor 对外接口草案 + +```http +POST /api/auth/login +GET /api/me + +GET /api/invoice-candidates +POST /api/invoice-jobs +GET /api/invoice-jobs/{jobId} +POST /api/invoice-jobs/{jobId}/retry + +GET /api/piaotong/accounts/{account}/auth-status +POST /api/piaotong/accounts/{account}/sms-code +POST /api/piaotong/accounts/{account}/sms-login +POST /api/piaotong/accounts/{account}/auth-qrcode +GET /api/piaotong/accounts/{account}/auth-qrcode/{authId} +POST /api/piaotong/accounts/{account}/logout + +POST /api/callbacks/piaotong/invoice +``` + +`POST /api/invoice-jobs` 请求示例: + +```json +{ + "candidateIds": ["A001", "A002"], + "taxAccount": "18900000000", + "invoiceKind": "82", + "buyer": { + "name": "购买方名称", + "taxpayerNum": "XX0000000000000000" + } +} +``` + +响应示例: + +```json +{ + "jobId": "job_20260424153000001", + "status": "NEED_AUTH", + "message": "当前数电账号需要短信或扫码认证", + "authOptions": ["QRCODE", "SMS"] +} +``` + +## 7. 数据库表建议 + +### 7.1 invoice_job + +| 字段 | 说明 | +| --- | --- | +| id | 平台开票任务 ID | +| company_id | 公司 ID | +| taxpayer_num | 销方纳税人识别号 | +| invoice_req_serial_no | 票通发票请求流水号,唯一 | +| biz_batch_no | 我方系统开票前写库返回批次号 | +| tax_account | 数电账号 | +| invoice_kind | 发票种类,数电普票通常为 `82` | +| status | 本地状态 | +| pt_code | 票通状态码 | +| pt_message | 票通返回信息 | +| invoice_no | 发票号码 | +| all_ele_inv_no | 数电发票号码 | +| invoice_date | 开票日期 | +| total_amount | 合计金额 | +| total_tax | 合计税额 | +| raw_request | 脱敏后的票通业务请求 | +| raw_response | 脱敏后的票通响应 | +| created_at / updated_at | 时间 | + +### 7.2 invoice_job_item + +保存本次开票关联的我方待开票数据 ID、商品行、金额、税率、数量、税收分类编码。 + +### 7.3 tax_account + +保存公司下可用数电账号、姓名、身份类型、手机号、最近认证状态。密码或密钥类数据必须加密保存,不能明文落库。 + +### 7.4 audit_log + +保存登录、开票、认证、回写、回调、重试等操作审计。 + +## 8. 票通报文封装 + +参考现有 Java Demo,后端需要实现: + +1. 业务 JSON 使用 UTF-8 序列化。 +2. 使用票通提供的 3DES key 加密业务 JSON,得到外层 `content`。 +3. 外层字段包含: + - `platformCode` + - `signType=RSA` + - `format=JSON` + - `version=1.0` + - `content` + - `timestamp` + - `serialNo` +4. 对外层字段按 key 排序,拼成 `key=value&key=value`,忽略 null,使用 `SHA1WithRSA` 和我方私钥签名。 +5. 响应先用票通公钥验签,再用 3DES 解密 `content`。 + +配置项必须放在环境变量或配置中心: + +```properties +PIAOTONG_BASE_URL=https://fpkj.vpiaotong.com/tp/openapi +PIAOTONG_PLATFORM_CODE=... +PIAOTONG_PLATFORM_ALIAS=... +PIAOTONG_3DES_KEY=... +PIAOTONG_PRIVATE_KEY=... +PIAOTONG_PUBLIC_KEY=... +``` + +## 9. 幂等与异常策略 + +- `invoiceReqSerialNo` 是开票幂等核心。同一张发票重试必须复用,不能重新生成。 +- 调用我方“开票前写库”成功后,即使票通调用超时,也不能直接判失败,应进入 `PROCESSING` 并主动查询。 +- 票通返回 `3999` 时,不应生成新发票任务,应把原任务置为 `NEED_AUTH`,认证后继续用原流水号重试或查询。 +- 我方状态回写失败时,本地记录为 `BIZ_SYNC_FAILED`,后台补偿重试。 +- 回调接口必须验签、去重、按状态版本更新,避免乱序回调覆盖成功状态。 + +## 10. 推荐一期范围 + +一期先做最小闭环: + +1. 登录并获取公司 ID。 +2. 查询待开票列表。 +3. 选择数据并发起蓝字数电普票开具。 +4. 票通短信登录和扫码认证。 +5. 开票状态查询和结果回写。 +6. 票通回调接收。 +7. 开票日志和失败重试。 + +冲红、二维码开票、发票文件下载、微信/支付宝卡包、企业注册入驻可放到二期。 + diff --git a/通用票通开票模块设计书.md b/通用票通开票模块设计书.md new file mode 100644 index 0000000..7786e78 --- /dev/null +++ b/通用票通开票模块设计书.md @@ -0,0 +1,571 @@ +# 通用票通开票模块设计书 + +## 1. 设计目标 + +结论:可以,而且我认为这是一个很适合当前阶段的做法。 + +在不先对接你现有业务系统待开票数据的前提下,可以先建设一个“通用票通开票模块”,目标是: + +- 先把票通接口、加密签名、认证流程、开票状态流转跑通 +- 先验证票通联调、测试账号、测试税号、回调、查询这些关键链路 +- 先形成一个独立可用的开票能力层 +- 后续再把你自己的待开票数据、锁单、回写逻辑接到这个能力层上 + +也就是说,第一阶段把系统拆成两层: + +- `通用票通开票层` +- `业务系统适配层` + +当前先只做第一层。 + +## 2. 为什么适合先这样做 + +先做通用票通模块有 5 个好处: + +- 把最大不确定性先消化掉:票通联调、认证、回调、状态码 +- 不被你现有业务系统接口进度卡住 +- 可以先做出可演示、可测试、可验收的开票闭环 +- 后续接你自己的平台时,改动集中在“数据映射”和“回写适配” +- 更适合 Codex 分阶段落地,每次任务可以更聚焦 + +这条路线本质上是先做: + +- `票通能力产品` + +再做: + +- `你自己业务平台的票通接入` + +## 3. 系统定位 + +这个模块在一期不关心“待开票数据来自哪里”,只关心: + +- 用户手工录入或粘贴开票数据 +- 系统把数据转换成票通标准报文 +- 系统调用票通完成开票 +- 系统处理认证、查询、回调、重试 + +所以它不是“农产品收购发票业务平台完整体”,而是一个: + +- `通用票通开票控制台` + +它先支持: + +- 企业和数电账号准备 +- 手工发起蓝字开票 +- 查看开票任务 +- 处理短信/扫码认证 +- 查看开票结果 + +后续再扩展: + +- 对接你自己的待开票业务单据 +- 自动锁单 +- 自动结果回写 + +## 4. 一期范围 + +一期只做“纯票通能力闭环”,不做以下内容: + +- 不对接你现有业务系统待开票列表 +- 不做你自己的业务锁单/预写库 +- 不做你自己的业务结果回写 +- 不做复杂经营报表 +- 不做红冲 + +一期只保留这些能力: + +1. 企业信息查询 +2. 数电账号维护/查询 +3. 认证状态查询 +4. 短信认证 +5. 扫码认证 +6. 手工录入开票信息 +7. 发起蓝字开票 +8. 查询开票状态 +9. 接收票通推送 +10. 查看开票任务与结果 + +## 5. 核心业务流程 + +### 5.1 初始化准备 + +1. 录入或查询开票企业税号 +2. 查询企业是否已在票通开通 +3. 查询企业下可用数电账号 +4. 选择一个开票账号 +5. 查询该账号当前认证状态 + +### 5.2 手工开票 + +1. 用户在页面上录入开票信息 +2. 系统校验必填字段 +3. 系统生成 `invoiceReqSerialNo` +4. 系统调用 `invoiceBlue.pt` +5. 记录本地开票任务 +6. 返回初始结果 + +### 5.3 认证处理 + +如果票通返回需要认证: + +1. 查询认证建议 +2. 根据返回结果进入短信认证或扫码认证 +3. 认证完成后继续原开票任务 +4. 复用原 `invoiceReqSerialNo` + +### 5.4 结果同步 + +结果同步同时走两条线: + +- 票通推送 +- 主动查询 + +处理原则: + +- 推送优先 +- 查询补偿 +- 状态幂等更新 + +## 6. 功能模块设计 + +建议先做 4 个模块。 + +### 6.1 企业与账号模块 + +作用: + +- 查询票通企业信息 +- 查询企业开户行及账号信息 +- 查询数电账号列表 +- 维护本地默认开票账号 + +建议菜单: + +- `票通企业` +- `数电账号` + +### 6.2 认证中心模块 + +作用: + +- 查询账号认证状态 +- 发送短信验证码 +- 提交短信登录 +- 获取实名认证二维码 +- 查询二维码扫码状态 +- 执行退出电子税局登录 + +建议菜单: + +- `认证中心` + +### 6.3 通用开票模块 + +作用: + +- 手工录入发票信息 +- 支持普通蓝字数电票 +- 支持农产品收购发票 +- 生成标准开票任务 +- 发起票通开票 + +建议菜单: + +- `手工开票` + +### 6.4 开票任务模块 + +作用: + +- 查看任务状态 +- 查看票通返回信息 +- 查看发票号码、数电发票号码 +- 查看失败原因 +- 手动查询状态 +- 重试失败任务 + +建议菜单: + +- `开票任务` + +## 7. 页面设计 + +建议新增 5 个页面。 + +### 7.1 票通企业页 + +展示内容: + +- 企业税号 +- 企业名称 +- 审核状态 +- 开通票种 +- 服务状态 + +操作: + +- 查询企业信息 +- 查询开户行及账号信息 + +### 7.2 数电账号页 + +展示内容: + +- 数电账号 +- 姓名 +- 身份类型 +- 认证状态 +- 登录状态 +- 风险认证状态 +- 是否绑定公众号 + +操作: + +- 查询账号列表 +- 登记账号 +- 设为默认账号 + +### 7.3 认证中心页 + +展示内容: + +- 当前认证建议 +- 当前认证状态 +- 短信验证码状态 +- 二维码状态 + +操作: + +- 查询认证状态 +- 发送短信验证码 +- 提交短信登录 +- 获取扫码二维码 +- 轮询扫码状态 +- 退出登录 + +### 7.4 手工开票页 + +展示内容: + +- 基本信息表单 +- 购买方信息 +- 销方信息 +- 开票项目明细 +- 农产品收购发票专用字段 + +操作: + +- 保存草稿 +- 发起开票 +- 清空表单 + +### 7.5 开票任务页 + +展示内容: + +- 发票请求流水号 +- 企业税号 +- 数电账号 +- 发票种类 +- 状态 +- 失败原因 +- 发票号码 +- 数电发票号码 +- 开票时间 + +操作: + +- 查看详情 +- 查询最新状态 +- 重新发起查询 +- 失败后重试 + +## 8. 后端设计 + +### 8.1 目录建议 + +```text +server/src/main/kotlin/com/bbit/platform/ + database/piaotong/ + PtInvoiceJobTable.kt + PtInvoiceJobItemTable.kt + PtTaxAccountTable.kt + PtCallbackLogTable.kt + + integration/piaotong/ + PiaotongClient.kt + PiaotongCrypto.kt + PiaotongDtos.kt + PiaotongMapper.kt + + modules/piaotong/company/ + PtCompanyModule.kt + + modules/piaotong/account/ + PtAccountModule.kt + + modules/piaotong/auth/ + PtAuthModule.kt + + modules/piaotong/invoice/ + PtInvoiceModule.kt + + modules/piaotong/task/ + PtTaskModule.kt + + modules/piaotong/callback/ + PtCallbackModule.kt +``` + +### 8.2 后端接口建议 + +#### 企业与账号 + +- `GET /api/piaotong/company/{taxpayerNum}` +- `GET /api/piaotong/company/{taxpayerNum}/bank-info` +- `GET /api/piaotong/accounts` +- `POST /api/piaotong/accounts/register` +- `PUT /api/piaotong/accounts/{id}/default` + +#### 认证 + +- `GET /api/piaotong/accounts/{account}/auth-status` +- `POST /api/piaotong/accounts/{account}/sms-code` +- `POST /api/piaotong/accounts/{account}/sms-login` +- `POST /api/piaotong/accounts/{account}/auth-qrcode` +- `GET /api/piaotong/accounts/{account}/auth-qrcode/{authId}` +- `POST /api/piaotong/accounts/{account}/logout` + +#### 开票 + +- `POST /api/piaotong/invoices` +- `POST /api/piaotong/invoices/validate` +- `GET /api/piaotong/invoices/{jobId}` +- `POST /api/piaotong/invoices/{jobId}/query` +- `POST /api/piaotong/invoices/{jobId}/retry` + +#### 推送 + +- `POST /api/callbacks/piaotong/invoice-summary` +- `POST /api/callbacks/piaotong/invoice-detail` + +## 9. 前端设计 + +### 9.1 目录建议 + +```text +web/src/ + api/piaotong/ + company.ts + account.ts + auth.ts + invoice.ts + task.ts + + types/piaotong/ + company.ts + account.ts + auth.ts + invoice.ts + task.ts + + features/piaotong/ + company/index.vue + accounts/index.vue + auth-center/index.vue + invoice-create/index.vue + tasks/index.vue + + components/piaotong/ + InvoiceItemEditor.vue + InvoicePreviewCard.vue + AuthQrcodePanel.vue + InvoiceStatusTag.vue +``` + +### 9.2 菜单建议 + +新增一级菜单: + +- `票通开票` + +二级菜单建议: + +- `票通企业` +- `数电账号` +- `认证中心` +- `手工开票` +- `开票任务` + +## 10. 数据库设计 + +### 10.1 pt_tax_account + +保存: + +- 企业税号 +- 数电账号 +- 姓名 +- 身份类型 +- 默认账号标记 +- 最近认证状态 +- 最近登录状态 +- 最近风险认证状态 + +### 10.2 pt_invoice_job + +保存: + +- 任务 ID +- 企业税号 +- 数电账号 +- 发票请求流水号 +- 发票类型 +- 特殊票种 +- 状态 +- 票通状态码 +- 状态描述 +- 发票号码 +- 数电发票号码 +- 开票日期 +- 金额 +- 税额 +- 原始请求 +- 原始响应 + +### 10.3 pt_invoice_job_item + +保存: + +- 所属任务 +- 商品名称 +- 税编 +- 数量 +- 单价 +- 金额 +- 税率 +- 税额 + +### 10.4 pt_callback_log + +保存: + +- 回调类型 +- 回调时间 +- 回调报文 +- 验签结果 +- 处理结果 + +## 11. 状态设计 + +建议本地状态统一为: + +- `DRAFT` +- `PENDING` +- `PROCESSING` +- `NEED_AUTH` +- `SUCCESS` +- `FAILED` + +票通状态映射建议: + +- `0000` -> `SUCCESS` +- `6666` -> `PENDING` 或 `PROCESSING` +- `7777` -> `PROCESSING` +- `3999` -> `NEED_AUTH` +- `9999` -> `FAILED` + +## 12. 关键接口映射 + +一期建议优先实现以下票通接口: + +- `2.45 查询数电账号列表` +- `2.8 查询数电账号认证状态` +- `2.9 开具蓝字数电发票` +- `2.11 查询发票主要信息` +- `2.13 推送发票主要信息` +- `2.4 获取登录短信验证码` +- `2.5 短信登录` +- `2.6 获取实名认证二维码` +- `2.7 查询实名认证二维码扫码状态` +- `2.47 查询企业信息` +- `2.49 查询企业开户行及账号` + +可选补充: + +- `2.3 数电账号登记` +- `2.12 查询发票全票面信息` +- `2.14 推送发票全票面信息` +- `2.46 退出电子税局登录` + +## 13. 农产品收购发票特别说明 + +虽然这是“通用票通开票模块”,但你这个项目核心还是农产品收购发票,所以一期最好直接支持它。 + +需要特别处理: + +- `specialInvoiceKind=02` +- 只能开数电普通票 +- `purchaseInvSellerIdType` 必填 +- `buyerTaxpayerNum` 必填 +- 购销双方字段语义与普通发票不同 + +建议做法: + +- 表单层支持“普通票模式”和“农产品收购模式”切换 +- 由后端统一做字段映射和报文转换 + +## 14. 与后续业务系统对接的衔接方式 + +后续如果接你自己的待开票平台,不推翻这套设计,只是增加一层适配。 + +后续新增的只会是: + +- 待开票列表读取 +- 业务单据锁定 +- 开票任务自动创建 +- 结果回写业务系统 + +也就是说,后续结构会变成: + +- 你的业务平台 -> 通用票通开票模块 -> 票通 + +而不是重新写一套票通集成。 + +## 15. 推荐实施顺序 + +建议按下面顺序推进: + +### 第一阶段 + +- 建票通企业页 +- 建数电账号页 +- 打通企业查询、账号查询、认证状态查询 + +### 第二阶段 + +- 建认证中心 +- 打通短信认证、扫码认证 + +### 第三阶段 + +- 建手工开票页 +- 打通蓝字开票 +- 建开票任务页 + +### 第四阶段 + +- 接票通推送 +- 做主动查询补偿 +- 做重试 + +## 16. 最终结论 + +可以先不接你自己的待开票数据,先单独构建一个“通用票通开票模块”,而且这条路线很合理。 + +我建议把它当成: + +- `票通能力底座` + +先完成这个底座,再把你的业务系统往上挂。这样后续不管接待开票列表、锁单、回写,都会顺很多,也更适合 Codex 分阶段持续推进。