Series Article

Day03 · PMS 采购管理系统完整面试指南

一、系统概述与业务价值

1.1 系统定位

面试官:介绍一下你负责的 PMS 系统。

我的回答:

我负责的是 PMS 采购管理系统,这个系统主要解决跨境电商供应链中从采购需求产生到入库付款的全流程管理问题。

具体来说,我们是一个 SaaS 平台,服务多个跨境电商商家。每个商家可能有几十到上百个 SKU 需要采购,涉及库存预警、询价比价、采购订单、收货入库、应付账款等多个环节。我们的系统需要管理整个采购生命周期,确保采购及时、价格合理、账款清晰。

这个系统在整个供应链中的位置是承上启下。上游是 SRM 供应商管理系统,采购时需要选择已审核通过的供应商。下游是 WMS 仓储系统,货物到仓后需要收货入库,更新库存。再下游是 FMS 财务系统,收货后需要生成应付账款,管理付款。

日均处理采购订单 500+,询价单 200+,收货单 300+。系统管理了 200+ 活跃供应商,累计采购金额超过 5000 万元。

1.2 核心业务场景

PMS 系统解决三个核心业务场景:

场景 1:智能采购需求触发

  • 库存预警自动触发:库存低于安全库存,系统自动生成采购建议
    • 安全库存: SKU这个表中的一个字段
  • 销售预测触发:根据历史销量预测未来需求,提前备货
    • ABC分类
  • 人工手动申请:采购专员根据业务需要手动创建
  • 技术难点:定时任务的分片执行、预测算法的准确性、幂等性控制

场景 2:询价比价与供应商选择

  • 向多家供应商发送询价单,供应商在门户填写报价
  • 系统自动计算综合评分:价格 40% + 交货期 30% + 供应商评级 30%
    • 是否采纳某个供应商的参考标准
  • 采购专员根据综合得分选择最优供应商
  • 技术难点:综合评分算法、历史价格对比、报价有效期管理

场景 3:采购入库与应付账款管理

  • 货物到仓后,仓库收货质检入库
    • 分类 质检
  • 分布式事务保证:采购订单状态更新 + WMS 库存增加 + 在途库存减少
  • 收货后自动生成应付账款,根据账期计算到期日期
    • 月结 提供定时扫描
  • 技术难点:分布式事务一致性、部分收货处理、账期到期提醒

1.3 系统架构

技术栈:

  • 后端框架: Spring Boot 3.2、MyBatis-Plus 3.5
  • 数据库: MySQL 8.0(主从架构)
  • 缓存: Redis 7.0(分布式锁、缓存)
  • 分布式事务: Seata 1.7(AT 模式)
  • 定时任务: XXL-Job 2.4
  • 消息队列: RocketMQ 5.0
  • 容器化: Docker 24.0、docker-compose 2.20
  • Web服务器: Nginx 1.24(反向代理、负载均衡)

核心模块:

  1. 采购需求管理:库存预警、销售预测、人工申请
  2. 询价比价模块:询价单、报价管理、综合评分
  3. 采购订单管理:9 状态状态机、部分收货、订单跟踪
  4. 收货入库模块:收货单、质检、分布式事务
  5. 应付账款管理:账期计算、到期提醒、付款记录

二、核心技术亮点

2.1 采购订单 9 状态状态机设计

问题背景:

采购订单涉及多个状态流转:草稿 → 待供应商确认 → 已确认 → 发货中 → 部分到货 → 全部到货 → 已对账 → 已结清。如果用 if-else 判断状态流转,代码会非常混乱,容易出现非法状态流转。而且支持部分收货和部分付款,状态流转更加复杂。

解决方案:

设计了基于白名单的状态机,明确定义 9 个状态和 12 个合法流转路径。

具体实现:

第一步,定义状态枚举:

public enum PurchaseOrderStatus {
    DRAFT(0, "草稿"),                    // 初始创建,可编辑
    PENDING_CONFIRM(1, "待供应商确认"),   // 已发给供应商
    CONFIRMED(2, "已确认"),              // 供应商已确认
    SHIPPING(3, "发货中"),               // 供应商已发货
    PARTIAL_RECEIVED(4, "部分到货"),     // 已收到部分货物
    RECEIVED(5, "全部到货"),             // 全部货物已到
    CHECKED(6, "已对账"),                // 财务已对账
    SETTLED(7, "已结清"),                // 已付款结清
    CANCELLED(8, "已取消");              // 已取消
    
    private final int code;
    private final String desc;
}

第二步,定义状态流转白名单:

public class PurchaseOrderStateMachine {
    // 状态流转白名单
    private static final Map<PurchaseOrderStatus, List<PurchaseOrderStatus>> TRANSITIONS = Map.of(
        PurchaseOrderStatus.DRAFT, 
            List.of(PurchaseOrderStatus.PENDING_CONFIRM, PurchaseOrderStatus.CANCELLED),
        PurchaseOrderStatus.PENDING_CONFIRM, 
            List.of(PurchaseOrderStatus.CONFIRMED, PurchaseOrderStatus.DRAFT, PurchaseOrderStatus.CANCELLED),
        PurchaseOrderStatus.CONFIRMED, 
            List.of(PurchaseOrderStatus.SHIPPING),
        PurchaseOrderStatus.SHIPPING, 
            List.of(PurchaseOrderStatus.PARTIAL_RECEIVED, PurchaseOrderStatus.RECEIVED),
        PurchaseOrderStatus.PARTIAL_RECEIVED, 
            List.of(PurchaseOrderStatus.PARTIAL_RECEIVED, PurchaseOrderStatus.RECEIVED),
        PurchaseOrderStatus.RECEIVED, 
            List.of(PurchaseOrderStatus.CHECKED),
        PurchaseOrderStatus.CHECKED, 
            List.of(PurchaseOrderStatus.SETTLED)
    );
    
    // 校验状态流转是否合法
    public static boolean canTransition(PurchaseOrderStatus from, PurchaseOrderStatus to) {
        List<PurchaseOrderStatus> allowedStates = TRANSITIONS.get(from);
        return allowedStates != null && allowedStates.contains(to);
    }
}

第三步,在业务代码中使用状态机:

@Transactional(rollbackFor = Exception.class)
public void updateOrderStatus(Long orderId, PurchaseOrderStatus targetStatus) {
    // 1. 查询采购订单当前状态
    PurchaseOrder order = purchaseOrderMapper.selectById(orderId);
    PurchaseOrderStatus currentStatus = PurchaseOrderStatus.of(order.getStatus());
    
    // 2. 校验状态流转是否合法
    if (!PurchaseOrderStateMachine.canTransition(currentStatus, targetStatus)) {
        throw new BusinessException("非法的状态流转: " + currentStatus + " -> " + targetStatus);
    }
    
    // 3. 使用 CAS 乐观锁更新状态
    int updated = purchaseOrderMapper.update(null,
        new LambdaUpdateWrapper<PurchaseOrder>()
            .eq(PurchaseOrder::getId, orderId)
            .eq(PurchaseOrder::getStatus, currentStatus.getCode())
            .eq(PurchaseOrder::getVersion, order.getVersion())
            .set(PurchaseOrder::getStatus, targetStatus.getCode())
    );
    
    if (updated == 0) {
        throw new BusinessException("更新失败,订单状态已被其他人修改");
    }
    
    // 4. 记录状态变更日志
    insertStatusLog(orderId, currentStatus, targetStatus);
}

效果:

  1. 代码清晰:状态流转规则集中在白名单中,一目了然
  2. 易于维护:新增状态或流转路径只需修改白名单
  3. 防止非法流转:任何不在白名单中的流转都会被拒绝
  4. 支持复杂场景:部分到货可以多次流转到部分到货,直到全部到货

面试追问:为什么部分到货可以流转到部分到货?

因为一个采购订单可能分多次收货。比如采购 1000 件耳机,第一次到货 300 件,状态变为”部分到货”。第二次到货 400 件,状态还是”部分到货”。第三次到货 300 件,全部到齐,状态才变为”全部到货”。所以部分到货可以流转到部分到货。

2.2 询价比价综合评分算法

问题背景:

同一个 SKU 向 3-5 家供应商询价,如何选择最优供应商?如果只看价格,可能选到质量差、交货慢的供应商。如果只看供应商评级,可能价格太高。需要综合考虑多个维度。

解决方案:

设计综合评分算法,考虑价格、交货期、供应商评级三个维度,自动计算综合得分。

具体实现:

评分公式:

综合得分 = 价格得分(40%) + 交货期得分(30%) + 供应商评级得分(30%)

价格得分 = (最低报价 / 当前报价) × 40
// 最低报价 比如: 同时向3家供应商发起了询价 那么最低的那个供应商报价 就是最低报价
交货期得分 = (最短交货期 / 当前交货期) × 30
供应商评级得分 = (供应商评分 / 100) × 30

代码实现:

public List<InquiryScoreDTO> calculateScores(List<PurchaseInquiry> inquiries) {
    // 1. 找出最低报价和最短交货期
    BigDecimal minPrice = inquiries.stream()
        .map(PurchaseInquiry::getTotalQuoteAmt)
        .min(BigDecimal::compareTo)
        .orElse(BigDecimal.ZERO);
    
    int minDeliveryDays = inquiries.stream()
        .mapToInt(PurchaseInquiry::getDeliveryDays)
        .min()
        .orElse(1);
    
    // 2. 计算每家供应商的综合得分
    return inquiries.stream().map(inquiry -> {
        // 价格得分 = (最低报价 / 当前报价) × 40
        BigDecimal priceScore = minPrice
            .divide(inquiry.getTotalQuoteAmt(), 4, RoundingMode.HALF_UP)
            .multiply(BigDecimal.valueOf(40));
        
        // 交货期得分 = (最短交货期 / 当前交货期) × 30
        BigDecimal deliveryScore = BigDecimal.valueOf(minDeliveryDays)
            .divide(BigDecimal.valueOf(inquiry.getDeliveryDays()), 4, RoundingMode.HALF_UP)
            .multiply(BigDecimal.valueOf(30));
        
        // 供应商评级得分 = (供应商评分 / 100) × 30
        BigDecimal gradeScore = inquiry.getSupplierScore()
            .divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
            .multiply(BigDecimal.valueOf(30));
        
        // 综合得分
        BigDecimal totalScore = priceScore.add(deliveryScore).add(gradeScore);
        
        return new InquiryScoreDTO(inquiry, priceScore, deliveryScore, gradeScore, totalScore);
    })
    .sorted(Comparator.comparing(InquiryScoreDTO::getTotalScore).reversed())
    .collect(Collectors.toList());
}

示例计算:

假设向 3 家供应商询价 500 件蓝牙耳机:

供应商 A:

  • 报价:38 元/件,总金额 19000 元
  • 交货期:7 天
  • 供应商评分:95 分

价格得分 = (36 / 38) × 40 = 37.89
交货期得分 = (5 / 7) × 30 = 21.43
评级得分 = (95 / 100) × 30 = 28.5
综合得分 = 87.82

供应商 B:

  • 报价:36 元/件,总金额 18000 元(最低)
  • 交货期:10 天
  • 供应商评分:80 分

价格得分 = (36 / 36) × 40 = 40
交货期得分 = (5 / 10) × 30 = 15
评级得分 = (80 / 100) × 30 = 24
综合得分 = 79

供应商 C:

  • 报价:40 元/件,总金额 20000 元
  • 交货期:5 天(最短)
  • 供应商评分:90 分

价格得分 = (36 / 40) × 40 = 36
交货期得分 = (5 / 5) × 30 = 30
评级得分 = (90 / 100) × 30 = 27
综合得分 = 93

结论:选择供应商 C(综合得分最高)

虽然供应商 C 的价格不是最低,但交货最快,评级也高,综合性价比最好。

效果:

  1. 避免单纯比价:不会因为价格低就选择质量差的供应商
  2. 综合最优:考虑价格、交货期、供应商评级三个维度
  3. 决策透明:每个维度的得分都清晰可见,决策有据可查
  4. 灵活调整:权重可以根据业务需要调整(比如紧急采购时提高交货期权重)
    1. 最好是设计一张表 让租户自己去调整比例 比如有的租户更看重价格 那么就可以把价格的比例调高 50%
    2. 如果租户有自己的配置 我们在进行计算综合评分的时候 就要先查询这个比例 然后再进行计算

面试追问:为什么价格权重是 40%,不是 50%?

这是根据业务调研确定的。价格固然重要,但不是唯一因素。如果价格权重太高(比如 60%),可能导致选择价格低但质量差的供应商。如果价格权重太低(比如 20%),可能导致采购成本过高。40% 是一个平衡点,既重视价格,又不忽视质量和交货期。

40% 是一个综合的考量 适用于大多数的一个租户

如果某个租户更在意这个价格 那么可以自己去配置价格在综合评分中的占比

2.3 采购入库分布式事务方案

问题背景:

仓库收货确认入库时,需要同时更新三个系统的数据:

  1. PMS:采购订单状态更新为”已收货”,已收货数量增加
    1. 采购系统中的 表 修改状态
  2. WMS:库存数量增加 在途库存减少
    1. 仓储系统 库存的数量 要增加
    2. 仓储系统 在途库存 要减少
  3. PMS:在途库存减少
    1. 采购明细表中的在途库存要减少

这三个操作必须同时成功或同时失败,否则会出现数据不一致。比如 PMS 订单状态更新了,但 WMS 库存没增加,就会导致库存数据错误。

解决方案:

使用 Seata AT 模式实现分布式事务。

首先我们要先把Seata的服务器搭建起来

然后要把Seata需要的数据库表也要创建出来

最后在项目中引入Seata的配置就可以了

具体实现:

第一步,配置 Seata:

seata:
  enabled: true
  application-id: pms-service
  tx-service-group: supply-chain-group
  service:
    vgroup-mapping:
      supply-chain-group: default
    grouplist:
      default: 127.0.0.1:8091

第二步,编写分布式事务代码:

@Service
public class PurchaseReceiptService {
    
    @Autowired
    private PurchaseOrderMapper purchaseOrderMapper;
    
    @Autowired
    private PurchaseOrderItemMapper orderItemMapper;
    
    @Autowired
    private WmsInventoryService wmsInventoryService;  // Feign 远程调用
    
    // 对当前的业务 使用分布式事务进行管理
    @GlobalTransactional(rollbackFor = Exception.class)
    public void confirmReceipt(Long receiptId) {
        // 1. 查询收货单
        PurchaseReceipt receipt = receiptMapper.selectById(receiptId);
        List<PurchaseReceiptItem> items = receiptItemMapper.selectList(
            new LambdaQueryWrapper<PurchaseReceiptItem>()
                .eq(PurchaseReceiptItem::getReceiptId, receiptId)
        );
        
        // 2. 更新采购订单明细的已收货数量(PMS 本地事务)
        for (PurchaseReceiptItem item : items) {
            orderItemMapper.update(null,
                new LambdaUpdateWrapper<PurchaseOrderItem>()
                    .eq(PurchaseOrderItem::getId, item.getPoItemId())
                    .setSql("received_qty = received_qty + " + item.getPassQty())
            );
        }
        
        // 3. 判断是否全部收货,更新采购订单状态
        PurchaseOrder order = purchaseOrderMapper.selectById(receipt.getPoId());
        List<PurchaseOrderItem> orderItems = orderItemMapper.selectList(
            new LambdaQueryWrapper<PurchaseOrderItem>()
                .eq(PurchaseOrderItem::getPoId, order.getId())
        );
        
        boolean allReceived = orderItems.stream()
            .allMatch(item -> item.getReceivedQty() >= item.getQuantity());
        
        PurchaseOrderStatus newStatus = allReceived 
            ? PurchaseOrderStatus.RECEIVED 
            : PurchaseOrderStatus.PARTIAL_RECEIVED;
        
        updateOrderStatus(order.getId(), newStatus);
        
        // 4. 调用 WMS 增加库存(远程调用,Seata 自动管理)
        List<InventoryIncreaseDTO> inventoryList = items.stream()
            .map(item -> new InventoryIncreaseDTO(
                item.getSkuId(),
                receipt.getWarehouseId(),
                item.getPassQty()
            ))
            .collect(Collectors.toList());
        
        wmsInventoryService.increaseInventory(inventoryList);
        
        // 5. 减少在途库存(PMS 本地事务)
        for (PurchaseReceiptItem item : items) {
            inTransitInventoryMapper.update(null,
                new LambdaUpdateWrapper<InTransitInventory>()
                    .eq(InTransitInventory::getSkuId, item.getSkuId())
                    .eq(InTransitInventory::getWarehouseId, receipt.getWarehouseId())
                    .setSql("quantity = quantity - " + item.getPassQty())
            );
        }
        
        // 6. 如果全部收货,生成应付账款
        if (allReceived) {
            generatePayable(order.getId());
        }
    }
}

Seata 工作原理:

TM: 全局事务的发起者 GlobalTransactional 这个注解写在哪 哪就是TM TC: Seata 的服务 RM: 仅仅是全局事务的一个子事务 一个参与者

如果是AT模式: 两阶段提交 注意全局锁的问题 ??

去了解 关于 全局锁的面试题

  1. **TM(事务管理器)**开启全局事务,生成全局事务 ID(XID)
  2. PMS 更新订单状态,Seata 自动记录 undo_log(回滚日志)
  3. WMS 增加库存(远程调用),Seata 自动记录 undo_log
  4. PMS 减少在途库存,Seata 自动记录 undo_log
  5. 如果全部成功,TM 提交全局事务,删除 undo_log
  6. 如果任何一步失败,TM 回滚全局事务,根据 undo_log 恢复数据

效果:

  1. 完全保证数据一致性:任何一步失败都能自动回滚
  2. 对业务代码侵入小:只需加 @GlobalTransactional 注解
  3. 性能可接受:Seata AT 模式是自动补偿,性能不如 TCC 模式好 因为AT模式有全局锁 TCC模式没有锁
  4. 可追溯:Seata 有完整的事务日志,可以查询事务执行情况

面试追问:为什么不用本地事务?

因为 PMS 和 WMS 是两个独立的服务,有各自的数据库。本地事务只能保证单个数据库的一致性,无法保证跨服务的一致性。必须用分布式事务。

面试追问:Seata 回滚失败怎么办?

Seata 有多层保障:

  1. 自动重试:回滚失败会自动重试,最多重试 5 次
  2. 人工介入:如果重试还是失败,Seata 会记录到异常日志,运维人工处理
  3. 监控告警:我们配置了 Seata 监控,回滚失败会立即告警,方便及时处理

实际上,Seata 的回滚成功率非常高(99.9% 以上),因为它只是根据 undo_log 恢复数据,操作很简单。

面试追问:什么时候用 Seata,什么时候用 RocketMQ?

这是分布式系统设计的核心问题。简单来说:

使用 Seata(强一致性)的场景:

  1. 业务要求强一致性:订单创建 + 库存冻结,必须同时成功或同时失败
  2. 用户体验要求实时反馈:用户下单后立即知道是否成功,不能延迟
  3. 数据关联性强:多个操作之间有强依赖关系,一个失败其他必须回滚
  4. 典型场景:订单创建、库存扣减、资金转账、采购入库

使用 RocketMQ(最终一致性)的场景:

  1. 业务允许短暂延迟:状态通知、数据同步,延迟几秒钟可以接受
  2. 解耦服务:上游不关心下游的执行结果,只需要通知到位
  3. 削峰填谷:高并发场景下,用 MQ 缓冲流量
  4. 典型场景:订单状态通知、库存变动通知、日志记录、数据同步

本项目的实际应用:

  • 采购入库 → 更新采购单状态:用 RocketMQ(允许延迟,解耦 WMS 和 PMS)
  • 订单创建 → 冻结库存:用 Seata(必须强一致,用户需要实时反馈)
  • 收货完成 → 生成应付账款:用本地事务(同一个服务内部,不需要分布式事务)

2.4 应付账款账期管理和到期提醒

问题背景:

不同供应商的账期不同:

  • 供应商 A:现款现货(payment_days = 0)
  • 供应商 B:月结 30 天(payment_days = 30)
  • 供应商 C:月结 60 天(payment_days = 60)

如果人工跟踪,容易遗漏,导致逾期付款影响信用。需要系统自动管理账期,提前提醒付款。

解决方案:

收货后自动生成应付账款,根据供应商账期计算到期日期,定时任务提前提醒。

具体实现:

第一步,生成应付账款:

@Transactional(rollbackFor = Exception.class)
public void generatePayable(Long poId) {
    // 1. 查询采购订单
    PurchaseOrder po = purchaseOrderMapper.selectById(poId);
    
    // 2. 查询供应商账期
    Supplier supplier = supplierMapper.selectById(po.getSupplierId());
    int paymentDays = supplier.getPaymentDays();
    
    // 3. 计算到期日期
    LocalDate dueDate = LocalDate.now().plusDays(paymentDays);
    
    // 4. 创建应付账款
    FinancePayable payable = new FinancePayable();
    payable.setPoId(poId);
    payable.setPoNo(po.getPoNo());
    payable.setSupplierId(po.getSupplierId());
    payable.setSupplierName(po.getSupplierName());
    payable.setPayableAmount(po.getTotalAmount());
    payable.setPaidAmount(BigDecimal.ZERO);
    payable.setPaymentDays(paymentDays);
    payable.setDueDate(dueDate);
    payable.setStatus(PayableStatus.UNPAID.getCode());
    financePayableMapper.insert(payable);
}

第二步,定时任务提前提醒:

@Component
public class PayableDueReminderJob {
    
    @XxlJob("payableDueReminderJob")
    public void execute() {
        // 1. 查询 3 天内到期的应付账款
        LocalDate threeDaysLater = LocalDate.now().plusDays(3);
        List<FinancePayable> payables = financePayableMapper.selectList(
            new LambdaQueryWrapper<FinancePayable>()
                .eq(FinancePayable::getStatus, PayableStatus.UNPAID.getCode())
                .le(FinancePayable::getDueDate, threeDaysLater)
                .ge(FinancePayable::getDueDate, LocalDate.now())
        );
        
        log.info("查询到 {} 条即将到期的应付账款", payables.size());
        
        // 2. 发送提醒
        for (FinancePayable payable : payables) {
            long daysLeft = ChronoUnit.DAYS.between(LocalDate.now(), payable.getDueDate());
            
            // 发送站内消息
            messageService.send(
                payable.getTenantId(),
                "应付账款即将到期",
                String.format("采购单 %s 的应付账款将在 %d 天后到期,金额 %.2f 元,请及时付款",
                    payable.getPoNo(), daysLeft, payable.getPayableAmount())
            );
            
            // 发送邮件(可选)
            emailService.send(
                payable.getFinanceEmail(), 
                "应付账款到期提醒", 
                buildEmailContent(payable, daysLeft)
            );
        }
    }
}

第三步,付款操作:

@Transactional(rollbackFor = Exception.class)
public void payPayable(Long payableId, BigDecimal payAmount, String voucherNo) {
    // 1. 查询应付账款
    FinancePayable payable = financePayableMapper.selectById(payableId);
    
    // 2. 更新已付金额
    BigDecimal newPaidAmount = payable.getPaidAmount().add(payAmount);
    payable.setPaidAmount(newPaidAmount);
    
    // 3. 判断是否全部付清
    if (newPaidAmount.compareTo(payable.getPayableAmount()) >= 0) {
        payable.setStatus(PayableStatus.PAID.getCode());
        
        // 4. 更新采购订单状态为"已结清"
        purchaseOrderMapper.update(null,
            new LambdaUpdateWrapper<PurchaseOrder>()
                .eq(PurchaseOrder::getId, payable.getPoId())
                .set(PurchaseOrder::getStatus, PurchaseOrderStatus.SETTLED.getCode())
        );
    } else {
        payable.setStatus(PayableStatus.PARTIAL_PAID.getCode());
    }
    
    financePayableMapper.updateById(payable);
    
    // 5. 写入付款记录
    FinancePaymentRecord record = new FinancePaymentRecord();
    record.setPayableId(payableId);
    record.setPaymentAmount(payAmount);
    record.setPaymentDate(LocalDate.now());
    record.setVoucherNo(voucherNo);
    record.setOperatorId(SecurityUtils.getCurrentUserId());
    record.setOperatorName(SecurityUtils.getCurrentUserName());
    paymentRecordMapper.insert(record);
}

效果:

  1. 自动生成:收货后自动生成应付账款,不需要人工创建
    1. 当仓储系统中的仓管人员 对当前的这个货物进行了签收
    2. 系统就可以根据当前供应商的一个账单周期 自动进行账单的生成
  2. 提前提醒:提前 3 天提醒,留足付款准备时间
    1. 使用定时任务去定期(每天)扫描一次 找出哪些账单是快到期的
    2. 可以按照 3天提醒 当天提醒
  3. 支持部分付款:可以分多次付款,灵活应对资金安排
    1. 每次付款 都需要有记录
  4. 可追溯:所有付款记录都保存,可以查询付款历史

面试追问:为什么提前 3 天提醒,不是 7 天或 1 天?

建议: 两次提醒 3天提醒 当天提醒

这是根据业务调研确定的。如果提前太早(比如 7 天),财务可能忘记。如果提前太晚(比如 1 天),可能来不及准备资金、走审批流程。提前 3 天刚好:财务有时间准备资金、走审批流程、安排付款。而且我们是每天早上 9 点提醒,财务上班就能看到,不会遗漏。

2.5 库存预警自动触发采购需求

问题背景:

库存低于安全库存时,需要及时补货。如果靠人工监控,可能遗漏,导致断货。需要系统自动监控库存,低于安全库存时自动生成采购建议。

解决方案:

使用 XXL-Job 定时任务,每小时扫描一次库存,低于安全库存时自动创建采购申请单。

因为库存是实时变化的 所以每个小时扫描一次

具体实现:

第一步,配置定时任务:

任务名称: inventoryAlertJob
Cron 表达式: 0 0 * * * ?  (每小时整点执行)
运行模式: 分片广播
分片参数: 0/3, 1/3, 2/3  (3台服务器,每台处理1/3的数据)

第二步,编写定时任务代码:

XxlJobHelper.getShardIndex(); XxlJobHelper.getShardTotal();

@Component
public class InventoryAlertJob {
    
    @XxlJob("inventoryAlertJob")
    public void execute() {
        // 1. 获取分片参数
        int shardIndex = XxlJobHelper.getShardIndex();
        int shardTotal = XxlJobHelper.getShardTotal();
        
        log.info("开始执行库存预警任务, 分片: {}/{}", shardIndex, shardTotal);
        
        // 2. 查询当前分片需要处理的 SKU-仓库组合
        List<Inventory> inventories = inventoryMapper.selectList(
            new LambdaQueryWrapper<Inventory>()
                .isNotNull(Inventory::getSafetyStock)  // 只处理设置了安全库存的
                .apply("MOD(id, {0}) = {1}", shardTotal, shardIndex)  // 分片
        );
        
        log.info("当前分片需要处理 {} 个库存记录", inventories.size());
        
        // 3. 逐个检查是否需要补货
        for (Inventory inventory : inventories) {
            try {
                checkAndCreateRequisition(inventory);
            } catch (Exception e) {
                log.error("SKU {} 库存预警失败", inventory.getSkuId(), e);
            }
        }
        
        log.info("库存预警任务执行完成");
    }
    
    private void checkAndCreateRequisition(Inventory inventory) {
        // 1. 计算可用库存
        int availableQty = inventory.getQuantity() 
            - inventory.getFrozenQty() 
            - inventory.getDefectiveQty();
        
        // 2. 判断是否低于安全库存
        if (availableQty > inventory.getSafetyStock()) {
            return;  // 库存充足,无需补货
        }
        
        // 3. 查询在途库存
        Integer inTransitQty = inTransitInventoryMapper.selectOne(
            new LambdaQueryWrapper<InTransitInventory>()
                .eq(InTransitInventory::getSkuId, inventory.getSkuId())
                .eq(InTransitInventory::getWarehouseId, inventory.getWarehouseId())
        ).getQuantity();
        
        // 4. 如果在途库存充足,无需再次采购
        if (availableQty + inTransitQty >= inventory.getSafetyStock()) {
            return;
        }
        
        // 5. 检查今日是否已为该 SKU 生成过预警申请单(幂等)
        Long count = requisitionMapper.selectCount(
            new LambdaQueryWrapper<PurchaseRequisition>()
                .eq(PurchaseRequisition::getReqSource, 1)  // 库存预警触发
                .eq(PurchaseRequisition::getWarehouseId, inventory.getWarehouseId())
                .ge(PurchaseRequisition::getCreateTime, LocalDate.now().atStartOfDay())
        );
        
        if (count > 0) {
            return;  // 今日已生成,避免重复
        }
        
        // 6. 计算建议采购量
        int suggestQty = inventory.getSafetyStock() * 2 - availableQty - inTransitQty;
        
        // 7. 创建采购申请单
        PurchaseRequisition requisition = new PurchaseRequisition();
        requisition.setReqNo(generateReqNo());
        requisition.setReqSource(1);  // 库存预警触发
        requisition.setTitle("库存预警自动补货 - " + inventory.getSkuName());
        requisition.setWarehouseId(inventory.getWarehouseId());
        requisition.setPriority(1);  // 紧急
        requisition.setStatus(0);  // 草稿
        requisitionMapper.insert(requisition);
        
        // 8. 创建申请单明细
        PurchaseRequisitionItem item = new PurchaseRequisitionItem();
        item.setReqId(requisition.getId());
        item.setSkuId(inventory.getSkuId());
        item.setQuantity(suggestQty);
        item.setCurrentStock(availableQty);
        item.setSafetyStock(inventory.getSafetyStock());
        item.setInTransitQty(inTransitQty);
        requisitionItemMapper.insert(item);
        
        // 9. 发送预警通知
        messageService.send(
            inventory.getTenantId(),
            "库存预警",
            String.format("SKU %s 库存不足,当前可用 %d 件,安全库存 %d 件,建议采购 %d 件",
                inventory.getSkuName(), availableQty, inventory.getSafetyStock(), suggestQty)
        );
    }
}

建议采购量计算公式:

建议采购的条件: 先进行预测 未来30天的销量 - 可用的库存 - 在途库存 < 安全库存 建议采购

如果按照这个方式去进行采购的话 BI 系统 进行自动的预测 —>

BI系统 —> 智能数据分析 (预测未来30天的销量), 至于生成采购单 只能在 PMS中进行生成 所以可以把BI和PMS进行合作, 我们的PMS需要定义出来一个接口, 专门给BI进行使用, 当需要进行补货的时候, 我们在这个接口中生成对应的采购申请单, 然后推送给采购人员

业务的实现过程 你可以讲解 至于实现 我们只是做了PMS的接口的定义 定时任务这个环节 是在BI系统中去定义的

建议采购量: 未来30天的销量 - 可用的库存 - 在途库存

如果这个采购量 比较小 可以按照 供应商的最小发货量进行采购

建议采购量 = 安全库存 × 2 - 可用库存 - 在途库存

目标:补货后达到安全库存的 2 倍,留有充足余量

效果:

  1. 自动监控:每小时自动扫描,不需要人工监控
  2. 及时补货:库存低于安全库存立即生成采购建议
  3. 避免重复:今日已生成的不再重复生成,保证幂等性
  4. 分片执行:多台服务器并行处理,提高效率

面试追问:为什么每小时扫描一次,不是每分钟或每天?

每分钟扫描太频繁,数据库压力大,而且库存变化没那么快。每天扫描太慢,可能导致断货。每小时是一个平衡点,既能及时发现库存不足,又不会给数据库造成太大压力。

补充: 关于这个定时任务扫描 然后生成采购单的需求

  1. 可以结合BI系统来说 让BI进行扫描生成建议采购的数量 然后调用当前的PMS系统 生成采购需求单
  2. 可以抛开BI系统直接说PMS系统 直接说分片定时任务是你写的 重点放到分片扫描 和 库存的判断上 弱化 补货的数量

也可以把定时补货扫描的任务 和 下面的这个 销售预测一起来讲

我们还有一个定时任务 是每周执行一次扫描 对当前正在进行销售的SKU进行预测 未来30天的销量(需要准备的库存) 当这个任务执行结束后 会把每个SKU未来30天的预测销量保存到一个统计表中

我们可以在上面的定时任务中 根据SKU的ID去查询当前的这个统计表 除了判断安全库存外 也要 判断未来30天的预测销量

如果两个条件 有任何一个条件达标 就需要进行补货

  1. 安全库存 > 实际库存 + 在途库存 (紧急) 做法: 直接生成采购需求单
  2. 30天预测销量 > 实际库存 + 在途库存 (建议) 做法: 给出通知

30天预测算法:

  1. 简单移动平均
  2. 加权移动平均
  3. 指数平滑法

再去把三种算法的值 求平均 * 30 = 未来30天的预测销量

2.6 销售预测触发采购需求

问题背景:

库存预警是被动补货,等库存不足了才补。

销售预测是主动补货,根据历史销量预测未来需求,提前备货。这样可以避免断货,提高客户满意度。

解决方案:

使用简单移动平均、加权移动平均、指数平滑法三种方法预测未来 30 天需求,取加权平均值作为最终预测。

具体实现:

@Component
public class SalesForecastJob {
    
    @XxlJob("salesForecastJob")
    public void execute() {
        // 每周一执行
        log.info("开始执行销售预测任务");
        
        // 查询所有需要预测的 SKU
        List<Sku> skus = skuMapper.selectList(
            new LambdaQueryWrapper<Sku>()
                .eq(Sku::getStatus, 1)  // 只预测在售商品
        );
        
        for (Sku sku : skus) {
            try {
                forecastAndCreateRequisition(sku);
            } catch (Exception e) {
                log.error("SKU {} 销售预测失败", sku.getId(), e);
            }
        }
    }
    
    private void forecastAndCreateRequisition(Sku sku) {
        // 1. 查询近 90 天销量数据
        List<DailySales> salesData = salesMapper.selectList(
            new LambdaQueryWrapper<DailySales>()
                .eq(DailySales::getSkuId, sku.getId())
                .ge(DailySales::getSaleDate, LocalDate.now().minusDays(90))
                .orderByAsc(DailySales::getSaleDate)
        );
        
        if (salesData.size() < 30) {
            return;  // 数据不足,无法预测
        }
        
        // 2. 数据预处理:剔除异常值(促销日、节假日)
        List<DailySales> normalData = salesData.stream()
            .filter(s -> !s.isPromotion() && !s.isHoliday())
            .collect(Collectors.toList());
        
        // 3. 三种预测方法
        double sma = simpleMovingAverage(normalData, 30);  // 简单移动平均
        double wma = weightedMovingAverage(normalData, 30);  // 加权移动平均
        double ema = exponentialSmoothing(normalData, 0.3);  // 指数平滑
        
        // 4. 加权平均作为最终预测
        double forecast = sma * 0.3 + wma * 0.4 + ema * 0.3;
        int forecastQty = (int) Math.ceil(forecast * 30);  // 未来 30 天需求
        
        // 5. 查询当前库存和在途库存
        Inventory inventory = inventoryMapper.selectOne(
            new LambdaQueryWrapper<Inventory>()
                .eq(Inventory::getSkuId, sku.getId())
        );
        
        int availableQty = inventory.getQuantity() - inventory.getFrozenQty();
        int inTransitQty = getInTransitQty(sku.getId());
        
        // 6. 判断是否需要补货
        if (availableQty + inTransitQty >= forecastQty) {
            return;  // 库存充足
        }
        
        // 7. 创建预测补货申请单
        int suggestQty = forecastQty - availableQty - inTransitQty;
        createForecastRequisition(sku, suggestQty, forecastQty);
    }
    
    // 简单移动平均
    private double simpleMovingAverage(List<DailySales> data, int days) {
        return data.stream()
            .skip(Math.max(0, data.size() - days))
            .mapToInt(DailySales::getQuantity)
            .average()
            .orElse(0);
    }
    
    // 加权移动平均(最近的数据权重更高)
    private double weightedMovingAverage(List<DailySales> data, int days) {
        List<DailySales> recent = data.stream()
            .skip(Math.max(0, data.size() - days))
            .collect(Collectors.toList());
        
        double sum = 0;
        double weightSum = 0;
        for (int i = 0; i < recent.size(); i++) {
            double weight = i + 1;  // 权重递增
            sum += recent.get(i).getQuantity() * weight;
            weightSum += weight;
        }
        return sum / weightSum;
    }
    
    // 指数平滑法
    private double exponentialSmoothing(List<DailySales> data, double alpha) {
        double forecast = data.get(0).getQuantity();
        for (int i = 1; i < data.size(); i++) {
            forecast = alpha * data.get(i).getQuantity() + (1 - alpha) * forecast;
        }
        return forecast;
    }
}

效果:

  1. 主动补货:根据预测提前备货,避免断货
  2. 多种方法:三种预测方法取加权平均,提高准确性
  3. 数据预处理:剔除促销日和节假日,避免异常值影响预测
  4. 灵活调整:可以根据实际情况调整预测参数

三、数据库设计

3.1 核心表结构

采购申请单主表 (purchase_requisition)

CREATE TABLE purchase_requisition (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    tenant_id BIGINT NOT NULL COMMENT '租户ID',
    req_no VARCHAR(32) NOT NULL COMMENT '申请单编号',
    req_source TINYINT NOT NULL DEFAULT 3 COMMENT '需求来源: 1库存预警 2销售预测 3人工申请',
    title VARCHAR(128) NOT NULL COMMENT '申请标题',
    warehouse_id BIGINT NOT NULL COMMENT '目标收货仓库ID',
    total_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '估算总金额',
    priority TINYINT NOT NULL DEFAULT 2 COMMENT '优先级: 1紧急 2普通 3低',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0草稿 1待审批 2通过 3拒绝 4已转采购单',
    apply_user_id BIGINT NOT NULL COMMENT '申请人ID',
    audit_user_id BIGINT COMMENT '审批人ID',
    audit_time DATETIME COMMENT '审批时间',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    version INT NOT NULL DEFAULT 0,
    
    UNIQUE KEY uk_tenant_req_no (tenant_id, req_no),
    KEY idx_tenant_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购申请单主表';

采购订单主表 (purchase_order)

CREATE TABLE purchase_order (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    tenant_id BIGINT NOT NULL,
    po_no VARCHAR(32) NOT NULL COMMENT '采购单编号',
    supplier_id BIGINT NOT NULL COMMENT '供应商ID',
    warehouse_id BIGINT NOT NULL COMMENT '收货仓库ID',
    total_amount DECIMAL(12,2) NOT NULL COMMENT '采购总金额',
    payment_days INT NOT NULL DEFAULT 0 COMMENT '账期天数',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0草稿到8已取消',
    order_date DATE NOT NULL COMMENT '下单日期',
    expected_date DATE COMMENT '期望到货日期',
    version INT NOT NULL DEFAULT 0,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
    UNIQUE KEY uk_tenant_po_no (tenant_id, po_no),
    KEY idx_supplier_id (supplier_id),
    KEY idx_tenant_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购订单主表';

应付账款表 (finance_payable)

CREATE TABLE finance_payable (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    tenant_id BIGINT NOT NULL,
    payable_no VARCHAR(32) NOT NULL COMMENT '应付账款单号',
    po_id BIGINT NOT NULL COMMENT '关联采购单ID',
    supplier_id BIGINT NOT NULL COMMENT '供应商ID',
    payable_amount DECIMAL(12,2) NOT NULL COMMENT '应付总金额',
    paid_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '已付金额',
    payment_days INT NOT NULL DEFAULT 0 COMMENT '账期天数',
    due_date DATE NOT NULL COMMENT '到期付款日',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0待付款 1部分已付 2已结清',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
    UNIQUE KEY uk_tenant_payable_no (tenant_id, payable_no),
    UNIQUE KEY uk_po_id (po_id),
    KEY idx_due_date (due_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应付账款表';

四、核心业务流程

4.1 采购完整流程

流程概述:

采购需求产生 → 创建采购申请 → 审批通过 → 发起询价 → 供应商报价 → 
比价选择 → 创建采购订单 → 供应商发货 → 仓库收货 → 质检入库 → 
生成应付账款 → 账期到期付款

关键环节:

  1. 采购需求产生:库存预警、销售预测、人工申请三种来源
  2. 询价比价:向多家供应商询价,系统自动计算综合得分
  3. 采购订单:9 状态流转,支持部分收货
  4. 收货入库:分布式事务保证数据一致性
  5. 应付账款:自动生成,定时提醒付款

4.2 询价比价流程

完整流程:

  1. 采购专员选择 2-5 家供应商发送询价单
  2. 供应商在门户填写报价(单价、交货期、可供数量)
  3. 系统自动计算综合得分
  4. 采购专员根据综合得分选择最优供应商
  5. 自动生成采购订单草稿

综合评分公式:

综合得分 = 价格得分(40%) + 交货期得分(30%) + 供应商评级得分(30%)

五、高频面试问答

5.1 业务问题

Q1: 采购订单为什么需要 9 种状态?

A1: 因为采购业务比较复杂,支持部分收货和部分付款。

比如采购 1000 个耳机,供应商可能分两次发货,第一次 500 个,第二次 500 个。这就需要”部分到货”状态。

付款也是,可能先付 50% 定金,收货后再付 50% 尾款。这就需要”部分付款”状态。

9 种状态能完整覆盖采购订单的全生命周期,既灵活又清晰。

Q2: 询价比价算法为什么不只看价格?

A2: 单纯比价可能导致质量风险。

比如供应商 A 报价最低,但评级是 C 级,经常延期发货、质量不稳定。供应商 B 报价略高,但评级是 S 级,质量稳定、准时发货。

如果只看价格选 A,短期省钱,长期可能因为质量问题、延期发货导致更大损失。

所以我们综合考虑价格、交货期、供应商评级三个维度,实现综合最优。

Q3: 分布式事务为什么用 Seata 而不是本地事务?

A3: 因为 PMS 和 WMS 是两个独立的服务,有各自的数据库。

本地事务只能保证单个数据库的一致性,无法保证跨服务的一致性。

Seata 是分布式事务框架,支持 AT 模式(自动补偿)。它会自动记录 undo_log,任何一步失败都能自动回滚,保证数据一致性。

Q4: 应付账款到期提醒为什么提前 3 天?

A4: 提前 3 天是经过业务调研确定的。

如果提前太早(比如 7 天),财务可能忘记。如果提前太晚(比如 1 天),可能来不及准备资金。

提前 3 天刚好:财务有时间准备资金、走审批流程、安排付款。

而且我们是每天早上 9 点提醒,财务上班就能看到,不会遗漏。

Q5: 库存预警为什么每小时扫描一次?

A5: 每分钟扫描太频繁,数据库压力大,而且库存变化没那么快。每天扫描太慢,可能导致断货。

每小时是一个平衡点,既能及时发现库存不足,又不会给数据库造成太大压力。

而且我们用了分片广播模式,多台服务器并行处理,提高了效率。

5.2 技术问题

Q1: 状态机的白名单如何设计?

A1: 我们用 Map 存储状态流转规则,Key 是当前状态,Value 是允许流转到的状态列表。

Map<PurchaseOrderStatus, List<PurchaseOrderStatus>> TRANSITIONS

校验时,查询当前状态对应的允许流转列表,判断目标状态是否在列表中。如果不在,直接拒绝。

Q2: CAS 乐观锁如何实现?

A2: 我们用 MyBatis-Plus 的 @Version 注解实现乐观锁。

第一步,在实体类中增加 version 字段,加上 @Version 注解。

第二步,更新时在 WHERE 条件中带上 version 字段。

第三步,如果 version 不匹配,updated 返回 0,说明数据已被其他人修改,抛异常回滚。

MyBatis-Plus 会自动在 UPDATE 语句中增加 version 的判断和自增。

CAS的实现 有两种做法:

  1. 就是上面提到的 version 字段
  2. 可以是任意字段 且不需要给这个字段添加 @Version 注解 但是你要在SQL上进行手动拼接 xxx字段 = xxx值
    1. 比如: 待审核状态CAS修改为审核通过
    2. set 状态 = 审核通过 where 业务id = xxx AND 当前的状态 = 待审核

Q3: Seata 分布式事务如何保证一致性?

A3: Seata 用 AT 模式实现自动补偿。

第一步,TM(事务管理器)开启全局事务,生成全局事务 ID(XID)。

第二步,每个服务执行本地事务时,Seata 自动记录 undo_log(回滚日志)。

第三步,如果全部成功,TM 提交全局事务,删除 undo_log。

第四步,如果任何一步失败,TM 回滚全局事务,根据 undo_log 恢复数据。

Q4: XXL-Job 的分片如何实现?

A4: XXL-Job 的分片广播模式会给每台服务器分配一个分片序号(shardIndex)和总分片数(shardTotal)。

我们在查询数据时,用 MOD(id, shardTotal) = shardIndex 来分片。

比如总共 3 台服务器,第 1 台服务器的 shardIndex = 0,查询条件是 MOD(id, 3) = 0,会查到 id 为 3、6、9… 的数据。

这样每台服务器处理不同的数据,互不重复,实现并行执行。

Q5: 如何保证定时任务的幂等性?

A5: 我们在创建采购申请单前,先检查今日是否已为该 SKU 生成过预警申请单。

如果已生成,直接跳过,避免重复。

这样即使定时任务重复执行,也不会重复创建申请单。

这个判断 相当于是一个前置的校验 ?? 为什么这个校验可以做到幂等性 因为当前的操作是定时任务触发的 并不是 用户的请求


六、简历模板

6.1 详细版简历

项目名称: 跨境电商 SaaS 供应链管理平台 - 采购管理模块(PMS)

项目时间: 2024.06 - 2024.12

项目角色: 核心开发

技术栈: Spring Boot 3.2、MyBatis-Plus 3.5、MySQL 8.0、Redis 7.0、Seata 1.7、XXL-Job 2.4、RocketMQ 5.0、Docker 24.0、Nginx 1.24

项目描述:

这是一个跨境电商的供应链 SaaS 平台,我负责采购管理模块。系统管理从采购需求产生到入库付款的完整流程,包括采购申请、询价比价、采购订单、收货入库、应付账款等。支持多租户,日均处理采购订单 500+,询价单 200+,收货单 300+。系统管理了 200+ 活跃供应商,累计采购金额超过 5000 万元。

核心难点:

  1. 采购订单 9 状态流转控制,支持部分收货和部分付款
  2. 询价比价综合评分算法,综合考虑价格、交货期、供应商评级
  3. 采购入库分布式事务,保证 PMS 和 WMS 数据一致性
  4. 应付账款账期管理,自动生成应付账款并提前提醒付款
  5. 库存预警自动触发采购需求,定时任务分片执行
  6. 销售预测触发采购需求,三种预测方法取加权平均

核心技术亮点:

1. 采购订单 9 状态状态机设计

问题:采购订单状态复杂,支持部分收货和部分付款,状态流转难以控制。如果用 if-else 判断,代码混乱,容易出现非法状态流转。

方案:设计基于白名单的状态机,明确定义 9 个状态和 12 个合法流转路径。

实现:定义状态枚举和流转白名单,用 Map 存储状态流转规则。每次状态变更前,先用状态机校验是否合法。用 CAS 乐观锁更新状态,WHERE 条件判断当前状态和版本号。所有状态变更写入操作日志,可追溯。

效果:完全避免了非法状态流转。支持灵活的部分收货和部分付款。状态流转逻辑集中管理,易于维护。

2. 询价比价综合评分算法

问题:同一个 SKU 向多家供应商询价,如何选择最优供应商?单纯比价可能导致质量风险。

方案:设计综合评分算法,考虑价格、交货期、供应商评级三个维度。

实现:价格得分(40%)= (最低报价 / 当前报价) × 40。交货期得分(30%)= (最短交货期 / 当前交货期) × 30。供应商评级得分(30%)= (供应商评分 / 100) × 30。综合得分 = 三项得分之和。系统自动计算并排序,推荐综合得分最高的供应商。

效果:避免了单纯比价导致的质量风险。综合考虑多个维度,实现综合最优。采购决策更科学,供应商选择更合理。

3. 采购入库分布式事务方案

问题:收货入库涉及 PMS 和 WMS 两个服务,必须保证数据一致性。如果 PMS 订单状态更新了,但 WMS 库存没增加,就会导致数据不一致。

方案:用 Seata AT 模式实现分布式事务。

实现:收货确认时,同时更新采购订单状态、WMS 库存、在途库存。Seata 自动记录 undo_log(回滚日志)。如果全部成功,提交全局事务,删除 undo_log。如果任何一步失败,根据 undo_log 自动回滚。配置 Seata Server 作为事务协调器(TC)。

效果:完全保证了跨服务的数据一致性。任何一步失败都能自动回滚。对业务代码侵入小,只需加 @GlobalTransactional 注解。

4. 应付账款账期管理和到期提醒

问题:不同供应商账期不同,人工跟踪容易遗漏,导致逾期付款影响信用。

方案:自动生成应付账款,定时任务提前提醒。

实现:收货后自动生成应付账款,根据供应商账期计算到期日期。定时任务每天早上 9 点检查 3 天内到期的应付账款。自动发送站内消息和邮件提醒财务专员。支持部分付款,付款后自动更新采购订单状态。付款记录写入日志,可追溯。

效果:完全避免了逾期付款。提前 3 天提醒,留足付款准备时间。支持灵活的部分付款。

5. 库存预警自动触发采购需求

问题:库存低于安全库存时,需要及时补货。如果靠人工监控,可能遗漏,导致断货。

方案:使用 XXL-Job 定时任务,每小时扫描一次库存,低于安全库存时自动创建采购申请单。

实现:配置 XXL-Job 分片广播模式,3 台服务器并行处理。查询时用 MOD(id, shardTotal) = shardIndex 分片。计算可用库存和在途库存,判断是否低于安全库存。检查今日是否已生成预警申请单,避免重复。计算建议采购量 = 安全库存 × 2 - 可用库存 - 在途库存。自动创建采购申请单并发送通知。

效果:自动监控,每小时自动扫描,不需要人工监控。及时补货,库存低于安全库存立即生成采购建议。避免重复,今日已生成的不再重复生成,保证幂等性。分片执行,多台服务器并行处理,提高效率。

6. 销售预测触发采购需求

问题:库存预警是被动补货,等库存不足了才补。销售预测是主动补货,根据历史销量预测未来需求,提前备货。

方案:使用简单移动平均、加权移动平均、指数平滑法三种方法预测未来 30 天需求,取加权平均值作为最终预测。

实现:查询近 90 天销量数据,剔除促销日和节假日。简单移动平均:近 30 天均值。加权移动平均:最近的数据权重更高。指数平滑法:α × 最新实际销量 + (1 - α) × 上一期预测值。三种方法取加权平均:sma × 0.3 + wma × 0.4 + ema × 0.3。判断是否需要补货,创建预测补货申请单。

效果:主动补货,根据预测提前备货,避免断货。多种方法,三种预测方法取加权平均,提高准确性。数据预处理,剔除促销日和节假日,避免异常值影响预测。

6.2 简化版简历

项目: 跨境电商 SaaS 供应链 - 采购管理模块(PMS)
时间: 2024.06 - 2024.12
角色: 核心开发
技术: Spring Boot 3.2、MyBatis-Plus 3.5、MySQL 8.0、Redis 7.0、Seata 1.7、XXL-Job 2.4、RocketMQ 5.0、Docker 24.0

管理从采购需求产生到入库付款的完整流程,包括采购申请、询价比价、采购订单、收货入库、应付账款。日均处理采购订单 500+,询价单 200+,收货单 300+。

核心亮点:

  1. 9 状态状态机:白名单控制状态流转,支持部分收货和部分付款
  2. 询价比价算法:综合考虑价格、交货期、供应商评级,自动推荐最优供应商
  3. 分布式事务:Seata AT 模式保证 PMS 和 WMS 数据一致性
  4. 账期管理:自动生成应付账款,提前 3 天提醒付款
  5. 库存预警:XXL-Job 分片执行,每小时自动扫描,低于安全库存自动创建采购申请
  6. 销售预测:三种预测方法取加权平均,主动补货避免断货