diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py
index 53db8a1..a1b0a48 100644
--- a/inventory-backend/app/api/v1/inbound/stock.py
+++ b/inventory-backend/app/api/v1/inbound/stock.py
@@ -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()
diff --git a/inventory-web/src/views/stock/stocktake/index.vue b/inventory-web/src/views/stock/stocktake/index.vue
index bcdaf5e..f475e94 100644
--- a/inventory-web/src/views/stock/stocktake/index.vue
+++ b/inventory-web/src/views/stock/stocktake/index.vue
@@ -80,13 +80,15 @@
盘点范围