feat(stocktake): 落地动盘(活跃库位抽盘)与导出盲盘保护

【导出收尾】/export-stocktake
会话 active 且 mode=blind 时,汇总表/差异明细/账实相符/未盘点四张 Sheet 的
账面数与差异数一律输出 '—',实盘数照常输出。会话 finished 后自动解锁 ——
盘点结束后核对差异是正常流程,否则报告本身没法用。
批量替换时误伤了 update_stocktake_quantity 里的差异计算,被静态检查脚本
抓出(会拿占位符 '—' 做减法),已改回按真实账面值计算。

【动盘算法】get_active_locations(company_name, days=30, top_n=50)
近 N 天异动最频繁的库位。出库经 (source_table, stock_id) 回查库存表取库位
(trans_outbound.warehouse_location 历史上 900 条里 829 条为空,实测回查
覆盖率 298/299);入库以库存表自身的 in_date / production_date 计时;
借出用 trans_borrow.location。报废/维修表无库位字段,未纳入。

【范围冻结】/draft/start-new
移除 scope_type=active 的 400 拦截,改为在开单时算出活跃库位并**冻结**进
scope_config。必须开单时算好 —— 若各接口实时计算,两次调用之间活跃度变化
会导致范围漂移,结束盘点时把已掉出范围的库存误判成盘亏。

【同源过滤】all-items / merged-list / generate-missing
三者都读同一份冻结的 scope_config。generate-missing 是重中之重:漏盘比对
只针对范围内库存,绝不把范围外未盘库存标成盘亏。
merged-list 一并加了过滤(原指令未提),否则抽屉仍会列出范围外物资,
与「总品项」对不上。

【前端】移除「活跃库位抽盘」的 disabled。

实测(IRIS,全仓 1126 → 抽盘 537):
  生成盘亏 537 条 | 落在抽盘范围内 537 | 落在范围外 0
  导出: 盲盘 active → 账面数/差异数 = '—';盲盘 finished → 76 / -76(解锁)
This commit is contained in:
yueli
2026-09-11 13:41:56 +08:00
parent 97c1a9131c
commit eabe70b00a
2 changed files with 194 additions and 23 deletions

View File

@ -18,6 +18,7 @@ from app.models.inbound.stocktake import (
STOCKTAKE_MODE_OPEN,
STOCKTAKE_MODE_BLIND,
STOCKTAKE_SCOPE_FULL,
STOCKTAKE_SCOPE_ACTIVE,
STOCKTAKE_STATUS_ACTIVE,
STOCKTAKE_STATUS_FINISHED,
)
@ -158,6 +159,108 @@ def _filter_by_company(query, model, company_name):
return query.filter(model.base.has(MaterialBase.company_name == company_name))
def get_active_locations(company_name, days=30, top_n=50):
"""
统计最近 N 天内异动最频繁的库位 —— 「动盘/抽盘」的范围来源。
数据来源(三张库存表都没有 company_id公司维度一律经 base_id 关联 material_base
- 出库 trans_outbound其 warehouse_location 历史上几乎没填900 条里 829 条为空,
且仅 2026-09 起才部分有值),故改为经 (source_table, stock_id) 回查库存表的
warehouse_location —— 实测近 30 天覆盖率 298/299。
- 入库:入库没有独立流水表,直接以 stock_buy.in_date /
stock_semi.production_date / stock_product.production_date 作为异动时间。
- 借出 trans_borrow用其 location 字段填充率同样很低80 条里仅 8 条有值)。
已知局限:
- 报废(trans_scrap)与维修(trans_repair)表没有库位字段,未纳入统计;
- 回查得到的是库存行**当前**的库位,不是异动发生当时的库位,
库存行事后迁库会让历史归因偏移;
- 库存行被删除后 JOIN 不到,该笔异动不计入。
:return: [{'location': str, 'moves': int, 'sku_count': int, 'last_move': str|None}, ...]
按异动次数降序,最多 top_n 条。
"""
since = beijing_time() - timedelta(days=days)
sql = db.text("""
WITH moves AS (
SELECT COALESCE(b.warehouse_location, s.warehouse_location, p.warehouse_location) AS loc,
o.sku AS sku, o.outbound_time AS ts,
COALESCE(m1.company_name, m2.company_name, m3.company_name) AS company
FROM trans_outbound o
LEFT JOIN stock_buy b ON o.source_table = 'stock_buy' AND b.id = o.stock_id
LEFT JOIN stock_semi s ON o.source_table = 'stock_semi' AND s.id = o.stock_id
LEFT JOIN stock_product p ON o.source_table = 'stock_product' AND p.id = o.stock_id
LEFT JOIN material_base m1 ON m1.id = b.base_id
LEFT JOIN material_base m2 ON m2.id = s.base_id
LEFT JOIN material_base m3 ON m3.id = p.base_id
WHERE o.outbound_time >= :since
UNION ALL
SELECT b2.warehouse_location, b2.sku, b2.in_date, m4.company_name
FROM stock_buy b2 JOIN material_base m4 ON m4.id = b2.base_id
WHERE b2.in_date >= :since
UNION ALL
SELECT s2.warehouse_location, s2.sku, s2.production_date, m5.company_name
FROM stock_semi s2 JOIN material_base m5 ON m5.id = s2.base_id
WHERE s2.production_date >= :since
UNION ALL
SELECT p2.warehouse_location, p2.sku, p2.production_date, m6.company_name
FROM stock_product p2 JOIN material_base m6 ON m6.id = p2.base_id
WHERE p2.production_date >= :since
UNION ALL
SELECT br.location, br.sku, br.borrow_time, m7.company_name
FROM trans_borrow br
LEFT JOIN stock_buy bs ON br.source_table = 'stock_buy' AND bs.id = br.stock_id
LEFT JOIN material_base m7 ON m7.id = bs.base_id
WHERE br.borrow_time >= :since
)
SELECT loc AS location,
count(*) AS moves,
count(DISTINCT sku) AS sku_count,
max(ts) AS last_move
FROM moves
WHERE loc IS NOT NULL AND loc <> ''
AND (:company IS NULL OR company = :company)
GROUP BY loc
ORDER BY moves DESC, loc
LIMIT :top_n
""")
rows = db.session.execute(sql, {
'since': since,
'company': company_name,
'top_n': top_n,
}).fetchall()
return [{
'location': r.location,
'moves': int(r.moves or 0),
'sku_count': int(r.sku_count or 0),
'last_move': r.last_move.strftime('%Y-%m-%d') if r.last_move else None,
} for r in rows]
def _get_session_scope(session_id):
"""读取会话的范围配置。
:return: (scope_type, locations) —— locations 仅当抽盘且配置了库位时非空
"""
if not session_id:
return STOCKTAKE_SCOPE_FULL, []
sess = StocktakeSession.query.filter_by(session_id=session_id).first()
if not sess:
return STOCKTAKE_SCOPE_FULL, []
cfg = sess.scope_config or {}
locs = cfg.get('locations') or []
if sess.scope_type != STOCKTAKE_SCOPE_ACTIVE or not locs:
return STOCKTAKE_SCOPE_FULL, []
return STOCKTAKE_SCOPE_ACTIVE, list(locs)
def get_stock_info(uuid_or_barcode, company_name=None):
"""
根据 uuid 或 barcode 查询库存信息(★ 精确匹配优先,性能与准确性兼顾)
@ -954,16 +1057,38 @@ def start_new_session():
return jsonify({"message": f"不支持的盘点模式: {mode}"}), 400
scope_type = (data.get('scope_type') or STOCKTAKE_SCOPE_FULL).strip().lower()
if scope_type != STOCKTAKE_SCOPE_FULL:
# ★ 抽盘的范围过滤all-items / generate-missing尚未实现此处必须先挡住
# 范围没生效就等于全仓盘点,而结束盘点时 generate-missing 会把
# 抽盘范围之外的所有库存批量标成盘亏 —— 会直接污染库存账。
return jsonify({"message": "活跃库位抽盘尚未实现,当前仅支持全仓盘点"}), 400
if scope_type not in (STOCKTAKE_SCOPE_FULL, STOCKTAKE_SCOPE_ACTIVE):
return jsonify({"message": f"不支持的盘点范围: {scope_type}"}), 400
scope_config = data.get('scope_config') or {}
if not isinstance(scope_config, dict):
return jsonify({"message": "scope_config 必须是对象"}), 400
# ★ 抽盘:开单时就把活跃库位**冻结**进 scope_config。
# 必须在创建会话这一刻算好并落库 —— all-items 与 generate-missing 稍后都要
# 读同一份范围;若各自实时计算,两次调用之间库位活跃度变化会导致范围漂移,
# 结束盘点时就会把「开单时在范围内、比对时已掉出范围」的库存误判成盘亏。
if scope_type == STOCKTAKE_SCOPE_ACTIVE:
try:
days = int(scope_config.get('days') or 30)
top_n = int(scope_config.get('top_n') or 50)
except (TypeError, ValueError):
return jsonify({"message": "days / top_n 必须是整数"}), 400
active_locs = get_active_locations(company_name, days=days, top_n=top_n)
if not active_locs:
return jsonify({
"message": f"最近 {days} 天内没有找到活跃库位,无法创建抽盘任务"
}), 400
scope_config = {
'days': days,
'top_n': top_n,
'locations': [x['location'] for x in active_locs],
'detail': active_locs,
'computed_at': beijing_time().strftime('%Y-%m-%d %H:%M:%S'),
}
new_session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}"
try:
@ -1264,6 +1389,21 @@ def export_stocktake():
# ★ 公司隔离:导出内容限定在当前用户所属公司
company_name = get_current_company_filter()
# ★ 盲盘保护会话仍在进行中active且为盲盘时报表里不得出现账面数与差异。
# 会话一旦 finished 即解锁 —— 盘点结束后再核对差异是正常流程,
# 否则报告本身就没法用了。
_sess = StocktakeSession.query.filter_by(session_id=session_id).first() if session_id else None
blind_locked = bool(
_sess
and _sess.status == STOCKTAKE_STATUS_ACTIVE
and _sess.mode == STOCKTAKE_MODE_BLIND
)
BLIND_BLANK = ''
def _qty(v):
"""账面数/差异:盲盘锁定期间输出占位符,绝不落真实数值。"""
return BLIND_BLANK if blind_locked else float(v or 0)
# 创建工作簿
wb = Workbook()
wb.remove(wb.active)
@ -1375,9 +1515,9 @@ def export_stocktake():
ws2.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border
ws2.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border
ws2.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border
ws2.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
ws2.cell(row=row_idx, column=5, value=_qty(draft.stock_qty)).border = thin_border
ws2.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
ws2.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
ws2.cell(row=row_idx, column=7, value=_qty(draft.diff_qty)).border = thin_border
ws2.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border
ws2.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border
ws2.cell(row=row_idx, column=10, value=draft.remark or '').border = thin_border
@ -1386,9 +1526,9 @@ def export_stocktake():
ws1.cell(row=master_row_idx, column=2, value=mat_info['sku']).border = thin_border
ws1.cell(row=master_row_idx, column=3, value=mat_info['spec']).border = thin_border
ws1.cell(row=master_row_idx, column=4, value=mat_info['location']).border = thin_border
ws1.cell(row=master_row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=5, value=_qty(draft.stock_qty)).border = thin_border
ws1.cell(row=master_row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=7, value=_qty(draft.diff_qty)).border = thin_border
ws1.cell(row=master_row_idx, column=8, value="有差异").border = thin_border
ws1.cell(row=master_row_idx, column=9, value=get_user_name(draft.user_id)).border = thin_border
ws1.cell(row=master_row_idx, column=10, value=to_beijing_time(draft.scan_time)).border = thin_border
@ -1422,9 +1562,9 @@ def export_stocktake():
ws3.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border
ws3.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border
ws3.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border
ws3.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
ws3.cell(row=row_idx, column=5, value=_qty(draft.stock_qty)).border = thin_border
ws3.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
ws3.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
ws3.cell(row=row_idx, column=7, value=_qty(draft.diff_qty)).border = thin_border
ws3.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border
ws3.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border
ws3.cell(row=row_idx, column=10, value=draft.remark or '').border = thin_border
@ -1433,9 +1573,9 @@ def export_stocktake():
ws1.cell(row=master_row_idx, column=2, value=mat_info['sku']).border = thin_border
ws1.cell(row=master_row_idx, column=3, value=mat_info['spec']).border = thin_border
ws1.cell(row=master_row_idx, column=4, value=mat_info['location']).border = thin_border
ws1.cell(row=master_row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=5, value=_qty(draft.stock_qty)).border = thin_border
ws1.cell(row=master_row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
ws1.cell(row=master_row_idx, column=7, value=_qty(draft.diff_qty)).border = thin_border
ws1.cell(row=master_row_idx, column=8, value="正常").border = thin_border
ws1.cell(row=master_row_idx, column=9, value=get_user_name(draft.user_id)).border = thin_border
ws1.cell(row=master_row_idx, column=10, value=to_beijing_time(draft.scan_time)).border = thin_border
@ -1607,18 +1747,18 @@ def export_stocktake():
ws5.cell(row=row_idx, column=3, value=item['spec']).border = thin_border
ws5.cell(row=row_idx, column=4, value=item['location']).border = thin_border
ws5.cell(row=row_idx, column=5, value=item.get('batch_no', '-')).border = thin_border # ★ 批号
ws5.cell(row=row_idx, column=6, value=float(item['stock_qty'])).border = thin_border
ws5.cell(row=row_idx, column=6, value=_qty(item['stock_qty'])).border = thin_border
ws5.cell(row=row_idx, column=7, value=float(item['actual_qty'])).border = thin_border
ws5.cell(row=row_idx, column=8, value=float(item['diff_qty'])).border = thin_border
ws5.cell(row=row_idx, column=8, value=_qty(item['diff_qty'])).border = thin_border
ws5.cell(row=row_idx, column=9, value=item['status']).border = thin_border
# 同时写入 Sheet 1 (汇总表) - 盘点人和时间留空
ws1.cell(row=master_row_idx, column=1, value=item['name']).border = thin_border
ws1.cell(row=master_row_idx, column=2, value=item['sku']).border = thin_border
ws1.cell(row=master_row_idx, column=3, value=item['spec']).border = thin_border
ws1.cell(row=master_row_idx, column=4, value=item['location']).border = thin_border
ws1.cell(row=master_row_idx, column=5, value=float(item['stock_qty'])).border = thin_border
ws1.cell(row=master_row_idx, column=5, value=_qty(item['stock_qty'])).border = thin_border
ws1.cell(row=master_row_idx, column=6, value=float(item['actual_qty'])).border = thin_border
ws1.cell(row=master_row_idx, column=7, value=float(item['diff_qty'])).border = thin_border
ws1.cell(row=master_row_idx, column=7, value=_qty(item['diff_qty'])).border = thin_border
ws1.cell(row=master_row_idx, column=8, value="未盘点").border = thin_border
ws1.cell(row=master_row_idx, column=9, value="").border = thin_border
ws1.cell(row=master_row_idx, column=10, value="").border = thin_border
@ -1715,10 +1855,23 @@ def generate_missing_stocktake():
scanned_keys.add((src_table, stock_id))
# 2. 获取所有真实库存 > 0 的记录
#
# ★★★ 抽盘范围(本功能最关键的一处)★★★
# 漏盘比对只针对**范围内**的库存。范围取自会话 scope_config 里开单时
# 冻结的库位清单,与 all-items / merged-list 完全同源。
# 若此过滤缺失或与其他接口不同源,结束盘点会把抽盘范围外的全部未盘
# 库存批量生成为盘亏记录 —— 直接污染库存账。
_scope_type, scope_locations = _get_session_scope(session_id)
def _in_scope(query, model):
if scope_locations:
query = query.filter(model.warehouse_location.in_(scope_locations))
return query
all_stock = []
# 采购库存
for item in _filter_by_company(StockBuy.query, StockBuy, company_name).filter(StockBuy.stock_quantity > 0).all():
for item in _in_scope(_filter_by_company(StockBuy.query, StockBuy, company_name), StockBuy).filter(StockBuy.stock_quantity > 0).all():
all_stock.append({
'source_table': 'stock_buy',
'stock_id': item.id,
@ -1728,7 +1881,7 @@ def generate_missing_stocktake():
# 半成品库存
if StockSemi:
for item in _filter_by_company(StockSemi.query, StockSemi, company_name).filter(StockSemi.stock_quantity > 0).all():
for item in _in_scope(_filter_by_company(StockSemi.query, StockSemi, company_name), StockSemi).filter(StockSemi.stock_quantity > 0).all():
all_stock.append({
'source_table': 'stock_semi',
'stock_id': item.id,
@ -1738,7 +1891,7 @@ def generate_missing_stocktake():
# 成品库存
if StockProduct:
for item in _filter_by_company(StockProduct.query, StockProduct, company_name).filter(StockProduct.stock_quantity > 0).all():
for item in _in_scope(_filter_by_company(StockProduct.query, StockProduct, company_name), StockProduct).filter(StockProduct.stock_quantity > 0).all():
all_stock.append({
'source_table': 'stock_product',
'stock_id': item.id,
@ -1843,6 +1996,13 @@ def get_draft_merged_list():
conditions.append("mb.company_name = :company")
params['company'] = company_name
# ★ 抽盘范围:与 all-items / generate-missing 读同一份冻结的库位清单。
# 不加这一条的话,抽屉里仍会列出范围外的物资,与「总品项」对不上。
_scope_type, scope_locations = _get_session_scope(session_id)
if scope_locations:
conditions.append("cs.warehouse_location = ANY(:locations)")
params['locations'] = scope_locations
if keyword:
conditions.append("(LOWER(cs.sku) LIKE :kw OR LOWER(mb.name) LIKE :kw)")
params['kw'] = f'%{keyword}%'
@ -1994,6 +2154,14 @@ def get_all_stocktake_items():
if company_name is not None:
conditions.append("mb.company_name = :company")
params['company'] = company_name
# ★ 抽盘范围:范围在开单时已冻结进会话 scope_config这里只读不算
# 确保与 generate-missing 用的是同一份库位清单
_scope_type, scope_locations = _get_session_scope(
request.args.get('session_id', '', type=str)
)
if scope_locations:
conditions.append("cs.warehouse_location = ANY(:locations)")
params['locations'] = scope_locations
if keyword:
conditions.append("(LOWER(cs.sku) LIKE :kw OR LOWER(mb.name) LIKE :kw OR LOWER(mb.spec_model) LIKE :kw)")
params['kw'] = f'%{keyword.lower()}%'
@ -2114,7 +2282,8 @@ def update_stocktake_quantity():
draft.quantity = quantity
draft.scan_time = beijing_time()
# 计算差异
# 计算差异(★ 这是写库计算,不是报表输出,绝不能用 _qty —— 那会在盲盘时
# 返回占位符 '—' 导致减法报错。账面数在这里始终按真实值参与计算。)
draft.diff_qty = quantity - float(draft.stock_qty or 0)
db.session.commit()

View File

@ -80,13 +80,15 @@
<span class="task-form-label">盘点范围</span>
<el-radio-group v-model="newScopeType" size="large">
<el-radio-button value="full">全仓盘点</el-radio-button>
<!-- 抽盘的范围过滤尚未实现此时开放会导致把范围外库存批量标成盘亏 -->
<el-radio-button value="active" disabled>活跃库位抽盘</el-radio-button>
<el-radio-button value="active">活跃库位抽盘</el-radio-button>
</el-radio-group>
</div>
<div class="task-form-hint">
{{ newMode === 'blind' ? '盲盘:作业时不显示账面数与差异' : '明盘:作业时可查看账面数与差异' }}
</div>
<div v-if="newScopeType === 'active'" class="task-form-hint">
抽盘仅盘点近 30 天有出入库/借还记录的前 50 个活跃库位
</div>
<div v-if="creatingNew" class="task-form-warn">
开启新一轮会把当前进行中的盘点置为已结束历史数据仍保留
</div>