引言:库存准确率的重要性

库存不准的后果

  • 超卖:系统有货实际没货,客户投诉
  • 滞销:实际有货系统没货,资金占用
  • 财务差异:账实不符,审计风险

库存准确率目标

  • 电商仓库:> 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 盘点管理核心要点

  1. 分类盘点:ABC分类,重点关注高价值
  2. 循环盘点:日常维护,持续保证准确率
  3. 差异处理:复盘确认,审批调整
  4. 数据分析:找出问题根源,持续改进

9.2 关键指标

指标目标值
库存准确率> 99.9%
盘点覆盖率100%/季度
差异处理时效< 24小时
盘点效率> 200库位/人/天

系列文章导航

本文是《跨境电商数字化转型指南》系列的第21篇

  • 18. WMS仓储系统架构设计
  • 19. WMS入库管理详解
  • 20. WMS拣货策略优化
  • 21. WMS库存盘点实战(本文)
  • 22. WMS与OMS集成