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 @@ 盘点范围 全仓盘点 - - 活跃库位抽盘 + 活跃库位抽盘
{{ newMode === 'blind' ? '盲盘:作业时不显示账面数与差异' : '明盘:作业时可查看账面数与差异' }}
+
+ 抽盘:仅盘点近 30 天有出入库/借还记录的前 50 个活跃库位 +
开启新一轮会把当前进行中的盘点置为已结束(历史数据仍保留)