From e9468673c0824c2d6408f8898ed5736c1129bb9e Mon Sep 17 00:00:00 2001 From: yueli Date: Fri, 11 Sep 2026 14:13:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(stocktake):=20=E6=8A=BD=E7=9B=98=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E6=94=B9=E4=B8=BA=E3=80=8C=E7=B3=BB=E7=BB=9F=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=20+=20=E4=BA=BA=E5=B7=A5=E5=9C=A8=E5=BA=93=E4=BD=8D?= =?UTF-8?q?=E6=A0=91=E4=B8=8A=E5=8B=BE=E9=80=89=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【后端】 - 新增 GET /stocktake/recommend-locations?days=30&top_n=50 调 get_active_locations 返回推荐库位 full_path 列表 + moves/sku_count/ last_move 明细。只推荐不落库,days/top_n 做范围钳制(1~365 / 1~500)。 - /draft/start-new 在 scope_type=active 时不再自行计算活跃库位,改为读取 payload 的 scope_config.locations 并校验归一化(去空白、去重、保序、 空列表 400、上限 2000)。范围决定权交还前端 UI —— 否则用户手改的勾选 会被后端覆盖。 【前端】欢迎页选中「活跃库位抽盘」时展开配置区: - 「近 30 天最活跃的前 [N] 个库位」+【获取推荐】 - el-tree(show-checkbox)数据取自 /v1/warehouse/tree,按公司前缀过滤 (IRIS 只留 Y*,LICA 只留 C*/L*,复用 getAllowedLocPrefixes) - 获取推荐后 setCheckedKeys 自动勾选;用户可自由增删 - 提交时 getCheckedNodes().map(n => n.full_path) 打包进 scope_config.locations 两个实现细节: - 推荐里有、但树上勾不到的库位(不在当前公司前缀内等)会明确告警并打印, 不让它们静默落选 —— 否则工人以为盘到了、实际没进范围。 - 已选库位数实时显示,因为 el-tree 默认级联:勾一个父节点会连带勾中整棵 子树,规模可能远超推荐数量,需要让用户看得见。 实测: recommend-locations(top_n=10) → 10 个库位 + 明细 start-new 传 3 个库位 → 落库正是这 3 个,总品项 36(全仓 1126) 不传 locations → 400;勾选为空 → 400 公司前缀过滤: IRIS→8 个(Y1~Y8),LICA→25 个,无越界 --- inventory-backend/app/api/v1/inbound/stock.py | 78 +++++-- .../src/views/stock/stocktake/index.vue | 205 +++++++++++++++++- 2 files changed, 264 insertions(+), 19 deletions(-) diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index 67699ad..7dc29b0 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -984,6 +984,48 @@ def get_stocktake_companies(): return jsonify({'code': 500, 'msg': str(e)}), 500 +@bp.route('/stocktake/recommend-locations', methods=['GET']) +@permission_required('inventory_stocktake') +def recommend_locations(): + """ + 推荐近 N 天最活跃的库位 —— 供前端「活跃库位抽盘」的树形勾选做预选。 + + ★ 只推荐、不落库。最终范围由用户在库位树上微调后,随 /draft/start-new + 的 scope_config.locations 一并提交,决定权在前端 UI。 + + 查询参数: days(默认30) / top_n(默认50) + 返回: { locations: [full_path...], detail: [{location,moves,sku_count,last_move}], days, top_n } + """ + company_name = get_current_company_filter() + if not company_name or company_name == '__NO_COMPANY__': + return jsonify({'code': 400, 'msg': '请先选择公司,再获取推荐库位'}), 400 + + try: + days = int(request.args.get('days', 30)) + top_n = int(request.args.get('top_n', 50)) + except (TypeError, ValueError): + return jsonify({'code': 400, 'msg': 'days / top_n 必须是整数'}), 400 + + # 防御性上限,避免把整库扫进来 + days = max(1, min(days, 365)) + top_n = max(1, min(top_n, 500)) + + try: + locs = get_active_locations(company_name, days=days, top_n=top_n) + return jsonify({ + 'code': 200, + 'data': { + 'locations': [x['location'] for x in locs], + 'detail': locs, + 'days': days, + 'top_n': top_n, + } + }), 200 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': str(e)}), 500 + + @bp.route('/draft/active-session', methods=['GET']) @permission_required('inventory_stocktake') def get_active_session(): @@ -1117,24 +1159,34 @@ def start_new_session(): # 必须在创建会话这一刻算好并落库 —— 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: + raw_locs = scope_config.get('locations') + if not isinstance(raw_locs, list): return jsonify({ - "message": f"最近 {days} 天内没有找到活跃库位,无法创建抽盘任务" + "message": "抽盘必须通过 scope_config.locations 提交库位列表(可先调 /stocktake/recommend-locations 拿推荐)" + }), 400 + + # 归一化:去空白、去重、保序 + clean_locs, seen = [], set() + for x in raw_locs: + s = str(x).strip() + if s and s not in seen: + seen.add(s) + clean_locs.append(s) + + if not clean_locs: + return jsonify({"message": "抽盘至少要勾选一个库位"}), 400 + if len(clean_locs) > 2000: + return jsonify({ + "message": f"单次抽盘最多 2000 个库位,当前 {len(clean_locs)} 个" }), 400 scope_config = { - 'days': days, - 'top_n': top_n, - 'locations': [x['location'] for x in active_locs], - 'detail': active_locs, + 'locations': clean_locs, + 'recommend_days': scope_config.get('recommend_days'), + 'recommend_top_n': scope_config.get('recommend_top_n'), 'computed_at': beijing_time().strftime('%Y-%m-%d %H:%M:%S'), } diff --git a/inventory-web/src/views/stock/stocktake/index.vue b/inventory-web/src/views/stock/stocktake/index.vue index ebc4d9e..1fe08b5 100644 --- a/inventory-web/src/views/stock/stocktake/index.vue +++ b/inventory-web/src/views/stock/stocktake/index.vue @@ -94,8 +94,47 @@
{{ newMode === 'blind' ? '盲盘:作业时不显示账面数与差异' : '明盘:作业时可查看账面数与差异' }}
-
- 抽盘:仅盘点近 30 天有出入库/借还记录的前 50 个活跃库位 + + +
+
+ 近 {{ RECOMMEND_DAYS }} 天最活跃的前 + + 个库位 + + 获取推荐 + +
+ + + +
+ 已选 {{ checkedLocCount }} 个库位 + 清空 +
⚠️ 开启新一轮将终结当前会话:已扫数据完整保留,但其他设备不能再加入该会话 @@ -536,8 +575,10 @@