feat(stocktake): 抽盘范围改为「系统推荐 + 人工在库位树上勾选」

【后端】
- 新增 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 个,无越界
This commit is contained in:
yueli
2026-09-11 14:13:10 +08:00
parent fc95357662
commit e9468673c0
2 changed files with 264 additions and 19 deletions

View File

@ -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'),
}