diff --git a/.gitignore b/.gitignore index 720821d..5347943 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /target /temp /AGENTS.md +/.workbuddy multilingualField/ !.mvn/wrapper/maven-wrapper.jar diff --git a/pom.xml b/pom.xml index ea78572..95aa142 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.imyeyu.timiserverapi TimiServerAPI - 1.0.53 + 1.0.54 jar TimiServerAPI imyeyu.com API @@ -190,6 +190,10 @@ timi-spring 0.0.20 + + org.springframework.boot + spring-boot-starter-freemarker + com.imyeyu.network timi-network diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoOrderReminderServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoOrderReminderServiceImplement.java index 760a676..a08e4b2 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoOrderReminderServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoOrderReminderServiceImplement.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.imyeyu.api.modules.common.entity.Notify; import com.imyeyu.api.modules.common.entity.NotifyDetail; import com.imyeyu.api.modules.common.service.NotifyService; +import com.imyeyu.api.modules.gao.entity.GaoSalesOrder; import com.imyeyu.api.modules.gao.entity.GaoUser; import com.imyeyu.api.modules.gao.mapper.GaoOrderReminderMapper; import com.imyeyu.api.modules.gao.mapper.GaoUserMapper; @@ -12,15 +13,21 @@ import com.imyeyu.api.modules.gao.vo.GaoOrderReminderCustomer; import com.imyeyu.api.modules.user.entity.User; import com.imyeyu.api.modules.user.service.UserService; import com.imyeyu.utils.Time; +import freemarker.template.Configuration; +import freemarker.template.Template; +import freemarker.template.TemplateException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.io.IOException; +import java.io.StringWriter; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.ZoneId; import java.util.ArrayList; -import java.util.LinkedHashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -38,57 +45,94 @@ public class GaoOrderReminderServiceImplement implements GaoOrderReminderService private static final String NOTIFY_BIZ_TYPE = "GAO_ORDER_REMINDER"; private static final String SMS_TEMPLATE_ID = "2725097"; private static final String ZONE_ID = "Asia/Shanghai"; + private static final String INTERNAL_TEMPLATE_NAME = "GaoOrderReminder.ftl"; private final GaoOrderReminderMapper mapper; private final GaoUserMapper gaoUserMapper; private final UserService userService; private final NotifyService notifyService; private final ObjectMapper jackson; + private final Configuration freemarkerConfiguration; + /// 发送订单收款提醒 @Override public void sendOrderReceiveReminder() { send(mapper.selectOrderReceiveCustomers(), ReminderType.ORDER_RECEIVE); } + /// 发送收款和履约核销提醒 @Override public void sendVerifyReminder() { - Map> storeCustomerMap = new LinkedHashMap<>(); - Map> storeActionMap = new LinkedHashMap<>(); - mergeCustomers(storeCustomerMap, storeActionMap, mapper.selectReceiveVerifyCustomers(), "核销收款"); - mergeCustomers(storeCustomerMap, storeActionMap, mapper.selectFulfillmentVerifyCustomers(), "核销履约"); - for (Map.Entry> entry : storeCustomerMap.entrySet()) { - String action = String.join("、", storeActionMap.get(entry.getKey())); - sendStoreReminder(entry.getKey(), new ArrayList<>(entry.getValue().values()), ReminderType.VERIFY.name(), action); - } - } - - private void mergeCustomers(Map> storeCustomerMap, - Map> storeActionMap, List customerList, String action) { - if (customerList == null || customerList.isEmpty()) { - return; - } - for (GaoOrderReminderCustomer customer : customerList) { - storeCustomerMap.computeIfAbsent(customer.getStoreId(), key -> new LinkedHashMap<>()) - .putIfAbsent(customer.getCustomerId(), customer); - storeActionMap.computeIfAbsent(customer.getStoreId(), key -> new LinkedHashSet<>()).add(action); - } + Map> storeEntryMap = new LinkedHashMap<>(); + mergeCustomers(storeEntryMap, mapper.selectReceiveVerifyCustomers(), "核销收款"); + mergeCustomers(storeEntryMap, mapper.selectFulfillmentVerifyCustomers(), "核销履约"); + sendStoreReminders(storeEntryMap, ReminderType.VERIFY.name()); } + /// 按提醒类型发送订单提醒 + /// + /// @param customerList 订单提醒列表 + /// @param reminderType 提醒类型 private void send(List customerList, ReminderType reminderType) { if (customerList == null || customerList.isEmpty()) { return; } - Map> storeCustomerMap = customerList.stream() - .collect(Collectors.groupingBy(GaoOrderReminderCustomer::getStoreId, LinkedHashMap::new, Collectors.toList())); - for (Map.Entry> entry : storeCustomerMap.entrySet()) { - sendStoreReminder(entry.getKey(), entry.getValue(), reminderType.name(), reminderType.label); + Map> storeEntryMap = new LinkedHashMap<>(); + mergeCustomers(storeEntryMap, customerList, reminderType.label); + sendStoreReminders(storeEntryMap, reminderType.name()); + } + + /// 合并同一门店下同一订单的多个提醒事项 + /// + /// @param storeEntryMap 门店提醒映射 + /// @param customerList 订单提醒列表 + /// @param action 待处理事项 + private void mergeCustomers(Map> storeEntryMap, + List customerList, String action) { + if (customerList == null || customerList.isEmpty()) { + return; + } + for (GaoOrderReminderCustomer customer : customerList) { + if (customer == null || isBlank(customer.getStoreId()) || isBlank(customer.getOrderId())) { + log.warn("GAO 订单提醒跳过无效订单数据: storeId[{}], customerId[{}], orderId[{}]", + customer == null ? null : customer.getStoreId(), + customer == null ? null : customer.getCustomerId(), + customer == null ? null : customer.getOrderId()); + continue; + } + String entryKey = "%s:%s".formatted(customer.getCustomerId(), customer.getOrderId()); + Map entryMap = storeEntryMap.computeIfAbsent(customer.getStoreId(), + key -> new LinkedHashMap<>()); + ReminderEntry entry = entryMap.computeIfAbsent(entryKey, + key -> new ReminderEntry(customer, new LinkedHashSet<>())); + entry.actionSet().add(action); } } - private void sendStoreReminder(String storeId, List customerList, String reminderType, - String action) { - String customerNames = formatCustomerNames(customerList); - String content = "您的客户 %s 的订单需要 %s,请留意订单状态。".formatted(customerNames, action); + /// 按门店发送提醒 + /// + /// @param storeEntryMap 门店提醒映射 + /// @param reminderType 提醒类型 + private void sendStoreReminders(Map> storeEntryMap, String reminderType) { + for (Map.Entry> entry : storeEntryMap.entrySet()) { + sendStoreReminder(entry.getKey(), new ArrayList<>(entry.getValue().values()), reminderType); + } + } + + /// 向门店店长发送订单提醒 + /// + /// @param storeId 门店 ID + /// @param entryList 订单提醒列表 + /// @param reminderType 提醒类型 + private void sendStoreReminder(String storeId, List entryList, String reminderType) { + String customerNames = formatCustomerNames(entryList); + String action = entryList.stream() + .flatMap(entry -> entry.actionSet().stream()) + .distinct() + .collect(Collectors.joining("、")); + String smsContent = "您的客户 %s 的订单需要 %s,请留意订单状态。".formatted(customerNames, action); + String internalContent = buildInternalContent(entryList); + List managerList = gaoUserMapper.selectEnabledStoreManagers(storeId); if (managerList == null || managerList.isEmpty()) { log.warn("GAO 订单提醒未找到启用中的店长: storeId[{}], reminderType[{}]", storeId, reminderType); @@ -103,35 +147,50 @@ public class GaoOrderReminderServiceImplement implements GaoOrderReminderService log.warn("GAO 订单提醒跳过不存在的店长用户: storeId[{}], userId[{}]", storeId, manager.getUserId()); continue; } - String remindedKey = "%s:%s:%s:%s".formatted(LocalDate.now(ZoneId.of(ZONE_ID)), storeId, manager.getUserId(), reminderType); + String remindedKey = "%s:%s:%s:%s".formatted(LocalDate.now(ZoneId.of(ZONE_ID)), storeId, + manager.getUserId(), reminderType); long sendAt = Time.now(); Notify notify = new Notify(); notify.setBizType(NOTIFY_BIZ_TYPE); notify.setBizRemindedType(reminderType); notify.setBizRemindedKey(remindedKey); notify.setDetailList(List.of( - createInternalDetail(manager.getUserId(), content, sendAt), - createSmsDetail(user.getPhoneNo(), customerNames, action, content, sendAt) + createInternalDetail(manager.getUserId(), internalContent, sendAt), + createSmsDetail(user.getPhoneNo(), customerNames, action, smsContent, sendAt) )); notifyService.createIfAbsent(notify); } catch (Exception e) { - log.error("GAO 订单提醒发送失败: storeId[{}], userId[{}], reminderType[{}]", storeId, manager.getUserId(), reminderType, e); + log.error("GAO 订单提醒发送失败: storeId[{}], userId[{}], reminderType[{}]", storeId, + manager.getUserId(), reminderType, e); } } } + /// 创建站内通知详情 + /// + /// @param userId 接收用户 ID + /// @param content Tiptap HTML 正文 + /// @param sendAt 发送时间 + /// @return 通知详情 private NotifyDetail createInternalDetail(String userId, String content, long sendAt) { NotifyDetail detail = new NotifyDetail(); detail.setMsgType(NotifyDetail.MsgType.INTERNAL); - detail.setTemplateId(SMS_TEMPLATE_ID); detail.setSubject("订单状态提醒"); detail.setContent(content); - detail.setContentType(NotifyDetail.ContentType.TEXT); + detail.setContentType(NotifyDetail.ContentType.TIP_TAP); detail.setSendTo(userId); detail.setSendAt(sendAt); return detail; } + /// 创建短信通知详情 + /// + /// @param phoneNo 手机号 + /// @param customerNames 客户名称摘要 + /// @param action 待处理事项 + /// @param content 短信正文 + /// @param sendAt 发送时间 + /// @return 通知详情 private NotifyDetail createSmsDetail(String phoneNo, String customerNames, String action, String content, long sendAt) { NotifyDetail detail = new NotifyDetail(); detail.setMsgType(NotifyDetail.MsgType.SMS); @@ -143,15 +202,154 @@ public class GaoOrderReminderServiceImplement implements GaoOrderReminderService return detail; } - private String formatCustomerNames(List customerList) { - List nameList = customerList.stream() - .map(GaoOrderReminderCustomer::getCustomerName) - .map(name -> name == null || name.isBlank() ? "未知客户" : name.trim()) - .toList(); - if (customerList.size() <= 2) { + /// 使用 FreeMarker HTML 模板构建站内通知正文 + /// + /// @param entryList 订单提醒列表 + /// @return HTML 正文 + private String buildInternalContent(List entryList) { + Map model = new LinkedHashMap<>(); + List> customerModelList = new ArrayList<>(); + Map> customerEntryMap = entryList.stream() + .collect(Collectors.groupingBy(entry -> "%s:%s".formatted(entry.order().getCustomerId(), entry.order().getCustomerName()), + LinkedHashMap::new, Collectors.toList())); + for (List customerEntryList : customerEntryMap.values()) { + GaoOrderReminderCustomer firstOrder = customerEntryList.getFirst().order(); + Map customerModel = new LinkedHashMap<>(); + customerModel.put("name", displayCustomerName(firstOrder.getCustomerName())); + customerModel.put("orderList", customerEntryList.stream() + .map(this::buildOrderTemplateModel) + .toList()); + customerModelList.add(customerModel); + } + model.put("customerList", customerModelList); + try { + Template template = freemarkerConfiguration.getTemplate(INTERNAL_TEMPLATE_NAME); + StringWriter writer = new StringWriter(); + template.process(model, writer); + return writer.toString(); + } catch (IOException | TemplateException e) { + throw new IllegalStateException("渲染 GAO 订单提醒站内信模板失败", e); + } + } + + /// 构建 FreeMarker 订单模型 + /// + /// @param entry 订单提醒 + /// @return 订单模板模型 + private Map buildOrderTemplateModel(ReminderEntry entry) { + GaoOrderReminderCustomer order = entry.order(); + Map orderModel = new LinkedHashMap<>(); + orderModel.put("orderId", order.getOrderId()); + orderModel.put("orderNo", textOrDefault(order.getOrderNo(), "未知订单")); + orderModel.put("orderStatus", orderStatusLabel(order.getOrderStatus())); + orderModel.put("paymentStatus", paymentStatusLabel(order.getPaymentStatus())); + orderModel.put("amountSummary", "成交 %s,已收 %s,待核销收款 %d 笔共 %s,未收 %s" + .formatted(formatMoney(order.getSaleAmount()), formatMoney(order.getPaidAmount()), + safeLong(order.getPendingReceiveCount()), formatMoney(receiveAmount(order)), formatMoney(order.getUnpaidAmount()))); + orderModel.put("fulfillmentSummary", "待核销履约 %d 单,涉及 %d 项明细" + .formatted(safeLong(order.getPendingFulfillmentCount()), safeLong(order.getPendingFulfillmentItemCount()))); + return orderModel; + } + + /// 格式化短信中的客户名称 + /// + /// @param entryList 订单提醒列表 + /// @return 客户名称摘要 + private String formatCustomerNames(List entryList) { + Map nameMap = new LinkedHashMap<>(); + for (ReminderEntry entry : entryList) { + GaoOrderReminderCustomer order = entry.order(); + nameMap.putIfAbsent(order.getCustomerId(), displayCustomerName(order.getCustomerName())); + } + List nameList = new ArrayList<>(nameMap.values()); + if (nameList.size() <= 2) { return String.join("、", nameList); } - return "%s、%s等 %d 位".formatted(nameList.getFirst(), nameList.get(1), customerList.size()); + return "%s、%s等 %d 位".formatted(nameList.getFirst(), nameList.get(1), nameList.size()); + } + + /// 获取待核销收款金额 + /// + /// @param order 订单提醒 + /// @return 待核销收款金额,单位分 + private long receiveAmount(GaoOrderReminderCustomer order) { + return order.getPendingReceiveAmount() == null + ? safeLong(order.getPendingPaidAmount()) : order.getPendingReceiveAmount(); + } + + /// 格式化金额 + /// + /// @param cents 金额,单位分 + /// @return 金额文本 + private String formatMoney(Long cents) { + return "¥" + BigDecimal.valueOf(safeLong(cents), 2).setScale(2).toPlainString(); + } + + /// 获取订单状态显示文本 + /// + /// @param status 订单状态 + /// @return 显示文本 + private String orderStatusLabel(GaoSalesOrder.Status status) { + if (status == null) { + return "未知"; + } + return switch (status) { + case ACTIVE -> "进行中"; + case FULFILLED -> "已全部履约"; + case CANCELLED -> "已取消"; + }; + } + + /// 获取付款状态显示文本 + /// + /// @param status 付款状态 + /// @return 显示文本 + private String paymentStatusLabel(GaoSalesOrder.PaymentStatus status) { + if (status == null) { + return "未知"; + } + return switch (status) { + case UNPAID -> "未付款"; + case PARTIALLY_PAID -> "部分付款"; + case PAID -> "已付清"; + }; + } + + /// 获取客户显示名称 + /// + /// @param name 客户名称 + /// @return 客户显示名称 + private String displayCustomerName(String name) { + return textOrDefault(name == null ? null : name.trim(), "未知客户"); + } + + /// 获取非空文本 + /// + /// @param value 原文本 + /// @param defaultValue 默认文本 + /// @return 非空文本 + private String textOrDefault(String value, String defaultValue) { + return isBlank(value) ? defaultValue : value; + } + + /// 判断文本为空 + /// + /// @param value 文本 + /// @return true 为文本为空 + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + /// 获取安全长整数 + /// + /// @param value 原值 + /// @return 非空数值 + private long safeLong(Long value) { + return value == null ? 0L : value; + } + + /// 订单提醒条目 + private record ReminderEntry(GaoOrderReminderCustomer order, Set actionSet) { } private enum ReminderType { diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoOrderReminderCustomer.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoOrderReminderCustomer.java index 52f9bab..0989b92 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoOrderReminderCustomer.java +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoOrderReminderCustomer.java @@ -1,5 +1,6 @@ package com.imyeyu.api.modules.gao.vo; +import com.imyeyu.api.modules.gao.entity.GaoSalesOrder; import lombok.Data; /// GAO 订单提醒客户 @@ -17,4 +18,40 @@ public class GaoOrderReminderCustomer { /// 客户名称 private String customerName; + + /// 订单 ID + private String orderId; + + /// 订单号 + private String orderNo; + + /// 订单状态 + private GaoSalesOrder.Status orderStatus; + + /// 付款状态 + private GaoSalesOrder.PaymentStatus paymentStatus; + + /// 成交金额,单位分 + private Long saleAmount; + + /// 待核销收款,单位分 + private Long pendingPaidAmount; + + /// 已收金额,单位分 + private Long paidAmount; + + /// 未收金额,单位分 + private Long unpaidAmount; + + /// 待核销收款笔数 + private Long pendingReceiveCount; + + /// 待核销收款金额,单位分 + private Long pendingReceiveAmount; + + /// 待核销履约单数量 + private Long pendingFulfillmentCount; + + /// 待核销履约明细数量 + private Long pendingFulfillmentItemCount; } diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockProductView.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockProductView.java index ed4ee30..1941fee 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockProductView.java +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockProductView.java @@ -32,6 +32,9 @@ public class GaoProductStockProductView { /// 可库存 SKU 数量 private Long skuCount; + /// 产品仅有一个可库存 SKU 时的 SKU ID + private String skuId; + /// 库存数量,使用基础单位 private BigDecimal onHandQuantity; diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockRecordPage.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockRecordPage.java index 85b0bbd..ed41e48 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockRecordPage.java +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoProductStockRecordPage.java @@ -19,6 +19,9 @@ public class GaoProductStockRecordPage extends BasePage { /// 产品 ID private String productId; + /// SKU ID + private String skuId; + /// 产品名称、拼音或编码关键词 private String keyword; diff --git a/src/main/resources/mapper/timi-server/gao/GaoOrderReminderMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoOrderReminderMapper.xml index e1443e0..7b03606 100644 --- a/src/main/resources/mapper/timi-server/gao/GaoOrderReminderMapper.xml +++ b/src/main/resources/mapper/timi-server/gao/GaoOrderReminderMapper.xml @@ -1,44 +1,90 @@ - + + FROM `gao_sales_order` o + WHERE o.`deleted_at` IS NULL AND o.`status` IN ('ACTIVE', 'FULFILLED') AND o.`payment_status` IN ('UNPAID', 'PARTIALLY_PAID') - GROUP BY o.`store_id`, o.`customer_id`, c.`name` - ORDER BY o.`store_id`, c.`name`, o.`customer_id` + ORDER BY o.`store_id`, c.`name`, o.`customer_id`, o.`purchased_at`, o.`order_no` diff --git a/src/main/resources/mapper/timi-server/gao/GaoProductStockMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoProductStockMapper.xml index 8da1e16..f668887 100644 --- a/src/main/resources/mapper/timi-server/gao/GaoProductStockMapper.xml +++ b/src/main/resources/mapper/timi-server/gao/GaoProductStockMapper.xml @@ -84,6 +84,7 @@ p.`name` AS `product_name`, p.`product_type`, COUNT(k.`id`) AS `sku_count`, + CASE WHEN COUNT(k.`id`) = 1 THEN MAX(k.`id`) END AS `sku_id`, COALESCE(SUM(COALESCE(s.`on_hand_quantity`, 0)), 0) AS `on_hand_quantity`, COALESCE(SUM(COALESCE(s.`reserved_quantity`, 0)), 0) AS `reserved_quantity`, COALESCE(SUM(COALESCE(s.`on_hand_quantity`, 0) - COALESCE(s.`reserved_quantity`, 0)), 0) AS `available_quantity` diff --git a/src/main/resources/mapper/timi-server/gao/GaoProductStockRecordMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoProductStockRecordMapper.xml index 6843b5c..e559371 100644 --- a/src/main/resources/mapper/timi-server/gao/GaoProductStockRecordMapper.xml +++ b/src/main/resources/mapper/timi-server/gao/GaoProductStockRecordMapper.xml @@ -31,6 +31,7 @@ 1 = 1 AND r.`store_id` = #{storeId} AND k.`product_id` = #{productId} + AND r.`sku_id` = #{skuId} AND (r.`product_name_snapshot` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%') OR p.`name_pinyin_full` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_initial` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_mixed` LIKE CONCAT(#{keyword}, '%') OR p.`code` LIKE CONCAT('%', #{keyword}, '%')) AND r.`biz_type` = #{bizType} AND r.`biz_id` = #{bizId} diff --git a/src/main/resources/templates/GaoOrderReminder.ftl b/src/main/resources/templates/GaoOrderReminder.ftl new file mode 100644 index 0000000..9904f5e --- /dev/null +++ b/src/main/resources/templates/GaoOrderReminder.ftl @@ -0,0 +1,22 @@ +
+

订单状态提醒

+

以下订单存在待处理事项,请及时处理。

+ <#list customerList as customer> +

客户:${customer.name?html}

+ <#list customer.orderList as order> +

订单号:${order.orderNo?html}

+
    +
  • 订单状态:${order.orderStatus?html}
  • +
  • 付款状态:${order.paymentStatus?html}
  • +
  • 金额情况:${order.amountSummary?html}
  • +
  • 履约情况:${order.fulfillmentSummary?html}
  • +
+

+ 查看订单详情 +

+ + +