feat(stocktake): 活跃会话接口补充已扫件数与发起人

多人协同场景下,加入者需要知道「谁开的、盘到哪了」,原接口只返回会话ID和
行数,信息不足以提示。

- /draft/active-session 新增 scanned(按 source_table+stock_id 去重,
  且排除 user_id='system' 的自动漏盘记录,避免进度虚高)
  与 initiator(该会话最早一条人工扫码记录的操作人姓名)
- 把 export_stocktake 内嵌的 get_user_name 提到模块级 _resolve_user_name
  供两处复用,并补齐它漏掉的 user_id 格式:
    盘点/流水里实际存的是 JWT 的 display_name,形如 "孙霞(sunxia)",
    而原实现只认 SysUser.username 的 "姓名/账号" 格式,导致解析不出来时
    原样返回。现兼容 "姓名(账号)" / "姓名/账号" / 纯数字ID 三种。

实测: SUPER_ADMIN 无参数 → scanned=1117, initiator='韩善龙'
      SALES + IRIS    → 会话 company_name 为 NULL(迁移前遗留),正确返回空
This commit is contained in:
yueli
2026-09-11 12:42:55 +08:00
parent 97ff63523c
commit f8403d2fb2

View File

@ -50,6 +50,52 @@ def _normalize_user_id(user_id=None):
return 'unknown' return 'unknown'
def _resolve_user_name(user_id):
"""把 user_id 解析为真实姓名,解析不出来就原样返回。
需要兼容三种来源:
1. "孙霞(sunxia)" —— 草稿/流水里实际存的格式,来自 JWT 的 display_name
2. "张三/zhangsan01" —— SysUser.username 的存储格式
3. 纯数字用户ID —— 直接按主键查
"""
if not user_id:
return '-'
raw = str(user_id).strip()
# 1. "姓名(账号)" / "姓名/账号" —— 分隔符前就是姓名,无需查库
if '(' in raw and raw.endswith(')'):
name = raw.split('(', 1)[0].strip()
if name:
return name
if '/' in raw:
name = raw.split('/', 1)[0].strip()
if name:
return name
if not SysUser:
return raw
try:
user = None
# 尝试通过ID或用户名查找
if raw.isdigit():
user = SysUser.query.get(int(raw))
if not user:
user = SysUser.query.filter(SysUser.username.like(f"%/{raw}")).first()
# 注意:此处不做 filter_by(username=...) 兜底,
# 避免 PostgreSQL 把 user_id 数字与 username 字符串列做类型比较导致报错
if not user:
return raw
# 2. 解析 username 格式: "张三/zhangsan01" -> 取前面的真实姓名
raw_username = getattr(user, 'username', None) or raw
if '/' in raw_username:
return raw_username.split('/')[0]
return raw_username
except Exception:
return raw
def get_stock_model(source_table): def get_stock_model(source_table):
"""根据source_table获取对应的库存模型""" """根据source_table获取对应的库存模型"""
if source_table == 'stock_buy': if source_table == 'stock_buy':
@ -790,7 +836,13 @@ def get_active_session():
「活跃」的定义:该公司所有草稿中 scan_time 最大的那个 session_id。 「活跃」的定义:该公司所有草稿中 scan_time 最大的那个 session_id。
注意 /draft/list 是按 SKU 排序的,拿不到「最近会话」,故单列此接口。 注意 /draft/list 是按 SKU 排序的,拿不到「最近会话」,故单列此接口。
返回: { session_id: str|null, total: int, last_scan_time: str|null } 返回: {
session_id: str|null,
total: int, # 该会话的草稿行数
scanned: int, # 实际扫到的件数(按物料去重,排除系统自动生成的漏盘记录)
last_scan_time: str|null,
initiator: str|null, # 发起人显示名(该会话最早一条扫码记录的操作人)
}
""" """
company_name = get_current_company_filter() company_name = get_current_company_filter()
@ -811,15 +863,45 @@ def get_active_session():
if not row or not row.session_id: if not row or not row.session_id:
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'data': {'session_id': None, 'total': 0, 'last_scan_time': None} 'data': {
'session_id': None, 'total': 0, 'scanned': 0,
'last_scan_time': None, 'initiator': None
}
}), 200 }), 200
session_id = row.session_id
# 该会话的明细统计 —— 必须同样带上公司条件,避免跨公司串号
def _scoped(q):
if company_name is not None:
q = q.filter(StocktakeDraft.company_name == company_name)
return q.filter(StocktakeDraft.session_id == session_id)
# 已扫件数:按 (source_table, stock_id) 去重。
# 排除 user_id == 'system' 的记录 —— 那是「结束盘点」时 generate-missing
# 自动生成的漏盘(实盘=0),不是人扫到的,计入会让进度虚高。
scanned = _scoped(
db.session.query(StocktakeDraft.source_table, StocktakeDraft.stock_id)
.filter(StocktakeDraft.user_id != 'system')
).distinct().count()
# 发起人:该会话最早一条非系统扫码记录的操作人。
# 注:/draft/add 更新已有记录时会把 user_id 改写成「最后操作者」,
# 所以这里取到的是最早那条记录的最后操作人,是当前模型下最接近发起人的信号。
first_row = _scoped(
db.session.query(StocktakeDraft.user_id)
.filter(StocktakeDraft.user_id != 'system')
).order_by(StocktakeDraft.scan_time.asc()).first()
initiator = _resolve_user_name(first_row[0]) if first_row and first_row[0] else None
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'data': { 'data': {
'session_id': row.session_id, 'session_id': session_id,
'total': int(row.total or 0), 'total': int(row.total or 0),
'last_scan_time': row.last_scan_time.strftime('%Y-%m-%d %H:%M:%S') if row.last_scan_time else None 'scanned': int(scanned or 0),
'last_scan_time': row.last_scan_time.strftime('%Y-%m-%d %H:%M:%S') if row.last_scan_time else None,
'initiator': initiator
} }
}), 200 }), 200
except Exception as e: except Exception as e:
@ -1165,31 +1247,7 @@ def export_stocktake():
} }
def get_user_name(user_id): def get_user_name(user_id):
"""获取用户真实姓名 return _resolve_user_name(user_id)
SysUser.username 存储格式为 "真实姓名/登录账号" (例如: 张三/zhangsan01)
"""
if not SysUser or not user_id:
return str(user_id) if user_id else '-'
try:
user = None
# 尝试通过ID或用户名查找
if str(user_id).isdigit():
user = SysUser.query.get(int(user_id))
if not user:
user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first()
# 注意:此处不再 fallback filter_by(username=...),
# 避免 PostgreSQL 将 user_id 数字与 username 字符串列做类型比较导致报错
if not user:
return str(user_id)
# 解析 username 格式: "张三/zhangsan01" -> 取前面的真实姓名
raw_username = getattr(user, 'username', None) or str(user_id)
if '/' in raw_username:
return raw_username.split('/')[0]
return raw_username
except:
return str(user_id)
def to_beijing_time(dt): def to_beijing_time(dt):
"""直接使用数据库中存储的标准时间(服务器时区已正确)""" """直接使用数据库中存储的标准时间(服务器时区已正确)"""