返回

MySQL先进先出(FIFO)库存扣减:日期优先扣减库存

mysql

MySQL 中如何根据日期扣减库存数量(先进先出)

遇到一个数据库库存管理问题:需要根据生产日期(或入库日期),按照先进先出(FIFO)的原则,从表中扣减指定数量的库存。换句话说,要优先扣减日期较早的库存记录。

原始的提问中已经指出了这是个重复问题, 指向了另一个问题的链接: How to Manage FIFO rule for reducing stock in a Point of Sale 但没有很好地解决我的具体情景. 我将在这里更系统, 更完善地讲解我的做法和各种可能.

表结构和示例数据

表结构如下:

CREATE TABLE ARTICOLISCADENZA(
    CODARTICOLO int NOT NULL,
    QUANTITA INT,
    DATA DATE,
    LOTTO VARCHAR(200),
    FOREIGN KEY(CODARTICOLO) REFERENCES ARTICOLIDETT(CODARTICOLO)
    ON DELETE CASCADE
    ON UPDATE CASCADE
);

示例数据:

CODARTICOLO QUANTITA DATA LOTTO
1503 4 2025-01-10 ...
1503 2 2025-02-10 ...

问题分析

我们需要编写一个或一组查询, 实现以下逻辑:对于给定的商品(CODARTICOLO)和要扣减的数量,从最早日期的库存记录开始扣减,直到扣减完为止。如果一条记录的库存不足以扣减,则继续扣减下一条(日期稍晚)记录。

例如,要从 CODARTICOLO = 1503 的商品中扣减 5 个数量,应该先扣减 DATA = '2025-01-10' 的 4 个,再扣减 DATA = '2025-02-10' 的 1 个。

解决方案

下面提供几种不同的解决方案,各有优缺点。

方案一:使用循环和临时表(存储过程)

最直观的方法是使用存储过程,通过循环逐条处理库存记录。

  1. 原理:

    • 创建一个临时表,按日期升序存储需要扣减库存的记录。
    • 循环遍历临时表,逐条更新 ARTICOLISCADENZA 表中的库存数量。
    • 如果当前记录的库存足够扣减,则直接更新数量并结束循环。
    • 如果当前记录的库存不足以扣减,则将该记录数量更新为 0,并更新剩余要扣减的数量,继续循环处理下一条记录。
  2. 代码示例:

DELIMITER //

CREATE PROCEDURE SubtractStock(IN articleCode INT, IN quantityToSubtract INT)
BEGIN
    DECLARE remainingQuantity INT DEFAULT quantityToSubtract;
    DECLARE currentDate DATE;
    DECLARE currentQuantity INT;
    DECLARE done INT DEFAULT FALSE;

    -- 创建临时表
    CREATE TEMPORARY TABLE IF NOT EXISTS TempStock (
        ID INT AUTO_INCREMENT PRIMARY KEY,
        DATA DATE,
        QUANTITA INT
    );
     -- 禁用外键检查
    SET foreign_key_checks = 0;
    
    -- 清空临时表
    TRUNCATE TABLE TempStock;

    -- 将需要处理的数据插入临时表,按日期排序
    INSERT INTO TempStock (DATA, QUANTITA)
    SELECT DATA, QUANTITA
    FROM ARTICOLISCADENZA
    WHERE CODARTICOLO = articleCode
    ORDER BY DATA ASC;

    -- 声明游标
     DECLARE cur CURSOR FOR SELECT DATA, QUANTITA FROM TempStock;
      DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
      
     -- 开启事务
    START TRANSACTION;
      OPEN cur;
      
      read_loop: LOOP
        FETCH cur INTO currentDate, currentQuantity;
            IF done THEN
              LEAVE read_loop;
            END IF;

        IF remainingQuantity > 0 THEN
            IF currentQuantity >= remainingQuantity THEN
                -- 库存足够,直接扣减
                UPDATE ARTICOLISCADENZA
                SET QUANTITA = QUANTITA - remainingQuantity
                WHERE CODARTICOLO = articleCode AND DATA = currentDate;
                SET remainingQuantity = 0;
                LEAVE read_loop;
            ELSE
                -- 库存不足,扣减当前记录并继续
                UPDATE ARTICOLISCADENZA
                SET QUANTITA = 0
                WHERE CODARTICOLO = articleCode AND DATA = currentDate;
                SET remainingQuantity = remainingQuantity - currentQuantity;
            END IF;
        END IF;
    END LOOP;
  
    CLOSE cur;
      -- 提交事务
        COMMIT;

    -- 删除临时表
    DROP TEMPORARY TABLE IF EXISTS TempStock;
    
      -- 启用外键检查
      SET foreign_key_checks = 1;
END //

DELIMITER ;

-- 调用存储过程
CALL SubtractStock(1503, 5);
  1. 安全建议:

    • 在存储过程中使用事务(START TRANSACTIONCOMMIT),确保数据的一致性。
    • 禁用再重新启用外键检查.
  2. 进阶技巧

    • 可以在更新ARTICOLISCADENZA数量的UPDATE语句后加入ROW_COUNT()判断更新了多少行. 进而判断是否真的更新成功.

方案二:使用变量和单条 UPDATE 语句(较为复杂)

这种方法尝试只用一条 UPDATE 语句完成所有扣减操作,避免了循环,但逻辑比较复杂。

  1. 原理:

    • 使用用户定义的变量来跟踪剩余要扣减的数量。
    • 通过 CASE 表达式判断当前记录的库存是否足够扣减。
    • 使用 ORDER BY 子句确保按日期升序处理。
    • @remaining这个用户变量,必须和UPDATE的表在同一个连接里, 如果不是同一个连接, UPDATE执行时获取不到这个变量。
  2. 代码示例:

SET @remaining := 5; -- 要扣减的总数量

UPDATE ARTICOLISCADENZA
SET QUANTITA =
    CASE
        WHEN @remaining <= 0 THEN QUANTITA  -- 已经扣减完毕,数量不变
        WHEN QUANTITA >= @remaining THEN QUANTITA - @remaining -- 库存足够,直接扣减
        ELSE 0  -- 库存不足,当前记录清零
    END,
@remaining := GREATEST(0, @remaining - QUANTITA) -- 更新剩余要扣减的数量

WHERE CODARTICOLO = 1503
ORDER BY DATA;
  1. 安全建议:
  • 复杂的单条UPDATE较难调试,容易出错,出错后排查较为困难.
  • 由于使用了变量,需要注意并发情况下的数据一致性问题。在较高并发的场景下,这种方法可能不适用,推荐使用方案一(存储过程)。
  1. 进阶使用技巧:
  • 这个UPDATE语句可以和SELECT组合使用,以在执行更新操作之后立即查看库存结果:
SET @remaining := 5; -- 要扣减的总数量

UPDATE ARTICOLISCADENZA
SET QUANTITA =
    CASE
        WHEN @remaining <= 0 THEN QUANTITA  -- 已经扣减完毕,数量不变
        WHEN QUANTITA >= @remaining THEN QUANTITA - @remaining -- 库存足够,直接扣减
        ELSE 0  -- 库存不足,当前记录清零
    END,
@remaining := GREATEST(0, @remaining - QUANTITA) -- 更新剩余要扣减的数量
WHERE CODARTICOLO = 1503
ORDER BY DATA;

SELECT * FROM ARTICOLISCADENZA WHERE CODARTICOLO = 1503;

方案三: 使用窗口函数(MySQL 8.0 及以上版本)

如果你的 MySQL 版本是 8.0 或更高版本,可以使用窗口函数来简化操作。

  1. 原理:

    • 使用SUM() OVER (ORDER BY DATA) 计算累积库存量
    • 利用CASE算出扣除后的剩余量。
    • GREATEST()函数确保剩余数量不会小于零.
  2. 代码示例:

WITH CalculatedStock AS (
  SELECT
      CODARTICOLO,
        QUANTITA,
      DATA,
        LOTTO,
      SUM(QUANTITA) OVER (PARTITION BY CODARTICOLO ORDER BY DATA) AS CumulativeQuantity
    FROM
        ARTICOLISCADENZA
    WHERE CODARTICOLO = 1503
),
Remaining as(
SELECT
  *,
  GREATEST(0, CumulativeQuantity - 5) AS RemainingQuantity -- 5为要减去的库存
FROM
  CalculatedStock
)
UPDATE ARTICOLISCADENZA AS t1
INNER JOIN Remaining AS t2
  ON t1.CODARTICOLO = t2.CODARTICOLO AND t1.DATA = t2.DATA
SET t1.QUANTITA = CASE
WHEN t2.QUANTITA > t2.RemainingQuantity THEN t2.RemainingQuantity
  ELSE t1.QUANTITA

  END;
  1. 进阶使用技巧:

    可以根据需要调整窗口函数的 PARTITION BYORDER BY 子句,进行更加复杂的库存计算.
    例如不同商品分组, 不同仓库等等.

总结

以上三种方法都可以实现根据日期扣减库存的需求。

  • 方案一(存储过程)最为直观和易于理解,适合处理复杂的逻辑和高并发场景。
  • 方案二(单条 UPDATE)性能可能稍好,但逻辑复杂,容易出错,且在并发情况下可能存在问题。
  • 方案三(窗口函数)需要 MySQL 8.0 或更高版本,代码简洁,但对于不熟悉窗口函数的人来说可能不太容易理解。
  • 方案一最安全可靠,推荐使用.

根据实际情况和数据库版本选择合适的解决方案。无论使用哪种方案,记得充分测试,确保业务数据正确性.