feat:修改设备列表、详情

feat:修改订单详情
feat:修改售后订单查询条件
feat:增加打印机设备及功能
This commit is contained in:
dzc committed 2026-09-04 15:14:51 +08:00
1 parent fc8afe4de7
commit 8514af0d9f
49 files changed
+3734 -222

No files matched your search

+317
View File
@@ -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;
+75 -14
View File
@@ -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
@@ -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);
}
}
@@ -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;
/**
* 打印状态
* 打印任务状态。
*
* <p>编码 {@code 1} 和 {@code 2} 分别兼容历史“已生成”和“已打印”记录。</p>
*/
@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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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。
*
* <p>仅当原任务因缺少来源终端而失败时使用;服务端仍会校验该终端与订单同商户,
* 并根据服务端绑定关系确定目标 EY_PRINT,客户端不能直接指定打印机。</p>
*/
@Schema(description = "补录的来源业务终端ID")
private Long sourceHardId;
/** 已打印任务重打时必须填写的原因;失败任务原任务重试时可为空。 */
@Schema(description = "重打原因")
private String reprintReason;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -17,6 +17,9 @@ public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIde
/** 售后申请用户主键集合;为空时不限制申请人。 */
private List<Long> userIds;
/** 关联原订单的用户主键集合;用于区分代订场景中的退款发起人和实际订单用户。 */
private List<Long> orderUserIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
@@ -37,28 +37,82 @@ public class HardInvoiceEntity extends Model<HardInvoiceEntity> {
@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<HardInvoiceEntity> {
*/
@Schema(description="租户ID")
private Long tenantId;
}
}
@@ -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;
/**
* 业务终端与尔印打印终端的一对一绑定关系。
*
* <p>记录使用物理删除,数据库分别对来源和目标设备设置唯一键,确保任何时刻都不能出现多重绑定。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TenantTable
@TableName("hard_printer_binding")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "业务终端与打印终端绑定关系")
public class HardPrinterBindingEntity extends Model<HardPrinterBindingEntity> {
/** 绑定关系主键。 */
@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;
}
@@ -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<HardPrinterConfigEntity> {
/** 配置主键。 */
@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;
}
@@ -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 绘制器。
*
* <p>商品名称按真实像素宽度换行,数量列固定右对齐,保证长名称不会与数量错配。</p>
*/
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<String> 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<String> 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<String> wrap(String text, int maxWidth, FontMetrics metrics) {
List<String> 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;
}
}
@@ -67,6 +67,9 @@ public class HardInvoiceVo extends HardInvoiceEntity implements BaseUserDeptInte
private String hardType;
/** 目标尔印打印终端名称。 */
private String printerHardName;
/**
* 用户相关
*/
@@ -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;
}
@@ -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;
}
@@ -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;非外卖或历史未记录时为空。
*
* <p>该字段只参与订单详情响应,不对应 hard_order 表持久化字段。</p>
*/
private HardTakeOutApptEntity takeOutAppt;
/**
* 付款人用户ID,来源于订单关联的父购物车。
*/
@@ -129,4 +137,28 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac
* 订单详情中的长期识别证据列表,按证据类型、角色和序号排序。
*/
private List<HardOrderEvidenceImageVo> 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;
}
@@ -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<HardInvoiceEntity> export(HardInvoiceEntity hardInvoice, Long[] ids) {
return hardInvoiceService.list(Wrappers.lambdaQuery(hardInvoice).in(ArrayUtil.isNotEmpty(ids), HardInvoiceEntity::getId, ids));
}
}
}
@@ -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<Void> 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));
}
}
@@ -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<Boolean> 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 = "正在处理中,请勿重复操作")
@@ -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<HardManagerEntity> export(HardManagerEntity hardManager, Long[] ids) {
return hardManagerService.list(Wrappers.lambdaQuery(hardManager).in(ArrayUtil.isNotEmpty(ids), HardManagerEntity::getId, ids));
}
}
}
@@ -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);
}
}
}
@@ -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<HardPrinterBindingEntity> {
}
@@ -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<HardPrinterConfigEntity> {
}
@@ -23,6 +23,7 @@ public interface HardManagerMapper extends PjcloudBaseMapper<HardManagerEntity>
MPJLambdaWrapper<HardManagerEntity> wrapper = new MPJLambdaWrapper<HardManagerEntity>()
.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<HardManagerEntity>
default Page<HardManagerVo> scopePageList(Page page, HardManagerDto dto) {
LambdaQueryWrapper<HardManagerEntity> wrapper = new LambdaQueryWrapper<HardManagerEntity>()
.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<HardManagerEntity>
list.setRecords(BeanUtils.toBean(list.getRecords(), HardManagerVo.class));
return list;
}
}
}
@@ -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<HardInvoiceEntity> {
/**
* 按数据权限分页查询打印任务。
*
* @param page 分页参数
* @param dto 查询条件
* @return 打印任务分页
*/
Page<HardInvoiceVo> scopeJoinPageList(Page page, HardInvoiceDto dto);
/**
@@ -37,4 +51,70 @@ public interface HardInvoiceService extends IService<HardInvoiceEntity> {
* 根据订单信息打印
*/
Boolean printByOrder(HardOrderEntity order);
}
/**
* 在预约核销事务内幂等创建兑换券任务,不执行图片或网络操作。
*
* @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<HardInvoiceEntity> listPendingTasks(Long printerHardId);
/**
* 校验终端身份、订单和版本后记录打印结果。
*
* @param printerHardId DEVICE-ID 请求头解析出的目标设备
* @param result 终端回执
* @return 接受或幂等忽略回执时返回 {@code true}
*/
Boolean reportPrintResult(Long printerHardId, HardPrintResultDto result);
/**
* 跨租户扫描并处理待生成和待打印任务。
*
* @return 本轮处理任务数量
*/
int processDueTasks();
}
@@ -21,6 +21,14 @@ public interface HardManagerService extends IService<HardManagerEntity> {
void edit(HardManagerDto dto);
/**
* 删除设备前校验其打印绑定和待处理任务。
*
* @param ids 待删除设备 ID 列表
* @return 实际删除结果
*/
boolean removeWithPrinterCheck(List<Long> ids);
HardManagerVo bind(HardBindDto dto);
HardManagerVo getByHardId(Long hardId);
@@ -41,4 +49,4 @@ public interface HardManagerService extends IService<HardManagerEntity> {
* 判断当前用户是否为合作商家超市管理员
*/
boolean isSuperMarketAdmin(PjcloudUser user);
}
}
@@ -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<HardManagerVo> devices);
/**
* 查询来源设备同商户下尚未绑定的 EY_PRINT 设备。
*
* @param sourceHardId 来源业务终端 ID
* @return 可绑定打印终端列表
*/
List<HardManagerEntity> listAvailablePrinters(Long sourceHardId);
/**
* 在当前操作人数据权限内查询商户下已经绑定打印机的业务终端,供无来源订单人工打印时选择。
*
* @param merchantId 商户 ID
* @return 已绑定来源业务终端列表
*/
List<HardManagerEntity> 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);
}
@@ -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.<HardPrinterBindingEntity>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.<HardPrinterBindingEntity>lambdaQuery()
.eq(HardPrinterBindingEntity::getPrinterHardId, printer.getId())
.last("limit 1"));
if (binding == null) throw new RuntimeException("尔印打印终端尚未绑定业务终端");
return assemble(binding);
}
/** {@inheritDoc} */
@Override
public void enrichDevices(List<HardManagerVo> devices) {
if (devices == null || devices.isEmpty()) return;
List<Long> deviceIds = devices.stream().map(HardManagerVo::getId).filter(Objects::nonNull).toList();
if (deviceIds.isEmpty()) return;
List<HardPrinterBindingEntity> bindings = bindingMapper.selectList(
Wrappers.<HardPrinterBindingEntity>lambdaQuery()
.in(HardPrinterBindingEntity::getSourceHardId, deviceIds)
.or().in(HardPrinterBindingEntity::getPrinterHardId, deviceIds));
List<Long> 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<Long, HardManagerEntity> managerMap = hardManagerMapper.selectBatchIds(relatedIds).stream()
.collect(Collectors.toMap(HardManagerEntity::getId, Function.identity(), (left, right) -> left));
List<Long> 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<Long, HardPrinterConfigEntity> configMap = printerIds.isEmpty() ? Map.of() : configMapper.selectList(
Wrappers.<HardPrinterConfigEntity>lambdaQuery()
.in(HardPrinterConfigEntity::getPrinterHardId, printerIds))
.stream().collect(Collectors.toMap(HardPrinterConfigEntity::getPrinterHardId,
Function.identity(), (left, right) -> left));
Map<Long, HardPrinterBindingEntity> sourceBindingMap = bindings.stream()
.collect(Collectors.toMap(HardPrinterBindingEntity::getSourceHardId,
Function.identity(), (left, right) -> left));
Map<Long, HardPrinterBindingEntity> 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<HardManagerEntity> listAvailablePrinters(Long sourceHardId) {
HardManagerEntity source = requireSource(sourceHardId);
List<Long> boundPrinterIds = bindingMapper.selectList(Wrappers.<HardPrinterBindingEntity>lambdaQuery()
.select(HardPrinterBindingEntity::getPrinterHardId))
.stream().map(HardPrinterBindingEntity::getPrinterHardId).filter(Objects::nonNull).toList();
return hardManagerMapper.selectList(Wrappers.<HardManagerEntity>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<HardManagerEntity> listBoundSources(Long merchantId) {
if (merchantId == null) throw new RuntimeException("商户不能为空");
List<Long> sourceIds = bindingMapper.selectList(Wrappers.<HardPrinterBindingEntity>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.<HardManagerEntity>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.<HardPrinterConfigEntity>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.<HardPrinterConfigEntity>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.<HardPrinterConfigEntity>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.<HardPrinterConfigEntity>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.<HardPrinterBindingEntity>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<String> pendingStatuses = List.of(HardPrintStatusEnum.PENDING_GENERATION.getType(),
HardPrintStatusEnum.GENERATE.getType(), HardPrintStatusEnum.FAILED.getType());
return invoiceMapper.selectCount(Wrappers.<HardInvoiceEntity>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;
}
}
@@ -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("当前设备不能核销该预约订单");
@@ -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<HardManagerMapper, HardM
private final RemoteMchService mchService;
private final RemoteDeptService deptService;
private final RemoteMqttEventService remoteMqttEventService;
/** 打印终端绑定服务,用于保护已绑定设备的关键属性和删除动作。 */
private final HardPrinterService hardPrinterService;
/**
* 为已完成数据权限过滤的设备分页批量补充终端运行状态。
*/
@@ -123,6 +128,7 @@ public class HardManagerServiceImpl extends ServiceImpl<HardManagerMapper, HardM
HardUtil.convertHardGroupList(list.getRecords(), CollectionUtils.convertMap(groupList, HardGroupEntity::getId));
}
}
hardPrinterService.enrichDevices(list.getRecords());
hardTerminalStatusService.enrich(list.getRecords());
}
return list;
@@ -167,6 +173,7 @@ public class HardManagerServiceImpl extends ServiceImpl<HardManagerMapper, HardM
if (ObjectUtil.isEmpty(detail)) {
throw new RuntimeException("设备不存在");
}
hardPrinterService.validateDeviceChange(detail, dto);
MchManageVo mch = mchService.getById(dto.getMerchantId()).getData();
if (ObjectUtil.isEmpty(mch)) {
throw new RuntimeException("商户不存在");
@@ -194,12 +201,24 @@ public class HardManagerServiceImpl extends ServiceImpl<HardManagerMapper, HardM
remoteMqttEventService.sendConfigChangeMsg(detail.getDeptId());
}
/** {@inheritDoc} */
@Override
public boolean removeWithPrinterCheck(List<Long> 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<HardManagerMapper, HardM
@Override
public HardManagerVo getByHardId(Long hardId) {
HardManagerEntity hard = baseMapper.selectById(hardId);
if (ObjectUtil.isEmpty(hard)) {
return null;
}
HardManagerVo vo = BeanUtils.toBean(hard, HardManagerVo.class);
List<HardManagerVo> records = List.of(vo);
if (ObjectUtil.isNotEmpty(hard.getDeptId())) {
R<Map<Long, SysDept>> 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;
}
@@ -147,13 +147,14 @@ public class HardTerminalStatusServiceImpl extends ServiceImpl<HardTerminalStatu
}
/**
* 判断设备是否属于支持在线监控的超市或食堂 Android 终端。
* 判断设备是否属于支持在线监控的收银或尔印打印 Android 终端。
*
* @param hardType {@code hard_manager.type} 的持久化编码
* @return 超市或食堂设备返回 {@code true}
* @return 超市、食堂或尔印打印设备返回 {@code true}
*/
private boolean isSupported(String hardType) {
return ObjectUtil.equals(HardTypeEnum.SUPERMARKET.getType(), hardType)
|| ObjectUtil.equals(HardTypeEnum.CANTEEN.getType(), hardType);
|| ObjectUtil.equals(HardTypeEnum.CANTEEN.getType(), hardType)
|| ObjectUtil.equals(HardTypeEnum.EY_PRINT.getType(), hardType);
}
}
@@ -434,13 +434,19 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
/**
* 后台订单详情在原有关联信息基础上补充长期识别证据,App 与终端详情继续使用不含证据的 join。
* 后台订单详情补充长期识别证据和外卖送达配置,App 与终端继续使用不含这些扩展信息的 join。
*/
@Override
public HardOrderVo joinWithEvidence(Long id) {
HardOrderVo detail = getSelf().join(id);
if (detail != null) {
detail.setEvidenceImages(hardOrderEvidenceImageService.listByOrderId(id));
// 外卖期望送达配置独立存储,仅后台详情按订单主键补充,避免扩大 App 与终端查询开销。
if (ObjectUtil.equals(detail.getOrderType(), HardOrderTypeEnum.TAKE_OUT.getType())) {
detail.setTakeOutAppt(hardTakeOutApptService.getOne(
new LambdaQueryWrapper<HardTakeOutApptEntity>().eq(HardTakeOutApptEntity::getOrderId, id),
false));
}
}
return detail;
}
@@ -3945,10 +3951,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.orderStatus(HardOrderStatusEnum.CANCEL.getType())
.verificationTime(verificationTime)
.build();
boolean isMealVerification = ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())
&& ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
&& ObjectUtil.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType());
int updated = isMealVerification
boolean isReservationVerification = isReservationVerification(order);
int updated = isReservationVerification
? baseMapper.update(updateObj, new LambdaUpdateWrapper<HardOrderEntity>()
.eq(HardOrderEntity::getId, order.getId())
.eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType()))
@@ -3956,10 +3960,37 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (updated == 0) {
throw new I18nBizException(ErrorCodes.HARD_VERIFY_STATUS_CHANGED);
}
if (isMealVerification) {
if (isReservationVerification) createReservationVerificationArtifacts(order, verificationTime, dto.getHardwareId());
return true;
}
/**
* 判断已加锁订单是否为超市或食堂的待核销预约,统一限定状态条件更新和兑换券任务创建范围。
*
* @param order 已持有数据库行锁的订单快照
* @return 超市或食堂预约订单仍为已支付状态时返回 {@code true}
*/
private static boolean isReservationVerification(HardOrderEntity order) {
return order != null
&& List.of(HardTypeEnum.SUPERMARKET.getType(), HardTypeEnum.CANTEEN.getType()).contains(order.getSysType())
&& ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
&& ObjectUtil.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType());
}
/**
* 在核销事务内创建预约兑换券;食堂专属取餐消息继续只为食堂订单生成。
*
* @param order 已完成业务校验的预约订单
* @param verificationTime 本次核销时间
* @param sourceHardId DEVICE-ID 解析出的实际核销终端;缺失时为空
*/
private void createReservationVerificationArtifacts(HardOrderEntity order, LocalDateTime verificationTime,
Long sourceHardId) {
if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())) {
createMealVerifiedOutbox(order, verificationTime);
}
return true;
// 打印任务与核销状态同事务提交;图片、文件和MQTT仅在提交后异步执行。
hardInvoiceService.createRedemptionVoucherTask(order, sourceHardId);
}
/**
@@ -3987,6 +4018,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
throw new I18nBizException(ErrorCodes.HARD_ROLLBACK_STATUS_CHANGED);
}
hardInvoiceService.cancelPendingVoucherTasks(order.getId());
if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())) {
stopPendingMealVerifiedOutbox(order);
hardBroadcastService.rollbackDoneBroadcast(order);
@@ -6,8 +6,17 @@
<id property="id" column="id"/>
<result property="deptId" column="dept_id"/>
<result property="hardId" column="hard_id"/>
<result property="orderId" column="hard_order_id"/>
<result property="orderId" column="order_id"/>
<result property="printerHardId" column="printer_hard_id"/>
<result property="printType" column="print_type"/>
<result property="printNo" column="print_no"/>
<result property="retryVersion" column="retry_version"/>
<result property="attemptCount" column="attempt_count"/>
<result property="status" column="status"/>
<result property="failureStage" column="failure_stage"/>
<result property="lastError" column="last_error"/>
<result property="printedAt" column="printed_at"/>
<result property="reprintReason" column="reprint_reason"/>
<result property="pic" column="pic"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
@@ -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 @@
<sql id="whereA">
a.del_flag = '0'
<if test="dto.orderId != null">
and a.order_id = #{dto.orderId}
</if>
<if test="dto.createTimeList != null and dto.createTimeList.length == 2">
and a.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
</if>
<if test="dto.printType != null and dto.printType != ''">
and a.print_type = #{dto.printType}
</if>
<if test="dto.status != null and dto.status != ''">
and a.status = #{dto.status}
</if>
</sql>
<select id="scopePageList" resultType="com.bsd.pjcloud.hard.api.vo.HardInvoiceVo">
<include refid="selectA"/>
m.name as hardName,
m.type as hardType,
printer.name as printerHardName,
o.hard_id as orderHardId,
o.user_id as orderUserId,
o.user_id as userId,
@@ -57,6 +85,7 @@
o.pay_time as orderPayTime
from hard_invoice a
left join hard_manager m on m.id = a.hard_id
left join hard_manager printer on printer.id = a.printer_hard_id
left join hard_order o on o.id = a.order_id
<where>
<include refid="whereA"/>
@@ -99,6 +99,12 @@
#{v}
</foreach>
</if>
<if test="dto.orderUserIds != null and dto.orderUserIds.size() != 0">
and o.user_id in
<foreach collection="dto.orderUserIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">
@@ -355,7 +355,15 @@
m.dept_id as hardTypeDeptId,
s.id as afterSaleId,
s.no as afterSaleNo,
p.user_id as payerUserId
p.user_id as payerUserId,
latest_invoice.id as latestInvoiceTaskId,
latest_invoice.status as latestInvoiceStatus,
latest_invoice.print_no as latestInvoicePrintNo,
latest_invoice.hard_id as latestInvoiceSourceHardId,
latest_voucher.id as latestVoucherTaskId,
latest_voucher.status as latestVoucherStatus,
latest_voucher.print_no as latestVoucherPrintNo,
latest_voucher.hard_id as latestVoucherSourceHardId
from hard_order a
left join hard_manager m on m.id = a.hard_id
<!-- 多次退款后只关联最新售后单,避免订单列表被一对多关联放大并返回不确定的售后 ID。 -->
@@ -366,6 +374,20 @@
and latest_sale.del_flag = '0'
)
left join hard_parent_car p on p.id = a.parent_car_id and p.del_flag = '0'
left join hard_invoice latest_invoice on latest_invoice.id = (
select max(invoice_task.id)
from hard_invoice invoice_task
where invoice_task.order_id = a.id
and (invoice_task.print_type = '1' or invoice_task.print_type is null)
and invoice_task.del_flag = '0'
)
left join hard_invoice latest_voucher on latest_voucher.id = (
select max(voucher_task.id)
from hard_invoice voucher_task
where voucher_task.order_id = a.id
and voucher_task.print_type = '2'
and voucher_task.del_flag = '0'
)
<where>
<include refid="whereA"/>
<include refid="appVisibilityWhere"/>
@@ -0,0 +1,55 @@
package com.bsd.pjcloud.hard.api.utils;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** 80mm 发票与兑换券 PNG 的宽度、换行、层级和动态高度测试。 */
class ThermalReceiptImageTest {
@Test
void longProductNamesGrowCanvasAndKeepQuantityInRightColumn() throws Exception {
ThermalReceiptImage receipt = new ThermalReceiptImage()
.title("预约核销兑换券")
.banner("第 2 次打印")
.sectionTitle("剩余可履约商品");
String longName = "超长套餐主商品名称".repeat(12);
for (int index = 0; index < 24; index++) {
receipt.item(longName + index, "x12", false, true)
.item("套餐组成商品" + index, "x24", true, false);
}
BufferedImage image = ImageIO.read(new ByteArrayInputStream(receipt.toPngBytes()));
assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, image.getWidth());
assertTrue(image.getHeight() > 512, "多行商品应触发画布动态扩容");
assertTrue(hasDarkPixel(image, 480, image.getWidth(), 0, image.getHeight()),
"数量列应在右侧保留独立对齐区域");
}
@Test
void invoiceAndVoucherImagesAlwaysUse576PixelPaperWidth() throws Exception {
BufferedImage invoice = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.title("发票").invoiceItem("独立单品", "2", "¥12.00").toPngBytes()));
BufferedImage voucher = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.title("兑换券").banner("第 1 次打印").verificationCode("123456").toPngBytes()));
assertEquals(576, invoice.getWidth());
assertEquals(576, voucher.getWidth());
}
private static boolean hasDarkPixel(BufferedImage image, int fromX, int toX, int fromY, int toY) {
for (int y = fromY; y < toY; y++) {
for (int x = fromX; x < toX; x++) {
int rgb = image.getRGB(x, y) & 0xFFFFFF;
if (rgb < 0x606060) return true;
}
}
return false;
}
}
@@ -2,6 +2,8 @@ package com.bsd.pjcloud.hard.controller.hard;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.HardPrinterInfoReportDto;
import com.bsd.pjcloud.hard.api.entity.HardPrinterConfigEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.service.HardApiService;
@@ -11,6 +13,7 @@ import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.service.HardOrderSyncService;
import com.bsd.pjcloud.hard.service.HardOrderTotalService;
import com.bsd.pjcloud.hard.service.HardPrinterService;
import com.bsd.pjcloud.hard.service.HardTerminalStatusService;
import com.bsd.pjcloud.hard.service.HardTerminalFaceService;
import com.bsd.pjcloud.hard.service.ReservationMenuQueryService;
@@ -20,7 +23,9 @@ import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -34,6 +39,9 @@ class HardApiControllerTest {
/** 发票服务,本组测试不调用。 */
private final HardInvoiceService hardInvoiceService = mock(HardInvoiceService.class);
/** 尔印打印绑定服务,本组购物车测试不调用。 */
private final HardPrinterService hardPrinterService = mock(HardPrinterService.class);
/** 购物车服务,提供带退款统计的订单商品。 */
private final HardCarService hardCarService = mock(HardCarService.class);
@@ -63,7 +71,7 @@ class HardApiControllerTest {
/** 被测 Android 硬件接口控制器。 */
private final HardApiController controller = new HardApiController(hardManagerService, hardInvoiceService,
hardCarService, hardOrderService, hardOrderSyncService, hardApiService, hardOrderTotalService,
hardPrinterService, hardCarService, hardOrderService, hardOrderSyncService, hardApiService, hardOrderTotalService,
reservationMenuQueryService, verificationRecordService, hardTerminalFaceService,
hardTerminalStatusService);
@@ -87,6 +95,25 @@ class HardApiControllerTest {
assertEquals(10, result.get(0).getOriginalProductCount());
}
/** 验证上报目标只使用可信 DEVICE-ID,并将服务保存结果原样返回。 */
@Test
void reportPrinterInfoUsesTrustedDeviceHeader() {
HardPrinterInfoReportDto request = new HardPrinterInfoReportDto();
request.setPrinterModel("GEZHI / micro-printer");
request.setUsbVendorId(10473);
request.setUsbProductId(649);
HardPrinterConfigEntity saved = HardPrinterConfigEntity.builder()
.printerHardId(20L).printerModel(request.getPrinterModel())
.usbVendorId(10473).usbProductId(649).paperWidth(80)
.autoCut("1").imageMode("GS_V0").build();
when(hardPrinterService.reportDeviceInfo(20L, request)).thenReturn(saved);
HardPrinterConfigEntity result = (HardPrinterConfigEntity) controller.reportPrinterInfo(20L, request).getData();
assertSame(saved, result);
verify(hardPrinterService).reportDeviceInfo(20L, request);
}
/** 构造带原购、剩余数量和原行金额的购物车展示对象。 */
private static HardCarVo car(int originalCount, int effectiveCount, long totalPrice) {
HardCarVo car = new HardCarVo();
@@ -0,0 +1,189 @@
package com.bsd.pjcloud.hard.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
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.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.HardOrderEntity;
import com.bsd.pjcloud.hard.mapper.HardInvoiceMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.service.HardPrinterService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.mockito.ArgumentCaptor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 打印任务手工创建、失败重试和终端回执的并发保护测试。
*/
class HardInvoiceServiceImplTest {
private final HardInvoiceMapper invoiceMapper = mock(HardInvoiceMapper.class);
private final HardOrderMapper orderMapper = mock(HardOrderMapper.class);
private final HardOrderService orderService = mock(HardOrderService.class);
private final HardManagerService managerService = mock(HardManagerService.class);
private final HardPrinterService printerService = mock(HardPrinterService.class);
private final ThreadPoolTaskExecutor taskExecutor = mock(ThreadPoolTaskExecutor.class);
private final HardInvoiceServiceImpl service = mock(HardInvoiceServiceImpl.class, CALLS_REAL_METHODS);
/** 只注入本组测试访问的依赖,其他外部服务保持隔离。 */
@BeforeEach
void setUp() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
HardInvoiceEntity.class);
ReflectionTestUtils.setField(service, "baseMapper", invoiceMapper);
ReflectionTestUtils.setField(service, "hardOrderMapper", orderMapper);
ReflectionTestUtils.setField(service, "orderService", orderService);
ReflectionTestUtils.setField(service, "hardManagerService", managerService);
ReflectionTestUtils.setField(service, "printerService", printerService);
ReflectionTestUtils.setField(service, "hardTaskExecutor", taskExecutor);
}
/** 手工创建任务必须先锁定订单,已有待处理任务时直接复用。 */
@Test
void createPrintTaskLocksOrderAndReusesOpenTask() {
HardOrderEntity order = order();
HardInvoiceEntity pending = task(100L, HardPrintStatusEnum.PENDING_GENERATION, 0);
when(orderMapper.selectByIdForUpdate(1L)).thenReturn(order);
when(invoiceMapper.selectOne(any(Wrapper.class))).thenReturn(pending);
HardPrintRequestDto request = new HardPrintRequestDto();
request.setOrderId(1L);
request.setPrintType(HardPrintTypeEnum.INVOICE.getType());
HardInvoiceEntity result = service.createPrintTask(request);
assertSame(pending, result);
verify(orderMapper).selectByIdForUpdate(1L);
verify(invoiceMapper, never()).insert(any(HardInvoiceEntity.class));
}
/** 两个重试请求读到同一失败版本时,未抢到更新的请求不得再次唤醒调度。 */
@Test
void retryTaskSchedulesOnlyWhenConditionalUpdateWins() {
HardInvoiceEntity failed = task(100L, HardPrintStatusEnum.FAILED, 2);
failed.setHardId(10L);
failed.setPic("voucher.png");
HardInvoiceEntity concurrent = task(100L, HardPrintStatusEnum.GENERATE, 3);
concurrent.setHardId(10L);
concurrent.setPrinterHardId(20L);
when(invoiceMapper.selectById(100L)).thenReturn(failed, concurrent);
when(orderService.getById(1L)).thenReturn(order());
when(managerService.getById(10L)).thenReturn(source());
when(printerService.findBySourceHardId(10L)).thenReturn(binding());
when(invoiceMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
HardInvoiceEntity result = service.retryTask(100L, 10L);
assertSame(concurrent, result);
verify(taskExecutor, never()).execute(any(Runnable.class));
}
/** 重打指定的旧记录已被并发请求生成下一任务时,直接复用新任务。 */
@Test
void reprintTaskReusesConcurrentOpenTask() {
HardInvoiceEntity printed = task(100L, HardPrintStatusEnum.PRINT, 0);
HardInvoiceEntity pending = task(101L, HardPrintStatusEnum.PENDING_GENERATION, 0);
pending.setPrintNo(2);
when(invoiceMapper.selectById(100L)).thenReturn(printed, printed);
when(orderMapper.selectByIdForUpdate(1L)).thenReturn(order());
when(invoiceMapper.selectOne(any(Wrapper.class))).thenReturn(pending);
HardInvoiceEntity result = service.reprintTask(100L, "纸张损坏");
assertSame(pending, result);
verify(orderMapper).selectByIdForUpdate(1L);
verify(invoiceMapper, never()).insert(any(HardInvoiceEntity.class));
}
/** 回执写入条件必须排除已取消,避免核销回退后的迟到成功回执复活任务。 */
@Test
void reportPrintResultExcludesCancelledStatusAtUpdateTime() {
HardInvoiceEntity pending = task(100L, HardPrintStatusEnum.GENERATE, 2);
pending.setPrinterHardId(20L);
when(managerService.getById(20L)).thenReturn(printer());
when(invoiceMapper.selectById(100L)).thenReturn(pending);
when(invoiceMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
HardPrintResultDto result = new HardPrintResultDto();
result.setTaskId(100L);
result.setOrderId(1L);
result.setRetryVersion(2);
result.setSuccess(true);
result.setAttemptCount(1);
assertFalse(service.reportPrintResult(20L, result));
@SuppressWarnings("unchecked")
ArgumentCaptor<LambdaUpdateWrapper<HardInvoiceEntity>> wrapperCaptor =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(invoiceMapper).update(isNull(), wrapperCaptor.capture());
wrapperCaptor.getValue().getSqlSegment();
Map<String, Object> values = wrapperCaptor.getValue().getParamNameValuePairs();
assertTrue(values.containsValue(HardPrintStatusEnum.CANCELLED.getType()));
}
/** 已打印后到达的失败回执幂等返回成功,不能再写回失败状态。 */
@Test
void lateFailureCannotOverwritePrintedTask() {
HardInvoiceEntity printed = task(100L, HardPrintStatusEnum.PRINT, 2);
printed.setPrinterHardId(20L);
when(managerService.getById(20L)).thenReturn(printer());
when(invoiceMapper.selectById(100L)).thenReturn(printed);
HardPrintResultDto result = new HardPrintResultDto();
result.setTaskId(100L);
result.setOrderId(1L);
result.setRetryVersion(2);
result.setSuccess(false);
result.setAttemptCount(3);
result.setErrorMessage("late error");
assertTrue(service.reportPrintResult(20L, result));
verify(invoiceMapper, never()).update(isNull(), any(Wrapper.class));
}
private static HardOrderEntity order() {
return HardOrderEntity.builder().id(1L).hardId(10L).merchantId(30L).deptId(40L).tenantId(50L).build();
}
private static HardManagerEntity source() {
return HardManagerEntity.builder().id(10L).type(HardTypeEnum.CANTEEN.getType()).merchantId(30L).build();
}
private static HardManagerEntity printer() {
return HardManagerEntity.builder().id(20L).type(HardTypeEnum.EY_PRINT.getType()).merchantId(30L).build();
}
private static HardPrinterBindingEntity binding() {
return HardPrinterBindingEntity.builder().sourceHardId(10L).printerHardId(20L).merchantId(30L).build();
}
private static HardInvoiceEntity task(Long id, HardPrintStatusEnum status, int retryVersion) {
return HardInvoiceEntity.builder().id(id).orderId(1L).printType(HardPrintTypeEnum.INVOICE.getType())
.printNo(1).retryVersion(retryVersion).status(status.getType()).build();
}
}
@@ -0,0 +1,178 @@
package com.bsd.pjcloud.hard.service.impl;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.dto.HardPrinterConfigDto;
import com.bsd.pjcloud.hard.api.dto.HardPrinterInfoReportDto;
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.HardPrinterBindingMapper;
import com.bsd.pjcloud.hard.mapper.HardPrinterConfigMapper;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** 尔印设备类型与 USB 配置约束测试。 */
class HardPrinterServiceImplTest {
private final HardManagerMapper managerMapper = mock(HardManagerMapper.class);
/** 打印配置数据访问对象,用于验证终端上报只修改允许字段。 */
private final HardPrinterConfigMapper configMapper = mock(HardPrinterConfigMapper.class);
/** 使用真实业务方法和注入 Mapper 的被测服务。 */
private final HardPrinterServiceImpl service = mock(HardPrinterServiceImpl.class, CALLS_REAL_METHODS);
/** 为每个用例注入相同 Mapper,并准备一台有效的 EY_PRINT 设备。 */
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "hardManagerMapper", managerMapper);
ReflectionTestUtils.setField(service, "configMapper", configMapper);
when(managerMapper.selectById(20L)).thenReturn(HardManagerEntity.builder()
.id(20L).type(HardTypeEnum.EY_PRINT.getType()).merchantId(30L).tenantId(50L).build());
}
@Test
void hardTypeRecognizesOnlyErYinPrinter() {
assertEquals("12", HardTypeEnum.EY_PRINT.getType());
assertEquals("尔印打印机器", HardTypeEnum.EY_PRINT.getDescription());
assertTrue(HardTypeEnum.isPrintHard(HardTypeEnum.EY_PRINT));
assertFalse(HardTypeEnum.isPrintHard(HardTypeEnum.CANTEEN));
assertFalse(HardTypeEnum.isPrintHard(null));
}
/** 未绑定打印机的普通设备应保留空配置,不能因空打印机 ID 导致列表查询失败。 */
@Test
void enrichDevicesSkipsPrinterConfigForUnboundDevice() {
HardPrinterBindingMapper bindingMapper = mock(HardPrinterBindingMapper.class);
ReflectionTestUtils.setField(service, "bindingMapper", bindingMapper);
HardManagerVo device = new HardManagerVo();
device.setId(10L);
device.setType(HardTypeEnum.SUPERMARKET.getType());
when(bindingMapper.selectList(any())).thenReturn(List.of());
when(managerMapper.selectBatchIds(List.of(10L))).thenReturn(List.of(
HardManagerEntity.builder().id(10L).type(HardTypeEnum.SUPERMARKET.getType()).build()));
assertDoesNotThrow(() -> service.enrichDevices(List.of(device)));
assertNull(device.getPrinterModel());
}
@Test
void usbVidAndPidMustBeConfiguredTogether() {
HardPrinterConfigDto config = validConfig();
config.setUsbVendorId(1234);
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.saveConfig(config));
assertEquals("USB VID和PID必须同时填写或同时留空", exception.getMessage());
}
@Test
void usbIdsMustFitUnsignedSixteenBitRange() {
HardPrinterConfigDto config = validConfig();
config.setUsbVendorId(65536);
config.setUsbProductId(1);
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.saveConfig(config));
assertEquals("USB VID必须在0到65535之间", exception.getMessage());
}
/** 终端重新上报真机参数时必须保留管理端维护的纸宽、切刀和图片模式。 */
@Test
void reportDeviceInfoPreservesEscPosConfiguration() {
HardPrinterConfigEntity existing = HardPrinterConfigEntity.builder()
.id(100L).printerHardId(20L).printerModel("旧型号")
.usbVendorId(1).usbProductId(2).paperWidth(80)
.autoCut("0").imageMode("ESC_STAR").tenantId(50L).build();
when(configMapper.selectOne(any())).thenReturn(existing);
HardPrinterConfigEntity result = service.reportDeviceInfo(20L, reportRequest());
assertEquals("GEZHI / micro-printer", result.getPrinterModel());
assertEquals(10473, result.getUsbVendorId());
assertEquals(649, result.getUsbProductId());
assertEquals(80, result.getPaperWidth());
assertEquals("0", result.getAutoCut());
assertEquals("ESC_STAR", result.getImageMode());
verify(configMapper).updateById(existing);
}
/** 首次上报时应创建默认 80mm、自动切刀和 GS v 0 配置。 */
@Test
void reportDeviceInfoCreatesDefaultConfiguration() {
when(configMapper.selectOne(any())).thenReturn(null);
HardPrinterConfigEntity result = service.reportDeviceInfo(20L, reportRequest());
assertEquals(20L, result.getPrinterHardId());
assertEquals("GEZHI / micro-printer", result.getPrinterModel());
assertEquals(10473, result.getUsbVendorId());
assertEquals(649, result.getUsbProductId());
assertEquals(80, result.getPaperWidth());
assertEquals("1", result.getAutoCut());
assertEquals("GS_V0", result.getImageMode());
verify(configMapper).insert(result);
}
/** 普通业务终端不能借用设备信息接口覆盖打印终端配置。 */
@Test
void reportDeviceInfoRejectsNonPrinterDevice() {
when(managerMapper.selectById(21L)).thenReturn(HardManagerEntity.builder()
.id(21L).type(HardTypeEnum.CANTEEN.getType()).merchantId(30L).tenantId(50L).build());
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.reportDeviceInfo(21L, reportRequest()));
assertEquals("目标设备必须是尔印打印终端", exception.getMessage());
}
/** 已绑定设备的部门属于一对一绑定身份,修改部门前必须先解绑。 */
@Test
void boundDeviceCannotChangeDepartment() {
HardManagerEntity before = HardManagerEntity.builder()
.id(20L).type(HardTypeEnum.EY_PRINT.getType()).merchantId(30L).deptId(40L).build();
HardManagerEntity after = HardManagerEntity.builder()
.id(20L).type(HardTypeEnum.EY_PRINT.getType()).merchantId(30L).deptId(41L).build();
doReturn(new HardPrinterBindingVo()).when(service).getBinding(20L);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.validateDeviceChange(before, after));
assertEquals("已绑定打印关系的设备不能直接修改类型、商户或部门,请先处理任务并解绑", exception.getMessage());
}
private static HardPrinterConfigDto validConfig() {
HardPrinterConfigDto config = new HardPrinterConfigDto();
config.setPrinterHardId(20L);
config.setPaperWidth(80);
config.setAutoCut("1");
config.setImageMode("GS_V0");
return config;
}
/** 构造现场 GEZHI 打印机对应的上报请求。 */
private static HardPrinterInfoReportDto reportRequest() {
HardPrinterInfoReportDto request = new HardPrinterInfoReportDto();
request.setPrinterModel(" GEZHI / micro-printer ");
request.setUsbVendorId(10473);
request.setUsbProductId(649);
return request;
}
}
@@ -324,7 +324,7 @@ class ReservationMenuQueryServiceImplTest {
ArgumentCaptor<HandCancelCodeDto> cancelCaptor = ArgumentCaptor.forClass(HandCancelCodeDto.class);
verify(hardOrderService).handCancelCode(cancelCaptor.capture());
assertEquals(1L, cancelCaptor.getValue().getOrderId());
assertEquals(-1L, cancelCaptor.getValue().getHardwareId());
assertNull(cancelCaptor.getValue().getHardwareId());
}
/** 验证部分退款成功的已支付订单仍可核销,全部退款仍由订单状态校验拦截。 */
@@ -23,6 +23,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.junit.jupiter.api.BeforeEach;
@@ -88,6 +89,7 @@ class HardCanteenTotalServiceImplTest {
service = new HardCanteenTotalServiceImpl(
mock(HardOrderMapper.class),
hardCarMapper,
mock(HardUserIdentitySupport.class),
remoteDeptService,
remoteMchService,
mock(RemoteDataScopeService.class),
@@ -1,23 +1,29 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
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.hard.HandCancelCodeDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardMessageOutboxEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardApptReservationService;
import com.bsd.pjcloud.hard.service.HardBroadcastService;
import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.hard.service.HardInvoiceService;
import com.bsd.pjcloud.wallet.api.feign.RemoteCodeUseService;
import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
@@ -57,6 +63,9 @@ class HardOrderServiceImplVerificationTest {
/** 取餐广播服务,用于验证食堂预约回退会重新启用待取餐提示。 */
private final HardBroadcastService hardBroadcastService = mock(HardBroadcastService.class);
/** 打印任务服务,用于验证回退会取消尚未打印的兑换券。 */
private final HardInvoiceService hardInvoiceService = mock(HardInvoiceService.class);
/** 被测订单服务,仅执行真实方法,未涉及的庞大依赖保持空值。 */
private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS);
@@ -65,11 +74,16 @@ class HardOrderServiceImplVerificationTest {
*/
@BeforeEach
void setUp() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
HardOrderEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
HardMessageOutboxEntity.class);
ReflectionTestUtils.setField(service, "baseMapper", hardOrderMapper);
ReflectionTestUtils.setField(service, "hardCarService", hardCarService);
ReflectionTestUtils.setField(service, "remoteCodeUseService", remoteCodeUseService);
ReflectionTestUtils.setField(service, "hardMessageOutboxMapper", hardMessageOutboxMapper);
ReflectionTestUtils.setField(service, "hardBroadcastService", hardBroadcastService);
ReflectionTestUtils.setField(service, "hardInvoiceService", hardInvoiceService);
}
/**
@@ -82,7 +96,7 @@ class HardOrderServiceImplVerificationTest {
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.handCancelCode(dto));
assertEquals("该预约订单正在退款,不能核销", exception.getMessage());
assertEquals(ErrorCodes.HARD_RESERVE_REFUNDING_NO_VERIFY, exception.getMessage());
verify(hardCarService, never()).getByOrderId(anyLong());
}
@@ -100,7 +114,22 @@ class HardOrderServiceImplVerificationTest {
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.handCancelCode(dto));
assertEquals("该预约订单商品已全部退款,不能核销", exception.getMessage());
assertEquals(ErrorCodes.HARD_RESERVE_FULL_REFUND_NO_VERIFY, exception.getMessage());
}
/** 超市预约核销与食堂预约共用兑换券任务,但不得创建食堂专属取餐消息。 */
@Test
void supermarketReservationCreatesVoucherWithoutMealOutbox() {
HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.PAY);
order.setSysType(HardTypeEnum.SUPERMARKET.getType());
LocalDateTime verificationTime = LocalDateTime.of(2026, 9, 4, 12, 0);
Boolean printable = ReflectionTestUtils.invokeMethod(service, "isReservationVerification", order);
ReflectionTestUtils.invokeMethod(service, "createReservationVerificationArtifacts", order, verificationTime, 100L);
assertTrue(Boolean.TRUE.equals(printable));
verify(hardInvoiceService).createRedemptionVoucherTask(order, 100L);
verify(hardMessageOutboxMapper, never()).insert(any(HardMessageOutboxEntity.class));
}
/**
@@ -119,6 +148,7 @@ class HardOrderServiceImplVerificationTest {
assertTrue(service.rollbackReserveVerification(1L));
verify(remoteCodeUseService).rollbackReservationCode(1L);
verify(hardInvoiceService).cancelPendingVoucherTasks(1L);
verify(hardBroadcastService).rollbackDoneBroadcast(order);
}
@@ -133,7 +163,7 @@ class HardOrderServiceImplVerificationTest {
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.rollbackReserveVerification(1L));
assertEquals("仅已核销订单支持退回到核销前", exception.getMessage());
assertEquals(ErrorCodes.HARD_ROLLBACK_VERIFIED_ONLY, exception.getMessage());
verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong());
}
@@ -148,7 +178,7 @@ class HardOrderServiceImplVerificationTest {
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.rollbackReserveVerification(1L));
assertEquals("该预约订单正在退款,不能退回到核销前", exception.getMessage());
assertEquals(ErrorCodes.HARD_ROLLBACK_REFUNDING, exception.getMessage());
verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong());
}
@@ -164,7 +194,7 @@ class HardOrderServiceImplVerificationTest {
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.rollbackReserveVerification(1L));
assertEquals("当前业务类型不支持退回到核销前", exception.getMessage());
assertEquals(ErrorCodes.HARD_ROLLBACK_BIZ_UNSUPPORTED, exception.getMessage());
verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong());
}
@@ -1,6 +1,7 @@
package com.bsd.pjcloud.mqtt.dto.hard;
import com.bsd.pjcloud.mqtt.dto.MqttHardBaseMsg;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@@ -11,6 +12,18 @@ public class HardPrintMsg extends MqttHardBaseMsg {
// 设备id
private Long hardId;
/**
* 产生订单或执行核销的来源业务终端 ID。
*/
@Schema(description = "来源业务终端ID")
private Long sourceHardId;
/**
* 实际接收 MQTT 并执行 USB 打印的 EY_PRINT 终端 ID。
*/
@Schema(description = "目标打印终端ID")
private Long targetHardId;
// 设备部门id
private Long mchDeptId;
@@ -20,6 +33,24 @@ public class HardPrintMsg extends MqttHardBaseMsg {
// 订单id
private Long orderId;
/**
* 打印凭证类型,1 发票、2 兑换券。
*/
@Schema(description = "打印凭证类型")
private String printType;
/**
* 同订单同凭证类型的打印序号。
*/
@Schema(description = "打印序号")
private Integer printNo;
/**
* 人工失败重试版本,同一版本的重复 MQTT 消息必须幂等去重。
*/
@Schema(description = "重试版本")
private Integer retryVersion;
// 安卓订单id
private String androidOrderCode;