Series Article

Day03 · 采购管理系统(PMS)

配套面试准备:完成本篇后,可以继续阅读:Day03_PMS核心业务面试准备 Day03_PMS采购管理系统完整面试指南

课程目标:深度理解跨境出海场景下的采购业务全链路,完整实现从采购需求产生到入库付款的闭环系统,掌握询价比价、采购状态机、库存联动、应付账款等核心业务模块的设计与实现。

今日交付物

  • 采购模块 6 张数据库表完整 SQL
  • 三种采购需求来源的触发机制设计
  • 询价比价完整业务流程与接口设计
  • 采购订单 8 状态状态机完整实现
  • 采购入库与 WMS 库存联动的事务设计
  • 应付账款账期管理与到期提醒
  • 采购退货逆向流程设计
  • 采购数据统计报表设计

第一节 采购管理业务全解析

1.1 采购管理在供应链中的位置

采购管理系统(PMS,Purchase Management System)是连接供应商和仓储的核心枢纽,承上启下:

graph LR
    A["🏭 供应商<br>SRM模块管理"] -->|"有了合格供应商"| B["🛒 采购管理<br>PMS 今天的重点"]
    B -->|"货物入库后"| C["📦 仓储管理<br>WMS 第四天"]
    C -->|"有库存后"| D["🏷️ 商品管理<br>PIM 第五天"]
    D -->|"商品上架后"| E["📑 订单管理<br>OMS 第五天"]

    style B fill:#fff3e0,stroke:#ff9800

1.2 没有采购管理系统时的真实痛点

一个没有 PMS 的跨境卖家是怎么运作的?

graph TD
    A["运营发现某SKU快断货了"] --> B["微信发消息给采购:<br> 这个货快没了,让工厂发货"]
    B --> C["采购在手机备忘录上<br>记下要补货这件事"]
    C --> D["采购在微信里联系供应商<br>说:老王,那个耳机给我来500个"]
    D --> E["供应商回复:好的<br>(没有任何书面记录)"]
    E --> F["两周后货没来<br>采购问:老王货呢?<br>老王:什么货?我们没记录啊"]
    F --> G["断货!平台自动下架<br>损失巨大"]

    H["同时另一个采购<br>也联系了另一家供应商<br>订了同样的500个"] --> I["两批货都来了<br>库存积压1000个<br>资金压死"]

PMS 系统化后解决的问题:

问题系统化解决方案
采购信息靠微信/备忘录记录所有采购需求在系统中创建,有单据编号,不会遗漏
重复采购,库存积压系统实时展示当前库存和在途库存,避免重复下单
不知道补货多少合适智能补货建议:基于安全库存和预测销量自动计算
供应商发货没有跟踪采购单状态跟踪,每个节点都有记录
对账靠人工数发票系统自动生成应付账款,账期到期自动提醒
采购成本没有统计多维度采购报表,品类/供应商/时间维度分析

1.3 采购业务核心概念

在进入系统设计之前,必须搞清楚这些业务术语:

术语含义举例
采购需求需要购买某种商品的业务需求,还未形成正式订单”耳机库存只剩50个,需要再采购500个”
采购申请单将采购需求正式化的内部单据,待审批采购专员填写的申请表,等待负责人审批
询价单向供应商询问价格的邀请函”你们耳机500个多少钱?“
报价单供应商针对询价给出的正式报价”500个,单价38元,含税,7天发货”
采购订单(PO)与供应商正式确认的采购合同(Purchase Order)含品名/数量/单价/交货期的正式单据
收货单货物到达仓库时创建的验收记录仓库人员核对实收数量后签字的单据
应付账款已收到货物但尚未付款的欠款采购了500个耳机,货已到,还没给供应商打款
账期供应商允许的延迟付款天数月结30天,即收货后30天内付款
现购收到货物同时付款,无账期paymentDays = 0
赊购先收货后付款,有账期paymentDays = 30/60/90

1.4 采购业务的参与角色

角色职责在 PMS 中的操作
采购专员日常采购执行创建采购申请、发询价、跟踪到货
采购负责人采购策略决策审批大额采购申请、确认最终供应商
仓储管理员验货入库创建收货单、质检确认、库存入账
财务专员资金管控核对发票、确认付款、管理应付账款
供应商(外部)履行采购合同在 Portal 确认采购单、填写发货信息

第二节 采购数据库表设计

2.1 采购模块表结构总览

erDiagram
    purchase_requisition {
        bigint id PK
        bigint tenant_id
        varchar req_no "申请单编号"
        tinyint req_source "来源1库存预警2销售预测3手动"
        tinyint status "状态0草稿1待审2通过3拒绝"
    }

    purchase_requisition_item {
        bigint id PK
        bigint req_id FK
        bigint sku_id FK
        int quantity "申请数量"
        date expected_date "期望到货日期"
    }

    purchase_inquiry {
        bigint id PK
        bigint tenant_id
        varchar inquiry_no "询价单编号"
        bigint req_id FK
        bigint supplier_id FK
        tinyint status "状态"
    }

    purchase_inquiry_item {
        bigint id PK
        bigint inquiry_id FK
        bigint sku_id FK
        decimal quoted_price "供应商报价"
        int delivery_days "承诺交货天数"
    }

    purchase_order {
        bigint id PK
        bigint tenant_id
        varchar po_no "采购订单号"
        bigint supplier_id FK
        decimal total_amount "总金额"
        tinyint status "0草稿到7已结清"
    }

    purchase_order_item {
        bigint id PK
        bigint po_id FK
        bigint sku_id FK
        int quantity "采购数量"
        int received_qty "已收货数量"
        decimal unit_price "采购单价"
    }

    purchase_receipt {
        bigint id PK
        bigint po_id FK
        varchar receipt_no "收货单编号"
        tinyint status "0待质检1通过2拒收"
    }

    purchase_return {
        bigint id PK
        bigint po_id FK
        varchar return_no "退货单编号"
        tinyint return_reason "退货原因"
        tinyint status "退货状态"
    }

    finance_payable {
        bigint id PK
        bigint po_id FK
        decimal payable_amount "应付金额"
        date due_date "到期日期"
        tinyint status "0待付1部分付2已结清"
    }

    purchase_requisition ||--o{ purchase_requisition_item : "包含多个SKU明细"
    purchase_requisition ||--o{ purchase_inquiry : "发起多个询价"
    purchase_inquiry ||--o{ purchase_inquiry_item : "包含多个SKU报价"
    purchase_inquiry }|--|| purchase_order : "确认后生成采购订单"
    purchase_order ||--o{ purchase_order_item : "包含多个SKU明细"
    purchase_order ||--o{ purchase_receipt : "可以多次部分收货"
    purchase_order ||--o{ purchase_return : "可以发起退货"
    purchase_order ||--|| finance_payable : "生成一条应付账款"

2.2 采购申请单主表

-- ============================================================
-- 采购申请单主表
-- 记录每一次采购需求的来源、申请人和审批结果
-- ============================================================
CREATE TABLE `purchase_requisition`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID,雪花算法',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `req_no`          VARCHAR(32)      NOT NULL           COMMENT '申请单编号,格式 REQ-YYYYMMDD-XXXX',
    `req_source`      TINYINT          NOT NULL DEFAULT 3  COMMENT '需求来源:1=库存预警自动触发 2=销售预测触发 3=人工手动申请',
    `title`           VARCHAR(128)     NOT NULL           COMMENT '申请标题,如:2025年1月蓝牙耳机补货申请',
    `warehouse_id`    BIGINT           NOT NULL           COMMENT '目标收货仓库ID',
    `expect_date`     DATE             NULL               COMMENT '期望整体到货日期',
    `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=已转采购单 5=已取消',
    `apply_user_id`   BIGINT           NOT NULL           COMMENT '申请人用户ID',
    `apply_user_name` VARCHAR(64)      NOT NULL           COMMENT '申请人姓名(冗余)'
    `apply_time`      DATETIME         NOT NULL           COMMENT '提交申请时间',
    `audit_user_id`   BIGINT           NULL               COMMENT '审批人用户ID',
    `audit_time`      DATETIME         NULL               COMMENT '审批时间',
    `audit_remark`    VARCHAR(512)     NULL               COMMENT '审批意见',
    `remark`          VARCHAR(512)     NULL               COMMENT '申请备注',
    `create_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `update_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `create_by`       BIGINT           NULL,
    `update_by`       BIGINT           NULL,
    `is_deleted`      TINYINT(1)       NOT NULL DEFAULT 0,
    `version`         INT              NOT NULL DEFAULT 0,

    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_tenant_req_no` (`tenant_id`, `req_no`),
    KEY `idx_tenant_status`    (`tenant_id`, `status`),
    KEY `idx_apply_user`       (`apply_user_id`),
    KEY `idx_apply_time`       (`apply_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '采购申请单主表';

2.3 采购申请单明细表

-- ============================================================
-- 采购申请单明细表
-- 一张申请单可包含多个 SKU 的采购需求
-- ============================================================
CREATE TABLE `purchase_requisition_item`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `req_id`          BIGINT           NOT NULL           COMMENT '关联申请单ID',
    `sku_id`          BIGINT           NOT NULL           COMMENT 'SKU ID',
    `sku_code`        VARCHAR(64)      NOT NULL           COMMENT 'SKU编码(冗余存储)',
    `sku_name`        VARCHAR(256)     NOT NULL           COMMENT 'SKU名称(冗余存储)',
    `quantity`        INT              NOT NULL           COMMENT '申请采购数量',
    `current_stock`   INT              NOT NULL DEFAULT 0  COMMENT '创建申请时的当前可用库存(快照,方便审批人参考)',
    `safety_stock`    INT              NOT NULL DEFAULT 0  COMMENT '安全库存阈值(快照)',
    `in_transit_qty`  INT              NOT NULL DEFAULT 0  COMMENT '创建时的在途库存数量(快照)',
    `ref_price`       DECIMAL(10, 4)   NULL               COMMENT '参考单价(最近一次采购价,用于估算总金额)',
    `expect_date`     DATE             NULL               COMMENT '该SKU期望到货日期(可与主表不同)',
    `remark`          VARCHAR(256)     NULL               COMMENT '该明细项的备注',

    PRIMARY KEY (`id`),
    KEY `idx_req_id`  (`req_id`),
    KEY `idx_sku_id`  (`sku_id`),
    KEY `idx_tenant_id` (`tenant_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '采购申请单明细表';

2.4 询价单与报价明细表

-- ============================================================
-- 询价单主表
-- 每次向一家供应商发询价,生成一张询价单
-- 一个采购申请可向多家供应商发询价
-- ============================================================
CREATE TABLE `purchase_inquiry`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `inquiry_no`      VARCHAR(32)      NOT NULL           COMMENT '询价单编号,格式 INQ-YYYYMMDD-XXXX',
    `req_id`          BIGINT           NULL               COMMENT '关联采购申请单ID(若不经申请直接询价则为NULL)',
    `supplier_id`     BIGINT           NOT NULL           COMMENT '询价的供应商ID',
    `supplier_name`   VARCHAR(128)     NOT NULL           COMMENT '供应商名称(冗余)',
    `status`          TINYINT          NOT NULL DEFAULT 0  COMMENT '状态:0=已发送 1=已报价 2=已选中(最终成交) 3=未选中 4=已过期',
    `send_time`       DATETIME         NOT NULL           COMMENT '发送询价时间',
    `quote_deadline`  DATETIME         NULL               COMMENT '要求供应商报价的截止时间',
    `quoted_time`     DATETIME         NULL               COMMENT '供应商实际回价时间',
    `response_hours`  DECIMAL(8, 2)    NULL               COMMENT '供应商响应时长(小时)= quoted_time - send_time,用于评分',
    `total_quote_amt` DECIMAL(12, 2)   NULL               COMMENT '供应商报价总金额',
    `quote_valid_days` INT             NULL               COMMENT '报价有效天数(从报价日起算)',
    `quote_expire_date` DATE           NULL               COMMENT '报价到期日期 = quoted_time + quote_valid_days',
    `remark`          VARCHAR(512)     NULL               COMMENT '询价备注(如特殊要求)',
    `supplier_remark` VARCHAR(512)     NULL               COMMENT '供应商回价时的备注',
    `create_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `update_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `create_by`       BIGINT           NULL,
    `is_deleted`      TINYINT(1)       NOT NULL DEFAULT 0,

    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_tenant_inquiry_no` (`tenant_id`, `inquiry_no`),
    KEY `idx_req_id`        (`req_id`),
    KEY `idx_supplier_id`   (`supplier_id`),
    KEY `idx_tenant_status` (`tenant_id`, `status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '询价单主表';

-- ============================================================
-- 询价明细与供应商报价表
-- 记录供应商对每个SKU的具体报价
-- ============================================================
CREATE TABLE `purchase_inquiry_item`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `inquiry_id`      BIGINT           NOT NULL           COMMENT '关联询价单ID',
    `sku_id`          BIGINT           NOT NULL           COMMENT 'SKU ID',
    `sku_code`        VARCHAR(64)      NOT NULL           COMMENT 'SKU编码',
    `sku_name`        VARCHAR(256)     NOT NULL           COMMENT 'SKU名称',
    `inquiry_qty`     INT              NOT NULL           COMMENT '询价数量(我方需求量)',
    `quoted_price`    DECIMAL(10, 4)   NULL               COMMENT '供应商报价单价(填写后表示已报价)',
    `quoted_qty`      INT              NULL               COMMENT '供应商可供数量(可能小于询价数量)',
    `delivery_days`   INT              NULL               COMMENT '供应商承诺的交货天数',
    `min_order_qty`   INT              NULL               COMMENT '供应商该SKU的最小起订量',
    `remark`          VARCHAR(256)     NULL               COMMENT '供应商该项的备注',

    PRIMARY KEY (`id`),
    KEY `idx_inquiry_id` (`inquiry_id`),
    KEY `idx_sku_id`     (`sku_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '询价明细及供应商报价表';

2.5 采购订单主表与明细表

-- ============================================================
-- 采购订单主表(Purchase Order,简称 PO)
-- 与供应商签订的正式采购合同
-- ============================================================
CREATE TABLE `purchase_order`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `po_no`           VARCHAR(32)      NOT NULL           COMMENT '采购单编号,格式 PO-YYYYMMDD-XXXX',

    -- 关联信息
    `req_id`          BIGINT           NULL               COMMENT '来源申请单ID(有审批流时关联)',
    `inquiry_id`      BIGINT           NULL               COMMENT '来源询价单ID',
    `supplier_id`     BIGINT           NOT NULL           COMMENT '供应商ID',
    `supplier_name`   VARCHAR(128)     NOT NULL           COMMENT '供应商名称(冗余,防止供应商信息变更后影响历史单)',
    `warehouse_id`    BIGINT           NOT NULL           COMMENT '收货仓库ID',
    `warehouse_name`  VARCHAR(64)      NOT NULL           COMMENT '仓库名称(冗余)',

    -- 金额信息
    `total_amount`    DECIMAL(12, 2)   NOT NULL           COMMENT '采购总金额(含税)',
    `tax_amount`      DECIMAL(12, 2)   NOT NULL DEFAULT 0  COMMENT '税额',
    `currency`        CHAR(3)          NOT NULL DEFAULT 'CNY' COMMENT '结算货币',
    `exchange_rate`   DECIMAL(10, 6)   NOT NULL DEFAULT 1  COMMENT '汇率(非人民币时使用),折算为CNY的比例',

    -- 付款信息
    `payment_type`    TINYINT          NOT NULL DEFAULT 1  COMMENT '付款方式:1=现购(现款现货) 2=赊购(有账期)',
    `payment_days`    INT              NOT NULL DEFAULT 0  COMMENT '账期天数,现购时为0',
    `paid_amount`     DECIMAL(12, 2)   NOT NULL DEFAULT 0  COMMENT '已付款金额',

    -- 时间信息
    `order_date`      DATE             NOT NULL           COMMENT '下单日期',
    `expected_date`   DATE             NULL               COMMENT '期望到货日期(我方要求)',
    `confirmed_date`  DATE             NULL               COMMENT '供应商承诺到货日期(供应商确认后填写)',
    `actual_delivery_date` DATE        NULL               COMMENT '实际发货日期(供应商填写)',

    -- 状态
    `status`          TINYINT          NOT NULL DEFAULT 0
        COMMENT '采购单状态:0=草稿 1=待供应商确认 2=已确认 3=发货中 4=部分到货 5=全部到货 6=已对账 7=已结清 8=已取消',

    -- 物流信息
    `logistics_company` VARCHAR(64)    NULL               COMMENT '物流公司名称(供应商填写)',
    `tracking_no`     VARCHAR(128)     NULL               COMMENT '物流运单号(供应商填写)',

    -- 其他
    `contract_no`     VARCHAR(64)      NULL               COMMENT '合同编号(线下合同)',
    `invoice_no`      VARCHAR(64)      NULL               COMMENT '发票编号',
    `remark`          VARCHAR(512)     NULL               COMMENT '备注',

    `create_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `update_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `create_by`       BIGINT           NULL,
    `update_by`       BIGINT           NULL,
    `is_deleted`      TINYINT(1)       NOT NULL DEFAULT 0,
    `version`         INT              NOT NULL DEFAULT 0,

    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_tenant_po_no`    (`tenant_id`, `po_no`),
    KEY `idx_supplier_id`           (`supplier_id`),
    KEY `idx_tenant_status`         (`tenant_id`, `status`),
    KEY `idx_expected_date`         (`expected_date`),
    KEY `idx_create_time`           (`create_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '采购订单主表';

-- ============================================================
-- 采购订单明细表
-- 记录每张采购单包含的 SKU 及其数量、价格
-- ============================================================
CREATE TABLE `purchase_order_item`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `po_id`           BIGINT           NOT NULL           COMMENT '关联采购单ID',
    `sku_id`          BIGINT           NOT NULL           COMMENT 'SKU ID',
    `sku_code`        VARCHAR(64)      NOT NULL           COMMENT 'SKU编码(冗余)',
    `sku_name`        VARCHAR(256)     NOT NULL           COMMENT 'SKU名称(冗余)',
    `spec`            VARCHAR(256)     NULL               COMMENT '规格描述,如:蓝色-XL码',
    `unit`            VARCHAR(16)      NOT NULL DEFAULT '件' COMMENT '单位',
    `quantity`        INT              NOT NULL           COMMENT '采购数量',
    `received_qty`    INT              NOT NULL DEFAULT 0  COMMENT '已累计收货数量(多次收货时逐步增加)',
    `unit_price`      DECIMAL(10, 4)   NOT NULL           COMMENT '采购单价(含税)',
    `amount`          DECIMAL(12, 2)   NOT NULL           COMMENT '小计金额 = unit_price × quantity',
    `expect_date`     DATE             NULL               COMMENT '该SKU期望到货日期',
    `remark`          VARCHAR(256)     NULL               COMMENT '备注',

    PRIMARY KEY (`id`),
    KEY `idx_po_id`   (`po_id`),
    KEY `idx_sku_id`  (`sku_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '采购订单明细表';

2.6 收货单表

-- ============================================================
-- 采购收货单主表
-- 货物到达仓库时创建,一张采购单可以多次收货(分批到货)
-- ============================================================
CREATE TABLE `purchase_receipt`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `receipt_no`      VARCHAR(32)      NOT NULL           COMMENT '收货单编号,格式 RCV-YYYYMMDD-XXXX',
    `po_id`           BIGINT           NOT NULL           COMMENT '关联采购单ID',
    `po_no`           VARCHAR(32)      NOT NULL           COMMENT '采购单编号(冗余)',
    `supplier_id`     BIGINT           NOT NULL           COMMENT '供应商ID(冗余)',
    `warehouse_id`    BIGINT           NOT NULL           COMMENT '收货仓库ID',
    `receive_date`    DATE             NOT NULL           COMMENT '实际收货日期',
    `receiver_id`     BIGINT           NOT NULL           COMMENT '收货人(仓储管理员)用户ID',
    `receiver_name`   VARCHAR(64)      NOT NULL           COMMENT '收货人姓名',
    `status`          TINYINT          NOT NULL DEFAULT 0
        COMMENT '状态:0=待质检 1=质检通过(入库中) 2=部分入库 3=全部入库 4=拒收',
    `total_qty`       INT              NOT NULL DEFAULT 0  COMMENT '本次收货总件数',
    `pass_qty`        INT              NOT NULL DEFAULT 0  COMMENT '质检通过件数',
    `reject_qty`      INT              NOT NULL DEFAULT 0  COMMENT '质检拒绝件数',
    `is_on_time`      TINYINT(1)       NULL               COMMENT '是否按时到货:1=准时 0=延迟,用于供应商绩效评分',
    `remark`          VARCHAR(512)     NULL               COMMENT '收货备注(如外包装破损等情况)',
    `create_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `update_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `create_by`       BIGINT           NULL,
    `update_by`       BIGINT           NULL,
    `is_deleted`      TINYINT(1)       NOT NULL DEFAULT 0,
    `version`         INT              NOT NULL DEFAULT 0,

    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_tenant_receipt_no` (`tenant_id`, `receipt_no`),
    KEY `idx_po_id`         (`po_id`),
    KEY `idx_warehouse_id`  (`warehouse_id`),
    KEY `idx_receive_date`  (`receive_date`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '采购收货单主表';

-- ============================================================
-- 收货单明细表
-- 记录每次收货时各 SKU 的实际收货数量和质检结果
-- ============================================================
CREATE TABLE `purchase_receipt_item`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `receipt_id`      BIGINT           NOT NULL           COMMENT '关联收货单ID',
    `po_item_id`      BIGINT           NOT NULL           COMMENT '关联采购单明细ID',
    `sku_id`          BIGINT           NOT NULL           COMMENT 'SKU ID',
    `sku_code`        VARCHAR(64)      NOT NULL           COMMENT 'SKU编码',
    `sku_name`        VARCHAR(256)     NOT NULL           COMMENT 'SKU名称',
    `expected_qty`    INT              NOT NULL           COMMENT '本次应到数量(根据采购单剩余未收数量)',
    `actual_qty`      INT              NOT NULL           COMMENT '实际收到数量',
    `pass_qty`        INT              NOT NULL DEFAULT 0  COMMENT '质检合格数量',
    `reject_qty`      INT              NOT NULL DEFAULT 0  COMMENT '质检不合格数量',
    `reject_reason`   VARCHAR(256)     NULL               COMMENT '不合格原因(如:外观划痕/功能异常/规格不符)',
    `location_id`     BIGINT           NULL               COMMENT '上架目标库位ID(质检通过后填写)',
    `status`          TINYINT          NOT NULL DEFAULT 0  COMMENT '0=待质检 1=已入库 2=已退货',

    PRIMARY KEY (`id`),
    KEY `idx_receipt_id` (`receipt_id`),
    KEY `idx_po_item_id` (`po_item_id`),
    KEY `idx_sku_id`     (`sku_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '收货单明细表';

2.7 采购退货单表

-- ============================================================
-- 采购退货单表
-- 因质量/数量/型号问题,将部分货物退还给供应商
-- ============================================================
CREATE TABLE `purchase_return`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `return_no`       VARCHAR(32)      NOT NULL           COMMENT '退货单编号,格式 RTN-YYYYMMDD-XXXX',
    `po_id`           BIGINT           NOT NULL           COMMENT '关联采购单ID',
    `po_no`           VARCHAR(32)      NOT NULL           COMMENT '采购单编号(冗余)',
    `supplier_id`     BIGINT           NOT NULL           COMMENT '供应商ID',
    `warehouse_id`    BIGINT           NOT NULL           COMMENT '出货仓库ID',
    `return_reason`   TINYINT          NOT NULL
        COMMENT '退货原因:1=质量问题 2=数量不符 3=型号/规格错误 4=包装损坏 5=逾期未到货 6=其他',
    `return_qty`      INT              NOT NULL           COMMENT '退货总数量',
    `return_amount`   DECIMAL(12, 2)   NOT NULL           COMMENT '退货金额',
    `status`          TINYINT          NOT NULL DEFAULT 0
        COMMENT '状态:0=草稿 1=已提交(等待供应商确认) 2=供应商确认 3=已出库 4=供应商已收货 5=退款/补货完成 6=已拒绝',
    `handle_type`     TINYINT          NULL
        COMMENT '处理方式:1=退款 2=补发相同商品 3=补发替代商品',
    `supplier_tracking_no` VARCHAR(128) NULL              COMMENT '退货物流单号',
    `evidence_urls`   JSON             NULL               COMMENT '问题证据图片/视频URL列表',
    `remark`          VARCHAR(512)     NULL               COMMENT '退货说明',
    `create_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `update_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `create_by`       BIGINT           NULL,
    `update_by`       BIGINT           NULL,
    `is_deleted`      TINYINT(1)       NOT NULL DEFAULT 0,
    `version`         INT              NOT NULL DEFAULT 0,

    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_tenant_return_no` (`tenant_id`, `return_no`),
    KEY `idx_po_id`       (`po_id`),
    KEY `idx_supplier_id` (`supplier_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '采购退货单表';

2.8 应付账款表

-- ============================================================
-- 应付账款表(Finance Payable)
-- 采购货物到库后生成,记录需要支付给供应商的款项
-- 属于财务模块,但与采购强相关,在此一并设计
-- ============================================================
CREATE TABLE `finance_payable`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `payable_no`      VARCHAR(32)      NOT NULL           COMMENT '应付账款单号,格式 PAY-YYYYMMDD-XXXX',
    `po_id`           BIGINT           NOT NULL           COMMENT '关联采购单ID',
    `po_no`           VARCHAR(32)      NOT NULL           COMMENT '采购单编号(冗余)',
    `supplier_id`     BIGINT           NOT NULL           COMMENT '供应商ID',
    `supplier_name`   VARCHAR(128)     NOT NULL           COMMENT '供应商名称(冗余)',
    `invoice_no`      VARCHAR(64)      NULL               COMMENT '发票编号(对账用)',
    `invoice_date`    DATE             NULL               COMMENT '发票日期(账期从此日期开始计算)',
    `payable_amount`  DECIMAL(12, 2)   NOT NULL           COMMENT '应付总金额',
    `paid_amount`     DECIMAL(12, 2)   NOT NULL DEFAULT 0  COMMENT '已付金额',
    `remaining_amount` DECIMAL(12, 2)  GENERATED ALWAYS AS (`payable_amount` - `paid_amount`) STORED
                                                           COMMENT '剩余未付金额(自动计算)',
    `currency`        CHAR(3)          NOT NULL DEFAULT 'CNY' COMMENT '货币',
    `payment_days`    INT              NOT NULL DEFAULT 0  COMMENT '账期天数',
    `due_date`        DATE             NOT NULL           COMMENT '到期付款日 = invoice_date + payment_days',
    `status`          TINYINT          NOT NULL DEFAULT 0  COMMENT '状态:0=待付款 1=部分已付 2=已结清 3=已作废',
    `overdue_days`    INT              NOT NULL DEFAULT 0  COMMENT '逾期天数,由定时任务每日更新',
    `remark`          VARCHAR(512)     NULL               COMMENT '备注',
    `create_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `update_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `create_by`       BIGINT           NULL,
    `update_by`       BIGINT           NULL,
    `is_deleted`      TINYINT(1)       NOT NULL DEFAULT 0,
    `version`         INT              NOT NULL DEFAULT 0,

    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_tenant_payable_no` (`tenant_id`, `payable_no`),
    UNIQUE KEY `uk_po_id`             (`po_id`)           COMMENT '一张采购单只对应一条应付账款',
    KEY `idx_supplier_id`  (`supplier_id`),
    KEY `idx_due_date`     (`due_date`)                   COMMENT '按到期日查询,用于提醒',
    KEY `idx_tenant_status` (`tenant_id`, `status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '应付账款表';

-- ============================================================
-- 付款记录表
-- 记录每一次实际付款操作
-- ============================================================
CREATE TABLE `finance_payment_record`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `payable_id`      BIGINT           NOT NULL           COMMENT '关联应付账款ID',
    `payment_amount`  DECIMAL(12, 2)   NOT NULL           COMMENT '本次付款金额',
    `payment_date`    DATE             NOT NULL           COMMENT '付款日期',
    `payment_method`  TINYINT          NOT NULL DEFAULT 1  COMMENT '付款方式:1=银行转账 2=支票 3=现金 4=其他',
    `voucher_no`      VARCHAR(64)      NOT NULL           COMMENT '付款凭证号(银行流水号/转账凭证)',
    `operator_id`     BIGINT           NOT NULL           COMMENT '操作人(财务专员)用户ID',
    `operator_name`   VARCHAR(64)      NOT NULL           COMMENT '操作人姓名',
    `remark`          VARCHAR(256)     NULL               COMMENT '付款备注',
    `create_time`     DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,

    PRIMARY KEY (`id`),
    KEY `idx_payable_id`  (`payable_id`),
    KEY `idx_payment_date` (`payment_date`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '付款记录表';


第三节 采购需求来源与触发机制

3.1 三种需求来源对比

这是柔性供应链系统的核心能力之一:需求不再只靠人工判断,系统可以智能感知并自动触发。

graph TD
    subgraph 来源1 库存预警自动触发
        A1["定时任务:每小时扫描一次"] --> A2["查询库存 < 安全库存的SKU"]
        A2 --> A3["自动创建采购申请单<br>req_source = 1"]
        A3 --> A4["推送通知给采购专员<br>请确认补货建议"]
    end

    subgraph 来源2 销售预测触发
        B1["定时任务:每周一执行"] --> B2["基于近30天销量预测<br>未来30天需求量"]
        B2 --> B3["计算:预测需求 > 当前库存+在途库存?"]
        B3 --> B4["自动创建预测补货申请<br>req_source = 2"]
    end

    subgraph 来源3 人工手动申请
        C1["采购专员手动创建"] --> C2["填写SKU/数量/<br>期望到货日期"]
        C2 --> C3["保存申请单<br>req_source = 3"]
    end

    A4 & B4 & C3 --> D["采购申请单<br>进入审批流程"]

库存预警: 每小时扫描一次 真实库存和安全库存的对比 如果不足 就可以创建申请单 销售预测: 每周一次 基于近30天的销售数据 来进行推测 未来30天的数据 然后判断库存是否够用 如果不够 也会自动创建申请单 人工手动申请

3.2 库存预警触发机制详解

flowchart TD
    A(["定时任务:每小时整点执行"]) --> B["查询该租户下所有设置了<br>安全库存阈值的SKU-仓库组合"]
    B --> C["对每个SKU-仓库组合计算:<br>可用库存 = 实物库存 - 冻结库存"]
    C --> D{"可用库存 ≤ 安全库存阈值?"}
    D -- 否 --> E["无需处理<br>继续下一个"]
    D -- 是 --> F{"当前是否已有<br>进行中的采购单<br>(含在途库存)?"}
    F -- "有,且在途量充足" --> G["无需再次采购<br>跳过"]
    F -- "没有或在途量不足" --> H["计算建议采购量<br>= 安全库存 × 2 - 可用库存 - 在途库存<br>(确保补货后达到安全库存的2倍)"]
    H --> I{"今日是否已为<br>该SKU生成过<br>预警申请单?"}
    I -- 是 --> J["避免重复生成<br>跳过(幂等)"]
    I -- 否 --> K["创建采购申请单<br>req_source=1<br>priority=1(紧急)"]
    K --> L["发送预警通知<br>给采购专员"]
    L --> E
    E --> M{"是否还有<br>未处理的SKU?"}
    M -- 是 --> C
    M -- 否 --> N(["任务结束"])

SaaS 只是负责生成 哪个SKU 多少个数量 价格(往期)

但是不能生成完成后 直接推给供货商

要生成后推送给 采购人员

  1. 要让采购人员知道该SKU 缺货了

  2. 要让采购人员去审查 自动生成的采购单 是否合理

建议采购量的计算公式:

建议采购量 = Max(安全库存, 预测月销量 × Lead_Time/30) - (可用库存 + 在途库存)

其中:
  安全库存  = 日均销量 × 安全天数(通常设为15天)
  日均销量  = 近30天总销量 ÷ 30(排除促销异常日)
  Lead_Time = 该SKU供应商的交货周期(天)
  可用库存  = 实物库存 - 冻结库存
  在途库存  = 已确认但未到货的采购单数量之和

示例:
  日均销量 = 20件/天
  安全天数 = 15天,安全库存 = 300件
  Lead_Time = 7天
  预测月销量 = 20 × 30 = 600件
  预测期需求 = 600 × 7/30 ≈ 140件
  可用库存 = 80件,在途库存 = 0件
  建议采购量 = Max(300, 140) - (80 + 0) = 300 - 80 = 220件

流程: 使用XXL-JOB定时任务去每小时扫描全部的SKU 找到需要进行补货的 然后生成采购单 并且推送通知给采购专员

要怎么计算是否需要进行补货 ?? 建议采购量 = Max(安全库存, 预测月销量 × Lead_Time/30) - (可用库存 + 在途库存)

频率 高频 SKU —> 分片广播 同时扫描 异步处理(主要是负责计算) —> 记录 分批次去扫描 模型 推算哪些商品 慢消品(跳过) 减少扫描的频率 10W SKU 假设两台机器 一次性 limit 20 计算

3.3 销售预测触发机制

flowchart LR
    A["历史销量数据<br>近90天每日销量"] --> B["数据预处理<br>识别并剔除异常值<br>(促销日、节假日)"]
    B --> C["选择预测模型"]
    C --> D["简单移动平均<br>近30天均值"]
    C --> E["加权移动平均<br>近期权重更高"]
    C --> F["指数平滑法<br>趋势感知更敏感"]
    D & E & F --> G["取三种方法预测结果<br>的加权平均值"]
    G --> H["预测未来30天需求量"]
    H --> I["判断是否需要补货<br>预测需求 > 当前库存+在途库存?"]
    I -->|是| J["生成补货建议申请单"]

简单移动平均:近 30 天均值

比如最近 30 天一共卖了 300 件:

日均销量 = 300 / 30 = 10 件/天

那么你可能预测未来每天卖 10 件。

它的特点是简单、稳定,但缺点是反应慢

比如某商品最近 7 天突然爆单了,但前 23 天销量很低,30 天平均会把这个增长“摊平”,导致预测偏低。

加权移动平均:近期权重更高

它不是简单平均,而是给最近的数据更高权重。

例如最近 3 天销量:

日期销量权重
前天820%
昨天1230%
今天2050%

预测值:

8 × 20% + 12 × 30% + 20 × 50%= 1.6 + 3.6 + 10= 15.2 件/天

它比简单平均更快感知“最近卖得变好了”。

缺点是:权重需要人为设定,比如最近一天给 50% 是否合理,需要结合业务经验或历史回测。

指数平滑法:是什么意思?

指数平滑法可以理解为:

新的预测 = 一部分使用最新实际销量 + 一部分保留上一期预测

公式一般是:

下一期预测值 = α × 最新实际销量 + (1 - α) × 上一期预测值

其中 α 叫平滑系数,取值在 0 到 1 之间。

比如:

α = 0.3 
昨天预测今天卖 10 件
今天实际卖了 20 件
明天预测 = 0.3 × 20 + 0.7 × 10= 6 + 7= 13 件

也就是说,今天突然卖了 20 件,模型不会立刻预测明天也卖 20 件,而是把预测从 10 调整到 13。

如果:

α = 0.8
明天预测 = 0.8 × 20 + 0.2 × 10= 18 件

这说明 α 越大,越相信最新销量,反应越快;α 越小,越保守,预测更平滑。

和加权移动平均有什么区别?

加权移动平均通常是这样:

最近 7 天分别给固定权重

比如:

今天 40%,昨天 25%,前天 15%……

而指数平滑法是:

每次用最新实际销量修正上一期预测

它不一定只看最近 7 天或 30 天,理论上所有历史数据都还在影响预测,只是越久远影响越小。

简单说:

加权移动平均:你手动规定每一天/每一段的权重。
指数平滑法:你设一个 α,模型自动让越新的数据权重越大、越旧的数据权重越小。


第四节 询价比价模块

生成采购单的需求后 不一定就是要立即采购 而是要对比 多个供应商 他们的服务(价格/响应的速度/到货的时间/最小的起订量…) 那么这些的筛选 就是要通过比价功能来进行实现

4.1 为什么需要询价流程

在没有询价系统之前,采购是怎么做的? “感觉老王家比较便宜,就找老王下单。” 这种方式的问题:采购价格完全靠经验,无法证明已经选了最优价格,容易存在利益输送。

询价比价的价值:

  • 价格透明:多家供应商公开报价,有据可查
  • 竞争降价:供应商知道会被比价,自然会给出竞争性价格
  • 决策留痕:为什么选这家供应商、不选那家,有客观数据支撑

4.2 询价完整业务流程

flowchart TD
    A["采购专员打开申请单<br>发起询价"] --> B["选择询价供应商<br>(已通过审核的同品类供应商)<br>建议选择2-5家"]
    B --> C["填写询价内容:<br>SKU列表/询价数量/<br>期望价格/报价截止时间/特殊要求"]
    C --> D["提交询价<br>系统生成询价单编号"]
    D --> E["系统自动通知各供应商<br>站内信 + 邮件"]
    E --> F["供应商在Portal查看询价单"]
    F --> G["供应商填写报价:<br>每个SKU的单价/可供数量<br>/交货天数/有效期/备注"]
    G --> H["供应商提交报价<br>系统记录响应时长<br>(用于绩效评分)"]
    H --> I{"是否所有供应商<br>都已报价?"}
    I -- 还有未报价 --> J{"报价截止时间<br>是否已到?"}
    J -- 未到 --> K["等待其他供应商报价"]
    K --> F
    J -- 已到期 --> L["系统标记未报价供应商<br>status=已过期"]
    I -- 全部已报 --> M
    L --> M["采购专员打开<br>报价对比界面"]
    M --> N["系统生成多维度对比表<br>价格/交期/响应速度/历史评级"]
    N --> O["采购专员综合评估<br>选定最优供应商"]
    O --> P["点击'选定此报价'<br>自动生成采购订单草稿"]
    P --> Q["其他供应商询价单<br>自动标记为'未选中'"]
    Q --> R["进入采购订单审批/确认流程"]

4.3 报价比较表设计

报价对比界面是询价模块最核心的 UI,需要让采购专员一眼看出哪家最优:

询价单 INQ-20250117-0001 报价对比

SKU:蓝牙耳机Pro-黑色 询价数量:500件

对比维度广州鑫源深圳博通义乌精品最优
供应商评级A级 ⭐⭐B级 ⭐C级广州鑫源
报价单价(元)38.00 ✅35.5032.00义乌精品
总金额(元)19,00017,75016,000 ✅义乌精品
交货天数7天 ✅10天15天广州鑫源
可供数量500件 ✅300件 ⚠️500件 ✅
响应时长3.5小时 ✅12小时36小时广州鑫源
历史合格率98.5% ✅91.2%78.3% ⚠️广州鑫源
报价有效期7天7天3天 ⚠️
综合评分85.2 ✅72.161.8广州鑫源
操作[选定此家][查看][查看]

⚠️ 深圳博通:可供 300 件,不足询价数量 500 件,选择此家需分批采购

⚠️ 义乌精品:历史质量合格率仅 78.3%,存在较高质量风险

⚠️ 义乌精品:报价有效期仅 3 天,需尽快决定

系统建议:综合评分最高为广州鑫源(虽然单价非最低),建议优先选择,原因:质量稳定、交货最快、可全量供货。

4.4 综合评分算法

系统自动计算每家供应商报价的综合评分,辅助决策:

综合评分(满分100分)= 价格评分×40% + 交期评分×30% + 质量评分×20% + 响应评分×10%

价格评分:
  以最低报价为基准,计算各供应商溢价比例
  溢价 0%        → 100分
  溢价 1%-5%     → 80分
  溢价 5%-10%    → 60分
  溢价 10%-15%   → 40分
  溢价 >15%      → 20分

交期评分:
  以最短交期为基准
  最短交期       → 100分
  多1-3天        → 80分
  多4-7天        → 60分
  多8-14天       → 40分
  多15天以上     → 20分

质量评分:
  直接使用供应商的历史质量合格率 × 100

响应评分:
  ≤2小时         → 100分
  2-8小时        → 80分
  8-24小时       → 60分
  24-48小时      → 40分
  >48小时        → 20分

4.5 历史价格查询功能

采购专员在评估报价时,可以查看该 SKU 的历史成交价格走势,判断当前报价是否合理:

graph LR
    A["采购专员查看报价"] --> B["系统自动加载<br>该SKU历史采购价格"]
    B --> C["展示近6次成交价<br>折线趋势图"]
    C --> D["计算价格偏差:<br>当前报价 vs 历史均价"]
    D --> E{"偏差判断"}
    E -- "低于历史均价10%以上" --> F["🟢 标注:价格优惠<br>可能有质量风险,注意确认"]
    E -- "高于历史均价10%以上" --> G["🔴 标注:价格偏高<br>需要与供应商协商或选其他家"]
    E -- "在历史均价±10%以内" --> H["🟡 标注:价格正常"]

第五节 采购订单管理(核心)

5.1 采购订单状态机深度解析

状态机 技术点 如果你要在面试的时候讲解这个状态机 就要把这个状态机所有的状态 以及 状态之间的流转 要背下来 背下来的目的是为了让你给面试官讲解的时候 更流畅 更自信

采购订单是 PMS 的核心实体,有 9 个状态,是本模块最复杂的状态机:

stateDiagram-v2
    [*] --> 草稿 : 手动创建或从询价自动生成
    草稿 --> 待供应商确认 : 采购专员确认后发给供应商
    待供应商确认 --> 已确认 : 供应商在Portal确认接单
    待供应商确认 --> 草稿 : 供应商提出修改意见
    已确认 --> 发货中 : 供应商更新物流单号
    发货中 --> 部分到货 : 仓库首次完成收货(未全部到货)
    部分到货 --> 部分到货 : 仓库再次完成收货(仍未全部到货)
    部分到货 --> 全部到货 : 仓库完成全部商品的收货
    发货中 --> 全部到货 : 一次性全部到货
    全部到货 --> 已对账 : 财务完成发票核对
    已对账 --> 已结清 : 完成付款,欠款清零
    草稿 --> 已取消 : 采购专员取消
    待供应商确认 --> 已取消 : 采购专员取消(通知供应商)
    已取消 --> [*]
    已结清 --> [*]

状态对应的业务含义与可执行操作:

状态码状态名业务含义本状态可执行操作
0草稿尚未发给供应商,可自由编辑编辑、发给供应商、取消
1待供应商确认已发给供应商,等待对方确认催单、取消
2已确认供应商已接单,可期待发货查看物流信息
3发货中供应商已填写物流单号,货在路上查看物流轨迹
4部分到货仓库已收到部分货物查看收货进度、催剩余
5全部到货全部货物已入库移交财务对账
6已对账财务核对发票完成付款操作
7已结清款项已全额支付仅查看
8已取消订单已取消查看原因

5.2 采购订单创建完整流程

flowchart TD
    A(["需要创建采购订单"]) --> B{"创建方式"}
    B -- "从询价单自动生成" --> C["系统读取选中的报价信息<br>自动填充:供应商/SKU/单价/数量"]
    B -- "手动创建" --> D["手动填写采购信息<br>选择供应商/添加SKU/填写单价"]
    C & D --> E["填写收货仓库、期望到货日期"]
    E --> F{"总金额是否<br>超过审批阈值?<br>(默认10000元)"}
    F -- 超过 --> G["需要提交采购负责人审批<br>先进入内部审批流程"]
    F -- 未超过 --> H["直接发给供应商确认"]
    G --> I["采购负责人审批"]
    I --> J{"审批结果"}
    J -- 拒绝 --> K["退回修改<br>注明拒绝原因"]
    J -- 通过 --> H
    K --> D
    H --> L["系统发送通知给供应商<br>Portal站内信 + 邮件"]
    L --> M["更新状态:草稿→待供应商确认"]
    M --> N["等待供应商确认"]

5.3 采购订单编号生成规则

格式:PO-{YYYYMMDD}-{4位序号}
示例:PO-20250117-0001

与供应商编号类似,但需要额外注意:
同一天可能存在多个租户各自生成大量PO,
序号在租户内唯一即可(UNIQUE KEY 约束在 tenant_id + po_no)

5.4 供应商确认采购单流程

flowchart TD
    A["供应商登录Portal<br>看到待确认的采购单"] --> B["查看采购单详情:<br>SKU列表/数量/单价/总金额/<br>收货地址/期望到货日期"]
    B --> C{"供应商的决定"}
    C -- "完全接受" --> D["点击'确认接单'<br>填写预计发货日期"]
    C -- "有部分问题" --> E["在备注中说明问题<br>如:X颜色库存不足<br>可供200件,非500件"]
    C -- "无法接受" --> F["点击'无法接单'<br>填写原因"]
    D --> G["系统更新状态:<br>待确认→已确认"]
    G --> H["发送通知给采购专员:<br>供应商已确认,预计X日发货"]
    E --> I["系统标记待沟通<br>发通知给采购专员"]
    I --> J["采购专员与供应商协商<br>可能需要修改数量或分批"]
    J --> D
    F --> K["系统通知采购专员:<br>该供应商无法接单,请更换"]
    K --> L["采购专员可:<br>1.修改订单找其他供应商<br>2.重新询价"]

5.5 采购订单的主从表关联设计

主从表(Master-Detail)设计是企业项目中最常见的数据库设计模式。

主表(purchase_order)存什么?

  • 整个采购单的汇总信息:总金额、供应商、仓库、状态、收货日期
  • 一个采购单只有一条主表记录

从表(purchase_order_item)存什么?

  • 每个 SKU 的具体信息:品名、数量、单价、小计
  • 一个采购单可以包含多个 SKU,每个 SKU 一条明细记录
graph TD
    A["purchase_order<br>主表:一条记录<br>PO-20250117-0001<br>总金额 19,000元<br>供应商:广州鑫源<br>状态:已确认"] --> B["purchase_order_item<br>明细1:蓝牙耳机-黑色<br>数量:300件<br>单价:38元<br>小计:11,400元"]
    A --> C["purchase_order_item<br>明细2:蓝牙耳机-白色<br>数量:200件<br>单价:38元<br>小计:7,600元"]
    B & C --> D["两条明细合计:<br>11,400 + 7,600 = 19,000元<br>与主表总金额一致"]

关键业务规则:

  • ==主表的 total_amount 必须等于所有明细的 amount 之和==
  • ==新增/修改/删除明细时,必须同步更新主表的 total_amount==
  • ==这个操作必须在同一个数据库事务中完成(要么全成功,要么全回滚)==
    • 主表和从表一定要保持数据/业务的一致性(事务)

第六节 采购审批流程

6.1 为什么需要采购审批

如果没有审批,采购专员可以随意下大额采购单,带来以下风险:

  • 采购数量超出实际需求,造成库存积压和资金占用
  • 采购价格偏高,损害公司利益
  • 甚至存在与供应商勾结的风险(采购专员收取回扣)

审批阈值配置:

金额区间审批要求
0 - 10,000 元无需审批,采购专员直接下单
10,001 - 50,000 元需采购负责人审批
50,001 - 200,000 元需采购负责人 + 财务负责人双签
200,000 元以上需总经理审批

以上阈值应该是可配置的,不同租户有不同设置,存储在系统配置表中。 我们的SaaS系统主要还是提供服务的 应该尽量的考虑到每个租户的不同的需求 把系统设计的更为灵活 及 方便 我们不应该干涉租户的决定 我们只是提供数据的整合和记录的功能

6.2 审批流程设计

flowchart TD
    A["采购专员提交采购申请"] --> B["系统计算申请总金额"]
    B --> C{"是否超过审批阈值?"}
    C -- 未超过 --> D["自动审批通过<br>直接可以发起询价/下单"]
    C -- "10000-50000" --> E["创建审批任务<br>推送给采购负责人"]
    C -- "50001-200000" --> F["创建审批任务<br>推送给采购负责人<br>和财务负责人"]
    C -- "&gt;200000" --> G["创建审批任务<br>推送给总经理"]
    E --> H["审批人查看申请单<br>查看:申请理由/SKU/数量/<br>当前库存/估算金额/供应商建议"]
    F --> H
    G --> H
    H --> I{"审批决定"}
    I -- "通过" --> J["更新申请单状态:审批通过<br>通知采购专员可以下单"]
    I -- "拒绝" --> K["填写拒绝原因<br>通知采购专员"]
    I -- "修改后重提" --> L["填写修改意见<br>退回给采购专员"]
    K --> M["采购专员可以<br>修改后重新提交"]
    L --> M
    J --> N["采购专员继续执行<br>发询价或直接下采购单"]

6.3 审批的时效性要求

flowchart LR
    A["审批任务创建"] --> B["24小时内未处理"]
    B --> C["系统发送提醒<br>催促审批人处理"]
    C --> D["再过24小时仍未处理"]
    D --> E["升级提醒:通知上级<br>避免因审批延迟影响采购"]
    E --> F["超过72小时未处理"]
    F --> G["自动升级审批人<br>由上级直接处理"]

定时任务 去扫描 超过 24小时还未处理的 审批 给需要进行审批的那个人 推送消息 (即时通讯)


第七节 采购入库与 WMS 联动

这是整个 PMS 中技术难度最高的部分:采购入库需要同时更新采购单状态和仓库库存,必须保证原子性,即两个操作要么同时成功,要么同时失败,不能出现”采购单显示已收货,但库存没增加”的情况。

7.1 入库业务完整流程

flowchart TD
    A(["货物到达仓库门口"]) --> B["仓储管理员核对<br>实物 vs 送货单"]
    B --> C{"单据匹配?"}
    C -- 不匹配 --> D["联系采购专员确认<br>可能是:发货多了/少了/发错了"]
    D --> E{"确认结果"}
    E -- "正常,数量有出入" --> F["按实际收到数量创建收货单"]
    E -- "发错货了" --> G["直接拒收<br>让供应商重新发货"]
    C -- 匹配 --> F
    F --> H["仓储管理员逐件扫描条码<br>或手动录入数量"]
    H --> I["质量检验<br>外观/功能/规格抽检"]
    I --> J{"质检结果"}
    J -- "全部合格" --> K["确认收货<br>填写上架库位"]
    J -- "部分合格" --> L["合格品正常入库<br>不合格品隔离放退货区"]
    J -- "全部不合格" --> M["拒收整批<br>创建退货单"]
    K --> N["系统执行入库事务<br>(关键步骤,见下方详解)"]
    L --> N
    N --> O["更新采购单状态<br>4=部分到货 或 5=全部到货"]
    O --> P["生成库存流水记录<br>变动类型=采购入库"]
    P --> Q["仓储管理员将货物<br>搬运到指定库位上架"]
    Q --> R(["入库完成"])
    G --> S(["拒收处理"])
    M --> T(["退货流程"])

7.2 入库事务的原子性设计(核心重点)

sequenceDiagram
    participant FE as 前端
    participant BE as 后端
    participant DB as 数据库
    participant Redis as Redis缓存

    FE->>BE: 确认收货(收货单ID,各SKU质检数量)
    BE->>DB: 开启数据库事务 BEGIN TRANSACTION

    BE->>DB: 1. 校验收货单状态(必须是待质检状态)
    DB-->>BE: 返回收货单信息

    BE->>DB: 2. 更新收货单明细(pass_qty/reject_qty)
    BE->>DB: 3. 更新收货单状态 → 全部入库

    BE->>DB: 4. 更新采购单明细received_qty += pass_qty
    BE->>DB: 5. 重新计算采购单总received_qty
    BE->>DB: 6. 判断采购单状态:部分到货 or 全部到货

    BE->>DB: 7. 增加WMS库存表数量<br>UPDATE inventory SET quantity = quantity + ? WHERE sku_id=? AND warehouse_id=?
    BE->>DB: 8. 写入库存流水记录(inventory_log)

    DB-->>BE: 全部SQL执行成功

    BE->>DB: COMMIT 提交事务(7步骤全部成功才提交)

    BE->>Redis: 清除该SKU的库存缓存
    BE-->>FE: 返回成功

    Note over BE,DB: 任意一步失败 → ROLLBACK 回滚<br>所有操作撤销,数据保持一致

第 0 步:前端发起确认收货

前端会传:

{ "receiptId": 8001, "items": [ { "receiptItemId": 8101, "skuId": 10001, "passQty": 280, "rejectQty": 20 } ] }

含义: passQty:质检合格数量,可以入库 rejectQty:质检不合格数量,不能进入可售库存

注意点:

  • passQty + rejectQty 不能大于本次应收数量。
  • passQty 不能小于 0。
  • 不能重复确认同一个收货单。
  • 前端传来的数量不能直接相信,后端必须重新校验。

第 1 步:开启数据库事务

BEGIN TRANSACTION

这一步的作用是把后续多个数据库操作绑定成一个整体。

也就是说:

要么全部成功 要么全部失败

如果没有事务,可能会出现:

收货单状态已更新 采购单 received_qty 已增加 但是库存没增加 或者库存增加了,但库存流水没写入

这种数据以后很难对账。

注意点:

  • 事务范围不要太大,只包住必须强一致的数据库操作。
  • 不要在事务里做耗时操作,比如发邮件、调外部接口。
  • Redis 缓存删除通常放在事务提交之后。

第 2 步:校验收货单状态

校验收货单状态(必须是待质检状态)

后端要查询收货单:

SELECT * FROM purchase_receipt WHERE id = #{receiptId} AND tenant_id = #{tenantId} AND is_deleted = 0;

然后校验:

收货单必须存在 必须属于当前租户 状态必须是待质检 不能已经入库 不能已经拒收 关联的采购单必须存在

更严谨一点可以加锁:

SELECT * FROM purchase_receipt WHERE id = #{receiptId} AND tenant_id = #{tenantId} FOR UPDATE;

这样可以防止两个仓库人员同时确认同一张收货单。

注意点:

  • 不允许前端传 status=全部入库 直接改状态。
  • 状态流转必须由后端业务动作控制。
  • 这里也是幂等和并发控制的入口。

第 3 步:更新收货单明细

更新收货单明细(pass_qty / reject_qty)

比如某个 SKU 本次应到 300 件,质检合格 280 件,不合格 20 件。

更新收货明细:

UPDATE purchase_receipt_item SET pass_qty = #{passQty}, reject_qty = #{rejectQty}, status = 1, update_time = NOW() WHERE id = #{receiptItemId} AND tenant_id = #{tenantId} AND status = 0;

这里的状态可以理解为:

0 = 待质检 1 = 已入库 2 = 已退货 / 待退货

注意点:

  • pass_qty + reject_qty 必须等于或小于本次收货数量。
  • 不合格数量不能加到可售库存。
  • 不合格品后续可以进入退货流程或不良品区。
  • 更新时最好带 status = 0 条件,防止重复确认。

第 4 步:更新收货单状态

更新收货单状态 → 全部入库

这里的“全部入库”指的是:这张收货单已经处理完成,不是整张采购单全部到货。

比如采购单买了 500 件,第一次只到了 300 件。这 300 件质检完成后,这张收货单可以是“全部入库”,但采购单仍然是“部分到货”。

注意点:

收货单状态:描述本次收货任务是否完成 采购单状态:描述整张采购订单是否全部完成

这两个状态不要混淆。

第 5 步:更新采购单明细 received_qty

更新采购单明细 received_qty += pass_qty

假设采购单明细是:

采购数量 quantity = 500 已入库数量 received_qty = 0

第一次收货:

本次到货 300 合格 pass_qty = 280 不合格 reject_qty = 20

如果系统定义 received_qty 表示“合格入库数量”,那么更新后:

received_qty = 0 + 280 = 280

SQL 应该写成:

UPDATE purchase_order_item SET received_qty = received_qty + #{passQty}, update_time = NOW() WHERE id = #{poItemId} AND tenant_id = #{tenantId};

重点是:

received_qty += pass_qty

而不是:

received_qty = pass_qty

因为采购单可能分多次收货。

比如第二次又合格 220 件:

received_qty = 280 + 220 = 500

这样才知道这条采购明细已经全部到货。

注意点:

  • 如果 reject_qty 不入库,就不要加到 received_qty。
  • 如果业务想记录“实收到货数量”,建议单独加字段:
    • arrived_qty:实际到货数量
    • received_qty:合格入库数量
    • reject_qty:不合格数量
  • 字段语义一定要清楚,否则后面库存、对账、退货都会乱。

第 6 步:重新计算采购单总 received_qty

重新计算采购单总 received_qty

这一步不是简单为了算一个数字,而是为了判断整张采购单的完成情况。

采购单可能有多条明细:

SKU-A 采购 500 件,已入库 500 件 SKU-B 采购 200 件,已入库 120 件 SKU-C 采购 100 件,已入库 100 件

这时整张采购单还不能算全部到货,因为 SKU-B 还差 80 件。

可以查询:

SELECT SUM(quantity) AS total_order_qty, SUM(received_qty) AS total_received_qty FROM purchase_order_item WHERE po_id = #{poId} AND tenant_id = #{tenantId} AND is_deleted = 0;

然后判断:

total_received_qty = 0 还没到货 0 < total_received_qty < total_order_qty 部分到货 total_received_qty >= total_order_qty 全部到货

注意点:

  • 判断时应该按明细维度更严谨,避免某个 SKU 超收掩盖另一个 SKU 未收。
  • 更严谨的判断是:所有明细 received_qty >= quantity,才算全部到货。
  • 如果允许超收,比如供应商多发 5 件,要有单独规则:允许入库、拒收、还是生成补充采购单。

第 7 步:判断采购单状态

判断采购单状态:部分到货 or 全部到货

根据上一步结果更新采购单主表状态。

例如:

UPDATE purchase_order SET status = 4, update_time = NOW() WHERE id = #{poId} AND tenant_id = #{tenantId};

状态含义:

4 = 部分到货 5 = 全部到货

如果全部到货,后续可以触发:

生成应付账款 进入财务对账 供应商绩效评分数据沉淀

注意点:

  • 状态只能从“发货中/部分到货”变成“部分到货/全部到货”。
  • 不能从“已结清”回退成“部分到货”。
  • 状态变更最好写操作日志。

第 8 步:增加 WMS 库存数量

增加WMS库存表数量 UPDATE inventory SET quantity = quantity + ? WHERE sku_id = ? AND warehouse_id = ?

这一步是把合格品真正加到库存里。

比如本次合格 280 件:

UPDATE inventory SET quantity = quantity + 280, update_time = NOW() WHERE tenant_id = #{tenantId} AND sku_id = #{skuId} AND warehouse_id = #{warehouseId};

注意点:

第一,必须用:

quantity = quantity + #{passQty}

不要用:

quantity = #{newQuantity}

因为两个入库操作并发时,后者可能覆盖前者。

第二,如果库存记录不存在,需要先创建:

先查 inventory 是否存在 不存在则 insert 一条库存记录 存在则 update quantity = quantity + N

真实项目里要有唯一索引:

UNIQUE KEY uk_inventory_sku_wh (tenant_id, warehouse_id, sku_id)

第三,只能把合格数量加入库存:

pass_qty 加库存 reject_qty 不加可售库存

不合格数量可以进入:

defective_qty 退货暂存区 采购退货单

第 9 步:写入库存流水

写入库存流水记录 inventory_log

库存流水是审计依据。库存只更新数量是不够的,还要记录这次库存为什么变化。

例如:

INSERT INTO inventory_log ( id, tenant_id, sku_id, warehouse_id, change_type, change_qty, before_qty, after_qty, biz_type, biz_id, remark, create_time ) VALUES ( #{id}, #{tenantId}, #{skuId}, #{warehouseId}, 'PURCHASE_IN', #{passQty}, #{beforeQty}, #{afterQty}, 'PURCHASE_RECEIPT', #{receiptId}, '采购入库', NOW() );

注意点:

  • 流水要和库存更新在同一个事务里。
  • 库存流水原则上只增不改,不允许随便删除。
  • 流水里最好记录业务单据 ID,比如 receipt_id、po_id。
  • 后续库存对账、审计、问题排查都依赖流水。

如果没有流水,未来有人问: 为什么这个 SKU 库存突然多了 280 件?

系统就查不出来。

第 10 步:提交事务

COMMIT

前面所有数据库操作都成功,才提交事务。

注意:你给的图里写的是:

COMMIT 提交事务(7步骤全部成功才提交)

严格说应该是:

8 个数据库步骤全部成功才提交

因为写库存流水也是关键步骤。

只要其中任何一步失败:

ROLLBACK

所有数据库操作撤销。

第 11 步:清除 Redis 库存缓存

BE -> Redis: 清除该SKU的库存缓存

这一步放在事务提交之后是对的。

因为如果事务还没提交就删缓存,其他请求可能马上查数据库,但数据库里的新库存还没提交,可能又把旧数据写回缓存。

常见做法:

先提交数据库事务 再删除 Redis 缓存

注意点:

  • Redis 删除失败不应该回滚数据库事务。
  • 删除失败可以记录日志,或者发送重试消息。
  • 高一致性场景可以使用延迟双删。
  • 如果订单模块使用 Redis 做库存扣减,还要考虑 Redis 库存和 MySQL 库存的同步策略。

为什么必须用事务?

场景:入库确认时,步骤7(更新库存)执行成功,但此时服务器宕机,步骤8(写流水)没有执行。

后果:库存数量增加了,但库存流水记录缺失。这会导致:

  • 库存对账时数据不一致
  • 无法追溯这次库存增加是哪张采购单造成的
  • 供应商绩效评分缺少数据

事务的作用:用事务包裹所有操作,只要有任何一步失败,所有操作全部回滚,数据库回到操作前的状态,不会出现”半完成”的脏数据。

7.3 库存更新的并发安全

sequenceDiagram
    participant W1 as 仓管员甲<br>(同时操作)
    participant W2 as 仓管员乙<br>(同时操作)
    participant DB as 数据库

    Note over DB: 当前库存:100件

    W1->>DB: UPDATE inventory<br>SET quantity = quantity + 50<br>WHERE sku_id = 1 AND warehouse_id = 1

    W2->>DB: UPDATE inventory<br>SET quantity = quantity + 30<br>WHERE sku_id = 1 AND warehouse_id = 1

    Note over DB: 两条UPDATE语句在数据库层面串行执行<br>(行级锁保证)<br>最终库存:100 + 50 + 30 = 180件 

    DB-->>W1: 更新成功,影响1行
    DB-->>W2: 更新成功,影响1行

关键点:库存更新使用 quantity = quantity + N 而不是 quantity = 180

  • 错误写法:SET quantity = 180(先查出100,加50,直接SET 150,丢失了乙的+30)
  • 正确写法:SET quantity = quantity + 50(数据库层面原子加法,自动串行处理并发)

7.4 部分收货处理

一张采购单下了500件,第一次来了300件,第二次来了200件,如何处理?

flowchart TD
    A["第一次到货:300件"] --> B["创建收货单 RCV-001<br>质检300件全部通过"]
    B --> C["入库事务执行:<br>库存 +300<br>purchase_order_item.received_qty = 300<br>采购单状态 → 部分到货(4)"]
    C --> D["第二次到货:200件"]
    D --> E["创建收货单 RCV-002<br>质检200件全部通过"]
    E --> F["入库事务执行:<br>库存 +200<br>purchase_order_item.received_qty = 300+200 = 500<br>500 = 采购数量500,已全部到货"]
    F --> G["采购单状态 → 全部到货(5)"]
    G --> H["生成应付账款记录<br>(全部到货后才生成)"]

7.5 库存联动的完整数据流

graph LR
    A["采购入库确认"] --> B["inventory 表<br>quantity +N"]
    A --> C["inventory_log 表<br>新增流水记录<br>type=采购入库"]
    A --> D["purchase_order_item 表<br>received_qty +N"]
    A --> E["purchase_receipt_item 表<br>status=已入库"]
    B --> F["Redis 缓存失效<br>下次查询重新从DB取"]
    D --> G["计算采购单状态<br>部分到货 or 全部到货"]
    G --> H["purchase_order 表<br>status 更新"]

第八节 采购退货处理

8.1 退货业务场景

退货原因具体表现处理优先级
质量问题产品功能异常、外观瑕疵、材质不符🔴 高
数量不符实收数量少于采购数量🟡 中
规格/型号错误发货的 SKU 与采购单不一致🔴 高
包装损坏包装严重破损,影响销售🟡 中
超期未到货货物严重超期,客户取消了对应订单🟠 中高

8.2 采购退货完整流程

flowchart TD
    A(["发现问题:需要退货"]) --> B["仓储管理员拍照记录<br>问题证据(照片/视频)"]
    B --> C["创建退货申请单<br>选择关联的采购单<br>填写:退货SKU/数量/原因/证据图片"]
    C --> D["采购专员审核退货申请"]
    D --> E{"是否需要审批?<br>(退货金额>5000时需要)"}
    E -- 需要 --> F["采购负责人审批"]
    E -- 不需要 --> G["直接通知供应商"]
    F --> G
    G --> H["供应商在Portal查看退货申请"]
    H --> I{"供应商的处理"}
    I -- "同意退货" --> J["供应商确认接受退货"]
    I -- "拒绝退货" --> K["填写拒绝理由<br>可能:认为质量没问题"]
    K --> L["进入争议处理流程<br>采购负责人介入协商<br>可要求第三方检测"]
    J --> M["仓储执行退货出库<br>库存相应减少<br>生成库存流水(退货出库)"]
    M --> N["安排退货物流<br>填写退货运单号"]
    N --> O["供应商收到退货<br>签收确认"]
    O --> P{"处理方式"}
    P -- "退款" --> Q["财务冲减应付账款<br>或申请退款"]
    P -- "补发货物" --> R["重新发货<br>进入正常收货流程"]
    Q & R --> S(["退货处理完成"])
    L --> T{"协商结果"}
    T -- "达成一致" --> J
    T -- "无法达成一致" --> U["上报公司处理<br>降低供应商评分"]

8.3 退货对库存的影响

flowchart LR
    A["退货申请通过<br>开始执行退货出库"] --> B["检查退货的货物<br>在哪个库位"]
    B --> C["从库位中拿出退货商品<br>移至退货暂存区"]
    C --> D["执行库存减少事务:<br>inventory.quantity -= 退货数量<br>写入库存流水:类型=退货出库"]
    D --> E{"退货商品的处置"}
    E -- "退还给供应商" --> F["打包出库<br>安排物流"]
    E -- "报损(无法退货)" --> G["库存报损处理<br>inventory.defective_qty减少<br>写入流水:类型=报损"]

第九节 应付账款管理

9.1 应付账款的业务意义

为什么不采购完成就立刻付款?

账期(Credit Term) 是商业信用的体现。供应商允许卖家先收货、后付款,本质上是供应商在向卖家提供短期融资。对卖家来说,账期越长,资金周转越灵活。

账期举例:

2025年1月17日:货物到仓库,采购金额 19,000 元
合同约定:月结30天(账期30天)
则应付账款到期日 = 2025年1月17日 + 30天 = 2025年2月16日

在2025年2月16日之前,这19,000元都不需要付给供应商。
这30天内,这笔钱可以用于其他经营活动。

9.2 应付账款全生命周期

flowchart TD
    A["采购全部到货<br>(purchase_order.status = 5)"] --> B["系统自动生成应付账款记录<br>finance_payable"]
    B --> C["初始状态:待付款<br>due_date = 开票日期 + payment_days"]
    C --> D["财务专员上传发票<br>核对:发票金额 vs 采购单金额"]
    D --> E{"发票金额是否一致?"}
    E -- "不一致" --> F["联系供应商核实<br>可能是发票开错了"]
    F --> D
    E -- "一致" --> G["应付账款状态更新为已对账"]
    G --> H["定时任务每天更新<br>overdue_days = today - due_date"]
    H --> I{"距到期天数"}
    I -- "7天前" --> J["发送提醒:7天后到期"]
    I -- "3天前" --> K["发送提醒:3天后到期"]
    I -- "当天" --> L["发送提醒:今天到期,请立即付款"]
    I -- "已逾期" --> M["发送逾期告警<br>记录逾期天数影响供应商评分"]
    J & K & L --> N["财务专员发起付款"]
    N --> O["录入付款信息:<br>付款金额/付款日期/银行流水号"]
    O --> P["写入付款记录表<br>finance_payment_record"]
    P --> Q["更新已付金额<br>paid_amount += 本次付款额"]
    Q --> R{"剩余金额 = 0?"}
    R -- 否 --> S["状态:部分已付"]
    S --> N
    R -- 是 --> T["状态:已结清"]
    T --> U["采购单状态同步更新<br>purchase_order.status = 7 已结清"]
    U --> V(["账款完成"])
    M --> N

9.3 应付账款汇总报表

财务专员每天需要查看应付账款的整体情况:

应付账款总览(截止今日)

未到期:
  ┌─────────────────────────────────────────┐
  │ 7天内到期   │ 3笔 │ 合计:28,500 元  │
  │ 7-30天内   │ 8笔 │ 合计:76,200 元  │
  │ 30天以上   │ 5笔 │ 合计:43,000 元  │
  └─────────────────────────────────────────┘

已逾期:
  ┌─────────────────────────────────────────┐
  │ 逾期1-7天  │ 1笔 │ 合计:12,000 元  │
  │ 逾期7-30天 │ 0笔 │ 合计:0 元        │
  │ 逾期30天以上│0笔 │ 合计:0 元        │
  └─────────────────────────────────────────┘

按供应商汇总(未结清):
  广州鑫源:19,000元(PO-20250117-0001,2025-02-16到期)
  深圳博通:28,500元(PO-20250115-0003,2025-02-15到期)
  ...

9.4 应付账款到期提醒定时任务

-- 每天 08:30 执行的到期提醒查询
-- 查询所有即将到期或已逾期的未结清应付账款

SELECT
    fp.id,
    fp.payable_no,
    fp.supplier_id,
    fp.supplier_name,
    fp.payable_amount,
    fp.paid_amount,
    fp.payable_amount - fp.paid_amount AS remaining_amount,
    fp.due_date,
    DATEDIFF(fp.due_date, CURDATE()) AS days_until_due,
    CASE
        WHEN DATEDIFF(fp.due_date, CURDATE()) < 0  THEN '已逾期'
        WHEN DATEDIFF(fp.due_date, CURDATE()) = 0  THEN '今日到期'
        WHEN DATEDIFF(fp.due_date, CURDATE()) <= 3 THEN '3天内到期'
        WHEN DATEDIFF(fp.due_date, CURDATE()) <= 7 THEN '7天内到期'
    END AS urgency_level
FROM finance_payable fp
WHERE
    fp.is_deleted = 0
    AND fp.status IN (0, 1)  -- 待付款或部分已付
    AND fp.due_date <= DATE_ADD(CURDATE(), INTERVAL 7 DAY)  -- 7天内到期或已逾期
ORDER BY
    fp.due_date ASC;

第十节 采购数据统计报表

10.1 采购核心 KPI

报表统计的做法

  1. 如果你的数据量不大 或者 你们要求是实时是
    1. 就可以直接查询统计返回数据即可
    2. 如果对实时性要求特别高的情况下 我们也可以使用Redis进行统计
  2. 如果你们的数据量很大 或者 你们要求不是实时的(比如: 当天显示的是统计到前一天的数据)
    1. 就可以通过定时任务去扫描 把统计的结果 保存到一个统计表中
    2. 当需要进行展示的时候 就直接查询统计表中的单条数据
  3. 列存储数据库
    1. 统计 分析
    2. 数据的双重的保存 (哪些需要进行统计 分析) 只保存特定的表数据就可以了
指标名称计算公式业务意义预警阈值
采购准时到货率准时到货采购单数 / 总到货采购单数衡量供应商整体履约质量< 90% 预警
采购质量合格率质检合格件数 / 总收货件数衡量采购商品整体质量< 95% 预警
采购单平均处理时长从创建到全部到货的平均天数衡量采购效率> 30天预警
应付账款逾期率逾期账款金额 / 总未结清金额衡量付款合规性> 5% 预警
退货率退货金额 / 总采购金额衡量采购质量控制水平> 3% 预警

10.2 重点报表设计

① 供应商采购金额排行榜

-- 查询指定时间段内各供应商的采购情况
SELECT
    po.supplier_id,
    po.supplier_name,
    COUNT(po.id)                         AS order_count,        -- 采购单数量
    SUM(po.total_amount)                 AS total_amount,        -- 总采购金额
    SUM(CASE WHEN po.status = 7 THEN po.total_amount ELSE 0 END) AS paid_amount, -- 已结清金额
    SUM(CASE WHEN po.status IN (5,6) THEN po.total_amount ELSE 0 END) AS unpaid_amount, -- 待付款金额
    ROUND(AVG(
        DATEDIFF(pr.receive_date, po.order_date)
    ), 1)                                AS avg_lead_time_days,  -- 实际平均交货周期
    s.grade                              AS supplier_grade,       -- 供应商当前评级
    s.score                              AS supplier_score
FROM purchase_order po
LEFT JOIN purchase_receipt pr ON po.id = pr.po_id AND pr.is_deleted = 0
LEFT JOIN supplier s ON po.supplier_id = s.id
WHERE
    po.tenant_id = #{tenantId}
    AND po.is_deleted = 0
    AND po.status != 8  -- 排除已取消的采购单
    AND po.order_date BETWEEN #{startDate} AND #{endDate}
GROUP BY po.supplier_id, po.supplier_name, s.grade, s.score
ORDER BY total_amount DESC
LIMIT 20;

② 采购月度趋势报表

-- 按月统计采购金额和订单数趋势
SELECT
    DATE_FORMAT(po.order_date, '%Y-%m')  AS month,
    COUNT(po.id)                         AS order_count,
    SUM(po.total_amount)                 AS total_amount,
    COUNT(DISTINCT po.supplier_id)       AS supplier_count,   -- 涉及供应商数量
    SUM(poi.quantity)                    AS total_qty,         -- 采购总件数
    ROUND(
        SUM(CASE WHEN po.status IN (4,5,6,7) THEN 1 ELSE 0 END) * 100.0 / COUNT(po.id)
    , 2)                                 AS delivery_rate       -- 到货率
FROM purchase_order po
LEFT JOIN purchase_order_item poi ON po.id = poi.po_id
WHERE
    po.tenant_id = #{tenantId}
    AND po.is_deleted = 0
    AND po.order_date BETWEEN #{startDate} AND #{endDate}
GROUP BY DATE_FORMAT(po.order_date, '%Y-%m')
ORDER BY month ASC;

③ SKU 采购价格历史

-- 查询某SKU的历史采购价格变化,用于评估当前报价是否合理
SELECT
    po.order_date                        AS order_date,
    po.po_no                             AS po_no,
    po.supplier_name                     AS supplier_name,
    poi.unit_price                       AS unit_price,
    poi.quantity                         AS quantity,
    poi.amount                           AS amount,
    s.grade                              AS supplier_grade
FROM purchase_order_item poi
JOIN purchase_order po ON poi.po_id = po.id
LEFT JOIN supplier s ON po.supplier_id = s.id
WHERE
    poi.tenant_id = #{tenantId}
    AND poi.sku_id = #{skuId}
    AND po.status NOT IN (0, 8)  -- 排除草稿和已取消
    AND po.is_deleted = 0
    AND poi.is_deleted = 0
ORDER BY po.order_date DESC
LIMIT 10;

10.3 采购报表看板布局

采购数据总览                        时间范围:[本月 ▼]  [自定义]

┌──────────┬──────────┬──────────┬──────────┐
│ 本月采购额 │ 本月订单数│ 准时到货率 │ 质量合格率 │
│ ¥286,500 │  42 单   │  94.3%   │  98.7%   │
│ ↑12%较上月│ ↑5 单    │ ↓1.2%    │ ↑0.3%    │
└──────────┴──────────┴──────────┴──────────┘

┌────────────────────────┬───────────────────────┐
│ 采购金额月度趋势(折线图)  │ 供应商占比(饼图)       │
│                         │                       │
│  30万  ___             │  ■ 广州鑫源 38%        │
│  20万 /   \___/        │  ■ 深圳博通 27%        │
│  10万                  │  ■ 义乌精品 18%        │
│  1月 2月 3月 4月 5月    │  ■ 其他 17%           │
└────────────────────────┴───────────────────────┘

┌────────────────────────────────────────────────┐
│ 7天内到期应付账款                               │
│ 广州鑫源 ¥19,000 → 2025-02-16(3天后)⚠️      │
│ 深圳博通 ¥28,500 → 2025-02-15(2天后)🔴      │
└────────────────────────────────────────────────┘

┌────────────────────────────────────────────────┐
│ 待处理事项                                       │
│ • 3 张采购申请单待审批                           │
│ • 2 张采购单待供应商确认(超过48小时未回应)     │
│ • 1 张收货单待质检                               │
│ • 1 张退货单待供应商处理                         │
└────────────────────────────────────────────────┘

第十一节 接口设计规范

11.1 采购模块完整接口清单

基础路径前缀: /api/pms

模块接口名称HTTP方法路径权限标识
采购申请申请单列表GET/requisitionspms:req:list
采购申请申请单详情GET/requisitions/{id}pms:req:list
采购申请新增申请单POST/requisitionspms:req:add
采购申请编辑申请单PUT/requisitions/{id}pms:req:edit
采购申请删除申请单DELETE/requisitions/{id}pms:req:delete
采购申请提交审批PUT/requisitions/{id}/submitpms:req:submit
采购申请审批通过PUT/requisitions/{id}/approvepms:req:audit
采购申请审批拒绝PUT/requisitions/{id}/rejectpms:req:audit
询价管理发起询价POST/inquiriespms:inquiry:add
询价管理询价单列表GET/inquiriespms:inquiry:list
询价管理报价对比GET/inquiries/comparepms:inquiry:list
询价管理选定报价PUT/inquiries/{id}/selectpms:inquiry:select
采购订单采购单列表GET/orderspms:order:list
采购订单采购单详情GET/orders/{id}pms:order:list
采购订单新增采购单POST/orderspms:order:add
采购订单编辑采购单PUT/orders/{id}pms:order:edit
采购订单发给供应商PUT/orders/{id}/sendpms:order:send
采购订单取消采购单PUT/orders/{id}/cancelpms:order:cancel
采购订单SKU价格历史GET/orders/price-historypms:order:list
收货管理创建收货单POST/receiptspms:receipt:add
收货管理收货单列表GET/receiptspms:receipt:list
收货管理确认入库PUT/receipts/{id}/confirmpms:receipt:confirm
退货管理创建退货单POST/returnspms:return:add
退货管理退货单列表GET/returnspms:return:list
退货管理确认退货出库PUT/returns/{id}/shippms:return:ship
应付账款应付账款列表GET/payablespms:payable:list
应付账款录入付款POST/payables/{id}/paypms:payable:pay
报表统计采购总览GET/report/overviewpms:report:view
报表统计供应商排行GET/report/supplier-rankpms:report:view
报表统计采购月度趋势GET/report/monthly-trendpms:report:view

11.2 关键接口请求/响应示例

① 确认入库接口(关键接口):

PUT /api/pms/receipts/1748291234567892/confirm
Content-Type: application/json

请求体:
{
  "receiptId": "1748291234567892",
  "items": [
    {
      "receiptItemId": "1748291234567901",
      "skuId": "1000001",
      "actualQty": 300,
      "passQty": 295,
      "rejectQty": 5,
      "rejectReason": "5件外观有划痕,无法正常销售",
      "locationId": "2000001"
    },
    {
      "receiptItemId": "1748291234567902",
      "skuId": "1000002",
      "actualQty": 200,
      "passQty": 200,
      "rejectQty": 0,
      "locationId": "2000002"
    }
  ],
  "remark": "包装完整,外箱无破损,随附装箱单与采购单核对一致"
}

响应体(成功):
{
  "code": 200,
  "msg": "入库成功",
  "data": {
    "receiptNo": "RCV-20250117-0001",
    "totalPassQty": 495,
    "totalRejectQty": 5,
    "poStatus": 5,
    "poStatusName": "全部到货",
    "inventoryChanges": [
      {
        "skuId": "1000001",
        "skuName": "蓝牙耳机Pro-黑色",
        "beforeQty": 80,
        "changeQty": 295,
        "afterQty": 375
      },
      {
        "skuId": "1000002",
        "skuName": "蓝牙耳机Pro-白色",
        "beforeQty": 45,
        "changeQty": 200,
        "afterQty": 245
      }
    ]
  }
}

② 录入付款接口:

POST /api/pms/payables/1748291234567893/pay
Content-Type: application/json

{
  "payableId": "1748291234567893",
  "paymentAmount": 19000.00,
  "paymentDate": "2025-02-10",
  "paymentMethod": 1,
  "voucherNo": "202502100001",
  "remark": "招商银行转账,实付19000元整"
}

响应体:
{
  "code": 200,
  "msg": "付款录入成功",
  "data": {
    "payableNo": "PAY-20250117-0001",
    "paymentAmount": 19000.00,
    "totalPaid": 19000.00,
    "remaining": 0.00,
    "status": 2,
    "statusName": "已结清"
  }
}

11.3 采购模块专用错误码

采购模块错误码范围:12000 - 12099

错误码含义触发场景
12001采购申请单不存在根据ID查不到数据
12002申请单状态不允许此操作状态流转非法
12003采购数量不能为0明细数量校验
12004SKU不存在或已下架添加明细时校验
12005采购单不存在根据ID查不到
12006采购单已发给供应商,不能修改编辑时状态校验
12007收货数量超过采购数量收货时数量校验
12008该采购单已生成应付账款重复生成检查
12009付款金额超过应付余额付款时金额校验
12010退货数量超过已收货数量退货时数量校验
12011询价未收到报价,无法生成采购单选定报价校验
12012报价已过期,请重新询价报价有效期校验

今日总结与作业

今日知识点回顾

业务层面:

  • 采购管理解决的核心问题:信息透明、避免重复采购、跟踪到货、账款管理
  • 三种采购需求来源:库存预警自动触发、销售预测触发、人工手动申请
  • 询价比价:为什么需要、完整流程、多维度综合评分算法
  • 采购订单 9 个状态的完整状态机,以及每个状态允许的操作
  • 主从表设计模式:主表存汇总信息,从表存明细信息
  • 部分收货处理:多次分批到货时如何累计 received_qty
  • 采购退货:6 种退货原因、完整逆向流程
  • 应付账款:账期计算、到期提醒、付款记录

技术层面:

  • 建议采购量公式:基于日均销量、安全天数、Lead Time 的计算
  • 入库事务原子性:8 步操作必须在同一个事务中,任一失败全部回滚
  • 库存并发安全:quantity = quantity + N 而非 quantity = 最终值
  • 幂等性设计:库存预警当日只生成一次申请单,防止重复触发
  • 数据库计算字段:remaining_amount GENERATED ALWAYS AS (payable_amount - paid_amount)
  • SQL 统计报表:GROUP BY、聚合函数、CASE WHEN 条件统计

今日作业

作业 1:数据库执行(必做)

  1. 执行本课所有建表 SQL:

    • purchase_requisition(申请单主表)
    • purchase_requisition_item(申请单明细)
    • purchase_inquiry(询价单)
    • purchase_inquiry_item(报价明细)
    • purchase_order(采购订单主表)
    • purchase_order_item(采购订单明细)
    • purchase_receipt + purchase_receipt_item(收货单)
    • purchase_return(退货单)
    • finance_payable + finance_payment_record(应付账款)
  2. 插入测试数据并验证:

-- 插入一张完整的采购订单(已全部到货状态)
INSERT INTO `purchase_order` (
    `id`, `tenant_id`, `po_no`, `supplier_id`, `supplier_name`,
    `warehouse_id`, `warehouse_name`, `total_amount`, `currency`,
    `payment_type`, `payment_days`, `paid_amount`,
    `order_date`, `expected_date`, `confirmed_date`,
    `status`, `create_by`, `create_time`, `update_time`
) VALUES (
    3001, 101, 'PO-20250117-0001', 1001, '广州市鑫源电子科技有限公司',
    4001, '广州总仓', 19000.00, 'CNY',
    2, 30, 0.00,
    '2025-01-17', '2025-01-24', '2025-01-18',
    5, 501, NOW(), NOW()
);

-- 采购订单明细
INSERT INTO `purchase_order_item` (
    `id`, `tenant_id`, `po_id`, `sku_id`, `sku_code`, `sku_name`,
    `unit`, `quantity`, `received_qty`, `unit_price`, `amount`
) VALUES
(3101, 101, 3001, 1000001, 'SKU-EARPHONE-B', '蓝牙耳机Pro-黑色',
 '件', 300, 295, 38.00, 11400.00),
(3102, 101, 3001, 1000002, 'SKU-EARPHONE-W', '蓝牙耳机Pro-白色',
 '件', 200, 200, 38.00, 7600.00);

-- 对应的应付账款(全部到货后自动生成)
INSERT INTO `finance_payable` (
    `id`, `tenant_id`, `payable_no`, `po_id`, `po_no`,
    `supplier_id`, `supplier_name`,
    `payable_amount`, `paid_amount`,
    `currency`, `payment_days`,
    `invoice_date`, `due_date`,
    `status`, `create_time`, `update_time`
) VALUES (
    5001, 101, 'PAY-20250117-0001', 3001, 'PO-20250117-0001',
    1001, '广州市鑫源电子科技有限公司',
    19000.00, 0.00,
    'CNY', 30,
    '2025-01-17', '2025-02-16',
    0, NOW(), NOW()
);

-- 验证查询:查看采购单与应付账款的联动
SELECT
    po.po_no,
    po.supplier_name,
    po.total_amount AS po_amount,
    po.status AS po_status,
    fp.payable_no,
    fp.payable_amount,
    fp.remaining_amount,
    fp.due_date,
    DATEDIFF(fp.due_date, CURDATE()) AS days_until_due
FROM purchase_order po
LEFT JOIN finance_payable fp ON po.id = fp.po_id
WHERE po.tenant_id = 101 AND po.is_deleted = 0;

作业 2:业务分析题(必做)

请回答以下问题(用自己的话表述):

  1. 询价与直接下单的选择 什么情况下应该走询价流程?什么情况下可以直接创建采购订单?请结合业务场景分析(提示:考虑金额大小、紧急程度、是否有长期合作供应商等因素)

  2. 事务的必要性:假设采购入库时,库存更新成功了,但写入库存流水失败了,且没有事务保护。请描述会产生哪些业务影响?如果一周后要查某个 SKU 的库存增加情况,会发现什么问题?

  3. 部分收货的库存处理:一张采购单共 500 件,第一次收货 300 件(其中 20 件质检不合格),第二次收货 200 件。请问:

    • 第一次收货后,purchase_order.status 是什么?
    • purchase_order_item.received_qty 是多少?
    • WMS 库存增加了多少?(质检不合格的怎么处理?)
    • 第二次全部到货后,状态如何变化?

参考答案:

  1. 采购金额较大、供应商不固定、价格波动明显、需要多家比价时,应该先走询价流程,比如第一次采购某个新品,采购负责人需要比较价格、交期和供应商评级。金额较小、需求紧急、已经有长期合作供应商且价格稳定时,可以直接创建采购订单,比如固定供应商每周补货常规 SKU。

  2. 如果没有事务,库存数量已经增加,但库存流水没有写入成功,系统表面上库存变多了,后续却查不到这次库存变化来源。一周后排查某个 SKU 为什么库存增加,只能看到当前库存,无法通过流水还原“哪张采购单、什么时候、谁操作、增加了多少”,会影响库存审计、成本核算和问题追责。因此采购入库时,更新收货单、更新采购单、增加库存、写库存流水必须放在同一个本地事务里。

  3. 第一次收货 300 件,其中 20 件质检不合格,真正可入库数量是 280 件。采购单还没有全部到货,所以 purchase_order.status 应该是“部分到货”。purchase_order_item.received_qty 只统计合格入库数量,第一次后为 280。WMS 库存 quantity 增加 280,不合格的 20 件进入不良品区、待退货区或质检异常记录,不进入可售库存。第二次再收到 200 件且全部合格后,累计合格收货 480 件,如果采购单按合格入库数量判断,则仍然是部分到货,并需要对剩余 20 件发起退货、补发或关闭差异;如果业务上允许把不合格件作为已到货但不可售库存,则采购单可进入全部到货,库存中合格库存增加 480,不良品库存增加 20。实际项目中建议按“合格入库数量”和“到货数量”分开记录,避免状态判断混乱。

作业 3:SQL 练习(必做)

基于今天建立的表结构,手动编写以下 SQL:

-- 练习1:查询某供应商(supplier_id=1001)的所有采购单
-- 要求:显示 po_no、total_amount、status、order_date,按创建时间倒序
-- 你的答案:
SELECT
    po_no,
    total_amount,
    status,
    order_date
FROM purchase_order
WHERE tenant_id = 101
  AND supplier_id = 1001
  AND is_deleted = 0
ORDER BY create_time DESC;



-- 练习2:统计各状态的采购单数量和总金额
-- 要求:按状态分组,显示状态码、状态名称(用CASE WHEN)、数量、总金额
-- 你的答案:
SELECT
    status,
    CASE status
        WHEN 0 THEN '待确认'
        WHEN 1 THEN '已确认'
        WHEN 2 THEN '部分到货'
        WHEN 3 THEN '全部到货'
        WHEN 4 THEN '部分付款'
        WHEN 5 THEN '已完成'
        WHEN 6 THEN '已取消'
        ELSE CONCAT('未知状态:', status)
    END AS status_name,
    COUNT(*) AS order_count,
    SUM(total_amount) AS total_amount
FROM purchase_order
WHERE tenant_id = 101
  AND is_deleted = 0
GROUP BY status
ORDER BY status;



-- 练习3:查询7天内到期的应付账款
-- 要求:显示 payable_no、supplier_name、remaining_amount、due_date、距到期天数
-- 按到期日升序排列
-- 你的答案:
SELECT
    payable_no,
    supplier_name,
    payable_amount - paid_amount AS remaining_amount,
    due_date,
    DATEDIFF(due_date, CURDATE()) AS days_until_due
FROM finance_payable
WHERE tenant_id = 101
  AND is_deleted = 0
  AND status IN (0, 1)
  AND due_date >= CURDATE()
  AND due_date < DATE_ADD(CURDATE(), INTERVAL 8 DAY)
ORDER BY due_date ASC;



-- 练习4:查询某SKU(sku_id=1000001)的最近10次采购价格
-- 要求:显示 po_no、order_date、supplier_name、unit_price、quantity
-- 按订单日期倒序
-- 你的答案:
SELECT
    po.po_no,
    po.order_date,
    po.supplier_name,
    poi.unit_price,
    poi.quantity
FROM purchase_order_item poi
JOIN purchase_order po ON poi.po_id = po.id
WHERE poi.tenant_id = 101
  AND po.tenant_id = 101
  AND poi.sku_id = 1000001
  AND po.is_deleted = 0
ORDER BY po.order_date DESC, po.create_time DESC
LIMIT 10;

作业 4:系统设计题(选做)

需求:某租户发现,同一种 SKU 经常有多个采购专员同时发起采购申请,导致重复采购、库存积压。

请设计一套”采购申请去重机制”:

  1. 去重的触发条件是什么?(相同 SKU、什么状态范围内算”进行中”?)
  2. 去重检查在哪个步骤执行?(创建申请时?提交审批时?)
  3. 检查发现重复时,系统如何提示?是阻止创建还是警告提示?
  4. 补充设计该功能需要的数据库字段或索引
  5. 写出去重检查的 SQL

参考答案:

  1. 去重条件可以定义为:同一租户、同一 SKU、同一目标仓库,在采购申请状态为“草稿、待审批、审批通过、已转采购单、采购中”的范围内,都算进行中。如果已经取消、驳回、采购完成,则不再参与去重。

  2. 去重检查建议分两层:创建申请时做提醒,提交审批时做强校验。创建草稿时允许保存,方便采购专员补充信息;提交审批时必须检查是否已有进行中的采购申请或采购订单,避免真正进入采购流程后重复采购。

  3. 如果发现重复,低风险场景可以提示“该 SKU 已存在进行中的采购申请,是否继续创建”,由负责人确认;高风险场景如同一仓库同一 SKU 已有审批通过的申请,则直接阻止提交,并给出已有单号、申请人、申请数量、当前状态,方便合并采购需求。

  4. 可以在 purchase_requisition_item 中增加 warehouse_idrequisition_status_snapshot,或者通过主表关联仓库和状态。索引建议建立:

CREATE INDEX idx_req_item_dedupe
ON purchase_requisition_item (tenant_id, sku_id, warehouse_id, requisition_id);

CREATE INDEX idx_req_status
ON purchase_requisition (tenant_id, status, is_deleted);
  1. 去重检查 SQL 示例:
SELECT
    pr.req_no,
    pr.status,
    pr.create_by,
    pri.sku_id,
    pri.quantity,
    pr.create_time
FROM purchase_requisition_item pri
JOIN purchase_requisition pr ON pri.requisition_id = pr.id
WHERE pri.tenant_id = #{tenantId}
  AND pr.tenant_id = #{tenantId}
  AND pri.sku_id = #{skuId}
  AND pr.warehouse_id = #{warehouseId}
  AND pr.status IN (0, 1, 2, 3)
  AND pr.is_deleted = 0
ORDER BY pr.create_time DESC;

今日亮点

1. 智能补货 / 采购需求自动生成

业务点:采购需求不只靠人工创建,还可以由库存预警、销售预测自动触发。

技术点:

定时任务 + 安全库存模型 + Lead Time 计算 + 幂等去重

面试可以这样讲:

我负责采购需求自动生成逻辑,系统会定时扫描库存数据,当可用库存低于安全库存时,根据日均销量、安全天数、供应商 Lead Time 和在途库存计算建议采购量,自动生成采购申请。同时做了幂等控制,避免同一个 SKU 在同一天重复生成采购申请,导致重复采购和库存积压。

这个点很真实,因为采购系统的核心价值就是“什么时候补、补多少”。

2. 采购申请去重与防重复采购

业务点:多个采购专员可能同时发现同一个 SKU 快缺货,如果都创建采购申请,会造成重复采购。

技术点:

业务幂等 + 唯一约束/状态范围判断 + 在途库存校验

面试可以这样讲:

在创建采购申请时,我会判断同一租户、同一 SKU、同一仓库下是否已经存在进行中的采购申请或采购单,比如待审批、已审批、待供应商确认、发货中、部分到货等状态。如果已经有足够在途数量,就不再重复生成新的申请,避免重复采购。

这个点适合和“柔性供应链”结合起来讲。

3. 询价比价机制

业务点:采购员不是直接拍脑袋选供应商,而是向多家供应商询价,再综合比较。

技术点:

多供应商询价 + 报价有效期 + 综合评分算法 + Portal 协同

面试可以这样讲:

我实现了询价比价流程,采购申请可以向 2-5 家已审核供应商发起询价,供应商在 Portal 端提交报价、可供数量、交期和报价有效期。系统在比价时不仅比较单价,还会结合交期、可供数量、历史评级等维度辅助采购员选择最优供应商。

注意这里不要吹成“AI 智能比价”,说“综合评分辅助决策”更稳。

4. 采购订单状态机

业务点:采购订单从草稿、待确认、已确认、发货中、部分到货、全部到货、已对账、已结清、已取消逐步流转,不能随意跳状态。

技术点:

状态机 + 状态流转校验 + 操作权限控制

面试可以这样讲:

我使用状态机管理采购订单生命周期,不允许前端直接修改状态,而是通过确认、发货、收货、对账、付款等业务动作驱动状态变化。比如只有已确认的采购单才能进入发货中,只有全部到货后才能生成应付账款。

这个是 PMS 必讲亮点。

5. 采购审批流

业务点:大额采购不能让采购专员随便下单,需要按金额走不同审批层级。

技术点:

审批阈值配置 + 动态审批人 + 超时提醒

面试可以这样讲:

我实现了采购审批流程,系统会根据采购金额判断是否需要审批,以及需要哪一级审批。比如 1 万以内自动通过,1-5 万由采购负责人审批,更高金额需要财务负责人或总经理审批。审批任务超时后会触发提醒,避免采购延误。

这个点适合讲权限和流程控制。

6. 采购入库与 WMS 库存联动事务

这是PMS最核心、最值得写简历的技术亮点。

业务点:仓库确认收货后,既要更新采购单收货数量,又要增加库存,还要写库存流水,任何一步失败都不能提交。

技术点:

分布式事务 + 库存原子更新 + 库存流水 + 缓存失效

面试可以这样讲:

采购入库是 PMS 中最关键的一致性场景。我把更新收货单、累计采购明细 received_qty、判断采购单状态、增加 WMS 库存、写入库存流水放在同一个数据库事务中,任何一步失败都会回滚,避免出现采购单显示已入库但库存没增加,或者库存增加但没有流水的问题。

7. 库存并发安全

业务点:同一个 SKU 可能同时发生多次入库,不能出现库存覆盖。

技术点:

数据库原子更新 + 行级锁

面试可以这样讲:

库存增加时没有采用“先查库存,再计算最终值,再 update”的方式,而是直接使用 quantity = quantity + N。这样数据库会通过行级锁保证同一行库存更新串行执行,避免两个入库操作互相覆盖。

可以举例:

UPDATE inventory SET quantity = quantity + #{receiveQty} WHERE tenant_id = #{tenantId} AND sku_id = #{skuId} AND warehouse_id = #{warehouseId};

这是非常容易被面试官认可的点。

8. 部分收货处理

业务点:一张采购单 500 件,可能第一次到 300 件,第二次到 200 件,而且还有部分质检不合格。

技术点:

主从表设计 + received_qty 累计 + 采购单状态自动计算

面试可以这样讲:

我处理了采购单的部分收货场景。每次收货后,系统会累计采购明细中的 received_qty,再和采购数量比较,自动判断采购单是部分到货还是全部到货。质检不合格的部分不会进入可用库存,而是进入退货或不良品处理流程。

这个点业务味很强,能体现做过真实流程。

9. 应付账款自动生成与到期提醒

业务点:采购全部到货后,财务需要根据采购单生成应付账款,并按账期提醒付款。

技术点:

应付账款生命周期 + 数据库计算字段 + 定时提醒

面试可以这样讲:

采购单全部到货后,系统会自动生成应付账款记录,付款时写入付款流水,并累计已付金额。剩余未付金额使用数据库计算字段 remaining_amount = payable_amount - paid_amount 保证一致性。系统每天扫描 7 天内到期或已逾期的账款,提醒财务处理。

这个适合和财务闭环一起讲。

10. 采购退货逆向流程

业务点:采购入库后发现质量问题、数量不符、型号错误,需要退货给供应商,并联动库存和应付账款。

技术点:

逆向流程 + 库存扣减事务 + 应付账款冲减

面试可以这样讲:

我实现了采购退货流程,支持质量问题、数量不符、规格错误等退货原因。退货出库时会减少库存并写库存流水,如果涉及退款或扣款,还会冲减应付账款,保证采购、仓储、财务三方数据一致。

最推荐写进简历的版本

如果学生主打 Day03,可以这样写:

负责 PMS 采购管理模块,完成采购需求生成、询价比价、采购审批、采购订单状态机、采购入库联动、采购退货和应付账款管理。

基于安全库存、日均销量、Lead Time 和在途库存设计建议采购量计算模型,通过定时任务自动生成采购申请,并加入幂等去重机制,避免重复采购。

设计采购订单状态机,约束草稿、待确认、发货中、部分到货、全部到货、已对账、已结清等状态流转,防止前端绕过业务流程直接修改状态。

实现采购入库与 WMS 库存联动,将收货单状态更新、采购明细 received_qty 累计、采购单状态计算、库存增加、库存流水写入放在同一个事务中,保证入库数据一致性。

库存更新采用 quantity = quantity + N 的数据库原子更新方式,避免并发入库时出现库存覆盖问题。

实现应付账款自动生成和到期提醒,采购全部到货后自动生成应付账款,付款时记录付款流水并更新剩余应付金额。

优先级建议

优先讲:

采购需求自动生成 + 幂等去重 采购订单状态机 采购入库与 WMS 库存联动事务

然后再加:

询价比价综合评分 应付账款自动生成与到期提醒

关于技术点: 建议大家 抽取 一两个 你认为比较有技术含量 或者是 你能够讲清楚的业务 然后开始从头写这个逐字稿 这个业务是什么 概括的说 这个业务是如何产生的 这个业务涉及的表有哪些 这些表中核心字段有哪些 这个业务的详细的执行过程是什么 这个业务中核心的判断点有什么 这个业务使用到的技术有哪些 这些技术周边的问题有哪些 这个业务是否有跟其他的业务关联

你要做到 自己心中没有疑问

明日预告

第四天:仓储管理系统(WMS)完整实现

明天将完整实现仓储管理系统,包括:

  • 多仓库和库位体系设计(库位编码规范)
  • 出入库全流程(含扫码操作逻辑)
  • 库存五种类型的动态管理
  • 库存盘点三种模式(全盘/抽盘/循环盘)
  • 仓库间调拨流程
  • 库存可视化看板

预习建议:

  • 回顾今天的入库事务设计(明天 WMS 会更详细地展开)
  • 了解什么是”库位”(可类比超市货架的排列方式)
  • 思考:一件商品从入仓到出仓,在仓库内部会经历哪些步骤?

学习提示:今天的内容是整个课程中业务最复杂的一天。采购管理的状态流转、询价比价、主从表设计、入库事务这几个知识点,是企业级项目开发的标准模式,面试时被考到的概率极高。

特别是入库事务的原子性这个设计,面试官经常会追问:“你们是怎么保证库存和采购单同时更新的?如果中间某一步失败了怎么处理?“——这就是今天讲的事务回滚机制,务必能清楚地讲出来。