1 题述
1.1.库存模型回顾
关于库存模型的一些历史博客,请参考:
商品库存模型-逻辑设计小议
存货成本确定方法-进价计算设计
如果你刚刚接触商品库存设计, 并没有对该逻辑进行过较为深入的思考, 建议先阅读这两篇博客.
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.入库逻辑(含撤消)
一般的入库逻辑, 即正常的插入入库日志即可.
批量插入入库日志逻辑本身比较简单, 此处不再赘述, 仅提几个需要注意的点:
- 剩余数量在插入入库日志时, 是与入库数量一致的.
- 由于唯一索引的存在, 在插入前需要确保一张单据内不同的日志之间不要存在相同的商品-价格, 如果出现了, 需要合并.
当要撤销某张单据的入库时, 首先判断该单据的入库日志是否已被调用(入库商品是否已经出库), 两种判断方式(选择一种即可):
- 单据对应的入库日志, 是否每一条日志的入库数量, 均与剩余数量相等;
- 是否有该入库单对应的出库日志;
如果判断入库日志没有被引用, 则将该单据的所有入库日志删除掉即可表示撤销.
2.3.出库逻辑(含撤销)
出库逻辑分为两大步, 第一大步, 是通过计算的方式, 将单据中包含的出库需求, 转化为包含对应入库单据(以及顺便包含单位成本)的数据.
- 检查当前出库需求, 能否得到满足, 即已经入库的商品数量, 是否不少于需要出库的数量(这一步不需要考虑单位成本).
查询出库需求对应的按商品区分的入库数量:
-- 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)));
}
}
- 查询对应商品的所有剩余数量为正的入库日志, 将之按照出库需求进行分配, 最后得到想要的包含入库日志信息的出库日志集合.
查询出库需求对应的所有入库日志:
-- 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;
}
}
}
第二大步, 执行修改数据操作, 除了一些必要的检查外, 实际的数据操作包含两步:
- 增加出库日志: 此步比较简单, 即批量入库日志的插入;
- 更新已有的入库日志: 主要讲入库日志的剩余数量进行相应的减少;
-- 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.失效场景
此处虽说是失效场景, 但实际仅是一些不太容易处理的场景, 譬如说:
对于销售退货这种单据, 本身是属于入库单据, 需要日志自身包含单位成本.
但销售模块负责人员, 很多都没有权限得知某件商品的单位成本, 此时如何取成本就成了一个不大不小的问题.
一般的解决策略, 是给企业几个可供选择的销售退货自动获取单位成本的方案, 如:
- 根据引用的销售入库单获取单位成本;
- 根据商品的最新单位成本设置成本;
等等
至于具体采用哪种方案, 则要看企业的需要了.
3.2.允许负库存
有些企业可能存在允许负库存的情况, 对于双日志库存模型而言也并非不能实现.
笔者设计了一个虚拟入库日志的解决策略, 即当存在负库存情况的时候, 提供一个虚拟的入库日志, 实际表示的则是负数量.
不过该方案还在测试中, 暂时就不提供设计的详细过程了.
end
来源:oschina
链接:https://my.oschina.net/yangyishe/blog/4298695