diff --git a/db/20260904_add_er_yin_printer.sql b/db/20260904_add_er_yin_printer.sql new file mode 100644 index 00000000..87070122 --- /dev/null +++ b/db/20260904_add_er_yin_printer.sql @@ -0,0 +1,317 @@ +-- 尔印独立打印终端、终端一对一绑定及持久化打印任务。 +-- 先执行硬件库结构,再执行UPMS字典和按钮权限;所有新增MQTT字段保持向后兼容。 +-- 本脚本需要 MySQL 8.x 兼容的窗口函数,历史序号回填和批量主键分配使用 ROW_NUMBER()。 + +ALTER TABLE `pjcloudx_hardware`.`hard_invoice` + MODIFY COLUMN `hard_id` bigint(20) NULL COMMENT '来源业务或核销终端id', + MODIFY COLUMN `pic` varchar(512) NULL COMMENT '后端生成的打印PNG地址'; + +-- 每个结构变更独立判断,可在兼容数据库自动提交部分DDL后安全重跑整个脚本。 +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`columns` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `column_name` = 'printer_hard_id') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `printer_hard_id` bigint(20) NULL COMMENT ''目标尔印打印终端id'' AFTER `hard_id`', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`columns` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `column_name` = 'print_type') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `print_type` char(2) NOT NULL DEFAULT ''1'' COMMENT ''打印类型,1发票、2兑换券'' AFTER `order_id`', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`columns` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `column_name` = 'print_no') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `print_no` int(11) NOT NULL DEFAULT 1 COMMENT ''同订单同类型打印序号'' AFTER `print_type`', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`columns` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `column_name` = 'retry_version') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `retry_version` int(11) NOT NULL DEFAULT 0 COMMENT ''人工失败重试版本'' AFTER `print_no`', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`columns` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `column_name` = 'attempt_count') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `attempt_count` int(11) NOT NULL DEFAULT 0 COMMENT ''终端USB累计尝试次数'' AFTER `retry_version`', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`columns` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `column_name` = 'failure_stage') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `failure_stage` varchar(32) NULL COMMENT ''BINDING、IMAGE_GENERATION、MQTT_DISPATCH或TERMINAL_PRINT'' AFTER `status`', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`columns` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `column_name` = 'last_error') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `last_error` varchar(2000) NULL COMMENT ''最近失败摘要'' AFTER `failure_stage`', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`columns` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `column_name` = 'printed_at') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `printed_at` datetime NULL COMMENT ''终端确认完整USB数据和切纸命令写入成功时间'' AFTER `last_error`', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`columns` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `column_name` = 'reprint_reason') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD COLUMN `reprint_reason` varchar(500) NULL COMMENT ''人工重打或再次核销原因'' AFTER `printed_at`', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +-- 唯一索引尚未建立才回填历史发票,避免迁移完成后重跑时覆盖新产生的兑换券任务。 +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`statistics` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `index_name` = 'uk_hard_invoice_order_type_no') = 0, + 'UPDATE `pjcloudx_hardware`.`hard_invoice` invoice + JOIN ( + SELECT ranked.`id`, ranked.`print_no` + FROM ( + SELECT `id`, ROW_NUMBER() OVER ( + PARTITION BY `tenant_id`, `order_id` + ORDER BY `create_time`, `id` + ) AS `print_no` + FROM `pjcloudx_hardware`.`hard_invoice` + ) ranked + ) ranked_rows ON ranked_rows.`id` = invoice.`id` + SET invoice.`print_type` = ''1'', + invoice.`print_no` = ranked_rows.`print_no`, + invoice.`retry_version` = 0, + invoice.`attempt_count` = 0', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`statistics` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `index_name` = 'uk_hard_invoice_order_type_no') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD UNIQUE KEY `uk_hard_invoice_order_type_no` (`tenant_id`, `order_id`, `print_type`, `print_no`)', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`statistics` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `index_name` = 'idx_hard_invoice_printer_status') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD KEY `idx_hard_invoice_printer_status` (`tenant_id`, `printer_hard_id`, `status`, `update_time`, `del_flag`)', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +SET @er_yin_ddl = IF( + (SELECT COUNT(*) FROM `information_schema`.`statistics` + WHERE `table_schema` = 'pjcloudx_hardware' AND `table_name` = 'hard_invoice' + AND `index_name` = 'idx_hard_invoice_source_status') = 0, + 'ALTER TABLE `pjcloudx_hardware`.`hard_invoice` ADD KEY `idx_hard_invoice_source_status` (`tenant_id`, `hard_id`, `status`, `del_flag`)', + 'SELECT 1' +); +PREPARE er_yin_stmt FROM @er_yin_ddl; +EXECUTE er_yin_stmt; +DEALLOCATE PREPARE er_yin_stmt; + +CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_printer_binding` +( + `id` bigint(20) NOT NULL COMMENT '绑定关系主键', + `source_hard_id` bigint(20) NOT NULL COMMENT 'SUPERMARKET或CANTEEN来源业务终端id', + `printer_hard_id` bigint(20) NOT NULL COMMENT 'EY_PRINT目标打印终端id', + `merchant_id` bigint(20) NOT NULL COMMENT '双方共同所属商户id快照', + `dept_id` bigint(20) NOT NULL COMMENT '双方共同所属商户部门id快照', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `tenant_id` bigint(20) NOT NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hard_printer_binding_source` (`tenant_id`, `source_hard_id`), + UNIQUE KEY `uk_hard_printer_binding_printer` (`tenant_id`, `printer_hard_id`), + KEY `idx_hard_printer_binding_merchant` (`tenant_id`, `merchant_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci + COMMENT = '业务终端与尔印打印终端一对一绑定'; + +CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_printer_config` +( + `id` bigint(20) NOT NULL COMMENT '配置主键', + `printer_hard_id` bigint(20) NOT NULL COMMENT 'EY_PRINT打印终端id', + `printer_model` varchar(128) DEFAULT NULL COMMENT '打印机型号', + `usb_vendor_id` int(11) DEFAULT NULL COMMENT 'USB Vendor ID十进制值', + `usb_product_id` int(11) DEFAULT NULL COMMENT 'USB Product ID十进制值', + `paper_width` int(11) NOT NULL DEFAULT 80 COMMENT '纸宽,单位mm,首期固定80', + `auto_cut` char(1) NOT NULL DEFAULT '1' COMMENT '自动切刀开关,1开启、0关闭', + `image_mode` varchar(16) NOT NULL DEFAULT 'GS_V0' COMMENT '图片指令模式,GS_V0或ESC_STAR', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `tenant_id` bigint(20) NOT NULL COMMENT '租户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hard_printer_config_printer` (`tenant_id`, `printer_hard_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci + COMMENT = '尔印打印终端USB与ESC/POS配置'; + +-- 字典和权限主键使用迁移日期种子或现有最大ID后的连续号,与项目其他迁移保持一致。 +SET @er_yin_dict_item_id_base = ( + SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026090400000000000) + FROM `pjcloudx`.`sys_dict_item` +); + +-- 在每个已经配置hard_type的租户中加入尔印打印终端。 +INSERT INTO `pjcloudx`.`sys_dict_item` +(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `remarks`, `del_flag`, `tenant_id`) +SELECT @er_yin_dict_item_id_base + ROW_NUMBER() OVER (ORDER BY d.`tenant_id`, d.`id`), + d.`id`, '12', '尔印打印机器', 'hard_type', '独立USB打印终端', 12, + 'system', NOW(), '尔印80mm ESC/POS打印终端', '0', d.`tenant_id` +FROM `pjcloudx`.`sys_dict` d +WHERE d.`dict_type` = 'hard_type' + AND d.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict_item` i + WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = 'hard_type' + AND i.`item_value` = '12' AND i.`del_flag` = '0' + ); + +-- 打印类型和状态字典按租户幂等创建,供管理端筛选与状态展示。 +SET @er_yin_dict_id_base = ( + SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026090400000000000) + FROM `pjcloudx`.`sys_dict` +); + +INSERT INTO `pjcloudx`.`sys_dict` +(`id`, `dict_type`, `description`, `create_by`, `create_time`, `remarks`, `system_flag`, `del_flag`, `tenant_id`) +SELECT @er_yin_dict_id_base + ROW_NUMBER() OVER (ORDER BY tenant.`tenant_id`, t.`dict_type`), + t.`dict_type`, t.`description`, 'system', NOW(), t.`description`, '1', '0', tenant.`tenant_id` +FROM (SELECT DISTINCT `tenant_id` FROM `pjcloudx`.`sys_dict` WHERE `del_flag` = '0') tenant +CROSS JOIN ( + SELECT 'hard_print_type' AS `dict_type`, '打印凭证类型' AS `description` + UNION ALL SELECT 'hard_print_status', '打印任务状态' +) t +WHERE NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict` d + WHERE d.`tenant_id` = tenant.`tenant_id` AND d.`dict_type` = t.`dict_type` AND d.`del_flag` = '0' + ); + +SET @er_yin_dict_item_id_base = ( + SELECT GREATEST(COALESCE(MAX(`id`), 0), 2026090400000000000) + FROM `pjcloudx`.`sys_dict_item` +); + +INSERT INTO `pjcloudx`.`sys_dict_item` +(`id`, `dict_id`, `item_value`, `label`, `dict_type`, `description`, `sort_order`, `create_by`, `create_time`, `del_flag`, `tenant_id`) +SELECT @er_yin_dict_item_id_base + ROW_NUMBER() OVER ( + ORDER BY d.`tenant_id`, d.`dict_type`, item.`sort_order`, item.`item_value` + ), + d.`id`, item.`item_value`, item.`label`, d.`dict_type`, item.`label`, item.`sort_order`, + 'system', NOW(), '0', d.`tenant_id` +FROM `pjcloudx`.`sys_dict` d +JOIN ( + SELECT 'hard_print_type' AS `dict_type`, '1' AS `item_value`, '发票' AS `label`, 1 AS `sort_order` + UNION ALL SELECT 'hard_print_type', '2', '兑换券', 2 + UNION ALL SELECT 'hard_print_status', '0', '待生成', 0 + UNION ALL SELECT 'hard_print_status', '1', '待打印', 1 + UNION ALL SELECT 'hard_print_status', '2', '已打印', 2 + UNION ALL SELECT 'hard_print_status', '3', '打印失败', 3 + UNION ALL SELECT 'hard_print_status', '4', '已取消', 4 +) item ON item.`dict_type` = d.`dict_type` +WHERE d.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_dict_item` i + WHERE i.`tenant_id` = d.`tenant_id` AND i.`dict_type` = d.`dict_type` + AND i.`item_value` = item.`item_value` AND i.`del_flag` = '0' + ); + +-- 在已有设备管理与打印记录菜单下补充按钮权限,不假定各环境菜单主键。 +SET @er_yin_menu_id_base = ( + SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026090400000000000) + FROM `pjcloudx`.`sys_menu` +); + +INSERT INTO `pjcloudx`.`sys_menu` +(`menu_id`, `name`, `permission`, `parent_id`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`) +SELECT @er_yin_menu_id_base + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`), + '绑定打印机', 'hard_hardManager_printer_bind', parent_menu.`menu_id`, + '1', 20, '0', '0', '2', 'system', NOW(), '0', parent_menu.`tenant_id` +FROM `pjcloudx`.`sys_menu` parent_menu +WHERE parent_menu.`permission` = 'hard_hardManager_view' AND parent_menu.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_menu` existing + WHERE existing.`tenant_id` = parent_menu.`tenant_id` + AND existing.`permission` = 'hard_hardManager_printer_bind' AND existing.`del_flag` = '0' + ); + +SET @er_yin_menu_id_base = ( + SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026090400000000000) + FROM `pjcloudx`.`sys_menu` +); + +INSERT INTO `pjcloudx`.`sys_menu` +(`menu_id`, `name`, `permission`, `parent_id`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`) +SELECT @er_yin_menu_id_base + ROW_NUMBER() OVER (ORDER BY parent_menu.`tenant_id`, parent_menu.`menu_id`), + '重打兑换券', 'hard_hardInvoice_voucher_reprint', parent_menu.`menu_id`, + '1', 20, '0', '0', '2', 'system', NOW(), '0', parent_menu.`tenant_id` +FROM `pjcloudx`.`sys_menu` parent_menu +WHERE parent_menu.`permission` = 'hard_hardInvoice_view' AND parent_menu.`del_flag` = '0' + AND NOT EXISTS ( + SELECT 1 FROM `pjcloudx`.`sys_menu` existing + WHERE existing.`tenant_id` = parent_menu.`tenant_id` + AND existing.`permission` = 'hard_hardInvoice_voucher_reprint' AND existing.`del_flag` = '0' + ); + +SET @er_yin_dict_id_base = NULL; +SET @er_yin_dict_item_id_base = NULL; +SET @er_yin_menu_id_base = NULL; +SET @er_yin_ddl = NULL; diff --git a/db/800pjcloudx_hardware.sql b/db/800pjcloudx_hardware.sql index 173d7d84..e5934cc3 100644 --- a/db/800pjcloudx_hardware.sql +++ b/db/800pjcloudx_hardware.sql @@ -167,22 +167,83 @@ CREATE TABLE hard_group DROP TABLE IF EXISTS `hard_invoice`; CREATE TABLE hard_invoice ( - id bigint NOT NULL COMMENT '编号', - dept_id bigint NOT NULL COMMENT '部门id', - hard_id bigint NOT NULL COMMENT '设备id', - order_id bigint NOT NULL COMMENT '硬件订单id', - status char(10) NOT NULL COMMENT '状态', - pic varchar(256) NOT NULL COMMENT '发票图片', - create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', - create_time datetime DEFAULT NULL COMMENT '创建时间', - update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', - update_time datetime DEFAULT NULL COMMENT '修改时间', - del_flag char(1) COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标记', - tenant_id bigint DEFAULT NULL COMMENT '租户ID', - PRIMARY KEY (id) + id bigint NOT NULL COMMENT '编号', + dept_id bigint NOT NULL COMMENT '部门id', + hard_id bigint DEFAULT NULL COMMENT '来源业务或核销终端id', + printer_hard_id bigint DEFAULT NULL COMMENT '目标尔印打印终端id', + order_id bigint NOT NULL COMMENT '硬件订单id', + print_type char(2) NOT NULL DEFAULT '1' COMMENT '打印类型,1发票、2兑换券', + print_no int NOT NULL DEFAULT 1 COMMENT '同订单同类型打印序号', + retry_version int NOT NULL DEFAULT 0 COMMENT '人工失败重试版本', + attempt_count int NOT NULL DEFAULT 0 COMMENT '终端USB累计尝试次数', + status char(10) NOT NULL DEFAULT '0' COMMENT '状态,0待生成、1待打印、2已打印、3打印失败、4已取消', + failure_stage varchar(32) DEFAULT NULL COMMENT '失败阶段', + last_error varchar(2000) DEFAULT NULL COMMENT '最近失败摘要', + printed_at datetime DEFAULT NULL COMMENT '终端确认写入成功时间', + reprint_reason varchar(500) DEFAULT NULL COMMENT '人工重打或再次核销原因', + pic varchar(512) DEFAULT NULL COMMENT '后端生成的打印PNG地址', + create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', + create_time datetime DEFAULT NULL COMMENT '创建时间', + update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', + update_time datetime DEFAULT NULL COMMENT '修改时间', + del_flag char(1) COLLATE utf8mb4_general_ci DEFAULT '0' COMMENT '删除标记', + tenant_id bigint DEFAULT NULL COMMENT '租户ID', + PRIMARY KEY (id), + UNIQUE KEY uk_hard_invoice_order_type_no (tenant_id, order_id, print_type, print_no), + KEY idx_hard_invoice_printer_status (tenant_id, printer_hard_id, status, update_time, del_flag), + KEY idx_hard_invoice_source_status (tenant_id, hard_id, status, del_flag) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 - COLLATE = utf8mb4_general_ci COMMENT ='发票记录'; + COLLATE = utf8mb4_general_ci COMMENT ='打印任务与凭证记录'; + +-- ---------------------------- +-- Table structure for hard_printer_binding +-- ---------------------------- +DROP TABLE IF EXISTS `hard_printer_binding`; +CREATE TABLE hard_printer_binding +( + id bigint NOT NULL COMMENT '绑定关系主键', + source_hard_id bigint NOT NULL COMMENT 'SUPERMARKET或CANTEEN来源业务终端id', + printer_hard_id bigint NOT NULL COMMENT 'EY_PRINT目标打印终端id', + merchant_id bigint NOT NULL COMMENT '双方共同所属商户id快照', + dept_id bigint NOT NULL COMMENT '双方共同所属商户部门id快照', + create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', + create_time datetime DEFAULT NULL COMMENT '创建时间', + update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', + update_time datetime DEFAULT NULL COMMENT '修改时间', + tenant_id bigint NOT NULL COMMENT '租户ID', + PRIMARY KEY (id), + UNIQUE KEY uk_hard_printer_binding_source (tenant_id, source_hard_id), + UNIQUE KEY uk_hard_printer_binding_printer (tenant_id, printer_hard_id), + KEY idx_hard_printer_binding_merchant (tenant_id, merchant_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci COMMENT ='业务终端与尔印打印终端一对一绑定'; + +-- ---------------------------- +-- Table structure for hard_printer_config +-- ---------------------------- +DROP TABLE IF EXISTS `hard_printer_config`; +CREATE TABLE hard_printer_config +( + id bigint NOT NULL COMMENT '配置主键', + printer_hard_id bigint NOT NULL COMMENT 'EY_PRINT打印终端id', + printer_model varchar(128) DEFAULT NULL COMMENT '打印机型号', + usb_vendor_id int DEFAULT NULL COMMENT 'USB Vendor ID十进制值', + usb_product_id int DEFAULT NULL COMMENT 'USB Product ID十进制值', + paper_width int NOT NULL DEFAULT 80 COMMENT '纸宽,单位mm,首期固定80', + auto_cut char(1) NOT NULL DEFAULT '1' COMMENT '自动切刀开关,1开启、0关闭', + image_mode varchar(16) NOT NULL DEFAULT 'GS_V0' COMMENT '图片指令模式,GS_V0或ESC_STAR', + create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', + create_time datetime DEFAULT NULL COMMENT '创建时间', + update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', + update_time datetime DEFAULT NULL COMMENT '修改时间', + tenant_id bigint NOT NULL COMMENT '租户ID', + PRIMARY KEY (id), + UNIQUE KEY uk_hard_printer_config_printer (tenant_id, printer_hard_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_general_ci COMMENT ='尔印打印终端USB与ESC/POS配置'; -- ---------------------------- -- Table structure for hard_car diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintFailureStageEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintFailureStageEnum.java new file mode 100644 index 00000000..b59481ae --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintFailureStageEnum.java @@ -0,0 +1,44 @@ +package com.bsd.pjcloud.hard.api.constant; + +import cn.hutool.core.util.ObjectUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 打印任务失败阶段,帮助管理端区分配置问题、服务端故障和终端故障。 + */ +@Getter +@AllArgsConstructor +public enum HardPrintFailureStageEnum { + + /** 来源业务终端缺失、未绑定打印机或绑定关系失效。 */ + BINDING("BINDING", "设备绑定"), + + /** 服务端读取业务数据、绘制 PNG 或上传文件失败。 */ + IMAGE_GENERATION("IMAGE_GENERATION", "图片生成"), + + /** 服务端向目标打印终端发布 MQTT 消息失败。 */ + MQTT_DISPATCH("MQTT_DISPATCH", "MQTT下发"), + + /** 打印终端下载图片、连接 USB 或写入 ESC/POS 数据失败。 */ + TERMINAL_PRINT("TERMINAL_PRINT", "终端打印"); + + /** 持久化阶段编码。 */ + private final String stage; + + /** 阶段中文说明。 */ + private final String description; + + /** + * 按持久化编码解析失败阶段。 + * + * @param stage 阶段编码 + * @return 匹配阶段;编码为空或未知时返回 {@code null} + */ + public static HardPrintFailureStageEnum valueOfByStage(String stage) { + if (ObjectUtil.isEmpty(stage)) return null; + return Arrays.stream(values()).filter(value -> ObjectUtil.equals(stage, value.stage)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintStatusEnum.java index 9a0eb75d..2e67b1aa 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintStatusEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintStatusEnum.java @@ -4,23 +4,46 @@ import cn.hutool.core.util.ObjectUtil; import lombok.AllArgsConstructor; import lombok.Getter; -import javax.xml.stream.events.StartDocument; import java.util.Arrays; /** - * 打印状态 + * 打印任务状态。 + * + *

编码 {@code 1} 和 {@code 2} 分别兼容历史“已生成”和“已打印”记录。

*/ @AllArgsConstructor @Getter public enum HardPrintStatusEnum { - GENERATE("1", "已生成"), + /** 任务已落库,等待后端生成并上传打印图片。 */ + PENDING_GENERATION("0", "待生成"), + + /** 图片已生成并允许下发,历史枚举名保留为 GENERATE 以保持二进制兼容。 */ + GENERATE("1", "待打印"), + + /** 打印终端已确认完整 USB 数据和切纸命令写入成功。 */ PRINT("2", "已打印"), + + /** 绑定、图片生成、MQTT 下发或终端打印阶段失败,可重试原任务。 */ + FAILED("3", "打印失败"), + + /** 核销回退等业务动作取消了尚未打印的任务。 */ + CANCELLED("4", "已取消"), ; + + /** 持久化状态编码。 */ private final String type; + + /** 状态中文说明。 */ private final String description; + /** + * 按持久化编码解析打印状态。 + * + * @param type 状态编码 + * @return 匹配状态;编码为空或未知时返回 {@code null} + */ public static HardPrintStatusEnum valueOfByType(String type) { if (ObjectUtil.isEmpty(type)) return null; - return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().get(); + return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().orElse(null); } } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintTypeEnum.java new file mode 100644 index 00000000..bea9223d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardPrintTypeEnum.java @@ -0,0 +1,38 @@ +package com.bsd.pjcloud.hard.api.constant; + +import cn.hutool.core.util.ObjectUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 打印凭证类型,用于选择后端图片模板和管理端业务动作。 + */ +@Getter +@AllArgsConstructor +public enum HardPrintTypeEnum { + + /** 支付订单的发票或消费小票。 */ + INVOICE("1", "发票"), + + /** 预约订单核销成功后生成的纸质兑换凭证。 */ + REDEMPTION_VOUCHER("2", "兑换券"); + + /** 持久化类型编码。 */ + private final String type; + + /** 类型中文说明。 */ + private final String description; + + /** + * 按持久化编码解析打印类型。 + * + * @param type 类型编码 + * @return 匹配类型;编码为空或未知时返回 {@code null} + */ + public static HardPrintTypeEnum valueOfByType(String type) { + if (ObjectUtil.isEmpty(type)) return null; + return Arrays.stream(values()).filter(value -> ObjectUtil.equals(type, value.type)).findFirst().orElse(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java index 39fcf538..f668557f 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardTypeEnum.java @@ -26,6 +26,8 @@ public enum HardTypeEnum { YUN_XFJ("9", "云版消费机"), YYX_4G("10", "4G-云音响"), YYX_WIFI("11", "wifi-云音响"), + /** 运行打印专用终端模式、通过 USB 驱动尔印 ESC/POS 打印机的设备。 */ + EY_PRINT("12", "尔印打印机器"), ; private final String type; private final String description; @@ -45,4 +47,15 @@ public enum HardTypeEnum { return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().get(); } + /** + * 判断指定设备类型是否为独立打印终端。 + * + * @param hardTypeEnum 待判断的设备类型 + * @return 类型为 {@link #EY_PRINT} 时返回 {@code true} + */ + public static boolean isPrintHard(HardTypeEnum hardTypeEnum) { + if (ObjectUtil.isEmpty(hardTypeEnum)) return false; + return List.of(EY_PRINT).contains(hardTypeEnum); + } + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRequestDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRequestDto.java new file mode 100644 index 00000000..556b9425 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRequestDto.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 管理端创建发票或兑换券打印任务的请求。 + */ +@Data +@Schema(description = "创建打印任务请求") +public class HardPrintRequestDto { + + /** 待打印凭证所属订单 ID。 */ + @NotNull(message = "订单ID不能为空") + @Schema(description = "订单ID") + private Long orderId; + + /** 打印类型编码,1 发票、2 兑换券。 */ + @NotBlank(message = "打印类型不能为空") + @Schema(description = "打印类型") + private String printType; + + /** 来源业务终端 ID;订单没有来源终端且同商户存在多个绑定时必填。 */ + @Schema(description = "来源业务终端ID") + private Long sourceHardId; + + /** 已打印任务再次创建下一序号时必填的重打原因。 */ + @Schema(description = "重打原因") + private String reprintReason; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintResultDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintResultDto.java new file mode 100644 index 00000000..d3d42b54 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintResultDto.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * EY_PRINT 终端提交的打印结果回执。 + */ +@Data +@Schema(description = "打印终端结果回执") +public class HardPrintResultDto { + + /** hard_invoice 打印任务主键。 */ + @NotNull(message = "打印任务ID不能为空") + @Schema(description = "打印任务ID") + private Long taskId; + + /** 任务所属订单 ID,用于阻断任务与订单错配。 */ + @NotNull(message = "订单ID不能为空") + @Schema(description = "订单ID") + private Long orderId; + + /** 终端实际处理的重试版本。 */ + @NotNull(message = "重试版本不能为空") + @Min(value = 0, message = "重试版本不能小于0") + @Schema(description = "重试版本") + private Integer retryVersion; + + /** 完整 USB 数据及切纸命令是否已经被接口接受。 */ + @NotNull(message = "打印结果不能为空") + @Schema(description = "是否成功") + private Boolean success; + + /** 本版本在终端执行的 USB 尝试次数。 */ + @NotNull(message = "尝试次数不能为空") + @Min(value = 1, message = "尝试次数不能小于1") + @Schema(description = "终端尝试次数") + private Integer attemptCount; + + /** 失败时的可诊断错误摘要,不得包含凭证或密码。 */ + @Schema(description = "错误摘要") + private String errorMessage; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRetryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRetryDto.java new file mode 100644 index 00000000..ca940e5c --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrintRetryDto.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 对既有打印任务执行失败重试或已打印重打的请求。 + */ +@Data +@Schema(description = "打印任务重试或重打请求") +public class HardPrintRetryDto { + + /** 既有 hard_invoice 打印任务主键。 */ + @NotNull(message = "打印任务ID不能为空") + @Schema(description = "打印任务ID") + private Long historyId; + + /** + * 失败任务需要补录的来源业务终端 ID。 + * + *

仅当原任务因缺少来源终端而失败时使用;服务端仍会校验该终端与订单同商户, + * 并根据服务端绑定关系确定目标 EY_PRINT,客户端不能直接指定打印机。

+ */ + @Schema(description = "补录的来源业务终端ID") + private Long sourceHardId; + + /** 已打印任务重打时必须填写的原因;失败任务原任务重试时可为空。 */ + @Schema(description = "重打原因") + private String reprintReason; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterBindingDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterBindingDto.java new file mode 100644 index 00000000..75632964 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterBindingDto.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 创建业务终端与尔印打印终端绑定关系的请求。 + */ +@Data +@Schema(description = "打印终端绑定请求") +public class HardPrinterBindingDto { + + /** SUPERMARKET 或 CANTEEN 来源终端 ID。 */ + @NotNull(message = "来源终端不能为空") + @Schema(description = "来源业务终端ID") + private Long sourceHardId; + + /** EY_PRINT 目标终端 ID。 */ + @NotNull(message = "打印终端不能为空") + @Schema(description = "目标打印终端ID") + private Long printerHardId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterConfigDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterConfigDto.java new file mode 100644 index 00000000..28d56d19 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterConfigDto.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 保存尔印打印终端 USB 与 ESC/POS 参数的请求。 + */ +@Data +@Schema(description = "打印终端配置请求") +public class HardPrinterConfigDto { + + /** EY_PRINT 设备 ID。 */ + @NotNull(message = "打印终端不能为空") + @Schema(description = "打印终端ID") + private Long printerHardId; + + /** 真机型号;未联调前允许为空。 */ + @Schema(description = "打印机型号") + private String printerModel; + + /** USB Vendor ID 十进制值;未联调前允许为空。 */ + @Schema(description = "USB VID") + private Integer usbVendorId; + + /** USB Product ID 十进制值;未联调前允许为空。 */ + @Schema(description = "USB PID") + private Integer usbProductId; + + /** 纸张宽度,首期只允许 80。 */ + @NotNull(message = "纸宽不能为空") + @Schema(description = "纸宽,单位mm") + private Integer paperWidth; + + /** 自动切刀开关,1 开启、0 关闭。 */ + @NotBlank(message = "自动切刀开关不能为空") + @Schema(description = "自动切刀开关") + private String autoCut; + + /** 图片指令模式,只允许 GS_V0 或 ESC_STAR。 */ + @NotBlank(message = "图片指令模式不能为空") + @Schema(description = "图片指令模式") + private String imageMode; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterInfoReportDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterInfoReportDto.java new file mode 100644 index 00000000..6c50bdcf --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardPrinterInfoReportDto.java @@ -0,0 +1,37 @@ +package com.bsd.pjcloud.hard.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 尔印打印终端上报实际 USB 打印机信息的请求。 + */ +@Data +@Schema(description = "打印终端实际 USB 打印机信息") +public class HardPrinterInfoReportDto { + + /** USB 描述符中的厂商和产品名称组合,最长保存 128 个字符。 */ + @NotBlank(message = "打印机型号不能为空") + @Size(max = 128, message = "打印机型号不能超过128个字符") + @Schema(description = "实际打印机型号") + private String printerModel; + + /** USB Vendor ID 十进制值,取值范围为无符号 16 位整数。 */ + @NotNull(message = "USB VID不能为空") + @Min(value = 0, message = "USB VID必须在0到65535之间") + @Max(value = 65535, message = "USB VID必须在0到65535之间") + @Schema(description = "USB VID") + private Integer usbVendorId; + + /** USB Product ID 十进制值,取值范围为无符号 16 位整数。 */ + @NotNull(message = "USB PID不能为空") + @Min(value = 0, message = "USB PID必须在0到65535之间") + @Max(value = 65535, message = "USB PID必须在0到65535之间") + @Schema(description = "USB PID") + private Integer usbProductId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java index e113b130..dd7e7063 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java @@ -17,6 +17,9 @@ public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIde /** 售后申请用户主键集合;为空时不限制申请人。 */ private List userIds; + /** 关联原订单的用户主键集合;用于区分代订场景中的退款发起人和实际订单用户。 */ + private List orderUserIds; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ private List identityTypes; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java index b7e4b2c9..b94a5b6c 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java @@ -37,28 +37,82 @@ public class HardInvoiceEntity extends Model { @Schema(description="部门id") private Long deptId; - /** - * 设备id - */ - @Schema(description="设备id") + /** + * 来源业务终端 ID;历史记录继续保留原设备语义,不改写为打印机 ID。 + */ + @Schema(description="来源业务终端ID") private Long hardId; + /** + * 实际接收任务的尔印打印终端 ID;历史记录和绑定失败任务允许为空。 + */ + @Schema(description="目标打印终端ID") + private Long printerHardId; + /** * 硬件订单id */ @Schema(description="硬件订单id") private Long orderId; - /** - * 状态 - */ - @Schema(description="状态") + /** + * 打印任务状态,编码来自 HardPrintStatusEnum。 + */ + @Schema(description="打印任务状态") private String status; - /** - * 发票图片 - */ - @Schema(description="发票图片") + /** + * 打印凭证类型,1 发票、2 兑换券;历史空值按发票兼容。 + */ + @Schema(description="打印凭证类型") + private String printType; + + /** + * 同一订单同一凭证类型的打印序号;重试同一任务时保持不变。 + */ + @Schema(description="打印序号") + private Integer printNo; + + /** + * 人工重试版本;终端用任务 ID 和该版本共同判断是否允许再次打印。 + */ + @Schema(description="重试版本") + private Integer retryVersion; + + /** + * 打印终端上报的 USB 累计尝试次数。 + */ + @Schema(description="终端尝试次数") + private Integer attemptCount; + + /** + * 最近失败阶段,编码来自 HardPrintFailureStageEnum。 + */ + @Schema(description="失败阶段") + private String failureStage; + + /** + * 最近失败摘要,不保存 MQTT 密码、访问令牌等敏感信息。 + */ + @Schema(description="最近失败原因") + private String lastError; + + /** + * 打印终端确认完整 USB 数据和切纸命令写入成功的时间。 + */ + @Schema(description="打印成功时间") + private LocalDateTime printedAt; + + /** + * 人工重打原因,自动首次打印或失败重试允许为空。 + */ + @Schema(description="重打原因") + private String reprintReason; + + /** + * 后端生成并上传的打印 PNG 地址;待生成和生成失败时允许为空。 + */ + @Schema(description="打印图片") private String pic; /** @@ -102,4 +156,4 @@ public class HardInvoiceEntity extends Model { */ @Schema(description="租户ID") private Long tenantId; -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterBindingEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterBindingEntity.java new file mode 100644 index 00000000..ec456307 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterBindingEntity.java @@ -0,0 +1,78 @@ +package com.bsd.pjcloud.hard.api.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 业务终端与尔印打印终端的一对一绑定关系。 + * + *

记录使用物理删除,数据库分别对来源和目标设备设置唯一键,确保任何时刻都不能出现多重绑定。

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TenantTable +@TableName("hard_printer_binding") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "业务终端与打印终端绑定关系") +public class HardPrinterBindingEntity extends Model { + + /** 绑定关系主键。 */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "绑定关系主键") + private Long id; + + /** 来源业务终端 ID,只允许 SUPERMARKET 或 CANTEEN。 */ + @Schema(description = "来源业务终端ID") + private Long sourceHardId; + + /** 目标打印终端 ID,只允许 EY_PRINT。 */ + @Schema(description = "目标打印终端ID") + private Long printerHardId; + + /** 双方共同所属商户 ID 快照。 */ + @Schema(description = "商户ID") + private Long merchantId; + + /** 双方共同所属商户部门 ID 快照。 */ + @Schema(description = "商户部门ID") + private Long deptId; + + /** 创建人。 */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** 创建时间。 */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** 修改人。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** 修改时间。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** 租户 ID。 */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterConfigEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterConfigEntity.java new file mode 100644 index 00000000..b053e41d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardPrinterConfigEntity.java @@ -0,0 +1,88 @@ +package com.bsd.pjcloud.hard.api.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 尔印打印终端的 USB 和 ESC/POS 参数配置。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TenantTable +@TableName("hard_printer_config") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "尔印打印终端配置") +public class HardPrinterConfigEntity extends Model { + + /** 配置主键。 */ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "配置主键") + private Long id; + + /** 配置所属 EY_PRINT 设备 ID。 */ + @Schema(description = "打印终端ID") + private Long printerHardId; + + /** 真机联调后填写的打印机型号。 */ + @Schema(description = "打印机型号") + private String printerModel; + + /** USB Vendor ID,使用十进制持久化。 */ + @Schema(description = "USB VID") + private Integer usbVendorId; + + /** USB Product ID,使用十进制持久化。 */ + @Schema(description = "USB PID") + private Integer usbProductId; + + /** 纸张宽度,首期固定为 80mm。 */ + @Schema(description = "纸宽,单位mm") + private Integer paperWidth; + + /** 是否在完整数据写入后发送自动切纸命令,1 开启、0 关闭。 */ + @Schema(description = "自动切刀开关") + private String autoCut; + + /** 图片指令模式,GS_V0 为默认值,ESC_STAR 为兼容回退。 */ + @Schema(description = "图片指令模式") + private String imageMode; + + /** 创建人。 */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建人") + private String createBy; + + /** 创建时间。 */ + @TableField(fill = FieldFill.INSERT) + @Schema(description = "创建时间") + private LocalDateTime createTime; + + /** 修改人。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改人") + private String updateBy; + + /** 修改时间。 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(description = "修改时间") + private LocalDateTime updateTime; + + /** 租户 ID。 */ + @Schema(description = "租户ID") + private Long tenantId; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java new file mode 100644 index 00000000..1cd63990 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java @@ -0,0 +1,333 @@ +package com.bsd.pjcloud.hard.api.utils; + +import javax.imageio.ImageIO; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * 面向 80mm、576px ESC/POS 打印的动态高度 PNG 绘制器。 + * + *

商品名称按真实像素宽度换行,数量列固定右对齐,保证长名称不会与数量错配。

+ */ +public class ThermalReceiptImage { + + /** 80mm 打印头常见有效像素宽度。 */ + public static final int PAPER_WIDTH_PIXELS = 576; + + /** 图片左右安全留白。 */ + private static final int HORIZONTAL_PADDING = 24; + + /** 图片顶部和底部安全留白。 */ + private static final int VERTICAL_PADDING = 24; + + /** 每次扩展画布的最小高度。 */ + private static final int HEIGHT_GROWTH = 512; + + /** 数量列保留宽度。 */ + private static final int QUANTITY_COLUMN_WIDTH = 96; + + /** 套餐组成项左侧缩进。 */ + private static final int CHILD_INDENT = 32; + + /** 中文优先逻辑字体名称。 */ + private static final String FONT_FAMILY = "SansSerif"; + + /** 当前可扩展画布。 */ + private BufferedImage canvas; + + /** 当前绘制上下文。 */ + private Graphics2D graphics; + + /** 下一块内容的顶部 Y 坐标。 */ + private int cursorY = VERTICAL_PADDING; + + /** + * 创建 576px 宽的白底收据画布。 + */ + public ThermalReceiptImage() { + canvas = new BufferedImage(PAPER_WIDTH_PIXELS, HEIGHT_GROWTH, BufferedImage.TYPE_INT_RGB); + graphics = canvas.createGraphics(); + configureGraphics(graphics); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, canvas.getWidth(), canvas.getHeight()); + graphics.setColor(Color.BLACK); + } + + /** + * 绘制居中粗体标题。 + * + * @param text 标题文本 + * @return 当前绘制器 + */ + public ThermalReceiptImage title(String text) { + setFont(Font.BOLD, 34); + drawCentered(text, 16); + return this; + } + + /** + * 绘制带黑色边框的大号打印次数提示。 + * + * @param text 提示文本,例如“第 1 次打印” + * @return 当前绘制器 + */ + public ThermalReceiptImage banner(String text) { + setFont(Font.BOLD, 38); + FontMetrics metrics = graphics.getFontMetrics(); + int height = metrics.getHeight() + 30; + ensureCapacity(height + 20); + graphics.setStroke(new BasicStroke(3F)); + graphics.drawRect(HORIZONTAL_PADDING, cursorY, contentWidth(), height); + int x = HORIZONTAL_PADDING + Math.max(0, (contentWidth() - metrics.stringWidth(text)) / 2); + graphics.drawString(text, x, cursorY + 15 + metrics.getAscent()); + cursorY += height + 20; + return this; + } + + /** + * 绘制醒目的核销短码。 + * + * @param shortCode 核销短码 + * @return 当前绘制器 + */ + public ThermalReceiptImage verificationCode(String shortCode) { + setFont(Font.BOLD, 52); + drawCentered(shortCode == null || shortCode.isBlank() ? "--" : shortCode, 18); + return this; + } + + /** + * 绘制一行左右键值信息,值过长时在下一行继续换行。 + * + * @param label 左侧字段名 + * @param value 右侧字段值 + * @return 当前绘制器 + */ + public ThermalReceiptImage keyValue(String label, String value) { + setFont(Font.PLAIN, 25); + String safeLabel = label == null ? "" : label; + String safeValue = value == null || value.isBlank() ? "--" : value; + FontMetrics metrics = graphics.getFontMetrics(); + int labelWidth = metrics.stringWidth(safeLabel + ":"); + int valueWidth = contentWidth() - labelWidth - 12; + List lines = wrap(safeValue, Math.max(120, valueWidth), metrics); + int lineHeight = metrics.getHeight() + 6; + ensureCapacity(lines.size() * lineHeight + 10); + graphics.drawString(safeLabel + ":", HORIZONTAL_PADDING, cursorY + metrics.getAscent()); + for (int index = 0; index < lines.size(); index++) { + int x = index == 0 ? HORIZONTAL_PADDING + labelWidth + 12 : HORIZONTAL_PADDING + labelWidth + 12; + graphics.drawString(lines.get(index), x, cursorY + index * lineHeight + metrics.getAscent()); + } + cursorY += lines.size() * lineHeight + 6; + return this; + } + + /** + * 绘制商品明细区标题。 + * + * @param text 标题文本 + * @return 当前绘制器 + */ + public ThermalReceiptImage sectionTitle(String text) { + setFont(Font.BOLD, 28); + ensureCapacity(48); + graphics.drawString(text == null ? "" : text, HORIZONTAL_PADDING, cursorY + graphics.getFontMetrics().getAscent()); + cursorY += graphics.getFontMetrics().getHeight() + 10; + return this; + } + + /** + * 绘制商品或套餐组成项,商品名换行且数量始终在首行右对齐。 + * + * @param name 商品名称 + * @param quantity 数量文本 + * @param child 是否为套餐组成项 + * @param emphasized 是否使用粗体标记套餐主项 + * @return 当前绘制器 + */ + public ThermalReceiptImage item(String name, String quantity, boolean child, boolean emphasized) { + return item(name, quantity, child, emphasized, QUANTITY_COLUMN_WIDTH); + } + + /** + * 绘制带数量和金额摘要的发票商品行。 + * + * @param name 商品名称 + * @param quantity 数量或重量文本 + * @param amount 含货币符号的商品总价 + * @return 当前绘制器 + */ + public ThermalReceiptImage invoiceItem(String name, String quantity, String amount) { + return item(name, String.format("%s %s", quantity, amount), false, false, 190); + } + + /** + * 使用指定尾列宽度绘制一条可换行明细。 + * + * @param name 左侧名称 + * @param trailing 首行右对齐摘要 + * @param child 是否为套餐组成项 + * @param emphasized 是否使用粗体 + * @param trailingWidth 尾列预留像素宽度 + * @return 当前绘制器 + */ + private ThermalReceiptImage item(String name, String trailing, boolean child, boolean emphasized, + int trailingWidth) { + setFont(emphasized ? Font.BOLD : Font.PLAIN, child ? 23 : 26); + FontMetrics metrics = graphics.getFontMetrics(); + int indent = child ? CHILD_INDENT : 0; + String prefix = child ? "- " : ""; + String safeName = prefix + (name == null || name.isBlank() ? "未命名商品" : name); + List lines = wrap(safeName, contentWidth() - indent - trailingWidth, metrics); + int lineHeight = metrics.getHeight() + 5; + ensureCapacity(lines.size() * lineHeight + 8); + for (int index = 0; index < lines.size(); index++) { + graphics.drawString(lines.get(index), HORIZONTAL_PADDING + indent, + cursorY + index * lineHeight + metrics.getAscent()); + } + String safeTrailing = trailing == null || trailing.isBlank() ? "x0" : trailing; + graphics.drawString(safeTrailing, PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - metrics.stringWidth(safeTrailing), + cursorY + metrics.getAscent()); + cursorY += lines.size() * lineHeight + 6; + return this; + } + + /** + * 绘制横向分隔线。 + * + * @return 当前绘制器 + */ + public ThermalReceiptImage divider() { + ensureCapacity(28); + graphics.setStroke(new BasicStroke(2F)); + graphics.drawLine(HORIZONTAL_PADDING, cursorY + 10, + PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY + 10); + cursorY += 28; + return this; + } + + /** + * 输出裁剪到实际高度的 PNG 字节。 + * + * @return PNG 字节 + * @throws IOException PNG 编码失败 + */ + public byte[] toPngBytes() throws IOException { + int finalHeight = cursorY + VERTICAL_PADDING; + BufferedImage result = new BufferedImage(PAPER_WIDTH_PIXELS, finalHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D resultGraphics = result.createGraphics(); + resultGraphics.setColor(Color.WHITE); + resultGraphics.fillRect(0, 0, result.getWidth(), result.getHeight()); + resultGraphics.drawImage(canvas, 0, 0, null); + resultGraphics.dispose(); + graphics.dispose(); + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + ImageIO.write(result, "png", output); + return output.toByteArray(); + } + } + + /** + * 设置当前字体。 + * + * @param style 字体样式 + * @param size 字体像素大小 + */ + private void setFont(int style, int size) { + graphics.setFont(new Font(FONT_FAMILY, style, size)); + } + + /** + * 绘制一行居中文本并推进游标。 + * + * @param text 文本 + * @param bottomSpace 底部留白 + */ + private void drawCentered(String text, int bottomSpace) { + String safeText = text == null ? "" : text; + FontMetrics metrics = graphics.getFontMetrics(); + ensureCapacity(metrics.getHeight() + bottomSpace); + int x = Math.max(HORIZONTAL_PADDING, + (PAPER_WIDTH_PIXELS - metrics.stringWidth(safeText)) / 2); + graphics.drawString(safeText, x, cursorY + metrics.getAscent()); + cursorY += metrics.getHeight() + bottomSpace; + } + + /** + * 按实际字体宽度拆分字符串,单个超宽字符仍至少独占一行。 + * + * @param text 原文本 + * @param maxWidth 最大像素宽度 + * @param metrics 当前字体度量 + * @return 非空行列表 + */ + private List wrap(String text, int maxWidth, FontMetrics metrics) { + List lines = new ArrayList<>(); + StringBuilder line = new StringBuilder(); + String safeText = text == null ? "" : text; + for (int offset = 0; offset < safeText.length();) { + int codePoint = safeText.codePointAt(offset); + String character = new String(Character.toChars(codePoint)); + if (line.length() > 0 && metrics.stringWidth(line + character) > maxWidth) { + lines.add(line.toString()); + line.setLength(0); + } + line.append(character); + offset += Character.charCount(codePoint); + } + if (line.length() > 0 || lines.isEmpty()) lines.add(line.toString()); + return lines; + } + + /** + * 确保画布能够容纳下一块内容,并在扩容时保留字体、画笔和已绘制像素。 + * + * @param requiredHeight 下一块内容需要的高度 + */ + private void ensureCapacity(int requiredHeight) { + if (cursorY + requiredHeight <= canvas.getHeight()) return; + int newHeight = canvas.getHeight(); + while (cursorY + requiredHeight > newHeight) newHeight += HEIGHT_GROWTH; + Font font = graphics.getFont(); + Color color = graphics.getColor(); + BufferedImage expanded = new BufferedImage(PAPER_WIDTH_PIXELS, newHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D expandedGraphics = expanded.createGraphics(); + configureGraphics(expandedGraphics); + expandedGraphics.setColor(Color.WHITE); + expandedGraphics.fillRect(0, 0, expanded.getWidth(), expanded.getHeight()); + expandedGraphics.drawImage(canvas, 0, 0, null); + expandedGraphics.setFont(font); + expandedGraphics.setColor(color); + graphics.dispose(); + canvas = expanded; + graphics = expandedGraphics; + } + + /** + * 配置文字抗锯齿和高质量图片渲染参数。 + * + * @param target 待配置绘制上下文 + */ + private static void configureGraphics(Graphics2D target) { + target.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + target.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + } + + /** + * 计算扣除左右安全留白后的有效宽度。 + * + * @return 内容宽度 + */ + private static int contentWidth() { + return PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING * 2; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java index 77a0a1d1..31f3f961 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java @@ -67,6 +67,9 @@ public class HardInvoiceVo extends HardInvoiceEntity implements BaseUserDeptInte private String hardType; + /** 目标尔印打印终端名称。 */ + private String printerHardName; + /** * 用户相关 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardPrinterBindingVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardPrinterBindingVo.java new file mode 100644 index 00000000..196e35ea --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardPrinterBindingVo.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.api.vo; + +import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; +import com.bsd.pjcloud.hard.api.entity.HardPrinterConfigEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 打印终端绑定关系及两端设备展示信息。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "打印终端绑定详情") +public class HardPrinterBindingVo extends HardPrinterBindingEntity { + + /** 来源业务终端名称。 */ + @Schema(description = "来源业务终端名称") + private String sourceHardName; + + /** 来源业务终端类型。 */ + @Schema(description = "来源业务终端类型") + private String sourceHardType; + + /** 目标打印终端名称。 */ + @Schema(description = "目标打印终端名称") + private String printerHardName; + + /** 目标打印终端当前配置;尚未保存时返回默认配置。 */ + @Schema(description = "打印终端配置") + private HardPrinterConfigEntity printerConfig; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardManagerVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardManagerVo.java index cf8c83e3..e48a8d26 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardManagerVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/manager/HardManagerVo.java @@ -59,4 +59,34 @@ public class HardManagerVo extends HardManagerEntity implements BaseDeptInterfac */ private String disconnectReason; + /** 当前来源业务终端所绑定的 EY_PRINT 设备 ID;未绑定或当前行为打印机时为空。 */ + private Long boundPrinterHardId; + + /** 当前来源业务终端所绑定的 EY_PRINT 设备名称。 */ + private String boundPrinterHardName; + + /** 当前 EY_PRINT 反向绑定的来源业务终端 ID;未绑定或当前行为来源设备时为空。 */ + private Long boundSourceHardId; + + /** 当前 EY_PRINT 反向绑定的来源业务终端名称。 */ + private String boundSourceHardName; + + /** EY_PRINT 型号;未维护配置或非打印设备时为空。 */ + private String printerModel; + + /** EY_PRINT 的 USB 厂商编号 VID;尚未真机回填时为空。 */ + private Integer usbVendorId; + + /** EY_PRINT 的 USB 产品编号 PID;尚未真机回填时为空。 */ + private Integer usbProductId; + + /** 热敏纸宽度,首期固定为 80mm。 */ + private Integer paperWidth; + + /** 自动切刀开关,1 开启、0 关闭。 */ + private String autoCut; + + /** 图片指令模式,首选 GS_V0,兼容 ESC_STAR。 */ + private String imageMode; + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java index 1fe36132..407e340a 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java @@ -5,6 +5,7 @@ import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardTakeOutApptEntity; import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface; import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface; import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletInterface; @@ -105,6 +106,13 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac private LocalTime apptEndTime; + /** + * 外卖订单的期望送达配置,来源于 hard_take_out_appt;非外卖或历史未记录时为空。 + * + *

该字段只参与订单详情响应,不对应 hard_order 表持久化字段。

+ */ + private HardTakeOutApptEntity takeOutAppt; + /** * 付款人用户ID,来源于订单关联的父购物车。 */ @@ -129,4 +137,28 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac * 订单详情中的长期识别证据列表,按证据类型、角色和序号排序。 */ private List evidenceImages; + + /** 当前订单最新发票任务 ID;没有记录时为空。 */ + private Long latestInvoiceTaskId; + + /** 当前订单最新发票任务状态编码。 */ + private String latestInvoiceStatus; + + /** 当前订单最新发票打印序号。 */ + private Integer latestInvoicePrintNo; + + /** 当前订单最新发票任务的来源业务终端 ID。 */ + private Long latestInvoiceSourceHardId; + + /** 当前订单最新兑换券任务 ID;没有记录时为空。 */ + private Long latestVoucherTaskId; + + /** 当前订单最新兑换券任务状态编码。 */ + private String latestVoucherStatus; + + /** 当前订单最新兑换券打印序号。 */ + private Integer latestVoucherPrintNo; + + /** 当前订单最新兑换券任务的来源业务终端 ID。 */ + private Long latestVoucherSourceHardId; } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardInvoiceController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardInvoiceController.java index 8f48b633..71fbfaab 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardInvoiceController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardInvoiceController.java @@ -9,6 +9,8 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.hard.api.dto.HardInvoiceDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintRequestDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintRetryDto; import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; import com.bsd.pjcloud.hard.service.HardInvoiceService; import org.springframework.security.access.prepost.PreAuthorize; @@ -22,6 +24,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; +import jakarta.validation.Valid; /** * 发票记录 @@ -48,6 +51,49 @@ public class HardInvoiceController { return R.ok(hardInvoiceService.print(orderId)); } + /** + * 创建发票或补建兑换券打印任务;已打印兑换券必须使用带专用权限的重打接口。 + * + * @param request 订单、打印类型和可选来源终端 + * @return 创建、复用或失败重试后的打印任务 + */ + @Operation(summary = "创建打印任务", description = "创建发票或补建兑换券打印任务") + @SysLog("创建打印任务") + @Idempotent(expireTime = 5, key = "'/hard-invoice/print:' + #request.getOrderId() + ':' + #request.getPrintType()", info = "正在创建打印任务,请勿重复操作") + @PostMapping("/print") + @PreAuthorize("@pms.hasPermission('hard_hardInvoice_admin_print')") + public R createPrintTask(@Valid @RequestBody HardPrintRequestDto request) { + return R.ok(hardInvoiceService.createPrintTask(request)); + } + + /** + * 重试原失败任务,重试版本增加但打印序号不变。 + * + * @param request 失败任务 ID,以及原任务缺少来源时可补录的来源终端 ID + * @return 重试后的任务 + */ + @Operation(summary = "重试失败打印任务") + @SysLog("重试失败打印任务") + @PostMapping("/retry") + @PreAuthorize("@pms.hasPermission('hard_hardInvoice_admin_print')") + public R retry(@Valid @RequestBody HardPrintRetryDto request) { + return R.ok(hardInvoiceService.retryTask(request.getHistoryId(), request.getSourceHardId())); + } + + /** + * 对已打印凭证创建下一打印序号并记录人工原因。 + * + * @param request 已打印任务 ID 和重打原因 + * @return 新打印任务 + */ + @Operation(summary = "人工重打兑换券") + @SysLog("人工重打兑换券") + @PostMapping("/reprint") + @PreAuthorize("@pms.hasPermission('hard_hardInvoice_voucher_reprint')") + public R reprint(@Valid @RequestBody HardPrintRetryDto request) { + return R.ok(hardInvoiceService.reprintTask(request.getHistoryId(), request.getReprintReason())); + } + @Operation(summary = "分页查询", description = "分页查询") @GetMapping("/page") @PreAuthorize("@pms.hasPermission('hard_hardInvoice_view')") @@ -100,4 +146,4 @@ public class HardInvoiceController { public List export(HardInvoiceEntity hardInvoice, Long[] ids) { return hardInvoiceService.list(Wrappers.lambdaQuery(hardInvoice).in(ArrayUtil.isNotEmpty(ids), HardInvoiceEntity::getId, ids)); } -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardPrinterController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardPrinterController.java new file mode 100644 index 00000000..6cd07888 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardPrinterController.java @@ -0,0 +1,132 @@ +package com.bsd.pjcloud.hard.controller; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.hard.api.dto.HardPrinterBindingDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterConfigDto; +import com.bsd.pjcloud.hard.service.HardPrinterService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 尔印打印终端绑定和配置管理接口。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/hard-printer") +@Tag(description = "hard-printer", name = "尔印打印终端管理") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class HardPrinterController { + + /** 打印终端绑定和配置服务。 */ + private final HardPrinterService hardPrinterService; + + /** + * 查询指定设备当前绑定详情。 + * + * @param hardId 来源或打印设备 ID + * @return 绑定详情 + */ + @GetMapping("/binding/{hardId}") + @Operation(summary = "查询打印机绑定") + @PreAuthorize("@pms.hasPermission('hard_hardManager_view')") + public R getBinding(@PathVariable Long hardId) { + return R.ok(hardPrinterService.getBinding(hardId)); + } + + /** + * 查询来源设备可绑定的 EY_PRINT 终端。 + * + * @param sourceHardId 来源业务终端 ID + * @return 可绑定设备列表 + */ + @GetMapping("/available") + @Operation(summary = "查询可绑定打印终端") + @PreAuthorize("@pms.hasPermission('hard_hardManager_printer_bind')") + public R listAvailable(@RequestParam Long sourceHardId) { + return R.ok(hardPrinterService.listAvailablePrinters(sourceHardId)); + } + + /** + * 查询商户下已经绑定打印机的来源终端。 + * + * @param merchantId 商户 ID + * @return 来源终端列表 + */ + @GetMapping("/sources") + @Operation(summary = "查询已绑定打印机的业务终端") + @PreAuthorize("@pms.hasPermission('hard_hardInvoice_admin_print')") + public R listBoundSources(@RequestParam Long merchantId) { + return R.ok(hardPrinterService.listBoundSources(merchantId)); + } + + /** + * 创建来源与打印终端的一对一绑定。 + * + * @param dto 绑定请求 + * @return 绑定详情 + */ + @PostMapping("/binding") + @Operation(summary = "绑定打印终端") + @SysLog("绑定打印终端") + @PreAuthorize("@pms.hasPermission('hard_hardManager_printer_bind')") + public R bind(@Valid @RequestBody HardPrinterBindingDto dto) { + return R.ok(hardPrinterService.bind(dto)); + } + + /** + * 解除来源终端的打印机绑定。 + * + * @param sourceHardId 来源业务终端 ID + * @return 空成功响应 + */ + @DeleteMapping("/binding/{sourceHardId}") + @Operation(summary = "解绑打印终端") + @SysLog("解绑打印终端") + @PreAuthorize("@pms.hasPermission('hard_hardManager_printer_bind')") + public R unbind(@PathVariable Long sourceHardId) { + hardPrinterService.unbind(sourceHardId); + return R.ok(); + } + + /** + * 查询打印终端当前参数。 + * + * @param printerHardId EY_PRINT 设备 ID + * @return 当前或默认配置 + */ + @GetMapping("/config/{printerHardId}") + @Operation(summary = "查询打印终端配置") + @PreAuthorize("@pms.hasPermission('hard_hardManager_view')") + public R getConfig(@PathVariable Long printerHardId) { + return R.ok(hardPrinterService.getConfig(printerHardId)); + } + + /** + * 保存打印终端参数。 + * + * @param dto 配置请求 + * @return 保存后的配置 + */ + @PutMapping("/config") + @Operation(summary = "保存打印终端配置") + @SysLog("保存打印终端配置") + @PreAuthorize("@pms.hasPermission('hard_hardManager_printer_bind')") + public R saveConfig(@Valid @RequestBody HardPrinterConfigDto dto) { + return R.ok(hardPrinterService.saveConfig(dto)); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java index 7556bdaa..4cddf6e0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java @@ -15,6 +15,9 @@ import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.dto.async.SyncOfflineReqVo; +import com.bsd.pjcloud.hard.api.dto.HardPrintRequestDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintResultDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterInfoReportDto; import com.bsd.pjcloud.hard.api.dto.hard.*; import com.bsd.pjcloud.hard.api.dto.order.HardChartLatitudeVo; import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto; @@ -55,6 +58,7 @@ public class HardApiController { private final HardManagerService hardManagerService; private final HardInvoiceService hardInvoiceService; + private final HardPrinterService hardPrinterService; private final HardCarService hardCarService; private final HardOrderService hardwareOrderService; private final HardOrderSyncService hardOrderSyncService; @@ -326,6 +330,73 @@ public class HardApiController { return R.ok(hardInvoiceService.printInvoice(orderId, historyId)); } + /** + * 查询当前 EY_PRINT 终端绑定关系和打印机参数,用于登录后进入打印专用模式。 + * + * @param hardId DEVICE-ID 请求头中的打印终端 ID + * @return 绑定来源和打印机配置 + */ + @GetMapping("/printer/bootstrap") + @Operation(summary = "获取打印终端启动配置") + public R printerBootstrap(@RequestHeader("DEVICE-ID") Long hardId) { + return R.ok(hardPrinterService.getPrinterBinding(hardId)); + } + + /** + * 保存当前 EY_PRINT 实际连接打印机的型号、VID 和 PID,目标设备只能由请求头确定。 + * + * @param hardId DEVICE-ID 请求头中的打印终端 ID + * @param dto Android USB Host 枚举出的打印机信息 + * @return 保留纸宽、切刀和图片模式后的完整打印机配置 + */ + @PostMapping("/printer/info") + @Operation(summary = "上报打印终端实际打印机信息") + public R reportPrinterInfo(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardPrinterInfoReportDto dto) { + return R.ok(hardPrinterService.reportDeviceInfo(hardId, dto)); + } + + /** + * 拉取当前 EY_PRINT 尚未完成的任务,补偿离线期间遗漏的 MQTT 消息。 + * + * @param hardId DEVICE-ID 请求头中的打印终端 ID + * @return 待打印任务列表 + */ + @GetMapping("/printer/tasks") + @Operation(summary = "拉取打印终端待办") + public R printerTasks(@RequestHeader("DEVICE-ID") Long hardId) { + return R.ok(hardInvoiceService.listPendingTasks(hardId)); + } + + /** + * 提交当前 EY_PRINT 的 USB 打印结果。 + * + * @param hardId DEVICE-ID 请求头中的打印终端 ID + * @param result 任务、订单、版本和结果 + * @return 接受或幂等忽略时返回 {@code true} + */ + @PostMapping("/printer/result") + @Operation(summary = "提交打印终端结果") + public R printerResult(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardPrintResultDto result) { + return R.ok(hardInvoiceService.reportPrintResult(hardId, result)); + } + + /** + * 业务终端按可信 DEVICE-ID 创建打印任务,禁止请求体跨商户指定来源设备。 + * + * @param hardId DEVICE-ID 请求头中的来源业务终端 ID + * @param request 订单和打印类型 + * @return 创建、复用或重试后的任务 + */ + @PostMapping("/printer/task") + @Operation(summary = "业务终端创建打印任务") + public R createPrinterTask(@RequestHeader("DEVICE-ID") Long hardId, + @Valid @RequestBody HardPrintRequestDto request) { + request.setSourceHardId(hardId); + return R.ok(hardInvoiceService.createPrintTask(request)); + } + @PostMapping("/sync-offline-order") @Operation(summary = "同步离线订单") @Idempotent(expireTime = 1, info = "正在处理中,请勿重复操作") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/manager/HardManagerController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/manager/HardManagerController.java index f7ea48a6..aad5ddf8 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/manager/HardManagerController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/manager/HardManagerController.java @@ -90,7 +90,7 @@ public class HardManagerController { @DeleteMapping @PreAuthorize("@pms.hasPermission('hard_hardManager_del')") public R removeById(@RequestBody Long[] ids) { - return R.ok(hardManagerService.removeBatchByIds(CollUtil.toList(ids))); + return R.ok(hardManagerService.removeWithPrinterCheck(CollUtil.toList(ids))); } @ResponseExcel @@ -99,4 +99,4 @@ public class HardManagerController { public List export(HardManagerEntity hardManager, Long[] ids) { return hardManagerService.list(Wrappers.lambdaQuery(hardManager).in(ArrayUtil.isNotEmpty(ids), HardManagerEntity::getId, ids)); } -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java new file mode 100644 index 00000000..449a6f65 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.hard.job; + +import com.bsd.pjcloud.hard.service.HardInvoiceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 持久化打印任务补偿调度器,恢复进程退出或 MQTT 离线期间遗留的任务。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HardPrintTaskJob { + + /** 打印任务服务。 */ + private final HardInvoiceService hardInvoiceService; + + /** + * 每十秒扫描待生成任务,并按固定间隔补偿下发待打印任务。 + */ + @Scheduled(fixedDelay = 10000L, initialDelay = 20000L) + public void processDueTasks() { + try { + int processed = hardInvoiceService.processDueTasks(); + if (processed > 0) log.info("打印任务补偿扫描完成,processed:{}", processed); + } catch (Exception exception) { + log.error("打印任务补偿扫描异常", exception); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterBindingMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterBindingMapper.java new file mode 100644 index 00000000..5bfb5698 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterBindingMapper.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.hard.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 打印终端一对一绑定关系数据访问接口。 + */ +@Mapper +public interface HardPrinterBindingMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterConfigMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterConfigMapper.java new file mode 100644 index 00000000..fe7ba412 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardPrinterConfigMapper.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.hard.mapper; + +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import com.bsd.pjcloud.hard.api.entity.HardPrinterConfigEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 尔印打印终端参数数据访问接口。 + */ +@Mapper +public interface HardPrinterConfigMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/manager/HardManagerMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/manager/HardManagerMapper.java index 65e6d7a8..e6c4ca29 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/manager/HardManagerMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/manager/HardManagerMapper.java @@ -23,6 +23,7 @@ public interface HardManagerMapper extends PjcloudBaseMapper MPJLambdaWrapper wrapper = new MPJLambdaWrapper() .selectAll(HardManagerEntity.class) .likeIfExists(HardManagerEntity::getName, dto.getName()) + .likeIfExists(HardManagerEntity::getUniqueCode, dto.getUniqueCode()) .eqIfExists(HardManagerEntity::getDeptId, dto.getDeptId()) .eqIfExists(HardManagerEntity::getType, dto.getType()) .eqIfExists(HardManagerEntity::getMerchantId, dto.getMerchantId()) @@ -43,6 +44,7 @@ public interface HardManagerMapper extends PjcloudBaseMapper default Page scopePageList(Page page, HardManagerDto dto) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .like(ObjectUtil.isNotEmpty(dto.getName()), HardManagerEntity::getName, dto.getName()) + .like(ObjectUtil.isNotEmpty(dto.getUniqueCode()), HardManagerEntity::getUniqueCode, dto.getUniqueCode()) .eq(ObjectUtil.isNotEmpty(dto.getDeptId()), HardManagerEntity::getDeptId, dto.getDeptId()) .eq(ObjectUtil.isNotEmpty(dto.getType()), HardManagerEntity::getType, dto.getType()) .eq(ObjectUtil.isNotEmpty(dto.getMerchantId()), HardManagerEntity::getMerchantId, dto.getMerchantId()) @@ -54,4 +56,4 @@ public interface HardManagerMapper extends PjcloudBaseMapper list.setRecords(BeanUtils.toBean(list.getRecords(), HardManagerVo.class)); return list; } -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java index 624b7f6b..fae73d06 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardInvoiceService.java @@ -3,12 +3,26 @@ package com.bsd.pjcloud.hard.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.hard.api.dto.HardInvoiceDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintRequestDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintResultDto; import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; +import java.util.List; + +/** + * 打印凭证图片、持久化任务及终端回执服务。 + */ public interface HardInvoiceService extends IService { + /** + * 按数据权限分页查询打印任务。 + * + * @param page 分页参数 + * @param dto 查询条件 + * @return 打印任务分页 + */ Page scopeJoinPageList(Page page, HardInvoiceDto dto); /** @@ -37,4 +51,70 @@ public interface HardInvoiceService extends IService { * 根据订单信息打印 */ Boolean printByOrder(HardOrderEntity order); -} \ No newline at end of file + + /** + * 在预约核销事务内幂等创建兑换券任务,不执行图片或网络操作。 + * + * @param order 已持有行锁的预约订单快照 + * @param sourceHardId 实际核销终端 ID;缺少请求头时为非正数 + * @return 创建或已存在的兑换券任务 + */ + HardInvoiceEntity createRedemptionVoucherTask(HardOrderEntity order, Long sourceHardId); + + /** + * 管理端创建首张发票或补建兑换券任务。 + * + * @param request 打印请求 + * @return 创建、复用或重试后的任务 + */ + HardInvoiceEntity createPrintTask(HardPrintRequestDto request); + + /** + * 重试失败任务并增加 retryVersion,打印序号保持不变。 + * + * @param historyId 打印任务 ID + * @return 重试后的任务 + */ + HardInvoiceEntity retryTask(Long historyId, Long sourceHardId); + + /** + * 对已打印任务创建下一打印序号,原因必须填写。 + * + * @param historyId 已打印任务 ID + * @param reason 人工重打原因 + * @return 新打印任务 + */ + HardInvoiceEntity reprintTask(Long historyId, String reason); + + /** + * 核销回退时取消该订单所有未打印兑换券任务。 + * + * @param orderId 订单 ID + * @return 被取消任务数量 + */ + int cancelPendingVoucherTasks(Long orderId); + + /** + * 查询指定 EY_PRINT 终端的待打印任务,供启动、重连和定时补拉。 + * + * @param printerHardId 目标打印终端 ID + * @return 待打印任务列表 + */ + List listPendingTasks(Long printerHardId); + + /** + * 校验终端身份、订单和版本后记录打印结果。 + * + * @param printerHardId DEVICE-ID 请求头解析出的目标设备 + * @param result 终端回执 + * @return 接受或幂等忽略回执时返回 {@code true} + */ + Boolean reportPrintResult(Long printerHardId, HardPrintResultDto result); + + /** + * 跨租户扫描并处理待生成和待打印任务。 + * + * @return 本轮处理任务数量 + */ + int processDueTasks(); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardManagerService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardManagerService.java index cef7f293..ecc7a503 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardManagerService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardManagerService.java @@ -21,6 +21,14 @@ public interface HardManagerService extends IService { void edit(HardManagerDto dto); + /** + * 删除设备前校验其打印绑定和待处理任务。 + * + * @param ids 待删除设备 ID 列表 + * @return 实际删除结果 + */ + boolean removeWithPrinterCheck(List ids); + HardManagerVo bind(HardBindDto dto); HardManagerVo getByHardId(Long hardId); @@ -41,4 +49,4 @@ public interface HardManagerService extends IService { * 判断当前用户是否为合作商家超市管理员 */ boolean isSuperMarketAdmin(PjcloudUser user); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardPrinterService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardPrinterService.java new file mode 100644 index 00000000..9ca8b37d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardPrinterService.java @@ -0,0 +1,120 @@ +package com.bsd.pjcloud.hard.service; + +import com.bsd.pjcloud.hard.api.dto.HardPrinterBindingDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterConfigDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterInfoReportDto; +import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; +import com.bsd.pjcloud.hard.api.entity.HardPrinterConfigEntity; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.vo.HardPrinterBindingVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; + +import java.util.List; + +/** + * 尔印打印终端绑定和配置服务。 + */ +public interface HardPrinterService { + + /** + * 查询指定来源或打印设备当前绑定详情。 + * + * @param hardId 来源业务终端或打印终端 ID + * @return 绑定详情;未绑定时返回 {@code null} + */ + HardPrinterBindingVo getBinding(Long hardId); + + /** + * 按目标打印终端身份读取启动绑定,拒绝来源业务终端借用打印模式接口。 + * + * @param printerHardId DEVICE-ID 声明的 EY_PRINT 设备 ID + * @return 已绑定的来源终端及打印参数 + */ + HardPrinterBindingVo getPrinterBinding(Long printerHardId); + + /** + * 批量补充设备分页中的绑定关系和打印参数,避免管理端逐行请求。 + * + * @param devices 已完成数据权限过滤的当前分页设备 + */ + void enrichDevices(List devices); + + /** + * 查询来源设备同商户下尚未绑定的 EY_PRINT 设备。 + * + * @param sourceHardId 来源业务终端 ID + * @return 可绑定打印终端列表 + */ + List listAvailablePrinters(Long sourceHardId); + + /** + * 在当前操作人数据权限内查询商户下已经绑定打印机的业务终端,供无来源订单人工打印时选择。 + * + * @param merchantId 商户 ID + * @return 已绑定来源业务终端列表 + */ + List listBoundSources(Long merchantId); + + /** + * 创建严格一对一绑定并初始化打印机默认配置。 + * + * @param dto 来源和目标设备 + * @return 新绑定详情 + */ + HardPrinterBindingVo bind(HardPrinterBindingDto dto); + + /** + * 在没有待处理任务时解除指定来源终端绑定。 + * + * @param sourceHardId 来源业务终端 ID + */ + void unbind(Long sourceHardId); + + /** + * 查询打印机配置,不存在时返回尚未持久化的默认配置。 + * + * @param printerHardId EY_PRINT 设备 ID + * @return 当前或默认配置 + */ + HardPrinterConfigEntity getConfig(Long printerHardId); + + /** + * 保存打印机型号、USB 和 ESC/POS 参数。 + * + * @param dto 打印机配置 + * @return 保存后的配置 + */ + HardPrinterConfigEntity saveConfig(HardPrinterConfigDto dto); + + /** + * 由当前打印终端上报实际打印机型号和 USB 标识,保留后台维护的 ESC/POS 参数。 + * + * @param printerHardId DEVICE-ID 请求头中的 EY_PRINT 设备 ID + * @param dto Android USB Host 读取到的型号、VID 和 PID + * @return 更新后的打印机配置 + */ + HardPrinterConfigEntity reportDeviceInfo(Long printerHardId, HardPrinterInfoReportDto dto); + + /** + * 按来源业务终端解析当前有效打印终端。 + * + * @param sourceHardId 来源终端 ID + * @return 当前绑定;未绑定时返回 {@code null} + */ + HardPrinterBindingEntity findBySourceHardId(Long sourceHardId); + + /** + * 校验已绑定设备不能直接变更类型、商户或部门。 + * + * @param before 修改前设备 + * @param after 修改请求 + */ + void validateDeviceChange(HardManagerEntity before, HardManagerEntity after); + + /** + * 校验设备删除前已经解除打印绑定且没有待处理任务。 + * + * @param hardId 待删除设备 ID + */ + void validateDeviceRemoval(Long hardId); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java index c1e4fada..35078724 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java @@ -3,44 +3,58 @@ package com.bsd.pjcloud.hard.service.impl; import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.util.ObjectUtil; -import cn.hutool.extra.spring.SpringUtil; -import com.alibaba.fastjson.JSON; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; import com.bsd.pjcloud.admin.api.entity.SysDept; -import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.admin.api.feign.RemoteFileService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.admin.api.utils.DataUtil; +import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService; +import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity; +import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.hard.api.constant.HardPrintFailureStageEnum; import com.bsd.pjcloud.hard.api.constant.HardPrintStatusEnum; +import com.bsd.pjcloud.hard.api.constant.HardPrintTypeEnum; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.dto.HardInvoiceDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintRequestDto; +import com.bsd.pjcloud.hard.api.dto.HardPrintResultDto; import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; +import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; -import com.bsd.pjcloud.hard.api.utils.Drawing2D; +import com.bsd.pjcloud.hard.api.utils.ThermalReceiptImage; import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; -import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.mapper.HardInvoiceMapper; -import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCarService; import com.bsd.pjcloud.hard.service.HardInvoiceService; import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.hard.service.HardOrderService; +import com.bsd.pjcloud.hard.service.HardPrinterService; import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService; import com.bsd.pjcloud.mqtt.dto.hard.HardPrintMsg; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; +import com.bsd.pjcloud.wallet.api.entity.WalletCodeUseEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteCodeUseService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil; @@ -48,72 +62,118 @@ import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.validation.annotation.Validated; import org.springframework.web.multipart.MultipartFile; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; -import java.util.ArrayList; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; /** - * 发票记录 + * 打印凭证记录与持久化任务服务实现。 * - * @author dzc - * @date 2025-01-05 14:17:32 + *

业务事务只创建任务;图片上传、MQTT 下发和终端打印均在事务提交后异步执行。

*/ @Validated @Slf4j @RequiredArgsConstructor @Service -public class HardInvoiceServiceImpl extends ServiceImpl implements HardInvoiceService { +public class HardInvoiceServiceImpl extends ServiceImpl + implements HardInvoiceService { - static private final String TAG = "发票记录"; + /** 核销回退后再次核销时自动记录的重打原因。 */ + private static final String REVERIFY_REASON = "核销回退后再次核销"; + /** MQTT 待打印任务允许补偿重投的最小间隔,单位秒。 */ + private static final long REDISPATCH_INTERVAL_SECONDS = 30L; + + /** 单轮跨租户扫描任务上限。 */ + private static final int SCAN_LIMIT = 100; + + /** 错误摘要数据库最大长度。 */ + private static final int ERROR_MAX_LENGTH = 2000; + + /** 设备管理服务。 */ private final HardManagerService hardManagerService; + + /** 订单服务。 */ private final HardOrderService orderService; + + /** 订单 Mapper,用于串行化同一订单的人工创建与重打序号分配。 */ + private final HardOrderMapper hardOrderMapper; + + /** 订单商品服务。 */ private final HardCarService carService; - private final HardOrderMapper hardOrderMapper; - private final HardManagerMapper hardManagerMapper; + /** 打印机绑定服务。 */ + private final HardPrinterService printerService; + /** 用户远程服务。 */ private final RemoteUserService userService; + + /** 部门远程服务。 */ private final RemoteDeptService deptService; + + /** 商户远程服务。 */ private final RemoteMchService remoteMchService; + + /** 钱包远程服务。 */ private final RemoteWalletService walletService; + + /** 文件上传远程服务。 */ private final RemoteFileService fileService; + + /** MQTT 消息远程服务。 */ private final RemoteMqttEventService mqttEventService; - /** 开票记录用户身份筛选与展示资料装配器。 */ + /** 预约核销码远程服务。 */ + private final RemoteCodeUseService remoteCodeUseService; + + /** 食堂菜品和套餐组成远程服务。 */ + private final RemoteCanteenSpuService remoteCanteenSpuService; + + /** 硬件业务异步线程池。 */ + private final ThreadPoolTaskExecutor hardTaskExecutor; + + /** 打印记录用户身份筛选与展示资料装配器。 */ private final HardUserIdentitySupport userIdentitySupport; + /** {@inheritDoc} */ @Override public Page scopeJoinPageList(Page page, HardInvoiceDto dto) { prepareIdentityQuery(dto); Page list = baseMapper.scopePageList(page, dto, DataScope.of()); if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 部门 - List deptIds = list.getRecords().stream().map(HardInvoiceVo::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); + List deptIds = list.getRecords().stream().map(HardInvoiceVo::getDeptId) + .filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { R> deptList = deptService.getDeptByDeptIds(deptIds); - if (ObjectUtil.isNotEmpty(deptList.getData())) { + if (deptList != null && ObjectUtil.isNotEmpty(deptList.getData())) { DataUtil.convertDeptList(list.getRecords(), deptList.getData()); } } userIdentitySupport.fillUserInfo(list.getRecords(), true); - // 商户 - List mchIds = list.getRecords().stream().map(HardInvoiceVo::getMerchantId).distinct().toList(); + List mchIds = list.getRecords().stream().map(HardInvoiceVo::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); if (ObjectUtil.isNotEmpty(mchIds)) { R> mchList = remoteMchService.getByIds(mchIds); - if (ObjectUtil.isNotEmpty(mchList.getData())) { - WalletDataUtil.convertMchList(list.getRecords(), CollectionUtils.convertMap(mchList.getData(), MchManageVo::getId)); + if (mchList != null && ObjectUtil.isNotEmpty(mchList.getData())) { + WalletDataUtil.convertMchList(list.getRecords(), + CollectionUtils.convertMap(mchList.getData(), MchManageVo::getId)); } } } @@ -121,9 +181,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl carList = carService.list(new LambdaQueryWrapper() - .eq(HardCarEntity::getParentCarId, order.getParentCarId()) - ); - // 查询用户 + .eq(HardCarEntity::getParentCarId, order.getParentCarId())); String username = "外部用户-未知用户"; String userPhone = "外部用户-未知手机号"; - UserInfo user = userService.getInnerByUserId(order.getUserId()).getData(); - if (ObjectUtil.isNotEmpty(user)) { - if (ObjectUtil.isNotEmpty(user.getSysUser())) { - username = user.getSysUser().getName(); - userPhone = user.getSysUser().getPhone(); - } - } - // 查询商户 - MchManageVo mch = Optional.of(remoteMchService.getById(order.getMerchantId())).map(R::getData).orElseThrow(() -> new RuntimeException("商户不存在")); - // 订单类型 - String orderType = "- -"; - HardOrderTypeEnum orderTypeEnum = HardOrderTypeEnum.valueOfByType(order.getOrderType()); - if (ObjectUtil.isNotEmpty(orderTypeEnum)) { - orderType = orderTypeEnum.getDescription(); - } - // 硬件类型 - String hardwareName = HardTypeEnum.CANTEEN.getType(); - HardTypeEnum sysTypeEnum = HardTypeEnum.valueOfByType(order.getSysType()); - if (ObjectUtil.isNotEmpty(sysTypeEnum)) { - hardwareName = sysTypeEnum.getDescription(); - } - // 钱包类型 - String walletTypeName = ""; - WalletManageVo wallet = walletService.getWalletById(order.getWalletId()).getData(); - if (ObjectUtil.isNotEmpty(wallet) && ObjectUtil.isNotEmpty(wallet.getWalletTypeUniqueCode())) { - try { - WalletTypeEnum walletTypeEnum = WalletTypeEnum.valueOfByCode(wallet.getWalletTypeUniqueCode()); - if (ObjectUtil.isNotEmpty(walletTypeEnum)) { - walletTypeName = walletTypeEnum.getDesc(); - } - } catch (Exception e) { - e.printStackTrace(); - log.error("|{}|钱包类型错误", TAG, e); - } - } - Drawing2D drawing = new Drawing2D(); - drawing.setTitleTextStyle() - .drawingCenterText(String.format("智慧%s", hardwareName)) - .drawingDottedLine(); - String createTime = LocalDateTimeUtil.format(order.getCreateTime(), DatePattern.NORM_DATETIME_PATTERN); - String payTime = LocalDateTimeUtil.format(order.getPayTime(), DatePattern.NORM_DATETIME_PATTERN); - drawing.setDefaultStyle() - .drawingRowText("订单号", order.getId().toString()) - .drawingRowText("订单类型", orderType) - .drawingRowText("用户", username) - .drawingRowText("手机号", userPhone); - if (ObjectUtil.isNotEmpty(walletTypeName)) { - drawing.drawingRowText("钱包类型", walletTypeName); - } - drawing.drawingRowText("商户", mch.getName()) - .drawingRowText("部门", mch.getDeptName()) - .drawingRowText("下单时间", createTime); - if (ObjectUtil.isNotEmpty(payTime)) { - drawing.drawingRowText("支付时间", payTime); + R userResult = userService.getInnerByUserId(order.getUserId()); + UserInfo user = userResult == null ? null : userResult.getData(); + if (user != null && user.getSysUser() != null) { + username = user.getSysUser().getName(); + userPhone = user.getSysUser().getPhone(); } + MchManageVo mch = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在"); + String orderType = Optional.ofNullable(HardOrderTypeEnum.valueOfByType(order.getOrderType())) + .map(HardOrderTypeEnum::getDescription).orElse("--"); + String hardwareName = Optional.ofNullable(HardTypeEnum.valueOfByType(order.getSysType())) + .map(HardTypeEnum::getDescription).orElse("设备"); + String walletTypeName = resolveWalletTypeName(order.getWalletId()); + + ThermalReceiptImage image = new ThermalReceiptImage().title(String.format("智慧%s发票", hardwareName)) + .divider().keyValue("订单号", displayOrderNo(order)).keyValue("订单类型", orderType) + .keyValue("用户", StrUtil.blankToDefault(username, "--")) + .keyValue("手机号", StrUtil.blankToDefault(userPhone, "--")); + if (StrUtil.isNotBlank(walletTypeName)) image.keyValue("钱包类型", walletTypeName); + image.keyValue("商户", mch.getName()).keyValue("部门", mch.getDeptName()) + .keyValue("下单时间", formatDateTime(order.getCreateTime())); + if (order.getPayTime() != null) image.keyValue("支付时间", formatDateTime(order.getPayTime())); if (ObjectUtil.isNotEmpty(carList)) { - drawing.drawingDottedLine(); - drawing.drawingRowText("商品", "数量", "总价"); - carList.forEach(v -> { - String name = v.getProductName(); - if (name.length() > 5) name = name.substring(0, 5) + "..."; - // 判断是重量还是数量 - String count = "0"; - if (ObjectUtil.isNotEmpty(v.getProductCount()) && !ObjectUtil.equals(v.getProductCount(), 0)) { - // 按数量 - count = v.getProductCount().toString(); - } else if (ObjectUtil.isNotEmpty(v.getProductWeight()) && v.getProductWeight().compareTo(BigDecimal.valueOf(0)) != 0) { - // 按重量 - count = v.getProductWeight() + "/kg"; - } - BigDecimal totalPrice = BigDecimal.valueOf(v.getTotalPrice()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - drawing.drawingRowText(name, count, String.valueOf(totalPrice.doubleValue())); - }); - - - BigDecimal totalPrice = BigDecimal.valueOf(order.getTotalPrice()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - BigDecimal amountMealPrice = BigDecimal.valueOf(order.getExtraPrice()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - if (ObjectUtil.isNotEmpty(order.getExtraPrice()) && ObjectUtil.notEqual(order.getExtraPrice(), 0)) { - drawing.drawingRowText("额外支付金额", String.valueOf(amountMealPrice.doubleValue())); + image.divider().sectionTitle("商品明细"); + carList.forEach(car -> image.invoiceItem(car.getProductName(), productQuantity(car), + "¥" + money(car.getTotalPrice()))); + if (order.getExtraPrice() != null && order.getExtraPrice() != 0) { + image.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue())); } - drawing.drawingRowText("小记", String.valueOf(totalPrice.doubleValue())).drawingDottedLine(); - } - String currentTime = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), DatePattern.NORM_DATETIME_PATTERN); - drawing.drawingRowText("打印时间", currentTime).drawingDottedLine(); - if (ObjectUtil.isNotEmpty(code)) { - drawing.drawingDottedLine().drawingRowText("取件码", code).drawingDottedLine(); + image.keyValue("小计", "¥" + money(order.getTotalPrice() == null ? null : order.getTotalPrice().longValue())) + .divider(); } + image.keyValue("打印时间", formatDateTime(LocalDateTime.now())); + if (StrUtil.isNotBlank(code)) image.divider().sectionTitle("取件码").verificationCode(code); try { - String fileName = String.format("invoice_%s_%s.png", order.getId(), LocalDateTimeUtil.now()); - byte[] bytes = drawing.getBytes(); - MultipartFile multipartFile = new MockMultipartFile("file", fileName + ".png", "image/png", new ByteArrayInputStream(bytes)); - Map uploadMap = (Map) fileService.innerUpload(multipartFile, null, null, null).getData(); - return (String) uploadMap.get("url"); - } catch (IOException e) { - e.printStackTrace(); - log.error("|{}|生成发票意外的错误dto:|{}|error:|{}|", TAG, JSON.toJSONString(order), e.getMessage()); - return ""; + return uploadPng("invoice_" + order.getId(), image.toPngBytes()); + } catch (IOException exception) { + throw new RuntimeException("生成发票图片失败", exception); } } + /** {@inheritDoc} */ @Override @Transactional(rollbackFor = Exception.class) public HardInvoiceVo generateInvoice(Long orderId) { - HardOrderEntity order = orderService.getById(orderId); - if (ObjectUtil.isEmpty(order)) throw new RuntimeException("设备订单不存在"); - HardManagerEntity hardManager = hardManagerService.getByHardId(order.getHardId()); - if (ObjectUtil.isEmpty(order)) throw new RuntimeException("设备不存在"); - String image = generateInvoice(order); - if (ObjectUtil.isEmpty(image)) return null; - HardInvoiceEntity insertObj = HardInvoiceEntity.builder().deptId(order.getDeptId()).hardId(hardManager.getId()).orderId(orderId).pic(image).status(HardPrintStatusEnum.GENERATE.getType()).build(); - baseMapper.insert(insertObj); - HardInvoiceVo result = BeanUtils.toBean(insertObj, HardInvoiceVo.class); + HardPrintRequestDto request = new HardPrintRequestDto(); + request.setOrderId(orderId); + request.setPrintType(HardPrintTypeEnum.INVOICE.getType()); + HardInvoiceEntity task = createPrintTask(request); + HardInvoiceVo result = BeanUtils.toBean(task, HardInvoiceVo.class); result.setHistoryId(result.getId()); return result; } + /** {@inheritDoc} */ @Override public Boolean printInvoice(Long orderId, Long historyId) { - HardOrderEntity order = orderService.getById(orderId); - if (ObjectUtil.isEmpty(order)) throw new RuntimeException("设备订单不存在"); HardInvoiceEntity detail = baseMapper.selectById(historyId); - if (ObjectUtil.isEmpty(detail)) throw new RuntimeException("开票记录不存在"); - if (!ObjectUtil.equals(detail.getStatus(), HardPrintStatusEnum.GENERATE.getType())) return false; - HardInvoiceEntity updateObj = HardInvoiceEntity.builder().id(historyId).status(HardPrintStatusEnum.PRINT.getType()).build(); - return baseMapper.updateById(updateObj) > 0; + if (detail == null || !Objects.equals(detail.getOrderId(), orderId)) { + throw new RuntimeException("打印任务与订单不匹配"); + } + if (detail.getPrinterHardId() != null) { + throw new RuntimeException("尔印打印终端必须使用带设备身份的打印结果接口"); + } + if (Objects.equals(detail.getStatus(), HardPrintStatusEnum.PRINT.getType())) return true; + if (!Objects.equals(detail.getStatus(), HardPrintStatusEnum.GENERATE.getType())) return false; + HardInvoiceEntity update = HardInvoiceEntity.builder().id(historyId) + .status(HardPrintStatusEnum.PRINT.getType()).printedAt(LocalDateTime.now()).build(); + return baseMapper.updateById(update) > 0; } + /** {@inheritDoc} */ @Override @Transactional(rollbackFor = Exception.class) public Boolean print(Long orderId) { - // 查询订单 - HardOrderEntity order = orderService.getById(orderId); - if (ObjectUtil.isEmpty(order)) throw new RuntimeException("订单不存在"); - getSelf().printByOrder(order); + HardPrintRequestDto request = new HardPrintRequestDto(); + request.setOrderId(orderId); + request.setPrintType(HardPrintTypeEnum.INVOICE.getType()); + createPrintTask(request); return true; } + /** {@inheritDoc} */ @Override - // @Transactional(rollbackFor = Exception.class) + @Transactional(rollbackFor = Exception.class) public Boolean printByOrder(HardOrderEntity order) { - // 查询商户 - MchManageVo mch = remoteMchService.getById(order.getMerchantId()).getData(); - if (ObjectUtil.isEmpty(mch)) throw new RuntimeException("设备不存在"); - // 查询设备 - HardManagerVo hard = hardManagerService.getByHardId(order.getHardId()); - if (ObjectUtil.isEmpty(hard)) throw new RuntimeException("设备不存在"); - String image = generateInvoice(order); - HardInvoiceEntity insertObj = HardInvoiceEntity.builder().deptId(order.getDeptId()).hardId(hard.getId()).orderId(order.getId()).pic(image).status(HardPrintStatusEnum.GENERATE.getType()).build(); - baseMapper.insert(insertObj); - HardPrintMsg hardPrintMsg = new HardPrintMsg(); - hardPrintMsg.setUserId(order.getUserId()); - hardPrintMsg.setHardId(order.getHardId()); - hardPrintMsg.setMchDeptId(mch.getDeptId()); - hardPrintMsg.setHistoryId(insertObj.getId()); - hardPrintMsg.setOrderId(order.getId()); - hardPrintMsg.setAndroidOrderCode(order.getAndroidOrderCode()); - hardPrintMsg.setPic(image); - R result = mqttEventService.sendPrint(hardPrintMsg); - if (result.getCode() != 0 || !result.getData()) { - log.error("|{}|根据订单信息打印错误order:|{}|error:|{}|", TAG, JSON.toJSONString(order), result.getMsg()); - throw new RuntimeException("打印错误"); - } + createInitialTask(order, HardPrintTypeEnum.INVOICE, order.getHardId(), null); return true; } - private HardInvoiceServiceImpl getSelf() { - return SpringUtil.getBean(getClass()); + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardInvoiceEntity createRedemptionVoucherTask(HardOrderEntity order, Long sourceHardId) { + return createInitialTask(order, HardPrintTypeEnum.REDEMPTION_VOUCHER, sourceHardId, null); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardInvoiceEntity createPrintTask(HardPrintRequestDto request) { + HardPrintTypeEnum printType = HardPrintTypeEnum.valueOfByType(request.getPrintType()); + if (printType == null) throw new RuntimeException("打印类型不正确"); + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(request.getOrderId()); + if (order == null) throw new RuntimeException("订单不存在"); + if (printType == HardPrintTypeEnum.REDEMPTION_VOUCHER) validateVoucherOrder(order); + HardInvoiceEntity latest = findLatestTask(order.getId(), printType); + if (latest != null && isOpenStatus(latest.getStatus())) { + return Objects.equals(latest.getStatus(), HardPrintStatusEnum.FAILED.getType()) + ? retryTask(latest.getId(), request.getSourceHardId()) : latest; + } + if (latest != null && Objects.equals(latest.getStatus(), HardPrintStatusEnum.PRINT.getType())) { + if (printType == HardPrintTypeEnum.REDEMPTION_VOUCHER) { + throw new RuntimeException("已打印兑换券请使用重打接口并填写原因"); + } + if (StrUtil.isBlank(request.getReprintReason())) throw new RuntimeException("再次打印必须填写原因"); + } + return createNewTask(order, printType, resolveManualSource(order, request.getSourceHardId()), + request.getReprintReason()); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardInvoiceEntity retryTask(Long historyId, Long sourceHardId) { + HardInvoiceEntity task = baseMapper.selectById(historyId); + if (task == null) throw new RuntimeException("打印任务不存在"); + if (!Objects.equals(task.getStatus(), HardPrintStatusEnum.FAILED.getType())) { + throw new RuntimeException("只有打印失败任务可以重试"); + } + HardOrderEntity order = orderService.getById(task.getOrderId()); + if (order == null) throw new RuntimeException("订单不存在"); + if (task.getHardId() != null && sourceHardId != null && sourceHardId > 0 + && !Objects.equals(task.getHardId(), sourceHardId)) { + throw new RuntimeException("已有来源终端的任务不能在重试时更换来源"); + } + Long resolvedSourceHardId = resolveManualSource(order, + task.getHardId() == null ? sourceHardId : task.getHardId()); + HardPrinterBindingEntity binding = printerService.findBySourceHardId(resolvedSourceHardId); + boolean regenerateImage = StrUtil.isBlank(task.getPic()) + || Objects.equals(task.getFailureStage(), HardPrintFailureStageEnum.IMAGE_GENERATION.getStage()); + int currentVersion = ObjectUtil.defaultIfNull(task.getRetryVersion(), 0); + var update = Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getId, task.getId()) + .eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.FAILED.getType()) + .eq(HardInvoiceEntity::getRetryVersion, currentVersion) + .set(HardInvoiceEntity::getHardId, resolvedSourceHardId) + .set(HardInvoiceEntity::getRetryVersion, currentVersion + 1) + .set(HardInvoiceEntity::getAttemptCount, 0); + if (binding == null) { + update.set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.FAILED.getType()) + .set(HardInvoiceEntity::getPrinterHardId, null) + .set(HardInvoiceEntity::getFailureStage, HardPrintFailureStageEnum.BINDING.getStage()) + .set(HardInvoiceEntity::getLastError, "来源终端未绑定尔印打印机"); + } else { + update.set(HardInvoiceEntity::getPrinterHardId, binding.getPrinterHardId()) + .set(HardInvoiceEntity::getStatus, regenerateImage + ? HardPrintStatusEnum.PENDING_GENERATION.getType() : HardPrintStatusEnum.GENERATE.getType()) + .set(regenerateImage, HardInvoiceEntity::getPic, null) + .set(HardInvoiceEntity::getFailureStage, null) + .set(HardInvoiceEntity::getLastError, null); + } + int updated = baseMapper.update(null, update); + HardInvoiceEntity result = baseMapper.selectById(task.getId()); + if (updated > 0 && binding != null) scheduleAfterCommit(result); + return result; + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardInvoiceEntity reprintTask(Long historyId, String reason) { + if (StrUtil.isBlank(reason)) throw new RuntimeException("人工重打必须填写原因"); + HardInvoiceEntity history = baseMapper.selectById(historyId); + if (history == null) throw new RuntimeException("打印任务不存在"); + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(history.getOrderId()); + if (order == null) throw new RuntimeException("订单不存在"); + history = baseMapper.selectById(historyId); + if (history == null) throw new RuntimeException("打印任务不存在"); + HardPrintTypeEnum printType = normalizePrintType(history.getPrintType()); + HardInvoiceEntity latest = findLatestTask(order.getId(), printType); + if (latest != null && !Objects.equals(latest.getId(), history.getId())) { + if (isOpenStatus(latest.getStatus())) return latest; + throw new RuntimeException("请基于最新已打印记录发起重打"); + } + if (!Objects.equals(history.getStatus(), HardPrintStatusEnum.PRINT.getType())) { + throw new RuntimeException("只有已打印任务可以创建下一次重打"); + } + if (printType == HardPrintTypeEnum.REDEMPTION_VOUCHER) validateVoucherOrder(order); + return createNewTask(order, printType, history.getHardId(), reason.trim()); + } + + /** {@inheritDoc} */ + @Override + public int cancelPendingVoucherTasks(Long orderId) { + return baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getOrderId, orderId) + .eq(HardInvoiceEntity::getPrintType, HardPrintTypeEnum.REDEMPTION_VOUCHER.getType()) + .in(HardInvoiceEntity::getStatus, List.of(HardPrintStatusEnum.PENDING_GENERATION.getType(), + HardPrintStatusEnum.GENERATE.getType(), HardPrintStatusEnum.FAILED.getType())) + .set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.CANCELLED.getType()) + .set(HardInvoiceEntity::getFailureStage, null) + .set(HardInvoiceEntity::getLastError, "预约核销已回退,任务取消")); + } + + /** {@inheritDoc} */ + @Override + public List listPendingTasks(Long printerHardId) { + requirePrinterIdentity(printerHardId); + return baseMapper.selectList(Wrappers.lambdaQuery() + .eq(HardInvoiceEntity::getPrinterHardId, printerHardId) + .eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType()) + .isNotNull(HardInvoiceEntity::getPic) + .orderByAsc(HardInvoiceEntity::getCreateTime) + .last("limit 100")); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean reportPrintResult(Long printerHardId, HardPrintResultDto result) { + requirePrinterIdentity(printerHardId); + HardInvoiceEntity task = baseMapper.selectById(result.getTaskId()); + if (task == null || !Objects.equals(task.getOrderId(), result.getOrderId())) { + throw new RuntimeException("打印任务与订单不匹配"); + } + if (!Objects.equals(task.getPrinterHardId(), printerHardId)) { + throw new RuntimeException("当前设备不是任务目标打印终端"); + } + int currentVersion = ObjectUtil.defaultIfNull(task.getRetryVersion(), 0); + if (result.getRetryVersion() < currentVersion) return true; + if (result.getRetryVersion() > currentVersion) throw new RuntimeException("打印回执版本超前"); + if (Objects.equals(task.getStatus(), HardPrintStatusEnum.PRINT.getType())) return true; + if (Objects.equals(task.getStatus(), HardPrintStatusEnum.CANCELLED.getType())) return false; + var update = Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getId, task.getId()) + .eq(HardInvoiceEntity::getRetryVersion, currentVersion) + .ne(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PRINT.getType()) + .ne(HardInvoiceEntity::getStatus, HardPrintStatusEnum.CANCELLED.getType()) + .set(HardInvoiceEntity::getAttemptCount, result.getAttemptCount()); + if (Boolean.TRUE.equals(result.getSuccess())) { + update.set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PRINT.getType()) + .set(HardInvoiceEntity::getPrintedAt, LocalDateTime.now()) + .set(HardInvoiceEntity::getFailureStage, null) + .set(HardInvoiceEntity::getLastError, null); + } else { + update.set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.FAILED.getType()) + .set(HardInvoiceEntity::getFailureStage, HardPrintFailureStageEnum.TERMINAL_PRINT.getStage()) + .set(HardInvoiceEntity::getLastError, + truncateError(StrUtil.blankToDefault(result.getErrorMessage(), "终端打印失败"))); + } + return baseMapper.update(null, update) > 0; + } + + /** {@inheritDoc} */ + @Override + public int processDueTasks() { + LocalDateTime redispatchBefore = LocalDateTime.now().minusSeconds(REDISPATCH_INTERVAL_SECONDS); + List candidates = baseMapper.selectList(Wrappers.lambdaQuery() + .and(wrapper -> wrapper.eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PENDING_GENERATION.getType()) + .or(pending -> pending.eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType()) + .and(age -> age.isNull(HardInvoiceEntity::getUpdateTime) + .or().le(HardInvoiceEntity::getUpdateTime, redispatchBefore)))) + .orderByAsc(HardInvoiceEntity::getCreateTime).last("limit " + SCAN_LIMIT)); + int processed = 0; + for (HardInvoiceEntity task : candidates) { + if (task.getTenantId() == null) { + log.warn("打印任务缺少租户ID,taskId:{}", task.getId()); + continue; + } + try { + TenantBroker.runAs(task.getTenantId(), tenantId -> processTask(task.getId())); + processed++; + } catch (Exception exception) { + log.error("打印任务补偿处理异常,taskId:{},tenantId:{}", task.getId(), task.getTenantId(), exception); + } + } + return processed; + } + + /** + * 幂等创建某次业务动作的首个任务;非终态任务存在时直接复用。 + */ + private HardInvoiceEntity createInitialTask(HardOrderEntity order, HardPrintTypeEnum printType, + Long sourceHardId, String reason) { + HardInvoiceEntity latest = findLatestTask(order.getId(), printType); + if (latest != null && isOpenStatus(latest.getStatus())) return latest; + if (latest != null && printType == HardPrintTypeEnum.INVOICE + && Objects.equals(latest.getStatus(), HardPrintStatusEnum.PRINT.getType())) return latest; + String actualReason = printType == HardPrintTypeEnum.REDEMPTION_VOUCHER && latest != null + && (Objects.equals(latest.getStatus(), HardPrintStatusEnum.CANCELLED.getType()) + || Objects.equals(latest.getStatus(), HardPrintStatusEnum.PRINT.getType())) ? REVERIFY_REASON : reason; + return createNewTask(order, printType, sourceHardId, actualReason); + } + + /** + * 分配下一打印序号并创建新任务。 + */ + private HardInvoiceEntity createNewTask(HardOrderEntity order, HardPrintTypeEnum printType, + Long sourceHardId, String reason) { + HardInvoiceEntity latest = findLatestTask(order.getId(), printType); + int printNo = latest == null ? 1 : ObjectUtil.defaultIfNull(latest.getPrintNo(), 0) + 1; + Long normalizedSource = sourceHardId != null && sourceHardId > 0 ? sourceHardId : null; + HardPrinterBindingEntity binding = printerService.findBySourceHardId(normalizedSource); + boolean canDispatch = binding != null; + HardInvoiceEntity task = HardInvoiceEntity.builder().deptId(order.getDeptId()).hardId(normalizedSource) + .printerHardId(canDispatch ? binding.getPrinterHardId() : null).orderId(order.getId()) + .printType(printType.getType()).printNo(printNo).retryVersion(0).attemptCount(0) + .status(canDispatch ? HardPrintStatusEnum.PENDING_GENERATION.getType() : HardPrintStatusEnum.FAILED.getType()) + .failureStage(canDispatch ? null : HardPrintFailureStageEnum.BINDING.getStage()) + .lastError(canDispatch ? null : normalizedSource == null ? "缺少来源终端" : "来源终端未绑定尔印打印机") + .reprintReason(StrUtil.trim(reason)) + .tenantId(order.getTenantId() == null ? TenantContextHolder.getTenantId() : order.getTenantId()).build(); + try { + baseMapper.insert(task); + } catch (DuplicateKeyException exception) { + HardInvoiceEntity concurrent = findTaskByPrintNo(order.getId(), printType, printNo); + if (concurrent != null) return concurrent; + throw exception; + } + if (canDispatch) scheduleAfterCommit(task); + return task; + } + + /** + * 在当前事务提交后把任务投递到硬件异步线程池;无事务时立即投递。 + */ + private void scheduleAfterCommit(HardInvoiceEntity task) { + Runnable submit = () -> { + try { + hardTaskExecutor.execute(() -> { + try { + TenantBroker.runAs(task.getTenantId(), tenantId -> processTask(task.getId())); + } catch (Exception exception) { + log.error("打印任务提交后处理异常,taskId:{},tenantId:{}", task.getId(), task.getTenantId(), exception); + } + }); + } catch (Exception exception) { + log.error("打印任务异步唤醒失败,周期任务将补偿,taskId:{}", task.getId(), exception); + } + }; + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 事务提交成功后才允许执行任何外部服务调用。 */ + @Override + public void afterCommit() { + submit.run(); + } + }); + } else { + submit.run(); + } + } + + /** + * 处理单个待生成或待打印任务。 + */ + private void processTask(Long taskId) { + HardInvoiceEntity task = baseMapper.selectById(taskId); + if (task == null || task.getPrinterHardId() == null) return; + if (Objects.equals(task.getStatus(), HardPrintStatusEnum.PENDING_GENERATION.getType())) task = generateTaskImage(task); + if (task != null && Objects.equals(task.getStatus(), HardPrintStatusEnum.GENERATE.getType())) dispatchTask(task); + } + + /** + * 生成并上传任务图片,失败时将原任务标记为图片生成失败。 + */ + private HardInvoiceEntity generateTaskImage(HardInvoiceEntity task) { + try { + HardOrderEntity order = orderService.getById(task.getOrderId()); + if (order == null) throw new RuntimeException("订单不存在"); + String image = Objects.equals(task.getPrintType(), HardPrintTypeEnum.REDEMPTION_VOUCHER.getType()) + ? generateVoucher(order, task.getPrintNo()) : generateInvoice(order); + if (StrUtil.isBlank(image)) throw new RuntimeException("图片上传地址为空"); + baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getId, task.getId()) + .eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PENDING_GENERATION.getType()) + .eq(HardInvoiceEntity::getRetryVersion, task.getRetryVersion()) + .set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType()) + .set(HardInvoiceEntity::getPic, image) + .set(HardInvoiceEntity::getFailureStage, null) + .set(HardInvoiceEntity::getLastError, null)); + return baseMapper.selectById(task.getId()); + } catch (Exception exception) { + markFailure(task.getId(), task.getRetryVersion(), HardPrintFailureStageEnum.IMAGE_GENERATION, exception); + return null; + } + } + + /** + * 向目标 EY_PRINT 发布 MQTT 任务,代理接受后仍保持待打印状态。 + */ + private void dispatchTask(HardInvoiceEntity task) { + try { + HardOrderEntity order = orderService.getById(task.getOrderId()); + if (order == null) throw new RuntimeException("订单不存在"); + HardPrintMsg message = new HardPrintMsg(); + message.setUserId(order.getUserId()); + message.setHardId(task.getPrinterHardId()); + message.setSourceHardId(task.getHardId()); + message.setTargetHardId(task.getPrinterHardId()); + message.setMchDeptId(order.getMerchantDeptId()); + message.setHistoryId(task.getId()); + message.setOrderId(order.getId()); + message.setPrintType(normalizePrintType(task.getPrintType()).getType()); + message.setPrintNo(ObjectUtil.defaultIfNull(task.getPrintNo(), 1)); + message.setRetryVersion(ObjectUtil.defaultIfNull(task.getRetryVersion(), 0)); + message.setAndroidOrderCode(order.getAndroidOrderCode()); + message.setPic(task.getPic()); + R result = mqttEventService.sendPrint(message); + if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { + throw new RuntimeException(result == null ? "MQTT服务无响应" + : StrUtil.blankToDefault(result.getMsg(), "MQTT服务未接受打印任务")); + } + baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getId, task.getId()) + .eq(HardInvoiceEntity::getRetryVersion, task.getRetryVersion()) + .eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType()) + .set(HardInvoiceEntity::getFailureStage, null) + .set(HardInvoiceEntity::getLastError, null)); + } catch (Exception exception) { + markFailure(task.getId(), task.getRetryVersion(), HardPrintFailureStageEnum.MQTT_DISPATCH, exception); + } + } + + /** + * 生成兑换券 PNG 并上传文件服务。 + */ + private String generateVoucher(HardOrderEntity order, Integer printNo) { + MchManageVo merchant = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在"); + R codeResult = remoteCodeUseService.getByBizId(order.getId()); + WalletCodeUseEntity code = codeResult == null ? null : codeResult.getData(); + List cars = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of); + Map metadata = loadProductMetadata(cars); + ThermalReceiptImage image = new ThermalReceiptImage().title("预约核销兑换券") + .banner(String.format("第 %d 次打印", ObjectUtil.defaultIfNull(printNo, 1))) + .keyValue("商户", merchant.getName()).sectionTitle("核销短码") + .verificationCode(code == null ? null : code.getShortCode()).divider() + .keyValue("订单号", displayOrderNo(order)) + .keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString()) + .keyValue("餐段", resolveMealName(order.getHourType())).divider().sectionTitle("剩余可履约商品"); + boolean hasProduct = false; + for (HardCarVo car : cars) { + int count = effectiveCount(car); + if (count <= 0) continue; + hasProduct = true; + ProductMetadata product = metadata.get(car.getProductId()); + boolean combo = product != null && product.combo(); + image.item(car.getProductName(), "x" + count, false, combo); + if (combo) { + product.items().stream().sorted(Comparator.comparing(item -> + ObjectUtil.defaultIfNull(item.getSortOrder(), Integer.MAX_VALUE))) + .forEach(item -> image.item(item.getName(), + "x" + ObjectUtil.defaultIfNull(item.getQuantity(), 1) * count, true, false)); + } + } + if (!hasProduct) image.item("无可履约商品", "x0", false, false); + image.divider().keyValue("打印时间", formatDateTime(LocalDateTime.now())); + try { + return uploadPng("voucher_" + order.getId() + "_" + printNo, image.toPngBytes()); + } catch (IOException exception) { + throw new RuntimeException("生成兑换券图片失败", exception); + } + } + + /** + * 批量加载套餐类型和组成项;远程失败时按普通单品降级。 + */ + private Map loadProductMetadata(List cars) { + List productIds = cars.stream().map(HardCarVo::getProductId).filter(Objects::nonNull).distinct().toList(); + if (productIds.isEmpty()) return Map.of(); + try { + R> result = remoteCanteenSpuService.getSpuListByIds(productIds); + if (result == null || !result.isOk() || result.getData() == null) return Map.of(); + List comboIds = result.getData().stream() + .filter(item -> item != null && Objects.equals(item.getSpuType(), 2)) + .map(CanteenSpuEntity::getId).toList(); + Map> comboItems = Map.of(); + if (!comboIds.isEmpty()) { + R>> comboResult = remoteCanteenSpuService.getComboItemsBySpuIds(comboIds); + if (comboResult != null && comboResult.isOk() && comboResult.getData() != null) comboItems = comboResult.getData(); + } + Map metadata = new LinkedHashMap<>(); + for (CanteenSpuEntity spu : result.getData()) { + if (spu == null || spu.getId() == null) continue; + boolean combo = Objects.equals(spu.getSpuType(), 2); + metadata.put(spu.getId(), new ProductMetadata(combo, + combo ? Optional.ofNullable(comboItems.get(spu.getId())).orElseGet(List::of) : List.of())); + } + return metadata; + } catch (Exception exception) { + log.warn("兑换券套餐元数据加载失败,按单品降级,orderProducts:{}", productIds, exception); + return Map.of(); + } + } + + /** + * 校验订单当前允许补建或重打兑换券。 + */ + private void validateVoucherOrder(HardOrderEntity order) { + boolean reservation = Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + && (order.getApptId() != null || order.getApptRuleId() != null); + if (!reservation || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())) { + throw new RuntimeException("只有已经核销且未回退的预约订单可以打印兑换券"); + } + if (Objects.equals(order.getRefundStatus(), HardRefundStatusEnum.APPLY.getStatus())) { + throw new RuntimeException("退款中的预约订单不能打印兑换券"); + } + List cars = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of); + if (cars.stream().noneMatch(car -> effectiveCount(car) > 0)) { + throw new RuntimeException("已全额退款的预约订单不能打印兑换券"); + } + } + + /** + * 为没有可靠来源终端的后台订单选择同商户绑定来源。 + */ + private Long resolveManualSource(HardOrderEntity order, Long requestedSourceHardId) { + Long sourceHardId = requestedSourceHardId; + if (sourceHardId == null || sourceHardId <= 0) sourceHardId = order.getHardId(); + if (sourceHardId == null || sourceHardId <= 0) { + List sources = printerService.listBoundSources(order.getMerchantId()); + if (sources.isEmpty()) throw new RuntimeException("该商户没有已绑定尔印打印机的业务终端"); + if (sources.size() > 1) throw new RuntimeException("该商户存在多个打印来源,请选择业务终端"); + sourceHardId = sources.get(0).getId(); + } + HardManagerEntity source = hardManagerService.getById(sourceHardId); + HardTypeEnum type = source == null ? null : HardTypeEnum.valueOfByType(source.getType()); + if (source == null || (type != HardTypeEnum.SUPERMARKET && type != HardTypeEnum.CANTEEN)) { + throw new RuntimeException("打印来源必须是超市或食堂终端"); + } + if (!Objects.equals(source.getMerchantId(), order.getMerchantId())) { + throw new RuntimeException("打印来源与订单不属于同一商户"); + } + return sourceHardId; + } + + /** 校验 DEVICE-ID 确实属于 EY_PRINT 终端。 */ + private void requirePrinterIdentity(Long printerHardId) { + if (printerHardId == null) throw new RuntimeException("DEVICE-ID不能为空"); + HardManagerEntity printer = hardManagerService.getById(printerHardId); + HardTypeEnum type = printer == null ? null : HardTypeEnum.valueOfByType(printer.getType()); + if (printer == null || !HardTypeEnum.isPrintHard(type)) throw new RuntimeException("当前设备不是尔印打印终端"); + } + + /** 将异常写入指定任务,条件版本防止旧处理覆盖人工新重试。 */ + private void markFailure(Long taskId, Integer retryVersion, HardPrintFailureStageEnum stage, Exception exception) { + HardInvoiceEntity failure = new HardInvoiceEntity(); + failure.setStatus(HardPrintStatusEnum.FAILED.getType()); + failure.setFailureStage(stage.getStage()); + failure.setLastError(truncateError(StrUtil.blankToDefault(exception.getMessage(), exception.getClass().getSimpleName()))); + baseMapper.update(failure, Wrappers.lambdaUpdate() + .eq(HardInvoiceEntity::getId, taskId) + .eq(HardInvoiceEntity::getRetryVersion, ObjectUtil.defaultIfNull(retryVersion, 0)) + .ne(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PRINT.getType()) + .ne(HardInvoiceEntity::getStatus, HardPrintStatusEnum.CANCELLED.getType())); + log.error("打印任务处理失败,taskId:{},stage:{}", taskId, stage.getStage(), exception); + } + + /** 查询订单指定类型的最新任务。 */ + private HardInvoiceEntity findLatestTask(Long orderId, HardPrintTypeEnum printType) { + return baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardInvoiceEntity::getOrderId, orderId).eq(HardInvoiceEntity::getPrintType, printType.getType()) + .orderByDesc(HardInvoiceEntity::getPrintNo).orderByDesc(HardInvoiceEntity::getId).last("limit 1")); + } + + /** 按打印序号查询并发创建结果。 */ + private HardInvoiceEntity findTaskByPrintNo(Long orderId, HardPrintTypeEnum printType, Integer printNo) { + return baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardInvoiceEntity::getOrderId, orderId).eq(HardInvoiceEntity::getPrintType, printType.getType()) + .eq(HardInvoiceEntity::getPrintNo, printNo).last("limit 1")); + } + + /** 判断任务是否仍应复用而不能创建新打印序号。 */ + private static boolean isOpenStatus(String status) { + return List.of(HardPrintStatusEnum.PENDING_GENERATION.getType(), HardPrintStatusEnum.GENERATE.getType(), + HardPrintStatusEnum.FAILED.getType()).contains(status); + } + + /** 兼容历史空打印类型。 */ + private static HardPrintTypeEnum normalizePrintType(String printType) { + return Optional.ofNullable(HardPrintTypeEnum.valueOfByType(printType)).orElse(HardPrintTypeEnum.INVOICE); + } + + /** 计算部分退款后的剩余可履约数量。 */ + private static int effectiveCount(HardCarVo car) { + if (car.getEffectiveProductCount() != null) return Math.max(0, car.getEffectiveProductCount()); + return Math.max(0, ObjectUtil.defaultIfNull(car.getProductCount(), 0) + - ObjectUtil.defaultIfNull(car.getRefundedProductCount(), 0)); + } + + /** 解析订单餐段显示名。 */ + private static String resolveMealName(String hourType) { + HardHourTypeEnum type = HardHourTypeEnum.valueOfByType(hourType); + return type == null ? StrUtil.blankToDefault(hourType, "--") : type.getDescription(); + } + + /** 上传 PNG 并校验文件服务返回地址。 */ + private String uploadPng(String filePrefix, byte[] bytes) { + String fileName = filePrefix + "_" + System.currentTimeMillis() + ".png"; + MultipartFile multipartFile = new MockMultipartFile("file", fileName, "image/png", bytes); + R result = fileService.innerUpload(multipartFile, null, null, null); + if (result == null || !result.isOk() || !(result.getData() instanceof Map uploadMap)) { + throw new RuntimeException(result == null ? "文件服务无响应" : StrUtil.blankToDefault(result.getMsg(), "文件服务上传失败")); + } + Object url = uploadMap.get("url"); + if (!(url instanceof String) || StrUtil.isBlank((String) url)) throw new RuntimeException("文件地址为空"); + return (String) url; + } + + /** 解析钱包类型中文名称,远程数据缺失时返回空字符串。 */ + private String resolveWalletTypeName(Long walletId) { + if (walletId == null) return ""; + try { + R result = walletService.getWalletById(walletId); + WalletManageVo wallet = result == null ? null : result.getData(); + if (wallet == null || StrUtil.isBlank(wallet.getWalletTypeUniqueCode())) return ""; + WalletTypeEnum type = WalletTypeEnum.valueOfByCode(wallet.getWalletTypeUniqueCode()); + return type == null ? "" : type.getDesc(); + } catch (Exception exception) { + log.warn("解析钱包类型失败,walletId:{}", walletId, exception); + return ""; + } + } + + /** 返回订单对外展示编号,业务编号为空时使用主键。 */ + private static String displayOrderNo(HardOrderEntity order) { + return StrUtil.blankToDefault(order.getOrderNo(), String.valueOf(order.getId())); + } + + /** 格式化本地日期时间。 */ + private static String formatDateTime(LocalDateTime value) { + return value == null ? "--" : LocalDateTimeUtil.format(value, DatePattern.NORM_DATETIME_PATTERN); + } + + /** 计算发票显示数量或重量。 */ + private static String productQuantity(HardCarEntity car) { + if (car.getProductCount() != null && car.getProductCount() != 0) return car.getProductCount().toString(); + if (car.getProductWeight() != null && car.getProductWeight().compareTo(BigDecimal.ZERO) != 0) { + return car.getProductWeight() + "/kg"; + } + return "0"; + } + + /** 将分转换为两位小数金额文本。 */ + private static String money(Long cents) { + if (cents == null) return "0.00"; + return BigDecimal.valueOf(cents).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP).toPlainString(); + } + + /** 校验远程响应并返回数据。 */ + private static T requireData(R result, String message) { + if (result == null || !result.isOk() || result.getData() == null) throw new RuntimeException(message); + return result.getData(); + } + + /** 截断错误摘要以适配数据库字段。 */ + private static String truncateError(String error) { + return StrUtil.sub(StrUtil.blankToDefault(error, "未知错误"), 0, ERROR_MAX_LENGTH); + } + + /** 菜品是否为套餐及其组成项。 */ + private record ProductMetadata(boolean combo, List items) { } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardPrinterServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardPrinterServiceImpl.java new file mode 100644 index 00000000..460ecd3d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardPrinterServiceImpl.java @@ -0,0 +1,435 @@ +package com.bsd.pjcloud.hard.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.common.core.util.BeanUtils; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.hard.api.constant.HardPrintStatusEnum; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; +import com.bsd.pjcloud.hard.api.dto.HardPrinterBindingDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterConfigDto; +import com.bsd.pjcloud.hard.api.dto.HardPrinterInfoReportDto; +import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; +import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; +import com.bsd.pjcloud.hard.api.entity.HardPrinterConfigEntity; +import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; +import com.bsd.pjcloud.hard.api.vo.HardPrinterBindingVo; +import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo; +import com.bsd.pjcloud.hard.mapper.HardInvoiceMapper; +import com.bsd.pjcloud.hard.mapper.HardPrinterBindingMapper; +import com.bsd.pjcloud.hard.mapper.HardPrinterConfigMapper; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.service.HardPrinterService; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 尔印打印终端绑定和配置服务实现。 + */ +@Service +@RequiredArgsConstructor +public class HardPrinterServiceImpl implements HardPrinterService { + + /** 默认纸张宽度,单位毫米。 */ + private static final int DEFAULT_PAPER_WIDTH = 80; + + /** 默认 GS v 0 图片光栅指令模式。 */ + private static final String IMAGE_MODE_GS_V0 = "GS_V0"; + + /** ESC * 图片指令兼容回退模式。 */ + private static final String IMAGE_MODE_ESC_STAR = "ESC_STAR"; + + /** 开关开启编码。 */ + private static final String ENABLED = "1"; + + /** 开关关闭编码。 */ + private static final String DISABLED = "0"; + + /** 绑定关系 Mapper。 */ + private final HardPrinterBindingMapper bindingMapper; + + /** 打印机配置 Mapper。 */ + private final HardPrinterConfigMapper configMapper; + + /** 设备 Mapper。 */ + private final HardManagerMapper hardManagerMapper; + + /** 打印任务 Mapper。 */ + private final HardInvoiceMapper invoiceMapper; + + /** {@inheritDoc} */ + @Override + public HardPrinterBindingVo getBinding(Long hardId) { + if (hardId == null) return null; + HardPrinterBindingEntity binding = bindingMapper.selectOne(Wrappers.lambdaQuery() + .and(wrapper -> wrapper.eq(HardPrinterBindingEntity::getSourceHardId, hardId) + .or().eq(HardPrinterBindingEntity::getPrinterHardId, hardId)) + .last("limit 1")); + return binding == null ? null : assemble(binding); + } + + /** {@inheritDoc} */ + @Override + public HardPrinterBindingVo getPrinterBinding(Long printerHardId) { + HardManagerEntity printer = requirePrinter(printerHardId); + HardPrinterBindingEntity binding = bindingMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardPrinterBindingEntity::getPrinterHardId, printer.getId()) + .last("limit 1")); + if (binding == null) throw new RuntimeException("尔印打印终端尚未绑定业务终端"); + return assemble(binding); + } + + /** {@inheritDoc} */ + @Override + public void enrichDevices(List devices) { + if (devices == null || devices.isEmpty()) return; + List deviceIds = devices.stream().map(HardManagerVo::getId).filter(Objects::nonNull).toList(); + if (deviceIds.isEmpty()) return; + List bindings = bindingMapper.selectList( + Wrappers.lambdaQuery() + .in(HardPrinterBindingEntity::getSourceHardId, deviceIds) + .or().in(HardPrinterBindingEntity::getPrinterHardId, deviceIds)); + List relatedIds = java.util.stream.Stream.concat(deviceIds.stream(), bindings.stream() + .flatMap(binding -> java.util.stream.Stream.of(binding.getSourceHardId(), binding.getPrinterHardId())) + .filter(Objects::nonNull)).distinct().toList(); + Map managerMap = hardManagerMapper.selectBatchIds(relatedIds).stream() + .collect(Collectors.toMap(HardManagerEntity::getId, Function.identity(), (left, right) -> left)); + List printerIds = java.util.stream.Stream.concat( + bindings.stream().map(HardPrinterBindingEntity::getPrinterHardId), + devices.stream().filter(device -> HardTypeEnum.EY_PRINT.getType().equals(device.getType())) + .map(HardManagerVo::getId)) + .filter(Objects::nonNull).distinct().toList(); + Map configMap = printerIds.isEmpty() ? Map.of() : configMapper.selectList( + Wrappers.lambdaQuery() + .in(HardPrinterConfigEntity::getPrinterHardId, printerIds)) + .stream().collect(Collectors.toMap(HardPrinterConfigEntity::getPrinterHardId, + Function.identity(), (left, right) -> left)); + Map sourceBindingMap = bindings.stream() + .collect(Collectors.toMap(HardPrinterBindingEntity::getSourceHardId, + Function.identity(), (left, right) -> left)); + Map printerBindingMap = bindings.stream() + .collect(Collectors.toMap(HardPrinterBindingEntity::getPrinterHardId, + Function.identity(), (left, right) -> left)); + for (HardManagerVo device : devices) { + HardPrinterBindingEntity sourceBinding = sourceBindingMap.get(device.getId()); + HardPrinterBindingEntity printerBinding = printerBindingMap.get(device.getId()); + if (sourceBinding != null) { + device.setBoundPrinterHardId(sourceBinding.getPrinterHardId()); + device.setBoundPrinterHardName(resolveName(managerMap.get(sourceBinding.getPrinterHardId()))); + } + if (printerBinding != null) { + device.setBoundSourceHardId(printerBinding.getSourceHardId()); + device.setBoundSourceHardName(resolveName(managerMap.get(printerBinding.getSourceHardId()))); + } + Long printerId = HardTypeEnum.EY_PRINT.getType().equals(device.getType()) + ? device.getId() : device.getBoundPrinterHardId(); + // 未绑定的普通设备没有目标打印机,不能使用空键访问 Map.of() 返回的不可变空映射。 + HardPrinterConfigEntity config = printerId == null ? null : configMap.get(printerId); + if (config != null) fillPrinterConfig(device, config); + } + } + + /** {@inheritDoc} */ + @Override + public List listAvailablePrinters(Long sourceHardId) { + HardManagerEntity source = requireSource(sourceHardId); + List boundPrinterIds = bindingMapper.selectList(Wrappers.lambdaQuery() + .select(HardPrinterBindingEntity::getPrinterHardId)) + .stream().map(HardPrinterBindingEntity::getPrinterHardId).filter(Objects::nonNull).toList(); + return hardManagerMapper.selectList(Wrappers.lambdaQuery() + .eq(HardManagerEntity::getType, HardTypeEnum.EY_PRINT.getType()) + .eq(HardManagerEntity::getMerchantId, source.getMerchantId()) + .eq(HardManagerEntity::getDeptId, source.getDeptId()) + .notIn(!boundPrinterIds.isEmpty(), HardManagerEntity::getId, boundPrinterIds) + .orderByAsc(HardManagerEntity::getName)); + } + + /** {@inheritDoc} */ + @Override + public List listBoundSources(Long merchantId) { + if (merchantId == null) throw new RuntimeException("商户不能为空"); + List sourceIds = bindingMapper.selectList(Wrappers.lambdaQuery() + .eq(HardPrinterBindingEntity::getMerchantId, merchantId) + .select(HardPrinterBindingEntity::getSourceHardId)) + .stream().map(HardPrinterBindingEntity::getSourceHardId).filter(Objects::nonNull).toList(); + if (sourceIds.isEmpty()) return List.of(); + return hardManagerMapper.selectListByScope(Wrappers.lambdaQuery() + .in(HardManagerEntity::getId, sourceIds) + .orderByAsc(HardManagerEntity::getName), DataScope.of()); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardPrinterBindingVo bind(HardPrinterBindingDto dto) { + HardManagerEntity source = requireSource(dto.getSourceHardId()); + HardManagerEntity printer = requirePrinter(dto.getPrinterHardId()); + if (!Objects.equals(source.getTenantId(), printer.getTenantId())) { + throw new RuntimeException("来源终端和打印终端必须属于同一租户"); + } + if (!Objects.equals(source.getMerchantId(), printer.getMerchantId())) { + throw new RuntimeException("来源终端和打印终端必须属于同一商户"); + } + if (!Objects.equals(source.getDeptId(), printer.getDeptId())) { + throw new RuntimeException("来源终端和打印终端必须属于同一商户部门"); + } + HardPrinterBindingEntity binding = HardPrinterBindingEntity.builder() + .sourceHardId(source.getId()) + .printerHardId(printer.getId()) + .merchantId(source.getMerchantId()) + .deptId(source.getDeptId()) + .tenantId(source.getTenantId()) + .build(); + try { + bindingMapper.insert(binding); + } catch (DuplicateKeyException exception) { + throw new RuntimeException("来源终端或打印终端已经绑定,请先解除原绑定", exception); + } + if (configMapper.selectCount(Wrappers.lambdaQuery() + .eq(HardPrinterConfigEntity::getPrinterHardId, printer.getId())) == 0) { + configMapper.insert(defaultConfig(printer)); + } + return assemble(binding); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public void unbind(Long sourceHardId) { + HardPrinterBindingEntity binding = findBySourceHardId(sourceHardId); + if (binding == null) return; + if (hasPendingTask(binding.getSourceHardId(), binding.getPrinterHardId())) { + throw new RuntimeException("存在待生成、待打印或打印失败任务,处理完成后才能解绑"); + } + bindingMapper.deleteById(binding.getId()); + } + + /** {@inheritDoc} */ + @Override + public HardPrinterConfigEntity getConfig(Long printerHardId) { + HardManagerEntity printer = requirePrinter(printerHardId); + HardPrinterConfigEntity config = configMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardPrinterConfigEntity::getPrinterHardId, printerHardId) + .last("limit 1")); + return config == null ? defaultConfig(printer) : config; + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardPrinterConfigEntity saveConfig(HardPrinterConfigDto dto) { + HardManagerEntity printer = requirePrinter(dto.getPrinterHardId()); + validateConfig(dto); + HardPrinterConfigEntity existing = configMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardPrinterConfigEntity::getPrinterHardId, printer.getId()) + .last("limit 1")); + HardPrinterConfigEntity config = BeanUtils.toBean(dto, HardPrinterConfigEntity.class); + config.setTenantId(printer.getTenantId()); + if (existing == null) { + configMapper.insert(config); + } else { + config.setId(existing.getId()); + configMapper.updateById(config); + } + return configMapper.selectById(config.getId()); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public HardPrinterConfigEntity reportDeviceInfo(Long printerHardId, HardPrinterInfoReportDto dto) { + HardManagerEntity printer = requirePrinter(printerHardId); + HardPrinterConfigEntity config = configMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardPrinterConfigEntity::getPrinterHardId, printer.getId()) + .last("limit 1")); + boolean create = config == null; + if (create) config = defaultConfig(printer); + config.setPrinterModel(StrUtil.trim(dto.getPrinterModel())); + config.setUsbVendorId(dto.getUsbVendorId()); + config.setUsbProductId(dto.getUsbProductId()); + if (create) { + configMapper.insert(config); + } else { + configMapper.updateById(config); + } + return config; + } + + /** {@inheritDoc} */ + @Override + public HardPrinterBindingEntity findBySourceHardId(Long sourceHardId) { + if (sourceHardId == null || sourceHardId <= 0) return null; + return bindingMapper.selectOne(Wrappers.lambdaQuery() + .eq(HardPrinterBindingEntity::getSourceHardId, sourceHardId) + .last("limit 1")); + } + + /** {@inheritDoc} */ + @Override + public void validateDeviceChange(HardManagerEntity before, HardManagerEntity after) { + if (before == null || after == null || after.getId() == null) return; + boolean changesBindingIdentity = !Objects.equals(before.getType(), after.getType()) + || !Objects.equals(before.getMerchantId(), after.getMerchantId()) + || !Objects.equals(before.getDeptId(), after.getDeptId()); + if (changesBindingIdentity && getBinding(before.getId()) != null) { + throw new RuntimeException("已绑定打印关系的设备不能直接修改类型、商户或部门,请先处理任务并解绑"); + } + } + + /** {@inheritDoc} */ + @Override + public void validateDeviceRemoval(Long hardId) { + HardPrinterBindingVo binding = getBinding(hardId); + if (binding != null) { + throw new RuntimeException("已绑定打印关系的设备不能删除,请先处理任务并解绑"); + } + if (hasPendingTask(hardId, hardId)) { + throw new RuntimeException("设备存在待生成、待打印或打印失败任务,不能删除"); + } + } + + /** + * 校验并返回来源业务终端。 + * + * @param hardId 来源设备 ID + * @return 有效来源设备 + */ + private HardManagerEntity requireSource(Long hardId) { + HardManagerEntity source = hardManagerMapper.selectById(hardId); + HardTypeEnum type = source == null ? null : HardTypeEnum.valueOfByType(source.getType()); + if (source == null || (type != HardTypeEnum.SUPERMARKET && type != HardTypeEnum.CANTEEN)) { + throw new RuntimeException("来源终端必须是超市或食堂设备"); + } + if (source.getMerchantId() == null) throw new RuntimeException("来源终端未绑定商户"); + return source; + } + + /** + * 校验并返回 EY_PRINT 打印终端。 + * + * @param hardId 打印设备 ID + * @return 有效打印设备 + */ + private HardManagerEntity requirePrinter(Long hardId) { + HardManagerEntity printer = hardManagerMapper.selectById(hardId); + HardTypeEnum type = printer == null ? null : HardTypeEnum.valueOfByType(printer.getType()); + if (printer == null || !HardTypeEnum.isPrintHard(type)) { + throw new RuntimeException("目标设备必须是尔印打印终端"); + } + if (printer.getMerchantId() == null) throw new RuntimeException("打印终端未绑定商户"); + return printer; + } + + /** + * 校验受首期协议约束的打印参数。 + * + * @param dto 待保存配置 + */ + private void validateConfig(HardPrinterConfigDto dto) { + if (!Objects.equals(dto.getPaperWidth(), DEFAULT_PAPER_WIDTH)) { + throw new RuntimeException("首期打印纸宽只支持80mm"); + } + if (!ENABLED.equals(dto.getAutoCut()) && !DISABLED.equals(dto.getAutoCut())) { + throw new RuntimeException("自动切刀开关只能为0或1"); + } + if (!IMAGE_MODE_GS_V0.equals(dto.getImageMode()) && !IMAGE_MODE_ESC_STAR.equals(dto.getImageMode())) { + throw new RuntimeException("图片指令模式只支持GS_V0或ESC_STAR"); + } + if ((dto.getUsbVendorId() == null) != (dto.getUsbProductId() == null)) { + throw new RuntimeException("USB VID和PID必须同时填写或同时留空"); + } + if (dto.getUsbVendorId() != null && (dto.getUsbVendorId() < 0 || dto.getUsbVendorId() > 65535)) { + throw new RuntimeException("USB VID必须在0到65535之间"); + } + if (dto.getUsbProductId() != null && (dto.getUsbProductId() < 0 || dto.getUsbProductId() > 65535)) { + throw new RuntimeException("USB PID必须在0到65535之间"); + } + dto.setPrinterModel(StrUtil.trim(dto.getPrinterModel())); + } + + /** + * 判断来源或目标设备是否仍有关联的非终态任务。 + * + * @param sourceHardId 来源设备 ID + * @param printerHardId 目标设备 ID + * @return 存在待生成、待打印或失败任务时返回 {@code true} + */ + private boolean hasPendingTask(Long sourceHardId, Long printerHardId) { + List pendingStatuses = List.of(HardPrintStatusEnum.PENDING_GENERATION.getType(), + HardPrintStatusEnum.GENERATE.getType(), HardPrintStatusEnum.FAILED.getType()); + return invoiceMapper.selectCount(Wrappers.lambdaQuery() + .in(HardInvoiceEntity::getStatus, pendingStatuses) + .and(wrapper -> wrapper.eq(HardInvoiceEntity::getHardId, sourceHardId) + .or().eq(HardInvoiceEntity::getPrinterHardId, printerHardId))) > 0; + } + + /** + * 读取关联设备名称,不存在时返回空值以便前端展示未配置状态。 + * + * @param manager 关联设备 + * @return 设备名称或空值 + */ + private static String resolveName(HardManagerEntity manager) { + return manager == null ? null : manager.getName(); + } + + /** + * 把打印参数投影到设备分页行。 + * + * @param target 设备分页行 + * @param config 当前打印配置 + */ + private static void fillPrinterConfig(HardManagerVo target, HardPrinterConfigEntity config) { + target.setPrinterModel(config.getPrinterModel()); + target.setUsbVendorId(config.getUsbVendorId()); + target.setUsbProductId(config.getUsbProductId()); + target.setPaperWidth(config.getPaperWidth()); + target.setAutoCut(config.getAutoCut()); + target.setImageMode(config.getImageMode()); + } + + /** + * 创建尚未持久化的首期默认打印配置。 + * + * @param printer 打印终端 + * @return 默认配置 + */ + private HardPrinterConfigEntity defaultConfig(HardManagerEntity printer) { + return HardPrinterConfigEntity.builder() + .printerHardId(printer.getId()) + .paperWidth(DEFAULT_PAPER_WIDTH) + .autoCut(ENABLED) + .imageMode(IMAGE_MODE_GS_V0) + .tenantId(printer.getTenantId()) + .build(); + } + + /** + * 装配绑定双方名称和打印机配置。 + * + * @param binding 绑定实体 + * @return 管理端展示对象 + */ + private HardPrinterBindingVo assemble(HardPrinterBindingEntity binding) { + HardPrinterBindingVo result = BeanUtils.toBean(binding, HardPrinterBindingVo.class); + HardManagerEntity source = hardManagerMapper.selectById(binding.getSourceHardId()); + HardManagerEntity printer = hardManagerMapper.selectById(binding.getPrinterHardId()); + if (source != null) { + result.setSourceHardName(source.getName()); + result.setSourceHardType(source.getType()); + } + if (printer != null) { + result.setPrinterHardName(printer.getName()); + result.setPrinterConfig(getConfig(printer.getId())); + } + return result; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java index aa980c5f..74e319f0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java @@ -66,7 +66,6 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ /** 食堂菜品远程服务,用于批量补充订单商品类型和套餐组成。 */ private final RemoteCanteenSpuService remoteCanteenSpuService; - /** {@inheritDoc} */ /** {@inheritDoc} */ @Override public ReservationMenuQueryVo query(ReservationMenuQueryDto dto) { @@ -135,8 +134,15 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ return cars != null && cars.stream().anyMatch(car -> resolveEffectiveProductCount(car) > 0); } + /** + * 校验可选 DEVICE-ID 是否属于订单商户,并保留缺少请求头时的空来源语义。 + * + * @param order 待核销预约订单 + * @param hardId 请求头提供的来源设备 ID;未提供时为空 + * @return 已校验的来源设备 ID;请求头缺失时返回 {@code null} + */ private Long resolveVerificationHardId(HardOrderEntity order, Long hardId) { - if (hardId == null) return -1L; + if (hardId == null) return null; HardManagerEntity hard = hardManagerService.getById(hardId); if (hard == null || !Objects.equals(hard.getMerchantId(), order.getMerchantId())) { throw new RuntimeException("当前设备不能核销该预约订单"); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardManagerServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardManagerServiceImpl.java index 804211ca..3ffb5403 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardManagerServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardManagerServiceImpl.java @@ -17,6 +17,7 @@ import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.hard.api.dto.HardGroupDto; +import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; import com.bsd.pjcloud.hard.api.dto.manager.HardManagerDto; import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto; import com.bsd.pjcloud.hard.api.dto.hard.HardBindDto; @@ -28,6 +29,7 @@ import com.bsd.pjcloud.hard.mapper.HardGroupMapper; import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.service.HardDoorManagerService; import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardPrinterService; import com.bsd.pjcloud.hard.service.HardTerminalStatusService; import com.bsd.pjcloud.hard.mqtt.config.MqttConfig; import com.bsd.pjcloud.hard.utils.MqTopicUtil; @@ -64,6 +66,9 @@ public class HardManagerServiceImpl extends ServiceImpl ids) { + if (ids == null || ids.isEmpty()) return false; + ids.forEach(hardPrinterService::validateDeviceRemoval); + return removeBatchByIds(ids); + } + @Override public HardManagerVo bind(HardBindDto dto) { HardManagerEntity detail = baseMapper.selectById(dto.getId()); if (ObjectUtil.isEmpty(detail)) { throw new RuntimeException("设备不存在"); } + if (HardTypeEnum.EY_PRINT.getType().equals(detail.getType())) { + // 打印终端必须先由管理端完成一对一来源绑定,避免未归属设备登录后接收错误商户任务。 + hardPrinterService.getPrinterBinding(detail.getId()); + } if (existUniqueCode(dto.getUniqueCode(), dto.getId())) { throw new RuntimeException("该唯一编码已经被绑定"); } @@ -211,11 +230,28 @@ public class HardManagerServiceImpl extends ServiceImpl records = List.of(vo); + if (ObjectUtil.isNotEmpty(hard.getDeptId())) { + R> deptResponse = deptService.getDeptByDeptIds(List.of(hard.getDeptId())); + if (ObjectUtil.isNotEmpty(deptResponse.getData())) { + DataUtil.convertDept(vo, deptResponse.getData().get(hard.getDeptId())); + } + } MchManageVo mch = mchService.getById(hard.getMerchantId()).getData(); if (ObjectUtil.isNotEmpty(mch)) { + WalletDataUtil.convertMch(vo, mch); vo.setMerchantDeptId(mch.getDeptId()); } + if (ObjectUtil.isNotEmpty(hard.getHardGroupId())) { + HardUtil.convertHardGroup(vo, hardGroupMapper.selectById(hard.getHardGroupId())); + } + // 单设备详情与分页列表保持同一运行语义,避免刷新详情页后丢失绑定和心跳状态。 + hardPrinterService.enrichDevices(records); + hardTerminalStatusService.enrich(records); return vo; } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalStatusServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalStatusServiceImpl.java index 926e9aab..5343abb9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalStatusServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalStatusServiceImpl.java @@ -147,13 +147,14 @@ public class HardTerminalStatusServiceImpl extends ServiceImpl().eq(HardTakeOutApptEntity::getOrderId, id), + false)); + } } return detail; } @@ -3945,10 +3951,8 @@ public class HardOrderServiceImpl extends ServiceImpl() .eq(HardOrderEntity::getId, order.getId()) .eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType())) @@ -3956,10 +3960,37 @@ public class HardOrderServiceImpl extends ServiceImpl - + + + + + + + + + + @@ -21,8 +30,17 @@ select a.id, a.dept_id as invoiceDeptId, a.hard_id, + a.printer_hard_id, a.order_id, + a.print_type, + a.print_no, + a.retry_version, + a.attempt_count, a.status, + a.failure_stage, + a.last_error, + a.printed_at, + a.reprint_reason, a.pic, a.create_by, a.create_time, @@ -34,15 +52,25 @@ a.del_flag = '0' + + and a.order_id = #{dto.orderId} + and a.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]} + + and a.print_type = #{dto.printType} + + + and a.status = #{dto.status} +