This commit is contained in:
dzc committed 2026-09-10 13:09:14 +08:00
1 parent 2ca14aa1e0
commit b254ef0783
31 files changed
+939 -79

No files matched your search

@@ -0,0 +1,6 @@
-- 智能柜手动结算「操作快照」字段(2026-09-09)
-- 用途:手动结算/取消订单时,在 hard_door_flow 保存完整操作快照
-- (操作人id/姓名/时间/购物车明细含商品名单价/合计/支付方式/备注),供事后二次判断复核
-- 与旷世识别回调字段(sku_callback_vo)互不干扰。
ALTER TABLE hard_door_flow
ADD COLUMN manual_settle_vo text DEFAULT NULL COMMENT '手动结算/取消操作快照(操作人+购物车明细+金额+备注)';
@@ -0,0 +1,206 @@
-- ================================================================
-- 智能柜订单手动结算 —— 测试数据脚本(2026-09-09)
-- ================================================================
-- 用途:在【测试环境】生成 10 笔可手动结算的智能柜免密订单
-- pay_type='9'(智能柜免密) + sys_type='4'(生鲜柜) + order_status='1'(待结算)
-- 覆盖表:hard_order(订单)/ hard_auth(用户授权)/ hard_door_flow(智能柜流程)
-- 标记:三张表 create_by='test-manual-settle',可重复执行(先清理再生成)
-- 执行:mysql -h127.0.0.1 -P33905 -uroot -p pjcloudx_hardware < 本文件
--
-- 前置条件(缺一不可,脚本会自动挑选并检查):
-- 1. 已有智能柜设备:hard_door_manager + hard_door_cabinet 有数据
-- 2. 柜内已配置商品:hard_spu_join 有 spu_id 指向有效 hard_spu 的记录
-- (否则订单详情页手动结算区显示"未找到该柜的商品配置",无法结算)
-- 3. 钱包库存在消费钱包(wallet_type.unique_code='2',同租户优先)
-- —— 结算时会真实扣款(force 强制扣款允许透支),并真实生成钱包流水
-- 说明:
-- - 本地 docker 为分库架构:硬件=pjcloudx_hardware 钱包=pjcloudx_wallet 用户=pjcloudx;
-- 单库环境请去掉 SQL 里的 pjcloudx_wallet. / pjcloudx. 库名前缀
-- - 订单 order_type='1'(BELOW 线下购买),与生产 TOB 用户走钱包扣款一致;
-- 不要用 TOC_VISITOR('8'),那会走支付宝免密扣款,测试环境无第三方单会结算失败
-- - 流程 flow_status='50'(已提交旷世识别、等待回调),即旷世跑路后的真实卡死状态
-- - 视频为公网示例视频(主摄 vjs.zencdn.net / 辅摄 MDN,浏览器实测可达;
-- media.w3.org / Google 桶在部分网络环境会被阻断,勿用),每单主摄/辅摄两路
-- - 极老库若报 parent_car_id 不能为 NULL,先执行:
-- ALTER TABLE hard_order MODIFY parent_car_id bigint NULL DEFAULT NULL;
-- ================================================================
-- ---------- 一、清理旧测试数据(脚本可重复执行) ----------
DELETE FROM hard_door_flow WHERE create_by = 'test-manual-settle';
DELETE FROM hard_auth WHERE create_by = 'test-manual-settle';
DELETE FROM hard_order WHERE create_by = 'test-manual-settle';
-- ---------- 二、自动挑选基础数据 ----------
-- 智能柜:优先挑有商品配置的柜子(hard_door_manager.id / 设备id / 租户 / 设备编号)
SELECT dm.id, dm.hard_id, dm.tenant_id, dm.device_code
INTO @hard_door_id, @hard_id, @tenant_id, @device_code
FROM hard_door_manager dm
WHERE dm.del_flag = '0'
AND EXISTS (
SELECT 1 FROM hard_spu_join j
WHERE j.hard_door_id = dm.id AND j.del_flag = '0' AND j.spu_id IS NOT NULL
)
ORDER BY dm.id
LIMIT 1;
-- 货柜号:挑该柜配置商品最多的货柜
SELECT j.cabinet_no INTO @cabinet_no
FROM hard_spu_join j
WHERE j.hard_door_id = @hard_door_id AND j.del_flag = '0' AND j.spu_id IS NOT NULL
GROUP BY j.cabinet_no
ORDER BY COUNT(*) DESC
LIMIT 1;
-- 商户信息:从设备取
SELECT MAX(m.merchant_id), MAX(m.dept_id) INTO @merchant_id, @merchant_dept_id
FROM hard_manager m
WHERE m.id = @hard_id AND m.del_flag = '0';
-- 商户类型:优先沿用同商户已有订单的 merchant_type(查不到则为 NULL,不影响结算)
SELECT MAX(o.merchant_type) INTO @merchant_type
FROM hard_order o
WHERE o.merchant_id = @merchant_id AND o.merchant_type IS NOT NULL;
-- 测试用户 + 消费钱包(wallet_type.unique_code='2'),同租户优先
SELECT w.user_id, w.id INTO @user_id, @wallet_id
FROM pjcloudx_wallet.wallet_manage w
JOIN pjcloudx_wallet.wallet_type t ON t.id = w.wallet_type_id AND t.del_flag = '0' AND t.unique_code = '2'
WHERE w.del_flag = '0'
ORDER BY (w.tenant_id = @tenant_id) DESC, w.id
LIMIT 1;
-- 用户部门(sys_user 不在本实例时可注释掉,手工 SET @user_dept_id)
SELECT MAX(u.dept_id) INTO @user_dept_id
FROM pjcloudx.sys_user u
WHERE u.user_id = @user_id;
-- 测试主键基址:各表当前最大 id + 100 亿,避开现有与后续雪花 id
SELECT COALESCE(MAX(id), 0) + 10000000000 INTO @order_id_base FROM hard_order;
SELECT COALESCE(MAX(id), 0) + 10000000000 INTO @flow_id_base FROM hard_door_flow;
SELECT COALESCE(MAX(id), 0) + 10000000000 INTO @auth_id_base FROM hard_auth;
-- ★ 检查:除 @merchant_type 外下列都不能为 NULL;为 NULL 说明缺前置数据,
-- 请手工 SET 对应变量(或先在 柜机管理页 给柜子上架商品)后重跑
SELECT @hard_door_id AS 柜配置id,
@hard_id AS 设备id,
@cabinet_no AS 货柜号,
@tenant_id AS 租户,
@merchant_id AS 商户id,
@merchant_dept_id AS 商户部门,
@merchant_type AS 商户类型_可空,
@user_id AS 用户id,
@wallet_id AS 钱包id,
@order_id_base AS 订单id基址;
-- ---------- 三、生成 10 组 授权 / 流程 / 订单 ----------
-- 1. 用户授权(hard_auth):已授权、未完成
INSERT INTO hard_auth
(id, user_id, hard_id, cabinet_no, auth_type, auth_status, auth_time, is_done, create_by, create_time, del_flag, tenant_id)
SELECT @auth_id_base + n, @user_id, @hard_id, @cabinet_no, 'TEST', '1',
NOW() - INTERVAL (n * 2) HOUR, '0',
'test-manual-settle', NOW() - INTERVAL (n * 2) HOUR, '0', @tenant_id
FROM (
SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10
) seq;
-- 2. 智能柜流程(hard_door_flow):卡在"已提交识别、等待旷世回调"(flow_status='50')
INSERT INTO hard_door_flow
(id, user_id, user_type, biz_type, hard_id, hard_door_id, cabinet_no, order_id, auth_id,
request_open_dto, request_open_vo, request_open_time,
is_open, open_callback_time, open_callback_vo,
video_callback_vo, video_callback_time,
sku_submit_dto, sku_submit_vo, sku_submit_recog_id, sku_submit_time,
flow_status, create_by, create_time, del_flag, tenant_id)
SELECT
@flow_id_base + n, @user_id, '0', '10', @hard_id, @hard_door_id, @cabinet_no,
@order_id_base + n, @auth_id_base + n,
-- 请求开门参数/返回
CONCAT('{"outOpenNo":"', @flow_id_base + n, '","hardDoorId":', @hard_door_id, ',"cabinetNo":', @cabinet_no, ',"userId":', @user_id, '}'),
CONCAT('{"code":0,"msg":"成功","data":{"outOpenNo":"', @flow_id_base + n, '","isOpen":true}}'),
NOW() - INTERVAL (n * 2 - 2) MINUTE,
-- 开门回调(成功开门)
'1', NOW() - INTERVAL (n * 2 - 1) MINUTE,
CONCAT('{"eventId":"TEST-EVT-', n, '","outOpenNo":"', @flow_id_base + n, '","deviceCode":"', IFNULL(@device_code, 0), '","cabinetNo":', @cabinet_no, ',"resultCode":"SUCCESS"}'),
-- 视频回调(公网示例视频,主摄/辅摄两路)
CONCAT('{"eventId":"TEST-EVT-', n, '","outOpenNo":"', @flow_id_base + n, '","deviceCode":"', IFNULL(@device_code, 0),
'","cabinetNo":', @cabinet_no, ',"resultCode":"SUCCESS","resultMsg":null,"videoList":[',
'{"cameraNo":1,"videoUrl":"https://vjs.zencdn.net/v/oceans.mp4"},',
'{"cameraNo":2,"videoUrl":"https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"}]}'),
NOW() - INTERVAL (n * 2) HOUR + INTERVAL 3 MINUTE,
-- 已提交旷世识别(模拟旷世跑路前的最后一步,识别回调永远不回来)
CONCAT('{"recog_id":"', @flow_id_base + n, '","resource_type":"1","container_id":"', @hard_door_id,
'","sub_mch_id":"', @order_id_base + n,
'","resource_urls":["https://media.w3.org/2010/05/sintel/trailer.mp4"]}'),
CONCAT('{"code":0,"is_success":true,"ks_recog_id":"TEST-RECOG-', n, '","msg":"提交成功"}'),
CONCAT('TEST-RECOG-', n),
NOW() - INTERVAL (n * 2) HOUR + INTERVAL 4 MINUTE,
-- 流程状态 50=已提交识别;sku_callback_*/pay_time 留空(未结算)
'50', 'test-manual-settle', NOW() - INTERVAL (n * 2) HOUR, '0', @tenant_id
FROM (
SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10
) seq;
-- 3. 订单(hard_order):待结算的智能柜免密订单(形态对齐后端 createHardDoorOrder)
INSERT INTO hard_order
(id, user_id, user_dept_id, merchant_id, merchant_type, merchant_dept_id, hard_id, wallet_id,
order_type, order_status, pay_type, sys_type,
total_price, extra_price, pay_price, is_offline_order, product_desc,
create_by, create_time, del_flag, tenant_id)
SELECT @order_id_base + n, @user_id, @user_dept_id, @merchant_id, @merchant_type, @merchant_dept_id,
@hard_id, @wallet_id,
'1', '1', '9', '4',
0, 0, 0, '0', CONCAT('智能柜手动结算测试订单-', n),
'test-manual-settle', NOW() - INTERVAL (n * 2) HOUR, '0', @tenant_id
FROM (
SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10
) seq;
-- ---------- 四、生成结果检查 ----------
SELECT o.id AS 订单id,
o.product_desc AS 商品描述,
o.order_status AS 订单状态,
o.pay_type AS 支付方式,
o.sys_type AS 设备类型,
f.id AS 流程id,
f.flow_status AS 流程状态,
f.cabinet_no AS 货柜号,
a.id AS 授权id,
a.is_done AS 授权完成
FROM hard_order o
JOIN hard_door_flow f ON f.order_id = o.id
JOIN hard_auth a ON a.id = f.auth_id
WHERE o.create_by = 'test-manual-settle'
ORDER BY o.id;
-- ================================================================
-- 测试入口与验证步骤
-- ================================================================
-- 1. 用对该订单商户有数据范围的管理员账号登录后台
-- 2. 入口一:订单管理 → 未识别订单(hard-recog,本批次 10 笔会出现在列表)→ 订单详情
-- 入口二:订单管理 → 订单列表 → 订单详情(售后订单详情页 order-refund)
-- 3. 详情页右侧导航应出现「手动结算」:
-- - 左侧为所选货柜按层商品(来自 hard_spu_join 配置),可调数量
-- - 右侧为已选购物车 + 自动播放的公网示例视频(主摄/辅摄可切换)
-- 4. 点「手动结算」→ 确认 → 期望:
-- - 订单 order_status 变为 5(已支付)、pay_time/pay_price/total_price 写入
-- - 生成 hard_parent_car / hard_car 商品明细,详情页"购物车"区出现商品
-- - 智能柜库存扣减(hard_spu.repertory_count / hard_spu_join.repertory_count 减少)
-- - hard_door_flow.flow_status 变 70、pay_time 写入、sku_callback_vo 记录手动结算留痕
-- - hard_auth.is_done 变 1
-- - 钱包库生成一笔 PAYMENT 扣款流水(允许透支)
-- 5. 点「用户未拿商品,取消订单」→ 确认 → 期望:order_status 变 30(已取消)、
-- flow 的 sku_callback_vo 记录手动取消留痕
--
-- ---------- 测试完成后的清理 ----------
-- 重新执行本脚本第一、二节即可重置 10 笔新订单;
-- 已结算订单产生的购物车明细按需清理:
-- DELETE pc, c FROM hard_parent_car pc
-- LEFT JOIN hard_car c ON c.parent_car_id = pc.id
-- WHERE pc.id IN (SELECT parent_car_id FROM hard_order
-- WHERE create_by = 'test-manual-settle' AND parent_car_id IS NOT NULL);
-- 钱包流水在钱包库 wallet_transaction(biz_type='PAYMENT', biz_id=订单id),按需处理
@@ -0,0 +1,42 @@
-- =====================================================================
-- 打印任务补偿扫描:由 hard 模块内 @Scheduled 改为 quartz 调度中心统一触发
-- 接口:GET /hard-invoice/task/process-due(HardInvoiceController#processDueTasks)
--
-- 上线步骤:
-- 1. 按部署环境修改 execute_path(默认为本机直连示例):
-- - 直连硬件服务(推荐,调度中心与服务同内网):
-- http://<hard服务地址>:6080/hard-invoice/task/process-due
-- - 走网关:
-- http://<网关地址>:9999/hard/hard-invoice/task/process-due
-- 2. 执行本 INSERT;
-- 3. 在管理端「工具 / 任务管理」页面找到该任务点击「启动」
-- (job_status=1 已发布不会自动运行,启动后变为 2 运行中)。
--
-- 注意:
-- * pjcloud-daemon-quartz 当前 quartz isClustered=false,调度中心必须单实例部署;
-- 调度中心多实例会导致同一触发点被多次执行(接口内分布式锁仅兜底重叠执行,
-- 不能区分多次正常触发)。
-- * 任务必须挂在租户 1 下管理;接口内部已跳过租户过滤,扫描全部租户的任务。
-- * 该 SQL 不含 ON DUPLICATE KEY,重复执行会主键冲突;若已在页面调整过任务,
-- 请勿重复执行,以免覆盖手工配置。
-- =====================================================================
INSERT INTO `pjcloudx_job`.`sys_job` (
`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`,
`cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
`job_execute_status`, `create_by`, `tenant_id`, `remark`
) VALUES (
1949000000000000001,
'hard-print-task-process-due',
'DEFAULT',
'1',
'3',
'http://127.0.0.1:6080/hard-invoice/task/process-due',
'0/10 * * * * ?',
'3',
'2',
'1',
'0',
'admin',
1,
'智能柜打印任务补偿扫描(原 HardPrintTaskJob,每10秒扫描待生成/待补偿下发的打印任务)'
);
@@ -0,0 +1,37 @@
package com.bsd.pjcloud.hard.api.dto.hard;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* Android 终端离线人员快照分页请求。
*
* <p>按设备绑定部门树的用户主键升序做固定上界游标分页;部门和钱包类型均由
* DEVICE-ID 解析,终端不可指定范围。首次请求 snapshotMaxId 传空,由服务端
* 固定本次快照上界,后续请求必须原样携带。</p>
*/
@Data
public class HardOfflineUserSyncDto {
/**
* 排他游标;首页传 0,之后使用上一页返回的 nextAfterId。
*/
@NotNull(message = "离线人员同步游标不能为空")
@Min(value = 0, message = "离线人员同步游标不能小于0")
private Long afterId;
/**
* 单页用户数量;默认 200,最大 500。
*/
@Min(value = 1, message = "离线人员同步分页大小必须大于0")
@Max(value = 500, message = "离线人员同步分页大小不能超过500")
private Integer limit;
/**
* 首次请求由服务端固定的快照用户主键上界;后续请求原样携带。
*/
@Min(value = 0, message = "离线人员同步快照上界无效")
private Long snapshotMaxId;
}
@@ -17,6 +17,9 @@ public class HardDoorManualSettleDto {
@Schema(description = "手动选择的商品列表", requiredMode = Schema.RequiredMode.REQUIRED)
private List<Item> items;
@Schema(description = "操作备注(选填,记录人工判断依据)")
private String reason;
@Data
public static class Item {
@@ -182,6 +182,12 @@ public class HardDoorFlowEntity extends Model<HardDoorFlowEntity> {
@Schema(description = "sku识别主动获取时间")
private LocalDateTime skuDrivingTime;
/**
* 手动结算/取消操作快照(操作人+购物车明细+金额+备注)
*/
@Schema(description = "手动结算/取消操作快照(操作人+购物车明细+金额+备注)")
private String manualSettleVo;
/**
* 支付时间
*/
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.hard.api.vo.android;
import lombok.Data;
import java.util.List;
/**
* Android 终端离线人员快照分页结果。
*/
@Data
public class HardOfflineUserSyncSnapshotVo {
/**
* 当前页用户记录;服务端按用户主键严格升序返回,仅包含绑定了可用钱包的用户。
*/
private List<HardOfflineUserVo> records;
/**
* 下一页排他游标;末页等于当前请求游标。
*/
private Long nextAfterId;
/**
* 是否还有位于固定快照上界内的下一页。
*/
private Boolean hasMore;
/**
* 本次快照固定的用户主键上界;分页期间数据变化不会扩大同步范围。
*/
private Long snapshotMaxId;
}
@@ -0,0 +1,48 @@
package com.bsd.pjcloud.hard.api.vo.android;
import lombok.Data;
/**
* Android 终端离线人员快照记录。
*
* <p>仅包含断网消费匹配和展示所需的字段:姓名已按首字符脱敏,不返回手机号、
* 头像等个人信息;余额为同步时刻的服务端快照,离线扣款后以本地订单为准。</p>
*/
@Data
public class HardOfflineUserVo {
/**
* 用户ID;同时作为快照分页游标。
*/
private Long userId;
/**
* 脱敏用户名,例如“张*”。
*/
private String username;
/**
* 钱包卡号;断网刷离线饭卡时的匹配键。
*/
private String walletCardNumber;
/**
* 钱包id。
*/
private Long walletId;
/**
* 钱包类型id。
*/
private Long walletTypeId;
/**
* 钱包余额(分)快照。
*/
private Integer walletBalance;
/**
* 用户所属的商户id,主要用于机器的离线模式。
*/
private Long canteenId;
}
@@ -6,7 +6,6 @@ import com.bsd.pjcloud.common.swagger.annotation.EnableOpenApi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* @author archetype auto
@@ -18,7 +17,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@EnableDiscoveryClient
@EnablePjcloudResourceServer
@SpringBootApplication
@EnableScheduling
public class HardApp {
public static void main(String[] args) {
SpringApplication.run(HardApp.class, args);
@@ -6,13 +6,18 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.annotation.Inner;
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 lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -32,6 +37,7 @@ import jakarta.validation.Valid;
* @author dzc
* @date 2025-01-05 14:17:32
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/hard-invoice")
@@ -41,6 +47,42 @@ public class HardInvoiceController {
private final HardInvoiceService hardInvoiceService;
private final RedissonClient redissonClient;
/** 打印任务补偿扫描的跨实例互斥锁键。 */
private static final String PROCESS_DUE_LOCK_KEY = "hard:print:process-due";
/**
* 打印任务补偿扫描入口,由 quartz 调度中心(sys_job 的 rest 任务)定时触发。
*
* <p>调度中心单点触发配合分布式锁保证同一时刻全集群至多一轮扫描,替代原 @Scheduled
* 在集群环境下各节点重复扫描、重复下发 MQTT 的行为。请求头若带 TENANT_ID 会被
* 租户过滤器限定为单租户,这里显式跳过租户过滤以扫描全部租户的任务,任务内部
* 仍按各自租户上下文处理。</p>
*/
@Operation(summary = "定时任务补偿扫描打印任务", description = "quartz 定时任务调用,扫描待生成与待补偿下发的打印任务")
@Inner(value = false)
@GetMapping("/task/process-due")
public R<Integer> processDueTasks() {
RLock lock = redissonClient.getLock(PROCESS_DUE_LOCK_KEY);
if (!lock.tryLock()) {
log.info("打印任务补偿扫描上一轮尚未结束,本轮跳过");
return R.ok(0);
}
try {
TenantContextHolder.setTenantSkip();
int processed = hardInvoiceService.processDueTasks();
if (processed > 0) log.info("打印任务补偿扫描完成,processed:{}", processed);
return R.ok(processed);
} catch (Exception exception) {
log.error("打印任务补偿扫描异常", exception);
return R.failed("打印任务补偿扫描异常:" + exception.getMessage());
} finally {
TenantContextHolder.clear();
lock.unlock();
}
}
@Operation(summary = "后台打印", description = "后台打印")
@SysLog("后台打印")
@@ -22,6 +22,7 @@ import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardTerminalFaceQualityLeaseVo;
import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo;
import com.bsd.pjcloud.hard.api.vo.android.HardOfflineUserSyncSnapshotVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
@@ -340,6 +341,21 @@ public class HardApiController {
return R.ok(hardApiService.getUserByCard(card));
}
/**
* 按当前设备绑定部门树同步脱敏后的离线人员快照。
*
* @param hardId DEVICE-ID 请求头中的来源业务终端 ID
* @param dto 排他游标、分页大小和固定快照上界
* @return 当前页离线人员、下一游标和快照上界
*/
@PostMapping("/offline-user/sync-snapshot")
@Operation(summary = "业务终端同步离线人员快照", description = "按设备绑定部门返回脱敏姓名的离线消费人员游标快照")
public R<HardOfflineUserSyncSnapshotVo> syncOfflineUserSnapshot(
@RequestHeader("DEVICE-ID") Long hardId,
@Valid @RequestBody HardOfflineUserSyncDto dto) {
return R.ok(hardApiService.syncOfflineUserSnapshot(hardId, dto));
}
@GetMapping("/chart-latitude")
@Operation(summary = "统计每天、每周、每月、每年的交易人数和次数")
public R<HardChartLatitudeVo> chartLatitude(@RequestParam Long merchantId) {
@@ -330,7 +330,7 @@ public class HardOrderController {
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_manualSettle')")
public R manualSettle(@RequestBody HardDoorManualSettleDto dto) {
PjcloudUser user = SecurityUtils.getUser();
hardwareOrderService.manualSettleHardDoorOrder(dto, user.getUsername());
hardwareOrderService.manualSettleHardDoorOrder(dto, user.getId(), user.getUsername());
return R.ok();
}
@@ -339,7 +339,7 @@ public class HardOrderController {
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_manualSettle')")
public R manualCancel(@RequestBody HardDoorManualSettleDto dto) {
PjcloudUser user = SecurityUtils.getUser();
hardwareOrderService.manualCancelHardDoorOrder(dto.getOrderId(), user.getUsername());
hardwareOrderService.manualCancelHardDoorOrder(dto.getOrderId(), user.getId(), user.getUsername(), dto.getReason());
return R.ok();
}
@@ -1,32 +0,0 @@
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);
}
}
}
@@ -1,9 +1,9 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardChartLatitudeVo;
import com.bsd.pjcloud.hard.api.dto.hard.HardOfflineUserSyncDto;
import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo;
import jakarta.validation.Valid;
import com.bsd.pjcloud.hard.api.vo.android.HardOfflineUserSyncSnapshotVo;
import java.util.List;
@@ -12,4 +12,12 @@ public interface HardApiService {
HardAdUserVo getUserById(Long userId, HardTypeEnum type);
HardAdUserVo getUserByCard(String card);
/**
* 按设备绑定部门树返回离线人员固定上界游标快照。
*
* <p>记录仅包含断网消费所需字段,姓名脱敏且不返回手机号头像;未绑定可用
* 钱包或钱包已冻结的用户不会出现在快照中。</p>
*/
HardOfflineUserSyncSnapshotVo syncOfflineUserSnapshot(Long hardId, HardOfflineUserSyncDto dto);
}
@@ -123,12 +123,12 @@ public interface HardOrderService extends IService<HardOrderEntity> {
/**
* 手动结算智能柜订单(第三方识别不可用时,人工核对视频后结算)
*/
Long manualSettleHardDoorOrder(HardDoorManualSettleDto dto, String operator);
Long manualSettleHardDoorOrder(HardDoorManualSettleDto dto, Long operatorId, String operatorName);
/**
* 手动取消智能柜订单(用户未拿商品)
*/
Long manualCancelHardDoorOrder(Long orderId, String operator);
Long manualCancelHardDoorOrder(Long orderId, Long operatorId, String operatorName, String reason);
/**
* 快速扣款
@@ -1,14 +1,25 @@
package com.bsd.pjcloud.hard.service.impl;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.bsd.pjcloud.admin.api.dto.DeptAllListDto;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.dto.hard.HardOfflineUserSyncDto;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo;
import com.bsd.pjcloud.hard.api.vo.android.HardOfflineUserSyncSnapshotVo;
import com.bsd.pjcloud.hard.api.vo.android.HardOfflineUserVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.service.HardApiService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -19,7 +30,15 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Validated
@Slf4j
@@ -27,9 +46,17 @@ import java.util.List;
@Service
public class HardApiServiceImpl implements HardApiService {
/**
* 离线人员快照默认分页大小;与 DTO 上限保持一致由校验注解约束。
*/
private static final int OFFLINE_USER_SYNC_DEFAULT_LIMIT = 200;
private final RemoteUserService userService;
private final RemoteWalletService remoteWalletService;
private final RemoteMchService remoteMchService;
private final RemoteDeptService remoteDeptService;
private final HardManagerMapper hardManagerMapper;
private final HardManagerService hardManagerService;
@Override
public HardAdUserVo getUserById(Long userId, HardTypeEnum type) {
@@ -92,4 +119,216 @@ public class HardApiServiceImpl implements HardApiService {
}
return result;
}
/**
* 按设备绑定部门树返回脱敏后的离线人员游标快照。
*
* <p>部门范围、钱包类型全部由 DEVICE-ID 对应的真实绑定关系解析;姓名只保留
* 首字符,其余以星号替代,且不返回手机号和头像。未绑定当前类型钱包、卡号为空
* 或钱包已冻结的用户不进入快照;游标始终按部门全部用户主键推进,被过滤用户
* 不会造成分页重复。</p>
*/
@Override
public HardOfflineUserSyncSnapshotVo syncOfflineUserSnapshot(Long hardId, HardOfflineUserSyncDto dto) {
TrustedDeviceContext trustedDevice = resolveTrustedDevice(hardId);
HardTypeEnum hardType = HardTypeEnum.valueOfByType(trustedDevice.hardType());
if (!ObjectUtil.equals(HardTypeEnum.CANTEEN, hardType)
&& !ObjectUtil.equals(HardTypeEnum.SUPERMARKET, hardType)) {
throw new RuntimeException("当前设备类型不支持离线人员同步");
}
WalletTypeEnum walletType = ObjectUtil.equals(HardTypeEnum.CANTEEN, hardType)
? WalletTypeEnum.EAT
: WalletTypeEnum.PAY;
List<Long> deptIds = resolveDepartmentTree(trustedDevice.merchantDeptId());
if (deptIds.isEmpty()) {
throw new RuntimeException("设备绑定部门不存在或已删除");
}
R<List<SysUser>> userResponse = userService.getUserIdListByDeptIdList(deptIds);
if (userResponse == null || !userResponse.isOk() || userResponse.getData() == null) {
String message = userResponse == null || StrUtil.isBlank(userResponse.getMsg())
? "查询部门用户失败"
: userResponse.getMsg();
throw new RuntimeException(message);
}
List<SysUser> users = userResponse.getData().stream()
.filter(user -> user.getUserId() != null && StrUtil.isNotBlank(user.getName()))
.sorted(Comparator.comparing(SysUser::getUserId))
.toList();
long normalizedAfterId = dto.getAfterId();
Long requestedSnapshotMaxId = dto.getSnapshotMaxId();
if (requestedSnapshotMaxId == null) {
requestedSnapshotMaxId = users.isEmpty() ? 0L : users.get(users.size() - 1).getUserId();
}
final long normalizedSnapshotMaxId = requestedSnapshotMaxId;
if (normalizedAfterId > normalizedSnapshotMaxId) {
throw new RuntimeException("离线人员同步游标超过快照上界");
}
int normalizedLimit = dto.getLimit() == null ? OFFLINE_USER_SYNC_DEFAULT_LIMIT : dto.getLimit();
List<SysUser> fetchedPage = users.stream()
.filter(user -> user.getUserId() > normalizedAfterId
&& user.getUserId() <= normalizedSnapshotMaxId)
.limit(normalizedLimit + 1L)
.toList();
boolean hasMore = fetchedPage.size() > normalizedLimit;
List<SysUser> pageUsers = hasMore ? fetchedPage.subList(0, normalizedLimit) : fetchedPage;
HardOfflineUserSyncSnapshotVo snapshot = new HardOfflineUserSyncSnapshotVo();
snapshot.setRecords(buildOfflineUserRecords(pageUsers, walletType));
// 游标按快照全集推进;本页末尾用户即使因无钱包被过滤也不会导致重复拉取。
snapshot.setNextAfterId(pageUsers.isEmpty()
? normalizedAfterId
: pageUsers.get(pageUsers.size() - 1).getUserId());
snapshot.setHasMore(hasMore);
snapshot.setSnapshotMaxId(normalizedSnapshotMaxId);
return snapshot;
}
/**
* 将一页部门用户组装为脱敏离线人员记录;食堂归属按部门聚合缓存一次查询。
*/
private List<HardOfflineUserVo> buildOfflineUserRecords(
List<SysUser> pageUsers,
WalletTypeEnum walletType
) {
if (pageUsers.isEmpty()) {
return List.of();
}
List<Long> userIds = pageUsers.stream().map(SysUser::getUserId).toList();
R<List<WalletManageEntity>> walletResponse = remoteWalletService.getWalletsByUserIds(userIds, walletType);
if (walletResponse == null || !walletResponse.isOk() || walletResponse.getData() == null) {
String message = walletResponse == null || StrUtil.isBlank(walletResponse.getMsg())
? "查询用户钱包失败"
: walletResponse.getMsg();
throw new RuntimeException(message);
}
Map<Long, WalletManageEntity> walletByUserId = walletResponse.getData().stream()
.filter(wallet -> wallet.getUserId() != null)
.collect(Collectors.toMap(
WalletManageEntity::getUserId,
Function.identity(),
(first, second) -> first
));
Map<Long, Long> canteenByDeptId = new HashMap<>();
List<HardOfflineUserVo> records = new ArrayList<>(pageUsers.size());
for (SysUser user : pageUsers) {
WalletManageEntity wallet = walletByUserId.get(user.getUserId());
// 无卡用户无法离线刷卡,冻结钱包不可消费,均不进入离线快照。
if (wallet == null || StrUtil.isBlank(wallet.getCardNumber())) {
continue;
}
if (ObjectUtil.equals(wallet.getIsFreeze(), YesNoEnum.YES.getCode())) {
continue;
}
HardOfflineUserVo record = new HardOfflineUserVo();
record.setUserId(user.getUserId());
record.setUsername(maskUsername(user.getName()));
record.setWalletCardNumber(wallet.getCardNumber());
record.setWalletId(wallet.getId());
record.setWalletTypeId(wallet.getWalletTypeId());
record.setWalletBalance(wallet.getBalance());
record.setCanteenId(resolveCanteenId(user.getDeptId(), canteenByDeptId));
records.add(record);
}
return records;
}
/**
* 按部门查询食堂商户并缓存;查询失败不阻断人员同步,仅本条记录缺少食堂归属。
*/
private Long resolveCanteenId(Long deptId, Map<Long, Long> cache) {
if (deptId == null) {
return null;
}
if (cache.containsKey(deptId)) {
return cache.get(deptId);
}
Long canteenId = null;
try {
R<MchManageVo> response = remoteMchService.getViewsCanteenMchByDeptId(deptId);
if (response != null && response.isOk() && response.getData() != null) {
canteenId = response.getData().getId();
}
} catch (Exception error) {
log.warn("离线人员同步查询部门食堂失败 deptId:{}", deptId, error);
}
cache.put(deptId, canteenId);
return canteenId;
}
/**
* 姓名脱敏:仅保留首字符,两字姓名补一个星号,更长姓名统一补两个星号。
*/
private String maskUsername(String name) {
if (StrUtil.isBlank(name)) {
return "未知";
}
if (name.length() == 1) {
return name;
}
return name.charAt(0) + (name.length() == 2 ? "*" : "**");
}
/**
* 在租户全部部门中收集绑定部门及其子孙部门主键。
*/
private List<Long> resolveDepartmentTree(Long merchantDeptId) {
R<List<SysDept>> response = remoteDeptService.getInnerAllDept(new DeptAllListDto());
if (response == null || !response.isOk() || response.getData() == null) {
String message = response == null || StrUtil.isBlank(response.getMsg())
? "查询部门树失败"
: response.getMsg();
throw new RuntimeException(message);
}
Set<Long> deptIds = new HashSet<>();
deptIds.add(merchantDeptId);
collectDescendants(response.getData(), merchantDeptId, deptIds);
return new ArrayList<>(deptIds);
}
/**
* 深度优先收集子孙部门;已访问主键直接跳过,防御异常父指针成环。
*/
private void collectDescendants(List<SysDept> allDepts, Long parentId, Set<Long> result) {
for (SysDept dept : allDepts) {
if (parentId.equals(dept.getParentId()) && result.add(dept.getDeptId())) {
collectDescendants(allDepts, dept.getDeptId(), result);
}
}
}
/**
* 校验 DEVICE-ID 对应当前租户真实设备,并解析设备绑定商户、部门与设备类型。
*/
private TrustedDeviceContext resolveTrustedDevice(Long hardId) {
if (hardId == null) {
throw new RuntimeException("DEVICE-ID不能为空");
}
HardManagerEntity hard = hardManagerMapper.selectById(hardId);
if (hard == null) {
throw new RuntimeException("当前租户下设备不存在");
}
if (hard.getMerchantId() == null) {
throw new RuntimeException("设备尚未绑定商户");
}
HardManagerVo hardDetail = hardManagerService.getByHardId(hardId);
if (hardDetail == null || hardDetail.getMerchantDeptId() == null) {
throw new RuntimeException("设备绑定商户缺少部门");
}
return new TrustedDeviceContext(
hard.getMerchantId(),
hardDetail.getMerchantDeptId(),
hard.getType()
);
}
/**
* 服务端可信设备上下文:设备自身并不携带任何商户信息,全部由绑定关系解析。
*/
private record TrustedDeviceContext(Long merchantId, Long merchantDeptId, String hardType) {
}
}
@@ -681,7 +681,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
.set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType())
.set(HardInvoiceEntity::getPic, image)
.set(HardInvoiceEntity::getFailureStage, null)
.set(HardInvoiceEntity::getLastError, null));
.set(HardInvoiceEntity::getLastError, null)
// update(null, wrapper) 不触发 update_time 自动填充,重派冷却依赖该字段,必须显式刷新
.set(HardInvoiceEntity::getUpdateTime, LocalDateTime.now()));
return baseMapper.selectById(task.getId());
} catch (Exception exception) {
markFailure(task.getId(), task.getRetryVersion(), HardPrintFailureStageEnum.IMAGE_GENERATION, exception);
@@ -719,7 +721,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
.eq(HardInvoiceEntity::getRetryVersion, task.getRetryVersion())
.eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType())
.set(HardInvoiceEntity::getFailureStage, null)
.set(HardInvoiceEntity::getLastError, null));
.set(HardInvoiceEntity::getLastError, null)
// update(null, wrapper) 不触发 update_time 自动填充,30 秒重派冷却依赖该字段,必须显式刷新
.set(HardInvoiceEntity::getUpdateTime, LocalDateTime.now()));
} catch (Exception exception) {
markFailure(task.getId(), task.getRetryVersion(), HardPrintFailureStageEnum.MQTT_DISPATCH, exception);
}
@@ -1196,7 +1196,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long manualSettleHardDoorOrder(HardDoorManualSettleDto dto, String operator) {
public Long manualSettleHardDoorOrder(HardDoorManualSettleDto dto, Long operatorId, String operatorName) {
if (ObjectUtil.isEmpty(dto) || ObjectUtil.isEmpty(dto.getOrderId()) || ObjectUtil.isEmpty(dto.getItems())) {
throw new RuntimeException("手动结算失败,参数错误");
}
@@ -1250,16 +1250,13 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (ObjectUtil.isEmpty(settleOrder) || !ObjectUtil.equals(settleOrder.getOrderStatus(), HardOrderStatusEnum.PAY.getType())) {
throw new RuntimeException("手动结算失败,订单支付未成功(支付宝免密扣款失败)");
}
// 更新流程
// 更新流程;手动结算快照始终落 manual_settle_vo,与旷世识别回调字段互不干扰
HardDoorFlowEntity flowUpdate = HardDoorFlowEntity.builder()
.id(flow.getId())
.flowStatus(HardDoorFlowEnum.FLOW_PAY_ORDER.getType())
.payTime(LocalDateTime.now())
.skuCallbackTime(LocalDateTime.now())
.manualSettleVo(buildManualSettleVo(order, dto, carListDto, operatorId, operatorName))
.build();
if (ObjectUtil.isEmpty(flow.getSkuCallbackVo())) {
flowUpdate.setSkuCallbackVo(buildManualDoorFlowVo(dto, operator, false));
}
hardDoorFlowService.updateById(flowUpdate);
// 完成授权流程
if (ObjectUtil.isNotEmpty(flow.getAuthId())) {
@@ -1270,7 +1267,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.build();
hardAuthService.updateById(authUpdate);
}
log.info("|{}|智能柜订单手动结算成功orderId|{}|operator|{}|items|{}|", TAG, orderId, operator, JSONUtil.toJsonStr(dto.getItems()));
log.info("|{}|智能柜订单手动结算成功orderId|{}|operator|{}|items|{}|", TAG, orderId, operatorName, JSONUtil.toJsonStr(dto.getItems()));
return orderId;
}
@@ -1279,7 +1276,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long manualCancelHardDoorOrder(Long orderId, String operator) {
public Long manualCancelHardDoorOrder(Long orderId, Long operatorId, String operatorName, String reason) {
if (ObjectUtil.isEmpty(orderId)) {
throw new RuntimeException("手动取消失败,参数错误");
}
@@ -1300,39 +1297,73 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (ObjectUtil.isEmpty(cancelId)) {
throw new RuntimeException("手动取消失败,订单取消未成功");
}
// 更新流程
// 更新流程;取消快照落 manual_settle_vo
HardDoorFlowVo flow = hardDoorFlowService.getByOrderId(orderId);
if (ObjectUtil.isNotEmpty(flow)) {
HardDoorFlowEntity flowUpdate = HardDoorFlowEntity.builder()
.id(flow.getId())
.flowStatus(HardDoorFlowEnum.FLOW_CALLBACK_RECOGNITION.getType())
.skuCallbackTime(LocalDateTime.now())
.manualSettleVo(buildManualCancelVo(orderId, operatorId, operatorName, reason))
.build();
if (ObjectUtil.isEmpty(flow.getSkuCallbackVo())) {
HardDoorManualSettleDto manualDto = new HardDoorManualSettleDto();
manualDto.setOrderId(orderId);
flowUpdate.setSkuCallbackVo(buildManualDoorFlowVo(manualDto, operator, true));
}
hardDoorFlowService.updateById(flowUpdate);
}
log.info("|{}|智能柜订单手动取消成功orderId|{}|operator|{}|", TAG, orderId, operator);
log.info("|{}|智能柜订单手动取消成功orderId|{}|operator|{}|", TAG, orderId, operatorName);
return cancelId;
}
/**
* 构建智能柜手动结算/取消的流程留痕(写入 skuCallbackVo)
* 构建智能柜手动结算快照(结算事务内对商品名/单价做实时快照,防止事后改价改名影响复核)
*/
private String buildManualDoorFlowVo(HardDoorManualSettleDto dto, String operator, Boolean isCancel) {
Map<String, Object> manualVo = new LinkedHashMap<>();
manualVo.put("manual", true);
manualVo.put("manualCancel", isCancel);
manualVo.put("operator", operator);
manualVo.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
if (ObjectUtil.isNotEmpty(dto)) {
manualVo.put("orderId", dto.getOrderId());
manualVo.put("items", dto.getItems());
private String buildManualSettleVo(HardOrderEntity order, HardDoorManualSettleDto dto, List<HardCreateCarDto> carListDto, Long operatorId, String operatorName) {
Map<String, Object> vo = new LinkedHashMap<>();
vo.put("manual", true);
vo.put("manualCancel", false);
vo.put("operatorId", operatorId);
vo.put("operator", operatorName);
vo.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
vo.put("orderId", order.getId());
if (ObjectUtil.isNotEmpty(dto) && ObjectUtil.isNotEmpty(dto.getReason())) {
vo.put("reason", dto.getReason());
}
return JSONUtil.toJsonStr(manualVo);
vo.put("payChannel", ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.TOC_VISITOR.getType()) ? "支付宝免密扣款" : "钱包强制扣款");
// 购物车快照:商品名/单价以结算时刻为准
List<Map<String, Object>> items = new ArrayList<>();
int totalPrice = 0;
List<HardSpuEntity> spuList = ObjectUtil.isEmpty(carListDto)
? List.of()
: hardSpuService.getByIds(carListDto.stream().map(HardCreateCarDto::getProductId).toList());
for (HardCreateCarDto car : carListDto) {
HardSpuEntity spu = spuList.stream().filter(v -> ObjectUtil.equals(v.getId(), car.getProductId())).findFirst().orElse(null);
int price = ObjectUtil.isEmpty(spu) || ObjectUtil.isEmpty(spu.getPrice()) ? 0 : spu.getPrice();
Map<String, Object> item = new LinkedHashMap<>();
item.put("productId", car.getProductId());
item.put("name", ObjectUtil.isEmpty(spu) ? String.valueOf(car.getProductId()) : spu.getName());
item.put("price", price);
item.put("productCount", car.getProductCount());
item.put("totalPrice", price * car.getProductCount());
totalPrice += price * car.getProductCount();
items.add(item);
}
vo.put("items", items);
vo.put("totalPrice", totalPrice);
return JSONUtil.toJsonStr(vo);
}
/**
* 构建智能柜手动取消快照
*/
private String buildManualCancelVo(Long orderId, Long operatorId, String operatorName, String reason) {
Map<String, Object> vo = new LinkedHashMap<>();
vo.put("manual", true);
vo.put("manualCancel", true);
vo.put("operatorId", operatorId);
vo.put("operator", operatorName);
vo.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
vo.put("orderId", orderId);
if (ObjectUtil.isNotEmpty(reason)) {
vo.put("reason", reason);
}
return JSONUtil.toJsonStr(vo);
}
@Override
@@ -18,7 +18,7 @@ import java.util.List;
public interface RemoteMqttEventService {
@NoToken
@GetMapping("/mqtt/send-default-msg")
@PostMapping("/mqtt/send-default-msg")
R<Boolean> sendDefaultMsg(@RequestBody MqttBaseDto dto);
/**
@@ -43,11 +43,12 @@ public interface RemoteMqttEventService {
R<Boolean> sendCanteenClassifyChangeMsg(@RequestParam Long deptId);
/**
* 超市商品变化
* 超市商品变化;spuIds 非空时终端只定向同步这些商品
*/
@NoToken
@GetMapping("/mqtt/send-supermarket-product-change-msg")
R<Boolean> sendSupermarketProductChangeMsg(@RequestParam Long deptId);
R<Boolean> sendSupermarketProductChangeMsg(@RequestParam Long deptId,
@RequestParam(value = "spuIds", required = false) List<Long> spuIds);
/**
* 超市分类变化
@@ -0,0 +1,17 @@
package com.bsd.pjcloud.mqtt.dto.hard;
import com.bsd.pjcloud.mqtt.dto.MqttHardBaseMsg;
import lombok.Data;
import java.util.List;
/**
* 超市商品变更消息,携带发生变化的商品 SPU 编号供终端定向同步;
* 未携带编号时终端回退为常规增量同步。
*/
@Data
public class HardSupermarketProductMsg extends MqttHardBaseMsg {
private List<Long> spuIds;
}
@@ -7,6 +7,7 @@ import com.bsd.pjcloud.mqtt.constant.MqttActionEnum;
import com.bsd.pjcloud.mqtt.dto.MqttBaseDto;
import com.bsd.pjcloud.mqtt.dto.hard.HardCustomerMsg;
import com.bsd.pjcloud.mqtt.dto.hard.HardPrintMsg;
import com.bsd.pjcloud.mqtt.dto.hard.HardSupermarketProductMsg;
import com.bsd.pjcloud.mqtt.dto.hard.HardTakeFoodBroadcastMsg;
import com.bsd.pjcloud.mqtt.service.MqttEventService;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -77,13 +78,20 @@ public class MqttEventController {
*/
@Inner
@GetMapping("/send-supermarket-product-change-msg")
public R<Boolean> sendSupermarketProductChangeMsg(@RequestParam Long deptId) {
public R<Boolean> sendSupermarketProductChangeMsg(@RequestParam Long deptId,
@RequestParam(required = false) List<Long> spuIds) {
MqttBaseDto dto = new MqttBaseDto<>();
mqttEventService.getTopicParamsByUserDeptId(dto, deptId);
if (ObjectUtil.isNotEmpty(deptId)) {
dto.setMerchantDeptId(deptId);
}
dto.setAction(MqttActionEnum.SUPERMARKET_PRODUCT);
// 携带变更商品编号时终端只定向同步这些商品,否则回退常规增量同步
if (ObjectUtil.isNotEmpty(spuIds)) {
HardSupermarketProductMsg msg = new HardSupermarketProductMsg();
msg.setSpuIds(spuIds);
dto.setData(msg);
}
return R.ok(mqttEventService.defaultAction(dto));
}
@@ -3,9 +3,13 @@ package com.bsd.pjcloud.supermarket.dto;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Schema(description = "AD商品 SPU 查询参数")
public class SupermarketSpuQueryDTO {
@@ -15,6 +19,12 @@ public class SupermarketSpuQueryDTO {
@Schema(description="商品 SPU 编号,自增")
private Long id;
/**
* 商品 SPU 编号集合,定向同步指定商品
*/
@Schema(description="商品 SPU 编号集合,定向同步指定商品")
private List<Long> ids;
/**
* 货号
*/
@@ -45,4 +55,12 @@ public class SupermarketSpuQueryDTO {
@Schema(description="商品性质(字典)")
private String productNature;
/**
* 增量同步起点:只返回该时间之后有变化的商品(含已删除的墓碑行)。
* 传入后查询不再过滤上架/删除状态,靠返回的 delFlag 区分。
*/
@Schema(description="增量同步起点,只返回该时间之后有变化的商品(含删除墓碑)")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}
@@ -87,6 +87,12 @@ public class SpuVo {
@Schema(description="创建时间")
private LocalDateTime createTime;
/**
* 是否删除;增量同步时为 "1" 的行是服务端删除墓碑,终端应清理本地对应商品
*/
@Schema(description="是否删除,增量同步墓碑标记")
private String delFlag;
}
@@ -123,6 +123,13 @@
<artifactId>pjcloud-common-sequence</artifactId>
<version>5.7.0</version>
</dependency>
<!-- 超市商品变更时通过 MQTT 通知终端定向同步 -->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-mqtt-api</artifactId>
<version>5.7.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
<profiles>
@@ -43,7 +43,9 @@ import com.bsd.pjcloud.supermarket.vo.erpspu.SmErpSpuStockExcel;
import com.bsd.pjcloud.supermarket.vo.sm.smspu.SmErpPageScopeVo;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.core.annotations.ParameterObject;
@@ -71,6 +73,7 @@ import java.util.stream.Collectors;
* @author zjf
* @date 2024-12-23 10:42:43
*/
@Slf4j
@Service
@AllArgsConstructor
public class SmErpSpuServiceImpl extends ServiceImpl<SmErpSpuMapper, SmErpSpuEntity> implements SmErpSpuService {
@@ -79,6 +82,8 @@ public class SmErpSpuServiceImpl extends ServiceImpl<SmErpSpuMapper, SmErpSpuEnt
private final SmErpAuxiliaryCodeService smErpAuxiliaryCodeService;
private final RemoteMqttEventService remoteMqttEventService;
private final SmErpStockMapper smErpStockMapper;
private final SmSpuMapper spuMapper;
@@ -232,6 +237,9 @@ public class SmErpSpuServiceImpl extends ServiceImpl<SmErpSpuMapper, SmErpSpuEnt
smErpAuxiliaryCodeService.saveBatch(smErpAuxiliaryCodeEntityList);
}
// 新增商品后通知终端定向同步
notifySupermarketProductChange(resolveSpuDeptId(supermarketSpu), Collections.singletonList(supermarketSpu.getId()));
return R.ok();
}
@@ -485,6 +493,8 @@ public class SmErpSpuServiceImpl extends ServiceImpl<SmErpSpuMapper, SmErpSpuEnt
}
}
// 商品信息变化后通知终端定向同步
notifySupermarketProductChange(resolveSpuDeptId(supermarketSpu), Collections.singletonList(supermarketSpu.getId()));
return R.ok();
}
@@ -492,6 +502,12 @@ public class SmErpSpuServiceImpl extends ServiceImpl<SmErpSpuMapper, SmErpSpuEnt
@Override
public Object removeSpuBatchByIds(ArrayList<Long> list) {
// 删除前留档部门归属,删除后按部门通知终端
List<SmErpSpuEntity> spuEntities = baseMapper.selectByIds(list);
// 逻辑删除不会更新 update_time,先补一次更新时间保证终端增量同步能拉到删除墓碑
baseMapper.update(null, new LambdaUpdateWrapper<SmErpSpuEntity>()
.set(SmErpSpuEntity::getUpdateTime, LocalDateTime.now())
.in(SmErpSpuEntity::getId, list));
baseMapper.deleteByIds(list);
//删除
@@ -504,9 +520,44 @@ public class SmErpSpuServiceImpl extends ServiceImpl<SmErpSpuMapper, SmErpSpuEnt
smErpAuxiliaryCodeService.remove(supermarketAuxiliaryCodeEntityLambdaQueryWrapper);
});
// 按部门分组通知,携带被删编号,终端直接清理本地商品
Map<Long, List<Long>> deptSpuIds = spuEntities.stream()
.filter(v -> v.getDeptId() != null)
.collect(Collectors.groupingBy(SmErpSpuEntity::getDeptId,
Collectors.mapping(SmErpSpuEntity::getId, Collectors.toList())));
deptSpuIds.forEach(this::notifySupermarketProductChange);
return R.ok();
}
/**
* 入参实体可能不携带部门信息,缺失时回查数据库。
*/
private Long resolveSpuDeptId(SmErpSpuEntity supermarketSpu) {
if (supermarketSpu.getDeptId() != null) {
return supermarketSpu.getDeptId();
}
if (supermarketSpu.getId() == null) {
return null;
}
SmErpSpuEntity dbSpu = baseMapper.selectById(supermarketSpu.getId());
return dbSpu != null ? dbSpu.getDeptId() : null;
}
/**
* 超市商品变更后通知终端定向同步;MQTT 发送失败只记日志,不影响业务事务。
*/
private void notifySupermarketProductChange(Long deptId, List<Long> spuIds) {
if (deptId == null || CollectionUtils.isEmpty(spuIds)) {
return;
}
try {
remoteMqttEventService.sendSupermarketProductChangeMsg(deptId, spuIds);
} catch (Exception e) {
log.error("|SmErpSpuServiceImpl|通知终端同步超市商品变更失败deptId:|{}|spuIds:|{}|", deptId, spuIds, e);
}
}
@Override
@Transactional
public R updateSpuStock(SupermarketSpuUpdateStockReqDTO updateStockReqDTO) {
@@ -38,6 +38,7 @@
es.sales_count,
es.dept_id,
es.create_by,
es.del_flag,
u.unit,
u.spec,
u.retail_price,
@@ -46,13 +47,27 @@
FROM sm_erp_spu es
left join sm_erp_unit u
on es.id = u.spu_id and u.is_base=1
left join sm_erp_stock st
on es.id = st.erp_spu_id
left join sm_erp_stock st
on es.id = st.erp_spu_id
<where>
u.status = 0 and es.status = 0 and es.del_flag = 0 and u.del_flag = 0
<choose>
<!-- 增量同步:按更新时间取变化行,不过滤上架/删除状态,墓碑行靠 delFlag 区分 -->
<when test="supermarketSpuQueryDTO.updateTime != null">
es.update_time &gt;= #{supermarketSpuQueryDTO.updateTime}
</when>
<otherwise>
u.status = 0 and es.status = 0 and es.del_flag = 0 and u.del_flag = 0
</otherwise>
</choose>
<if test="supermarketSpuQueryDTO.id != null">
AND es.id = #{supermarketSpuQueryDTO.id}
</if>
<if test="supermarketSpuQueryDTO.ids != null and supermarketSpuQueryDTO.ids.size() > 0">
AND es.id IN
<foreach collection="supermarketSpuQueryDTO.ids" item="spuIdItem" open="(" separator="," close=")">
#{spuIdItem}
</foreach>
</if>
<if test="supermarketSpuQueryDTO.productCode != null">
AND es.product_code = #{supermarketSpuQueryDTO.productCode}
</if>
@@ -9,8 +9,8 @@ spring:
name: @artifactId@
cloud:
nacos:
username: @nacos.username@
password: @nacos.password@
username: nacos
password: Aa@bsd.321
discovery:
server-addr: ${NACOS_HOST:pjcloud-register}:${NACOS_PORT:8848}
config:
@@ -30,6 +30,10 @@ public interface RemoteWalletService {
@GetMapping("/wallet-manage/get-wallet")
R<WalletManageEntity> getWallet(@RequestParam Long userId, @RequestParam WalletTypeEnum walletTypeCode);
@NoToken
@GetMapping("/wallet-manage/get-wallets-by-user-ids")
R<List<WalletManageEntity>> getWalletsByUserIds(@RequestParam List<Long> userIds, @RequestParam WalletTypeEnum walletTypeCode);
@NoToken
@GetMapping("/wallet-manage/get-wallet-by-wallet-id")
R<WalletManageEntity> getWalletByWalletTypeId(@RequestParam Long userId, @RequestParam Long walletTypeId);
@@ -143,6 +143,19 @@ public class WalletManageController {
return R.ok(wallet);
}
/**
* 按用户 id 列表批量查询同一钱包类型的钱包。
*
* <p>供硬件服务聚合离线人员快照使用;未开通该类型钱包的用户不出现在结果中,
* 空入参返回空列表而不是失败。</p>
*/
@Inner
@GetMapping("/get-wallets-by-user-ids")
public R<List<WalletManageEntity>> getWalletsByUserIds(@RequestParam List<Long> userIds,
@RequestParam WalletTypeEnum walletTypeCode) {
return R.ok(walletManageService.getWalletsByUserIds(userIds, walletTypeCode));
}
@Inner
@GetMapping("/del-wallet")
public R<Object> delWallet(@RequestParam("userIds") List<Long> userIds) {
@@ -37,6 +37,14 @@ public interface WalletManageService extends IService<WalletManageEntity> {
*/
WalletManageEntity getWallet(@NotNull Long userId, @NotNull WalletTypeEnum walletTypeCode);
/**
* 按用户 id 列表批量获取同一钱包类型的全部钱包(没有缓存)。
*
* <p>供离线人员快照等批量聚合场景使用;入参为空或钱包类型不存在时返回空列表,
* 未开通该类型钱包的用户不出现在结果中。</p>
*/
List<WalletManageEntity> getWalletsByUserIds(List<@NotNull Long> userIds, @NotNull WalletTypeEnum walletTypeCode);
/**
* 获取钱包(没有缓存)
*/
@@ -71,6 +71,11 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
static private final String TAG = "钱包管理";
/**
* 按用户 id 批量查询钱包时的单批 in 参数上限。
*/
private static final int WALLETS_BY_USER_IDS_BATCH = 500;
private final WalletTypeMapper walletTypeMapper;
private final WalletTypeService walletTypeService;
@@ -158,6 +163,27 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
);
}
@Override
public List<WalletManageEntity> getWalletsByUserIds(List<Long> userIds, WalletTypeEnum walletTypeCode) {
if (ObjectUtil.isEmpty(userIds)) {
return List.of();
}
WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode);
if (ObjectUtil.isEmpty(walletType)) {
return List.of();
}
// 单条 in 查询按批拆分,避免部门人数较多时超出数据库参数上限。
List<WalletManageEntity> wallets = new ArrayList<>();
for (int start = 0; start < userIds.size(); start += WALLETS_BY_USER_IDS_BATCH) {
int end = Math.min(start + WALLETS_BY_USER_IDS_BATCH, userIds.size());
wallets.addAll(baseMapper.selectList(new LambdaQueryWrapper<WalletManageEntity>()
.in(WalletManageEntity::getUserId, userIds.subList(start, end))
.eq(WalletManageEntity::getWalletTypeId, walletType.getId())
));
}
return wallets;
}
@Override
public WalletManageEntity getWallet(String cardNumber) {
return baseMapper.selectOne(new LambdaQueryWrapper<WalletManageEntity>()