双日志商品库存模型设计

原创
2020/06/02 17:07
阅读数 892

1 题述

1.1.库存模型回顾

关于库存模型的一些历史博客,请参考:
商品库存模型-逻辑设计小议
存货成本确定方法-进价计算设计
如果你刚刚接触商品库存设计, 并没有对该逻辑进行过较为深入的思考, 建议先阅读这两篇博客.

1.2.双日志库存模型简述

以往博客中我提到的, 关于同时兼顾1)可查询历史库存2)可准确确定商品成本,推荐的方法是库存日志法.

但该法有两个缺陷:

  1. 一些场合下, 出库计算会十分复杂, 计算量比较大;
  2. 有时会有金额/价格的(四舍五入导致的)近似误差产生;

基于此, 在约一年前, 我们又发明了一种双日志库存模型, 弥补了这两个缺陷. 经过一年左右调试和使用, 可以确定该方法确实比单日志库存模型要好用.

该方法简而言之, 就是设计两种库存日志.

  1. 入库日志: 核心包括记录入库单号及类型, 入库日期, 仓库/商品/商品属性/批次, 入库数量, 单位成本, 剩余数量;
  2. 出库日志: 核心包括记录出库单号及类型, 对应入库单号及类型和入库日志id, 出库日期, 仓库/商品/商品属性/批次, 出库数量, 单位成本(冗余);

入库日志上尤其需包含剩余数量, 这样进行进销存统计时就只需统计入库日志, 而不必统计出库日志.
出库日志上需包含对应的入库日志id, 一个入库日志会对应一个或多个出库日志, 这也意味着, 在出入库平衡的情况下, 出库日志数量一定不少于入库日志数量.

2 设计

简单起见, 本文将一般都会包含的仓库/商品/商品属性/批次信息, 简化为商品信息, 其他常见的冗余信息如客户/供应商信息,生产日期等也不包含, 以方便读者理解.

2.1.数据库设计

入库日志设计:

CREATE TABLE `pss_warehouse_inbound_log` (
  `id` bigint(18) NOT NULL COMMENT 'id',
  `inbound_type` varchar(4) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT '入库表单类型',
  `inbound_id` bigint(18) NOT NULL COMMENT '入库表单id',
  `inbound_code` varchar(32) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT '入库表单编码',
  `opening_date` date NOT NULL COMMENT '开单日期',
  `product_id` bigint(18) NOT NULL COMMENT '商品id',
  `inbound_count` decimal(15,4) NOT NULL COMMENT '入库数量',
  `unit_cost` decimal(15,4) NOT NULL COMMENT '单位成本',
  `residue_count` decimal(15,4) NOT NULL COMMENT '剩余数量',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_warehouse_inbound_log_ipu` (`inbound_id`,`product_id`,`unit_cost`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='入库日志表';

出库日志设计:

CREATE TABLE `pss_warehouse_outbound_log` (
  `id` bigint(18) NOT NULL COMMENT 'id',
  `outbound_type` varchar(4) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT '出库表单类型',
  `outbound_id` bigint(18) NOT NULL COMMENT '出库表单id',
  `outbound_code` varchar(32) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT '出库表单编码',
  `opening_date` date NOT NULL COMMENT '开单日期',
  `inbound_type` varchar(4) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT '入库表单类型',
  `inbound_id` bigint(18) NOT NULL COMMENT '入库表单id',
  `inbound_code` varchar(32) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT '入库表单编码',
  `inbound_log_id` bigint(18) NOT NULL COMMENT '入库日志id',
  `product_id` bigint(18) NOT NULL COMMENT '商品id',
  `outbound_count` decimal(15,4) NOT NULL COMMENT '出库数量',
  `unit_cost` decimal(15,4) NOT NULL COMMENT '单位成本',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_warehouse_outbound_log_opui` (`outbound_id`,`product_id``unit_cost`,`inbound_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='出库日志表';
2.2.入库逻辑(含撤消)

一般的入库逻辑, 即正常的插入入库日志即可.
批量插入入库日志逻辑本身比较简单, 此处不再赘述, 仅提几个需要注意的点:

  1. 剩余数量在插入入库日志时, 是与入库数量一致的.
  2. 由于唯一索引的存在, 在插入前需要确保一张单据内不同的日志之间不要存在相同的商品-价格, 如果出现了, 需要合并.

当要撤销某张单据的入库时, 首先判断该单据的入库日志是否已被调用(入库商品是否已经出库), 两种判断方式(选择一种即可):

  1. 单据对应的入库日志, 是否每一条日志的入库数量, 均与剩余数量相等;
  2. 是否有该入库单对应的出库日志;

如果判断入库日志没有被引用, 则将该单据的所有入库日志删除掉即可表示撤销.

2.3.出库逻辑(含撤销)

出库逻辑分为两大步, 第一大步, 是通过计算的方式, 将单据中包含的出库需求, 转化为包含对应入库单据(以及顺便包含单位成本)的数据.

  1. 检查当前出库需求, 能否得到满足, 即已经入库的商品数量, 是否不少于需要出库的数量(这一步不需要考虑单位成本).

查询出库需求对应的按商品区分的入库数量:

-- mybatis-mysql平台
select product_id,
sum(residue_count) as history_count,
from pss_warehouse_inbound_log
-- 出库的openingDate必须不能早于入库,否则就产生了逻辑上的悖论
where opening_date <= #{condition.openingDate} 
and 
<foreach collection="lstLog" item="mLog" open="(" separator="or" close=")">
    <trim prefix="(" suffix=")">
    product_id=#{mLog.productId,jdbcType=BIGINT}
    -- 此处由于常常包含更复杂的仓库/商品属性/批次,所以没有采用更简单的in过滤
    </trim>
</foreach>
group by product_id
having history_count > 0

计算是否能满足:

//java平台
List<WarehouseInboundLogVO> lstInboundLogVO = warehouseInboundLogMapper.selectGroupBatch(lstInboundLogParam, mapCondition);
Map<String, WarehouseInboundLogVO> mapInboundLogVO = new HashMap<>();
for (WarehouseInboundLogVO mInboundLogVO : lstInboundLogVO) {
    //此处的key在本文章实际就是productId
    String strKey = getWarehouseInboundLogKey(mInboundLogVO);
    if (mapInboundLogVO.get(strKey) == null) {
        mapInboundLogVO.put(strKey, mInboundLogVO);
    } else {
        //理论上,只要mapCondition选择正确,此处不会报错
        return ResponseData.getFailInstance("批次信息重复");
    }
}
for (String strKey : mapLog.keySet()) {
    if (mapInboundLogVO.get(strKey) == null) {
        return ResponseData.getFailInstance("出库批次库存数量为0", WarehouseCollection.getInstance(mapLog.get(strKey)));
    }
    if (mapLog.get(strKey).getOutboundCount().compareTo(mapInboundLogVO.get(strKey).getHistoryCount()) > 0) {
        return ResponseData.getFailInstance("批次库存数量小于待出库数量", WarehouseCollection.getInstance(mapInboundLogVO.get(strKey)));
    }
}
  1. 查询对应商品的所有剩余数量为正的入库日志, 将之按照出库需求进行分配, 最后得到想要的包含入库日志信息的出库日志集合.

查询出库需求对应的所有入库日志:

-- mybatis-mysql平台
select
<include refid="Base_Column_List"/>
from pss_warehouse_inbound_log
where opening_date <= #{condition.openingDate} 
and residue_count > 0 
and
<foreach collection="lstLog" item="mLog" separator="or" open="(" close=")">
    <trim prefix="(" suffix=")">
    product_id=#{mLog.productId,jdbcType=BIGINT}
    -- 此处由于常常包含更复杂的仓库/商品属性/批次,所以没有采用更简单的in过滤
    </trim>
</foreach>
-- 与查询全部库存的区别在于,没有group by,根据opening_date进行排序以实现先进先出原则
order by opening_date asc

计算包含入库信息和成本后的出库日志集合

//java平台
//此前先将出库日志按照key转化为map
//计算出库日志结果(正常:只计算正库存)[不涉及数据库]
List<WarehouseOutboundLog> lstOutboundLogResult = new ArrayList<>();
for (String strKey : mapOutboundLog.keySet()) {
    WarehouseOutboundLog mOutboundLog = mapOutboundLog.get(strKey);
    //剩余待出库数量
    BigDecimal decResidueOutboundCount = mOutboundLog.getOutboundCount();
    List<WarehouseInboundLog> lstInboundLog = mapInboundLogList.get(strKey);
    for (WarehouseInboundLog mInboundLog : lstInboundLog) {
        //此步将入库信息填充到出库日志上,包含入库单据,入库日志id及单位成本
        WarehouseOutboundLog mOutboundLogNew = setInboundLogInfoClone(mOutboundLog, mInboundLog);
        //必定不会为0(因为后面的break)
        //入库日志可用数量与出库日志需抵消数量比较
        //-1:入库日志数量不足,全部交给出库日志,同时转到下一个入库日志
        //1or0:入库数量超出or恰好,部分交给出库日志,同时Break;
        int intCompareResult = mInboundLog.getResidueCount().compareTo(decResidueOutboundCount);
        if (intCompareResult < 0) {
            //当前日志库存数量不足,转至下一个
            decResidueOutboundCount = decResidueOutboundCount.subtract(mInboundLog.getResidueCount());
            mOutboundLogNew.setOutboundCount(mInboundLog.getResidueCount());
            lstOutboundLogResult.add(mOutboundLogNew);
        } else {
            //当前日志库存数量相等或超出,跳出(需重新计算成本)
            mOutboundLogNew.setOutboundCount(decResidueOutboundCount);
            lstOutboundLogResult.add(mOutboundLogNew);
            break;
        }

    }
}

第二大步, 执行修改数据操作, 除了一些必要的检查外, 实际的数据操作包含两步:

  1. 增加出库日志: 此步比较简单, 即批量入库日志的插入;
  2. 更新已有的入库日志: 主要讲入库日志的剩余数量进行相应的减少;
-- mybatis-mysql平台
update pss_warehouse_inbound_log pwil,pss_warehouse_outbound_log pwol
set pwil.residue_count=pwil.residue_count - pwol.outbound_count
where pwol.outbound_id = #{lngOutboundId,jdbcType=BIGINT}
  and pwol.inbound_log_id = pwil.id

至于撤销操作, 基本就是第二大步的逆过程(不需要进行太多的检查操作), 具体过程略.

2.4.常见查询逻辑

最常见的, 是查询某个日期的进销存数量/金额查询:

-- mybatis-mysql平台
select product_id,
sum(residue_count) as history_count,
from pss_warehouse_inbound_log
-- 此处的openingDate就是查询库存的日期
where opening_date <= #{condition.openingDate} 
group by product_id
having history_count > 0

此sql还存在很多变种, 用于查询某个状态的库存状况.

还有一种查询, 是查询某段时间的出入库日志流(库存日志查询), sql参考如下:

-- mybatis-mysql平台
select id,
inbound_type as doc_type,inbound_id as doc_id,inbound_code as doc_code,opening_date,
product_id,
inbound_count as add_count,unit_cost,
1 as log_mark
from pss_warehouse_inbound_log
where product_id=#{condition.productId}
and opening_date >=#{condition.startDate}
and opening_date <=#{condition.endDate}
union
select id,
outbound_type as doc_type,outbound_id as doc_id,outbound_code as doc_code,opening_date,
product_id,
-outbound_count as add_count,unit_cost,
-1 as log_mark
from pss_warehouse_outbound_log
where product_id=#{condition.productId}
and opening_date >=#{condition.startDate}
and opening_date <=#{condition.endDate}
order by opening_date asc,log_mark desc,id asc

3 其他

3.1.失效场景

此处虽说是失效场景, 但实际仅是一些不太容易处理的场景, 譬如说:
对于销售退货这种单据, 本身是属于入库单据, 需要日志自身包含单位成本.
但销售模块负责人员, 很多都没有权限得知某件商品的单位成本, 此时如何取成本就成了一个不大不小的问题.
一般的解决策略, 是给企业几个可供选择的销售退货自动获取单位成本的方案, 如:

  1. 根据引用的销售入库单获取单位成本;
  2. 根据商品的最新单位成本设置成本;
    等等

至于具体采用哪种方案, 则要看企业的需要了.

3.2.允许负库存

有些企业可能存在允许负库存的情况, 对于双日志库存模型而言也并非不能实现.
笔者设计了一个虚拟入库日志的解决策略, 即当存在负库存情况的时候, 提供一个虚拟的入库日志, 实际表示的则是负数量.
不过该方案还在测试中, 暂时就不提供设计的详细过程了.

end

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部