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:
@ -984,6 +984,48 @@ def get_stocktake_companies():
|
|||||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
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'])
|
@bp.route('/draft/active-session', methods=['GET'])
|
||||||
@permission_required('inventory_stocktake')
|
@permission_required('inventory_stocktake')
|
||||||
def get_active_session():
|
def get_active_session():
|
||||||
@ -1117,24 +1159,34 @@ def start_new_session():
|
|||||||
# 必须在创建会话这一刻算好并落库 —— all-items 与 generate-missing 稍后都要
|
# 必须在创建会话这一刻算好并落库 —— all-items 与 generate-missing 稍后都要
|
||||||
# 读同一份范围;若各自实时计算,两次调用之间库位活跃度变化会导致范围漂移,
|
# 读同一份范围;若各自实时计算,两次调用之间库位活跃度变化会导致范围漂移,
|
||||||
# 结束盘点时就会把「开单时在范围内、比对时已掉出范围」的库存误判成盘亏。
|
# 结束盘点时就会把「开单时在范围内、比对时已掉出范围」的库存误判成盘亏。
|
||||||
|
# ★ 抽盘:范围由**前端**决定(系统推荐 + 人工在库位树上微调后提交),
|
||||||
|
# 后端只做校验与归一化,不再自行计算 —— 否则用户手改的勾选会被覆盖。
|
||||||
if scope_type == STOCKTAKE_SCOPE_ACTIVE:
|
if scope_type == STOCKTAKE_SCOPE_ACTIVE:
|
||||||
try:
|
raw_locs = scope_config.get('locations')
|
||||||
days = int(scope_config.get('days') or 30)
|
if not isinstance(raw_locs, list):
|
||||||
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({
|
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
|
}), 400
|
||||||
|
|
||||||
scope_config = {
|
scope_config = {
|
||||||
'days': days,
|
'locations': clean_locs,
|
||||||
'top_n': top_n,
|
'recommend_days': scope_config.get('recommend_days'),
|
||||||
'locations': [x['location'] for x in active_locs],
|
'recommend_top_n': scope_config.get('recommend_top_n'),
|
||||||
'detail': active_locs,
|
|
||||||
'computed_at': beijing_time().strftime('%Y-%m-%d %H:%M:%S'),
|
'computed_at': beijing_time().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -94,8 +94,47 @@
|
|||||||
<div class="task-form-hint">
|
<div class="task-form-hint">
|
||||||
{{ newMode === 'blind' ? '盲盘:作业时不显示账面数与差异' : '明盘:作业时可查看账面数与差异' }}
|
{{ newMode === 'blind' ? '盲盘:作业时不显示账面数与差异' : '明盘:作业时可查看账面数与差异' }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="newScopeType === 'active'" class="task-form-hint">
|
|
||||||
抽盘:仅盘点近 30 天有出入库/借还记录的前 50 个活跃库位
|
<!-- ★ 抽盘范围:系统推荐 + 人工在库位树上勾选微调,最终范围以勾选结果为准 -->
|
||||||
|
<div v-if="newScopeType === 'active'" class="scope-panel">
|
||||||
|
<div class="scope-recommend-row">
|
||||||
|
<span class="scope-recommend-text">近 {{ RECOMMEND_DAYS }} 天最活跃的前</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="recTopN"
|
||||||
|
:min="1"
|
||||||
|
:max="500"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
class="scope-topn-input"
|
||||||
|
/>
|
||||||
|
<span class="scope-recommend-text">个库位</span>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
:loading="recLoading"
|
||||||
|
@click="fetchRecommendLocations"
|
||||||
|
>
|
||||||
|
获取推荐
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tree
|
||||||
|
ref="locTreeRef"
|
||||||
|
v-loading="treeLoading"
|
||||||
|
class="scope-tree"
|
||||||
|
:data="locTreeData"
|
||||||
|
node-key="full_path"
|
||||||
|
show-checkbox
|
||||||
|
default-expand-all
|
||||||
|
:props="{ label: 'name', children: 'children' }"
|
||||||
|
@check="updateCheckedLocCount"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="scope-selected">
|
||||||
|
已选 <strong>{{ checkedLocCount }}</strong> 个库位
|
||||||
|
<el-button link type="primary" size="small" @click="resetScopeSelection">清空</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="creatingNew" class="task-form-warn">
|
<div v-if="creatingNew" class="task-form-warn">
|
||||||
⚠️ 开启新一轮将<strong>终结当前会话</strong>:已扫数据完整保留,但其他设备不能再加入该会话
|
⚠️ 开启新一轮将<strong>终结当前会话</strong>:已扫数据完整保留,但其他设备不能再加入该会话
|
||||||
@ -536,8 +575,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import { getAllStocktakeItems, getDraftMergedList, updateStocktakeQuantity, scanStockByBarcode, getStocktakeCompanies } from '@/api/inbound/stock'
|
import { getAllStocktakeItems, getDraftMergedList, updateStocktakeQuantity, scanStockByBarcode, getStocktakeCompanies } from '@/api/inbound/stock'
|
||||||
|
import { getWarehouseTree } from '@/api/common/warehouse'
|
||||||
|
import { getAllowedLocPrefixes } from '@/utils/warehouseCompany'
|
||||||
import QrScanner from '@/components/QrScanner/index.vue'
|
import QrScanner from '@/components/QrScanner/index.vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close, WarningFilled } from '@element-plus/icons-vue'
|
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close, WarningFilled } from '@element-plus/icons-vue'
|
||||||
@ -680,6 +721,112 @@ const SCOPE_LABELS: Record<string, string> = { full: '全仓盘点', active: '
|
|||||||
const modeLabel = (m?: string | null) => (m ? MODE_LABELS[m] || m : '')
|
const modeLabel = (m?: string | null) => (m ? MODE_LABELS[m] || m : '')
|
||||||
const scopeLabel = (s?: string | null) => (s ? SCOPE_LABELS[s] || s : '')
|
const scopeLabel = (s?: string | null) => (s ? SCOPE_LABELS[s] || s : '')
|
||||||
|
|
||||||
|
// ★ 抽盘范围:系统推荐 + 人工在库位树上微调
|
||||||
|
const RECOMMEND_DAYS = 30
|
||||||
|
const recTopN = ref(50) // 推荐「近 N 天最活跃的前 X 个库位」中的 X
|
||||||
|
const recLoading = ref(false) // 获取推荐中
|
||||||
|
const treeLoading = ref(false) // 库位树加载中
|
||||||
|
const locTreeData = ref<any[]>([]) // 库位树(已按当前公司前缀过滤)
|
||||||
|
const locTreeRef = ref() // el-tree 实例,用于 setCheckedKeys / getCheckedNodes
|
||||||
|
const checkedLocCount = ref(0) // 当前勾选的库位数(含父节点,实时展示避免误判规模)
|
||||||
|
|
||||||
|
// 库位树按公司前缀过滤(IRIS 只看 Y,LICA 看 C/L;未配置的公司不过滤)
|
||||||
|
const filterTreeByCompany = (nodes: any[], company: string) => {
|
||||||
|
const prefixes = getAllowedLocPrefixes(company)
|
||||||
|
if (!prefixes.length) return nodes
|
||||||
|
return nodes.filter((n: any) =>
|
||||||
|
prefixes.some((p: string) => String(n?.name || '').toUpperCase().startsWith(p.toUpperCase()))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLocationTree = async () => {
|
||||||
|
if (locTreeData.value.length) return
|
||||||
|
treeLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await getWarehouseTree()
|
||||||
|
const raw = res?.data || []
|
||||||
|
locTreeData.value = filterTreeByCompany(raw, selectedCompany.value)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取库位树失败', e)
|
||||||
|
ElMessage.error('获取库位树失败')
|
||||||
|
locTreeData.value = []
|
||||||
|
} finally {
|
||||||
|
treeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCheckedLocCount = () => {
|
||||||
|
const nodes = locTreeRef.value?.getCheckedNodes() || []
|
||||||
|
checkedLocCount.value = nodes.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取推荐并把它们在树上自动勾选
|
||||||
|
const fetchRecommendLocations = async () => {
|
||||||
|
if (!selectedCompany.value) {
|
||||||
|
ElMessage.warning('请先选择公司')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recLoading.value = true
|
||||||
|
try {
|
||||||
|
// 先把树准备好,否则 setCheckedKeys 对尚未渲染的节点无效
|
||||||
|
await loadLocationTree()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const res: any = await request({
|
||||||
|
url: '/v1/inbound/stock/stocktake/recommend-locations',
|
||||||
|
method: 'get',
|
||||||
|
params: withCompany({ days: RECOMMEND_DAYS, top_n: recTopN.value })
|
||||||
|
})
|
||||||
|
const locs: string[] = res?.data?.locations || []
|
||||||
|
if (!locs.length) {
|
||||||
|
ElMessage.warning(`最近 ${RECOMMEND_DAYS} 天没有找到活跃库位`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// false = 不触发子节点级联之外的行为,按 el-tree 默认规则勾选
|
||||||
|
locTreeRef.value?.setCheckedKeys(locs, false)
|
||||||
|
updateCheckedLocCount()
|
||||||
|
|
||||||
|
// 推荐里有、但树上勾不到的(不在当前公司前缀范围内等)要如实告知,
|
||||||
|
// 否则这些库位会静默落选 —— 工人以为盘到了,其实没进范围
|
||||||
|
const checkedPaths = new Set(
|
||||||
|
(locTreeRef.value?.getCheckedNodes() || []).map((n: any) => n.full_path)
|
||||||
|
)
|
||||||
|
const missing = locs.filter(l => !checkedPaths.has(l))
|
||||||
|
if (missing.length) {
|
||||||
|
ElMessage.warning(`推荐中 ${missing.length} 个库位不在当前公司的库位树上,未能勾选`)
|
||||||
|
console.warn('未能勾选的推荐库位:', missing)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已勾选 ${locs.length} 个推荐库位`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '获取推荐库位失败')
|
||||||
|
} finally {
|
||||||
|
recLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集树上勾选的库位 full_path,作为抽盘范围提交
|
||||||
|
const buildScopeConfig = () => {
|
||||||
|
const nodes = locTreeRef.value?.getCheckedNodes() || []
|
||||||
|
return {
|
||||||
|
locations: nodes.map((n: any) => n.full_path).filter(Boolean),
|
||||||
|
recommend_days: RECOMMEND_DAYS,
|
||||||
|
recommend_top_n: recTopN.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换回全仓时清空树上的勾选,避免残留到下一次抽盘
|
||||||
|
const resetScopeSelection = () => {
|
||||||
|
locTreeRef.value?.setCheckedKeys([], false)
|
||||||
|
checkedLocCount.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(newScopeType, (val) => {
|
||||||
|
if (val === 'active') loadLocationTree()
|
||||||
|
else resetScopeSelection()
|
||||||
|
})
|
||||||
|
|
||||||
const persistSession = (sessionId: string) => {
|
const persistSession = (sessionId: string) => {
|
||||||
currentSessionId.value = sessionId || ''
|
currentSessionId.value = sessionId || ''
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@ -773,7 +920,7 @@ const api = {
|
|||||||
data: { ...data, session_id: currentSessionId.value }
|
data: { ...data, session_id: currentSessionId.value }
|
||||||
}),
|
}),
|
||||||
// ★ 新增: 开始新会话(携带盘点模式与范围配置,由后端落库到 stocktake_session)
|
// ★ 新增: 开始新会话(携带盘点模式与范围配置,由后端落库到 stocktake_session)
|
||||||
startNewSession: (payload: { mode: string; scope_type: string }) => request({
|
startNewSession: (payload: { mode: string; scope_type: string; scope_config?: any }) => request({
|
||||||
url: '/v1/inbound/stock/draft/start-new',
|
url: '/v1/inbound/stock/draft/start-new',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
params: withCompany(),
|
params: withCompany(),
|
||||||
@ -938,6 +1085,11 @@ const startNewSession = async () => {
|
|||||||
ElMessage.warning('请先选择公司')
|
ElMessage.warning('请先选择公司')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// ★ 抽盘必须先在库位树上勾选范围,否则会开出一个空范围的会话
|
||||||
|
if (newScopeType.value === 'active' && !buildScopeConfig().locations.length) {
|
||||||
|
ElMessage.warning('抽盘至少要勾选一个库位')
|
||||||
|
return
|
||||||
|
}
|
||||||
await doStartNewSession()
|
await doStartNewSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -947,10 +1099,13 @@ const doStartNewSession = async () => {
|
|||||||
try {
|
try {
|
||||||
// ★ 先清掉旧会话,避免签发失败时残留上一个 session_id
|
// ★ 先清掉旧会话,避免签发失败时残留上一个 session_id
|
||||||
clearPersistedSession()
|
clearPersistedSession()
|
||||||
// 后端签发新 session_id 并把盘点模式/范围落库到 stocktake_session,不删任何历史数据
|
// 后端签发新 session_id 并把盘点模式/范围落库到 stocktake_session,不删任何历史数据。
|
||||||
|
// ★ 抽盘范围由前端在库位树上勾选决定,后端不再自行计算
|
||||||
|
const scopeConfig = newScopeType.value === 'active' ? buildScopeConfig() : {}
|
||||||
const res: any = await api.startNewSession({
|
const res: any = await api.startNewSession({
|
||||||
mode: newMode.value,
|
mode: newMode.value,
|
||||||
scope_type: newScopeType.value
|
scope_type: newScopeType.value,
|
||||||
|
scope_config: scopeConfig
|
||||||
})
|
})
|
||||||
persistSession(res?.session_id || res?.data?.session_id || '')
|
persistSession(res?.session_id || res?.data?.session_id || '')
|
||||||
await enterScanning('新盘点会话已开始')
|
await enterScanning('新盘点会话已开始')
|
||||||
@ -1646,6 +1801,44 @@ const goToVarianceReview = () => {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ★ 抽盘范围配置区(推荐 + 库位树勾选) */
|
||||||
|
.scope-panel {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.scope-recommend-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.scope-recommend-text { font-size: 12px; color: #606266; }
|
||||||
|
.scope-topn-input { width: 100px; }
|
||||||
|
/* 平板大屏:定高 + 滚动,避免树把表单撑得过长 */
|
||||||
|
.scope-tree {
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
--el-tree-node-content-height: 30px;
|
||||||
|
}
|
||||||
|
.scope-selected {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.scope-selected strong { color: #409eff; font-size: 14px; }
|
||||||
|
|
||||||
/* 场景 A 的进度提示与次要入口 */
|
/* 场景 A 的进度提示与次要入口 */
|
||||||
.active-session-tip {
|
.active-session-tip {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user