引言:库存准确率的重要性
库存不准的后果:
- 超卖:系统有货实际没货,客户投诉
- 滞销:实际有货系统没货,资金占用
- 财务差异:账实不符,审计风险
库存准确率目标:
- 电商仓库:> 99.9%
- 普通仓库:> 99.5%
- 高价值仓库:> 99.99%
一、盘点类型
1.1 盘点方式对比
| 类型 | 说明 | 频率 | 适用场景 |
|---|---|---|---|
| 全盘 | 盘点所有库存 | 年度/季度 | 财务结算、年终 |
| 循环盘点 | 按计划轮流盘点 | 每日 | 日常管理 |
| 动碰盘点 | 有变动时盘点 | 实时 | 高价值商品 |
| 抽盘 | 随机抽查 | 每周 | 抽样检查 |
| 盲盘 | 不显示系统数量 | 按需 | 防止作弊 |
1.2 盘点策略选择
┌─────────────────┐
│ 库存准确率 │
│ 目标 99.9% │
└────────┬────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 全盘 │ │ 循环盘点 │ │ 动碰盘点 │
│ 年度1次 │ │ 每日执行 │ │ 实时触发 │
└──────────┘ └──────────┘ └──────────┘
│ │ │
▼ ▼ ▼
财务结算 日常维护 高价值商品
二、盘点数据模型
2.1 盘点计划
CREATE TABLE t_count_plan (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
plan_no VARCHAR(32) NOT NULL COMMENT '计划单号',
warehouse_id VARCHAR(32) NOT NULL,
plan_name VARCHAR(64) NOT NULL COMMENT '计划名称',
count_type VARCHAR(16) NOT NULL COMMENT '盘点类型:FULL/CYCLE/SPOT',
-- 盘点范围
scope_type VARCHAR(16) NOT NULL COMMENT '范围类型:ALL/ZONE/SKU/LOCATION',
scope_value TEXT COMMENT '范围值(JSON)',
-- 计划时间
plan_date DATE NOT NULL COMMENT '计划日期',
start_time TIME COMMENT '开始时间',
end_time TIME COMMENT '结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'CREATED',
created_by VARCHAR(32),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_plan_no (plan_no),
KEY idx_warehouse_date (warehouse_id, plan_date)
) ENGINE=InnoDB COMMENT='盘点计划';
2.2 盘点任务
CREATE TABLE t_count_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_no VARCHAR(32) NOT NULL COMMENT '任务单号',
plan_no VARCHAR(32) NOT NULL COMMENT '计划单号',
warehouse_id VARCHAR(32) NOT NULL,
-- 盘点范围
zone_code VARCHAR(16) COMMENT '库区',
location_from VARCHAR(32) COMMENT '起始库位',
location_to VARCHAR(32) COMMENT '结束库位',
-- 执行信息
operator_id VARCHAR(32) COMMENT '盘点员',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
start_time DATETIME,
end_time DATETIME,
-- 统计
total_locations INT DEFAULT 0 COMMENT '库位数',
counted_locations INT DEFAULT 0 COMMENT '已盘库位数',
difference_count INT DEFAULT 0 COMMENT '差异数',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_task_no (task_no),
KEY idx_plan_no (plan_no)
) ENGINE=InnoDB COMMENT='盘点任务';
2.3 盘点明细
CREATE TABLE t_count_detail (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_no VARCHAR(32) NOT NULL,
location_code VARCHAR(32) NOT NULL COMMENT '库位',
sku_id VARCHAR(32) NOT NULL,
-- 数量
system_qty INT NOT NULL COMMENT '系统数量',
count_qty INT COMMENT '盘点数量',
difference_qty INT COMMENT '差异数量',
-- 盘点信息
count_time DATETIME COMMENT '盘点时间',
counter_id VARCHAR(32) COMMENT '盘点人',
-- 复盘
recount_qty INT COMMENT '复盘数量',
recount_time DATETIME,
recounter_id VARCHAR(32),
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
remark VARCHAR(256),
KEY idx_task_no (task_no),
KEY idx_location (location_code)
) ENGINE=InnoDB COMMENT='盘点明细';
三、循环盘点
3.1 循环盘点策略
ABC分类盘点:
- A类(高价值/高周转):每周盘点
- B类(中等):每月盘点
- C类(低价值/低周转):每季度盘点
@Service
public class CycleCountPlanService {
/**
* 生成循环盘点计划
*/
public CountPlan generateDailyPlan(String warehouseId, LocalDate date) {
CountPlan plan = new CountPlan();
plan.setPlanNo(generatePlanNo());
plan.setWarehouseId(warehouseId);
plan.setPlanName("循环盘点-" + date);
plan.setCountType(CountType.CYCLE);
plan.setPlanDate(date);
// 获取今日应盘点的SKU
List<String> skuIds = getSkusToCount(warehouseId, date);
plan.setScopeType(ScopeType.SKU);
plan.setScopeValue(JSON.toJSONString(skuIds));
return countPlanRepository.save(plan);
}
/**
* 根据ABC分类获取今日应盘SKU
*/
private List<String> getSkusToCount(String warehouseId, LocalDate date) {
List<String> result = new ArrayList<>();
// A类:每周盘点(周一到周五轮流)
int dayOfWeek = date.getDayOfWeek().getValue();
if (dayOfWeek <= 5) {
List<String> aSkus = skuService.getSkusByClass(warehouseId, "A");
int batchSize = aSkus.size() / 5 + 1;
int start = (dayOfWeek - 1) * batchSize;
int end = Math.min(start + batchSize, aSkus.size());
result.addAll(aSkus.subList(start, end));
}
// B类:每月盘点(按日期轮流)
int dayOfMonth = date.getDayOfMonth();
List<String> bSkus = skuService.getSkusByClass(warehouseId, "B");
int bBatchSize = bSkus.size() / 20 + 1; // 假设每月20个工作日
int bStart = (dayOfMonth - 1) % 20 * bBatchSize;
int bEnd = Math.min(bStart + bBatchSize, bSkus.size());
if (bStart < bSkus.size()) {
result.addAll(bSkus.subList(bStart, bEnd));
}
return result;
}
}
3.2 生成盘点任务
@Service
public class CountTaskService {
/**
* 根据计划生成盘点任务
*/
public List<CountTask> generateTasks(String planNo) {
CountPlan plan = countPlanRepository.findByPlanNo(planNo);
List<CountTask> tasks = new ArrayList<>();
// 获取盘点范围内的库位
List<LocationInventory> inventories = getInventoriesToCount(plan);
// 按库区分组,每个库区一个任务
Map<String, List<LocationInventory>> byZone = inventories.stream()
.collect(Collectors.groupingBy(LocationInventory::getZone));
for (Map.Entry<String, List<LocationInventory>> entry : byZone.entrySet()) {
CountTask task = new CountTask();
task.setTaskNo(generateTaskNo());
task.setPlanNo(planNo);
task.setWarehouseId(plan.getWarehouseId());
task.setZoneCode(entry.getKey());
task.setStatus(TaskStatus.PENDING);
task.setTotalLocations(entry.getValue().size());
countTaskRepository.save(task);
// 生成盘点明细
for (LocationInventory inv : entry.getValue()) {
CountDetail detail = new CountDetail();
detail.setTaskNo(task.getTaskNo());
detail.setLocationCode(inv.getLocationCode());
detail.setSkuId(inv.getSkuId());
detail.setSystemQty(inv.getQuantity());
detail.setStatus(DetailStatus.PENDING);
countDetailRepository.save(detail);
}
tasks.add(task);
}
return tasks;
}
}
四、盘点执行
4.1 PDA盘点操作
@RestController
@RequestMapping("/api/pda/count")
public class PDACountController {
/**
* 获取盘点任务
*/
@GetMapping("/task")
public Result<CountTaskVO> getTask(@RequestParam String operatorId) {
CountTask task = countTaskService.getAssignedTask(operatorId);
return Result.success(convertToVO(task));
}
/**
* 获取下一个盘点库位
*/
@GetMapping("/next")
public Result<CountDetailVO> getNextLocation(@RequestParam String taskNo) {
CountDetail detail = countDetailService.getNextPending(taskNo);
if (detail == null) {
return Result.success(null, "盘点完成");
}
CountDetailVO vo = new CountDetailVO();
vo.setId(detail.getId());
vo.setLocationCode(detail.getLocationCode());
vo.setSkuId(detail.getSkuId());
// 盲盘模式不显示系统数量
if (!isBlindCount(taskNo)) {
vo.setSystemQty(detail.getSystemQty());
}
return Result.success(vo);
}
/**
* 提交盘点结果
*/
@PostMapping("/submit")
public Result<CountResultVO> submitCount(@RequestBody CountSubmitRequest request) {
CountDetail detail = countDetailService.getById(request.getDetailId());
// 1. 校验库位
if (!detail.getLocationCode().equals(request.getScannedLocation())) {
return Result.fail("库位不匹配");
}
// 2. 记录盘点数量
detail.setCountQty(request.getCountQty());
detail.setCountTime(LocalDateTime.now());
detail.setCounterId(request.getOperatorId());
// 3. 计算差异
int diff = request.getCountQty() - detail.getSystemQty();
detail.setDifferenceQty(diff);
// 4. 设置状态
if (diff == 0) {
detail.setStatus(DetailStatus.MATCHED);
} else {
detail.setStatus(DetailStatus.DIFFERENCE);
}
countDetailService.save(detail);
// 5. 更新任务进度
updateTaskProgress(detail.getTaskNo());
// 6. 返回结果
CountResultVO result = new CountResultVO();
result.setSystemQty(detail.getSystemQty());
result.setCountQty(request.getCountQty());
result.setDifferenceQty(diff);
result.setNeedRecount(Math.abs(diff) > 0);
return Result.success(result);
}
/**
* 空库位确认
*/
@PostMapping("/empty")
public Result<Void> confirmEmpty(@RequestBody EmptyConfirmRequest request) {
// 扫描库位,确认为空
String locationCode = request.getLocationCode();
// 检查系统是否有该库位的库存记录
List<LocationInventory> inventories =
inventoryService.getByLocation(request.getWarehouseId(), locationCode);
if (inventories.isEmpty()) {
// 系统也是空的,正常
return Result.success();
}
// 系统有库存但实际为空,记录差异
for (LocationInventory inv : inventories) {
CountDetail detail = new CountDetail();
detail.setTaskNo(request.getTaskNo());
detail.setLocationCode(locationCode);
detail.setSkuId(inv.getSkuId());
detail.setSystemQty(inv.getQuantity());
detail.setCountQty(0);
detail.setDifferenceQty(-inv.getQuantity());
detail.setStatus(DetailStatus.DIFFERENCE);
countDetailService.save(detail);
}
return Result.success();
}
}
4.2 盲盘模式
@Service
public class BlindCountService {
/**
* 盲盘:不显示系统数量,防止盘点人员作弊
*/
public CountDetailVO getBlindCountDetail(Long detailId) {
CountDetail detail = countDetailRepository.findById(detailId);
CountDetailVO vo = new CountDetailVO();
vo.setId(detail.getId());
vo.setLocationCode(detail.getLocationCode());
vo.setSkuId(detail.getSkuId());
vo.setSkuName(skuService.getSkuName(detail.getSkuId()));
// 不返回系统数量
vo.setSystemQty(null);
return vo;
}
}
五、差异处理
5.1 差异审核流程
发现差异 ──> 复盘确认 ──> 差异审核 ──> 库存调整
│ │ │ │
▼ ▼ ▼ ▼
记录差异 二次盘点 主管审批 调整库存
5.2 复盘服务
@Service
public class RecountService {
/**
* 创建复盘任务
*/
public void createRecountTask(String taskNo) {
// 获取有差异的明细
List<CountDetail> differences =
countDetailRepository.findDifferences(taskNo);
for (CountDetail detail : differences) {
// 标记需要复盘
detail.setNeedRecount(true);
detail.setRecountStatus(RecountStatus.PENDING);
countDetailRepository.save(detail);
}
}
/**
* 提交复盘结果
*/
public void submitRecount(Long detailId, int recountQty, String recounterId) {
CountDetail detail = countDetailRepository.findById(detailId);
detail.setRecountQty(recountQty);
detail.setRecountTime(LocalDateTime.now());
detail.setRecounterId(recounterId);
// 判断复盘结果
if (recountQty == detail.getSystemQty()) {
// 复盘与系统一致,说明首次盘点错误
detail.setRecountStatus(RecountStatus.MATCH_SYSTEM);
detail.setFinalQty(detail.getSystemQty());
} else if (recountQty == detail.getCountQty()) {
// 复盘与首次盘点一致,确认差异
detail.setRecountStatus(RecountStatus.CONFIRM_DIFFERENCE);
detail.setFinalQty(recountQty);
} else {
// 三次结果都不同,需要主管介入
detail.setRecountStatus(RecountStatus.NEED_REVIEW);
}
countDetailRepository.save(detail);
}
}
5.3 库存调整
@Service
public class InventoryAdjustService {
/**
* 根据盘点结果调整库存
*/
@Transactional
public void adjustByCountResult(String taskNo, String approver) {
List<CountDetail> confirmedDiffs =
countDetailRepository.findConfirmedDifferences(taskNo);
for (CountDetail detail : confirmedDiffs) {
int adjustQty = detail.getFinalQty() - detail.getSystemQty();
if (adjustQty == 0) continue;
// 创建调整单
InventoryAdjust adjust = new InventoryAdjust();
adjust.setAdjustNo(generateAdjustNo());
adjust.setWarehouseId(detail.getWarehouseId());
adjust.setLocationCode(detail.getLocationCode());
adjust.setSkuId(detail.getSkuId());
adjust.setBeforeQty(detail.getSystemQty());
adjust.setAdjustQty(adjustQty);
adjust.setAfterQty(detail.getFinalQty());
adjust.setAdjustType(adjustQty > 0 ? "PROFIT" : "LOSS");
adjust.setSourceType("COUNT");
adjust.setSourceNo(taskNo);
adjust.setApprover(approver);
adjust.setApproveTime(LocalDateTime.now());
adjustRepository.save(adjust);
// 调整库存
if (adjustQty > 0) {
inventoryService.addInventory(
detail.getWarehouseId(),
detail.getLocationCode(),
detail.getSkuId(),
adjustQty,
"COUNT_PROFIT",
adjust.getAdjustNo()
);
} else {
inventoryService.deductInventory(
detail.getWarehouseId(),
detail.getLocationCode(),
detail.getSkuId(),
-adjustQty,
"COUNT_LOSS",
adjust.getAdjustNo()
);
}
// 同步OMS可售库存
omsClient.syncInventory(detail.getWarehouseId(), detail.getSkuId());
}
}
}
六、动碰盘点
6.1 动碰盘点触发
@Service
public class TouchCountService {
/**
* 拣货时触发动碰盘点
*/
@EventListener
public void onPickComplete(PickCompleteEvent event) {
String locationCode = event.getLocationCode();
String skuId = event.getSkuId();
// 检查是否需要动碰盘点
if (shouldTriggerCount(locationCode, skuId)) {
createTouchCountTask(locationCode, skuId);
}
}
private boolean shouldTriggerCount(String locationCode, String skuId) {
// 高价值商品每次拣货后盘点
SKU sku = skuService.getSku(skuId);
if (sku.getPrice().compareTo(new BigDecimal("1000")) > 0) {
return true;
}
// 库存低于安全库存时盘点
LocationInventory inv = inventoryService.get(locationCode, skuId);
if (inv.getQuantity() < sku.getSafetyStock()) {
return true;
}
return false;
}
private void createTouchCountTask(String locationCode, String skuId) {
CountDetail detail = new CountDetail();
detail.setTaskNo("TOUCH-" + generateNo());
detail.setLocationCode(locationCode);
detail.setSkuId(skuId);
detail.setSystemQty(inventoryService.getQty(locationCode, skuId));
detail.setCountType(CountType.TOUCH);
detail.setStatus(DetailStatus.PENDING);
detail.setPriority(Priority.HIGH);
countDetailRepository.save(detail);
// 推送到PDA
pdaPushService.pushCountTask(detail);
}
}
七、盘点报表
7.1 盘点准确率报表
@Service
public class CountReportService {
public CountAccuracyReport generateAccuracyReport(
String warehouseId, LocalDate startDate, LocalDate endDate) {
CountAccuracyReport report = new CountAccuracyReport();
report.setWarehouseId(warehouseId);
report.setStartDate(startDate);
report.setEndDate(endDate);
// 获取期间内的盘点数据
List<CountDetail> details = countDetailRepository
.findByDateRange(warehouseId, startDate, endDate);
// 计算准确率
long totalLocations = details.size();
long matchedLocations = details.stream()
.filter(d -> d.getDifferenceQty() == 0)
.count();
report.setTotalLocations(totalLocations);
report.setMatchedLocations(matchedLocations);
report.setAccuracyRate(matchedLocations * 100.0 / totalLocations);
// 按SKU分类统计
Map<String, List<CountDetail>> bySku = details.stream()
.collect(Collectors.groupingBy(CountDetail::getSkuId));
List<SkuAccuracy> skuStats = new ArrayList<>();
for (Map.Entry<String, List<CountDetail>> entry : bySku.entrySet()) {
SkuAccuracy stat = new SkuAccuracy();
stat.setSkuId(entry.getKey());
stat.setTotalCount(entry.getValue().size());
stat.setMatchCount((int) entry.getValue().stream()
.filter(d -> d.getDifferenceQty() == 0).count());
stat.setTotalDifference(entry.getValue().stream()
.mapToInt(d -> Math.abs(d.getDifferenceQty())).sum());
skuStats.add(stat);
}
report.setSkuStats(skuStats);
return report;
}
}
7.2 差异分析报表
public DifferenceAnalysisReport analyzeDifferences(String warehouseId, String period) {
DifferenceAnalysisReport report = new DifferenceAnalysisReport();
// 获取差异数据
List<CountDetail> differences = countDetailRepository
.findDifferencesByPeriod(warehouseId, period);
// 按差异类型分类
Map<String, Integer> byType = new HashMap<>();
byType.put("PROFIT", 0); // 盘盈
byType.put("LOSS", 0); // 盘亏
for (CountDetail d : differences) {
if (d.getDifferenceQty() > 0) {
byType.merge("PROFIT", d.getDifferenceQty(), Integer::sum);
} else {
byType.merge("LOSS", -d.getDifferenceQty(), Integer::sum);
}
}
report.setDifferenceByType(byType);
// 按库区分析
Map<String, Integer> byZone = differences.stream()
.collect(Collectors.groupingBy(
d -> locationService.getZone(d.getLocationCode()),
Collectors.summingInt(d -> Math.abs(d.getDifferenceQty()))
));
report.setDifferenceByZone(byZone);
// 高频差异SKU
List<String> highDiffSkus = differences.stream()
.collect(Collectors.groupingBy(CountDetail::getSkuId, Collectors.counting()))
.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(10)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
report.setHighDifferenceSkus(highDiffSkus);
return report;
}
八、最佳实践
8.1 盘点时机选择
| 时机 | 优点 | 缺点 |
|---|---|---|
| 夜间盘点 | 不影响作业 | 需要夜班人员 |
| 周末盘点 | 时间充裕 | 加班成本 |
| 作业间隙 | 灵活 | 可能被打断 |
| 停工盘点 | 准确 | 影响业务 |
8.2 提高盘点效率
// 1. 按路径优化盘点顺序
public List<CountDetail> optimizeCountPath(List<CountDetail> details) {
return details.stream()
.sorted(Comparator.comparing(CountDetail::getLocationCode))
.collect(Collectors.toList());
}
// 2. 批量扫描
public void batchCount(String locationCode, List<SkuCountItem> items) {
for (SkuCountItem item : items) {
CountDetail detail = findOrCreate(locationCode, item.getSkuId());
detail.setCountQty(item.getQuantity());
countDetailRepository.save(detail);
}
}
// 3. 差异预警
public void checkDifferenceThreshold(CountDetail detail) {
double diffRate = Math.abs(detail.getDifferenceQty()) * 1.0 / detail.getSystemQty();
if (diffRate > 0.1) { // 差异超过10%
alertService.sendAlert("盘点差异预警",
String.format("库位%s SKU%s 差异率%.2f%%",
detail.getLocationCode(), detail.getSkuId(), diffRate * 100));
}
}
九、总结
9.1 盘点管理核心要点
- 分类盘点:ABC分类,重点关注高价值
- 循环盘点:日常维护,持续保证准确率
- 差异处理:复盘确认,审批调整
- 数据分析:找出问题根源,持续改进
9.2 关键指标
| 指标 | 目标值 |
|---|---|
| 库存准确率 | > 99.9% |
| 盘点覆盖率 | 100%/季度 |
| 差异处理时效 | < 24小时 |
| 盘点效率 | > 200库位/人/天 |
系列文章导航
本文是《跨境电商数字化转型指南》系列的第21篇
- 18. WMS仓储系统架构设计
- 19. WMS入库管理详解
- 20. WMS拣货策略优化
- 21. WMS库存盘点实战(本文)
- 22. WMS与OMS集成