fix(entry): 恢复入口跟踪全部设备工位,修复鞍座联动状态竞争
- 还原入口跟踪原有设备网格(2行):上卷小车/称重位/地辊/倒卷小车,仅上卷鞍座保留一个 - 移动可在所有入口设备间进行,仅「上卷鞍座」进入生产环节 - 修复 ensure_online 误将鞍座暂存计划回退导致卡死:移动到鞍座直接置生产中, ensure_online 排除 on_saddle 计划 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -66,8 +66,10 @@ async def _saddle_plan(db: AsyncSession):
|
|||||||
|
|
||||||
|
|
||||||
async def ensure_online(db: AsyncSession):
|
async def ensure_online(db: AsyncSession):
|
||||||
"""保证恰好一条 online(队首,最早录入的 ready)。"""
|
"""保证恰好一条 online(队首,最早录入的 ready)。鞍座上的计划不参与队列。"""
|
||||||
res = await db.execute(select(ProductionPlan).where(ProductionPlan.status == "online"))
|
res = await db.execute(
|
||||||
|
select(ProductionPlan).where(ProductionPlan.status == "online", ProductionPlan.on_saddle != 1)
|
||||||
|
)
|
||||||
online = list(res.scalars())
|
online = list(res.scalars())
|
||||||
if len(online) > 1:
|
if len(online) > 1:
|
||||||
# 仅保留最早的一条为 online,其余回退 ready
|
# 仅保留最早的一条为 online,其余回退 ready
|
||||||
@@ -88,25 +90,27 @@ async def ensure_online(db: AsyncSession):
|
|||||||
|
|
||||||
|
|
||||||
async def move_to_saddle(db: AsyncSession, plan: ProductionPlan):
|
async def move_to_saddle(db: AsyncSession, plan: ProductionPlan):
|
||||||
"""把在线计划移动到上卷鞍座(staged,等待速度/投入生产)。"""
|
"""把计划移动到上卷鞍座:上卷即获得速度 → 生产中。"""
|
||||||
occupied = await _saddle_plan(db)
|
occupied = await _saddle_plan(db)
|
||||||
if occupied and occupied.id != plan.id:
|
if occupied and occupied.id != plan.id:
|
||||||
raise ValueError("上卷鞍座已被占用,请等待当前钢卷生产完成")
|
raise ValueError("上卷鞍座已被占用,请等待当前钢卷生产完成")
|
||||||
|
now = datetime.now()
|
||||||
plan.on_saddle = 1
|
plan.on_saddle = 1
|
||||||
plan.position = SADDLE_NAME
|
plan.position = SADDLE_NAME
|
||||||
plan.saddle_at = datetime.now()
|
plan.saddle_at = now
|
||||||
plan.run_started_at = None
|
if plan.status != "produced":
|
||||||
plan.run_speed = 0
|
plan.run_started_at = now
|
||||||
plan.run_length_m = 0
|
plan.run_speed = SIM_SPEED_M_MIN
|
||||||
if plan.status not in ("producing", "produced"):
|
plan.run_length_m = 0
|
||||||
plan.status = "online"
|
plan.status = "producing"
|
||||||
await ensure_online(db)
|
await ensure_online(db)
|
||||||
|
|
||||||
|
|
||||||
async def commit_plan(db: AsyncSession, plan: ProductionPlan):
|
async def commit_plan(db: AsyncSession, plan: ProductionPlan):
|
||||||
"""投入生产:鞍座计划有速度 → 生产中。"""
|
"""投入生产(兜底):鞍座计划置为生产中。"""
|
||||||
if plan.on_saddle != 1:
|
if plan.on_saddle != 1:
|
||||||
await move_to_saddle(db, plan)
|
await move_to_saddle(db, plan)
|
||||||
|
return
|
||||||
if plan.run_started_at is None:
|
if plan.run_started_at is None:
|
||||||
plan.run_started_at = datetime.now()
|
plan.run_started_at = datetime.now()
|
||||||
plan.run_speed = SIM_SPEED_M_MIN
|
plan.run_speed = SIM_SPEED_M_MIN
|
||||||
@@ -166,17 +170,11 @@ async def _produce(db: AsyncSession, plan: ProductionPlan):
|
|||||||
|
|
||||||
|
|
||||||
async def advance_saddle(db: AsyncSession):
|
async def advance_saddle(db: AsyncSession):
|
||||||
"""推进鞍座计划:staged 自动获得速度 → 生产中;累计长度到 2000m → 完成。"""
|
"""推进鞍座计划:累计带头长度到 2000m → 生产完成。"""
|
||||||
plan = await _saddle_plan(db)
|
plan = await _saddle_plan(db)
|
||||||
if not plan:
|
if not plan:
|
||||||
return
|
return
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
# staged(在线且在鞍座)自动获得速度,模拟 PLC 速度信号
|
|
||||||
if plan.status == "online" and plan.run_started_at is None:
|
|
||||||
plan.run_started_at = now
|
|
||||||
plan.run_speed = SIM_SPEED_M_MIN
|
|
||||||
plan.status = "producing"
|
|
||||||
await ensure_online(db)
|
|
||||||
if plan.status == "producing" and plan.run_started_at:
|
if plan.status == "producing" and plan.run_started_at:
|
||||||
elapsed = (now - plan.run_started_at).total_seconds()
|
elapsed = (now - plan.run_started_at).total_seconds()
|
||||||
plan.run_length_m = min(TARGET_LENGTH_M, (plan.run_speed or 0) / 60.0 * elapsed)
|
plan.run_length_m = min(TARGET_LENGTH_M, (plan.run_speed or 0) / 60.0 * elapsed)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="entry-page">
|
<div class="entry-page">
|
||||||
|
|
||||||
<!-- ─── 上卷鞍座(单个生产工位)─── -->
|
<!-- ─── 上卷鞍座(唯一进入生产的工位)─── -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
上卷鞍座
|
上卷鞍座
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
<button class="btn btn-outline" @click="refreshAll">刷新</button>
|
<button class="btn btn-outline" @click="refreshAll">刷新</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div v-if="saddle" class="saddle-station">
|
<div v-if="saddle" class="saddle-station">
|
||||||
<div class="saddle-info">
|
<div class="saddle-info">
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
<div class="si-row"><span class="si-k">来料重量[t]</span><span class="si-v">{{ fmt(saddle.incoming_weight, 3) }}</span></div>
|
<div class="si-row"><span class="si-k">来料重量[t]</span><span class="si-v">{{ fmt(saddle.incoming_weight, 3) }}</span></div>
|
||||||
<div class="si-row"><span class="si-k">轧制模式</span><span class="si-v">{{ saddle.rolling_mode || '—' }}</span></div>
|
<div class="si-row"><span class="si-k">轧制模式</span><span class="si-v">{{ saddle.rolling_mode || '—' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="saddle-run">
|
<div class="saddle-run">
|
||||||
<div class="metric-box">
|
<div class="metric-box">
|
||||||
<span class="mb-label">线速度</span>
|
<span class="mb-label">线速度</span>
|
||||||
@@ -42,38 +40,42 @@
|
|||||||
<div class="prog-bar-wrap" style="height:10px;">
|
<div class="prog-bar-wrap" style="height:10px;">
|
||||||
<div class="prog-bar-fill" :style="{ width: progPct(saddle) + '%', background: progColor(saddle) }"></div>
|
<div class="prog-bar-fill" :style="{ width: progPct(saddle) + '%', background: progColor(saddle) }"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rp-tip">
|
<div class="rp-tip">带头到达 {{ TARGET }} m 后自动产出实绩并完成;下一卷需从队列再次「移动」到鞍座</div>
|
||||||
<template v-if="saddle.status === 'producing'">带头到达 {{ TARGET }} m 后自动产出实绩并完成</template>
|
|
||||||
<template v-else>已在鞍座,等待速度信号;可点「投入生产」手动开始</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="saddle-empty">
|
<div v-else class="saddle-empty">
|
||||||
上卷鞍座空闲 — 从队列点击「移动」,在弹窗中选择「上卷鞍座」即可开始生产
|
上卷鞍座空闲 — 点击计划「移动」并选择「上卷鞍座」即可上卷生产
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── 入口位置图 ─── -->
|
<!-- ─── 入口跟踪设备位置 ─── -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">入口位置 <span class="ch-badge">{{ positions.length }} 个工位</span></div>
|
<div class="card-header">入口跟踪 <span class="ch-badge">{{ equipCols.length }} × 2 工位</span></div>
|
||||||
<div class="card-body">
|
<div class="entry-grid">
|
||||||
<div class="pos-grid">
|
<div v-for="(row, ri) in equipRows" :key="ri" class="entry-row">
|
||||||
<div
|
<div
|
||||||
v-for="pos in positions"
|
v-for="pos in row"
|
||||||
:key="pos"
|
:key="pos"
|
||||||
:class="['pos-cell', { saddle: pos === SADDLE, filled: !!occupantOf(pos) }]"
|
:class="['pos-cell', { filled: !!occupantOf(pos) }]"
|
||||||
>
|
>
|
||||||
<div class="pos-title">{{ pos }}<span v-if="pos === SADDLE" class="pos-tag">生产</span></div>
|
<div class="pos-title">{{ pos }}</div>
|
||||||
<div v-if="occupantOf(pos)" class="pos-occ">
|
<table class="pos-table">
|
||||||
<div class="po-coil">{{ occupantOf(pos).cold_coil_no || occupantOf(pos).plan_no }}</div>
|
<tbody>
|
||||||
<div class="po-sub">{{ occupantOf(pos).steel_grade || '—' }}</div>
|
<tr><td class="k">冷卷号</td><td class="v">{{ occ(pos,'cold_coil_no') }}</td></tr>
|
||||||
<div class="po-sub">{{ fmt(occupantOf(pos).product_thickness, 2) }}×{{ fmt(occupantOf(pos).product_width, 0) }}</div>
|
<tr><td class="k">热卷号</td><td class="v">{{ occ(pos,'hot_coil_no') }}</td></tr>
|
||||||
<span :class="['badge', badgeOf(occupantOf(pos).status)]" style="margin-top:3px;">{{ statusLabel(occupantOf(pos).status) }}</span>
|
<tr><td class="k">钢种</td><td class="v">{{ occ(pos,'steel_grade') }}</td></tr>
|
||||||
|
<tr><td class="k">来料厚[mm]</td><td class="v">{{ occNum(pos,'incoming_thickness',2) }}</td></tr>
|
||||||
|
<tr><td class="k">成品厚[mm]</td><td class="v">{{ occNum(pos,'product_thickness',2) }}</td></tr>
|
||||||
|
<tr><td class="k">成品宽[mm]</td><td class="v">{{ occNum(pos,'product_width',0) }}</td></tr>
|
||||||
|
<tr><td class="k">来料重[t]</td><td class="v">{{ occNum(pos,'incoming_weight',3) }}</td></tr>
|
||||||
|
<tr><td class="k">轧制模式</td><td class="v">{{ occ(pos,'rolling_mode') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-if="occupantOf(pos)" class="pos-act">
|
||||||
|
<span class="action-link" @click="openMove(occupantOf(pos))">移动</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="pos-empty">空</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,13 +118,13 @@
|
|||||||
|
|
||||||
<!-- ─── 移动-位置选择弹窗 ─── -->
|
<!-- ─── 移动-位置选择弹窗 ─── -->
|
||||||
<div v-if="moveDialog.visible" class="modal-mask" @click.self="moveDialog.visible=false">
|
<div v-if="moveDialog.visible" class="modal-mask" @click.self="moveDialog.visible=false">
|
||||||
<div class="modal-box" style="width:480px;">
|
<div class="modal-box" style="width:520px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
移动计划 — {{ moveDialog.plan && (moveDialog.plan.cold_coil_no || moveDialog.plan.plan_no) }}
|
移动计划 — {{ moveDialog.plan && (moveDialog.plan.cold_coil_no || moveDialog.plan.plan_no) }}
|
||||||
<span class="modal-close" @click="moveDialog.visible=false">✕</span>
|
<span class="modal-close" @click="moveDialog.visible=false">✕</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="kv-label" style="margin-bottom:8px;">选择目标位置(只有「上卷鞍座」会触发生产)</div>
|
<div class="kv-label" style="margin-bottom:8px;">选择目标位置(只有「上卷鞍座」会进入生产环节)</div>
|
||||||
<div class="pos-pick">
|
<div class="pos-pick">
|
||||||
<span
|
<span
|
||||||
v-for="pos in positions"
|
v-for="pos in positions"
|
||||||
@@ -147,20 +149,19 @@ import { getPlans, getSaddle, movePlan, commitProducing } from '@/api'
|
|||||||
const STATUS_LABEL = { ready: '准备好', online: '在线', producing: '生产中', produced: '生产完成' }
|
const STATUS_LABEL = { ready: '准备好', online: '在线', producing: '生产中', produced: '生产完成' }
|
||||||
const STATUS_BADGE = { ready: 'badge-gray', online: 'badge-green', producing: 'badge-yellow', produced: 'badge-blue' }
|
const STATUS_BADGE = { ready: 'badge-gray', online: 'badge-green', producing: 'badge-yellow', produced: 'badge-blue' }
|
||||||
const SADDLE = '上卷鞍座'
|
const SADDLE = '上卷鞍座'
|
||||||
const POSITIONS = [
|
const EQUIP_ROWS = [
|
||||||
'1#上卷小车', '2#上卷小车',
|
['1#上卷小车', '1#称重位', '1#地辊', '1#倒卷小车'],
|
||||||
'1#称重位', '2#称重位',
|
['2#上卷小车', '2#称重位', '2#地辊', '2#倒卷小车'],
|
||||||
'1#地辊', '2#地辊',
|
|
||||||
'1#倒卷小车', '2#倒卷小车',
|
|
||||||
SADDLE,
|
|
||||||
]
|
]
|
||||||
|
const POSITIONS = [...EQUIP_ROWS[0], ...EQUIP_ROWS[1], SADDLE]
|
||||||
const TARGET = 2000
|
const TARGET = 2000
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'EntryTracking',
|
name: 'EntryTracking',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
plans: [], saddle: null, TARGET, SADDLE, positions: POSITIONS,
|
plans: [], saddle: null, TARGET, SADDLE,
|
||||||
|
positions: POSITIONS, equipRows: EQUIP_ROWS, equipCols: EQUIP_ROWS[0],
|
||||||
timer: null, fastTimer: null, moving: false,
|
timer: null, fastTimer: null, moving: false,
|
||||||
moveDialog: { visible: false, plan: null, target: '' },
|
moveDialog: { visible: false, plan: null, target: '' },
|
||||||
}
|
}
|
||||||
@@ -198,12 +199,11 @@ export default {
|
|||||||
progPct(p) { return Math.max(0, Math.min(100, (p.run_length_m || 0) / TARGET * 100)) },
|
progPct(p) { return Math.max(0, Math.min(100, (p.run_length_m || 0) / TARGET * 100)) },
|
||||||
progColor(p) { return this.progPct(p) >= 100 ? 'var(--accent-green)' : 'var(--sms-teal)' },
|
progColor(p) { return this.progPct(p) >= 100 ? 'var(--accent-green)' : 'var(--sms-teal)' },
|
||||||
occupantOf(pos) {
|
occupantOf(pos) {
|
||||||
if (pos === SADDLE) return this.saddle || this.plans.find(p => p.on_saddle === 1) || null
|
|
||||||
return this.plans.find(p => p.position === pos && p.on_saddle !== 1) || null
|
return this.plans.find(p => p.position === pos && p.on_saddle !== 1) || null
|
||||||
},
|
},
|
||||||
openMove(plan) {
|
occ(pos, key) { const p = this.occupantOf(pos); return p ? (p[key] || '—') : '' },
|
||||||
this.moveDialog = { visible: true, plan, target: plan.position || '' }
|
occNum(pos, key, n) { const p = this.occupantOf(pos); return p && p[key] != null ? Number(p[key]).toFixed(n) : '' },
|
||||||
},
|
openMove(plan) { this.moveDialog = { visible: true, plan, target: plan.position || '' } },
|
||||||
async confirmMove() {
|
async confirmMove() {
|
||||||
const { plan, target } = this.moveDialog
|
const { plan, target } = this.moveDialog
|
||||||
if (!target) return
|
if (!target) return
|
||||||
@@ -244,7 +244,6 @@ export default {
|
|||||||
.si-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; }
|
.si-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; }
|
||||||
.si-k { color: $text-muted; }
|
.si-k { color: $text-muted; }
|
||||||
.si-v { color: $text-primary; font-family: $font-mono; font-weight: 600; &.hl { color: $sms-teal; } }
|
.si-v { color: $text-primary; font-family: $font-mono; font-weight: 600; &.hl { color: $sms-teal; } }
|
||||||
|
|
||||||
.saddle-run { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-content: start; }
|
.saddle-run { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-content: start; }
|
||||||
.saddle-run .metric-box { min-width: 0; }
|
.saddle-run .metric-box { min-width: 0; }
|
||||||
.run-prog { grid-column: 1 / -1; }
|
.run-prog { grid-column: 1 / -1; }
|
||||||
@@ -253,20 +252,25 @@ export default {
|
|||||||
.rp-tip { font-size: 11px; color: $text-muted; margin-top: 6px; }
|
.rp-tip { font-size: 11px; color: $text-muted; margin-top: 6px; }
|
||||||
.saddle-empty { text-align: center; padding: 30px; color: $text-muted; font-size: 13px; }
|
.saddle-empty { text-align: center; padding: 30px; color: $text-muted; font-size: 13px; }
|
||||||
|
|
||||||
// ── 位置图 ──
|
// ── 入口设备网格(恢复原布局,2 行)──
|
||||||
.pos-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
.entry-grid { padding: 10px; display: flex; flex-direction: column; gap: 8px; overflow-x: auto; }
|
||||||
|
.entry-row { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 8px; }
|
||||||
.pos-cell {
|
.pos-cell {
|
||||||
background: $bg-panel; border: 1px solid $border; border-radius: 6px; padding: 8px; min-height: 110px;
|
background: $bg-panel; border: 1px solid $border; border-radius: 4px; padding: 4px 6px 6px; min-height: 200px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
&.filled { border-color: $sms-teal; background: rgba($sms-teal, .04); }
|
&.filled { border-color: $sms-teal; background: rgba($sms-teal, .04); }
|
||||||
&.saddle { border-style: dashed; border-color: $accent-yellow;
|
|
||||||
&.filled { border-style: solid; border-color: $accent-yellow; background: rgba($accent-yellow, .06); } }
|
|
||||||
}
|
}
|
||||||
.pos-title { font-size: 11.5px; font-weight: 700; color: $text-primary; padding-bottom: 5px; border-bottom: 1px dashed $border; margin-bottom: 6px; display: flex; justify-content: space-between; }
|
.pos-title {
|
||||||
.pos-tag { font-size: 9px; color: $accent-yellow; border: 1px solid rgba($accent-yellow, .5); border-radius: 2px; padding: 0 4px; }
|
text-align: center; font-size: 11.5px; font-weight: 700; color: $text-primary;
|
||||||
.pos-occ { display: flex; flex-direction: column; gap: 2px; }
|
padding: 3px 0 5px; border-bottom: 1px dashed $border; margin-bottom: 4px; letter-spacing: .3px;
|
||||||
.po-coil { font-family: $font-mono; font-weight: 700; color: $sms-teal; font-size: 12.5px; }
|
}
|
||||||
.po-sub { font-size: 11px; color: $text-secondary; }
|
.pos-table {
|
||||||
.pos-empty { color: $text-muted; font-size: 12px; text-align: center; padding-top: 16px; }
|
width: 100%; border-collapse: collapse; font-size: 10.5px; line-height: 1.5; flex: 1;
|
||||||
|
td { padding: 1px 2px; vertical-align: top; white-space: nowrap; }
|
||||||
|
td.k { color: $text-muted; text-align: right; width: 52%; font-size: 10px; }
|
||||||
|
td.v { color: $sms-teal; text-align: right; font-family: $font-mono; font-weight: 600; }
|
||||||
|
}
|
||||||
|
.pos-act { text-align: center; padding-top: 4px; border-top: 1px dashed $border; }
|
||||||
|
|
||||||
.action-link { color: $accent-green; cursor: pointer; font-size: 12px; &:hover { text-decoration: underline; } }
|
.action-link { color: $accent-green; cursor: pointer; font-size: 12px; &:hover { text-decoration: underline; } }
|
||||||
|
|
||||||
@@ -280,7 +284,6 @@ export default {
|
|||||||
&.saddle { grid-column: 1 / -1; border-style: dashed; border-color: $accent-yellow; color: $accent-yellow;
|
&.saddle { grid-column: 1 / -1; border-style: dashed; border-color: $accent-yellow; color: $accent-yellow;
|
||||||
&.active { color: #fff; background: $accent-yellow; border-style: solid; } }
|
&.active { color: #fff; background: $accent-yellow; border-style: solid; } }
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||||
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
|
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
|
||||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
|
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
|
||||||
|
|||||||
Reference in New Issue
Block a user