merge overseas PDA frontend changes
This commit is contained in:
+19
-3
@@ -1,6 +1,8 @@
|
||||
import { API_ROOT } from '../config/runtime.js'
|
||||
|
||||
function authRequest(path, data) {
|
||||
const pendingRefreshes = new Map()
|
||||
|
||||
function authRequest(path, data, accessToken = '') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `${API_ROOT}${path}`
|
||||
console.info('[Overseas PDA auth] request', { method: 'POST', url })
|
||||
@@ -8,6 +10,8 @@ function authRequest(path, data) {
|
||||
url,
|
||||
method: 'POST',
|
||||
data,
|
||||
header: { Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
||||
success(response) {
|
||||
const body = response.data || {}
|
||||
console.info('[Overseas PDA auth] response', { url, statusCode: response.statusCode, code: body.code,
|
||||
@@ -15,9 +19,10 @@ function authRequest(path, data) {
|
||||
contentType: response.header?.['Content-Type'] || response.header?.['content-type'],
|
||||
requestId: body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'],
|
||||
traceId: body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] })
|
||||
if (String(body.code ?? response.statusCode) !== '200') {
|
||||
if (response.statusCode !== 200 || String(body.code) !== '200') {
|
||||
const error = new Error(body.message || '认证服务请求失败')
|
||||
error.code = body.code || response.statusCode
|
||||
error.statusCode = response.statusCode
|
||||
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
|
||||
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
|
||||
reject(error)
|
||||
@@ -40,5 +45,16 @@ export function login(loginId, password) {
|
||||
}
|
||||
|
||||
export function refresh(refreshToken) {
|
||||
return authRequest('/auth/refresh', { refreshToken })
|
||||
if (pendingRefreshes.has(refreshToken)) return pendingRefreshes.get(refreshToken)
|
||||
const pending = authRequest('/auth/refresh', { refreshToken }).finally(() => {
|
||||
if (pendingRefreshes.get(refreshToken) === pending) pendingRefreshes.delete(refreshToken)
|
||||
})
|
||||
pendingRefreshes.set(refreshToken, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
export function getPendingRefresh(refreshToken) { return pendingRefreshes.get(refreshToken) || null }
|
||||
|
||||
export function logout(accessToken, refreshToken) {
|
||||
return authRequest('/auth/logout', { refreshToken }, accessToken)
|
||||
}
|
||||
|
||||
+14
-1
@@ -24,8 +24,14 @@ export function normalizeDispatchTask(value) {
|
||||
taskNo: String(value?.taskNo || ''),
|
||||
dispatchNo: String(value?.dispatchNo || ''),
|
||||
vehiclePlate: String(value?.vehiclePlate || ''),
|
||||
tripNo: String(value?.tripNo || ''),
|
||||
tripRecordVersion: value?.tripRecordVersion == null ? null : String(value.tripRecordVersion),
|
||||
tripDispatchCount: count(value?.tripDispatchCount),
|
||||
driverWarning: String(value?.driverWarning || ''),
|
||||
status: String(value?.status || ''),
|
||||
canDepart: value?.canDepart === true,
|
||||
canPick: value?.canPick === true,
|
||||
pickBlockReason: String(value?.pickBlockReason || ''),
|
||||
departBlockReason: String(value?.departBlockReason || ''),
|
||||
items: asList(value?.items).map((item) => ({
|
||||
...item,
|
||||
@@ -98,6 +104,13 @@ export function departDispatch(dispatchNo, request) {
|
||||
return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
|
||||
}
|
||||
|
||||
export function tripDepartureRequest(task, idempotencyKey) {
|
||||
if (!task?.tripNo || task.tripRecordVersion == null || !/^\d+$/.test(String(task.tripRecordVersion))) {
|
||||
throw new Error('车次信息不完整,请刷新任务后再发车')
|
||||
}
|
||||
return { recordVersion: String(task.tripRecordVersion), idempotencyKey, remark: '海外 PDA 整车核平后发车' }
|
||||
}
|
||||
|
||||
export async function listOutboundTasks(status = '') {
|
||||
const suffix = status ? `?status=${encoded(status)}` : ''
|
||||
return asList(await overseasRequest('GET', `${OUTBOUND_BASE}${suffix}`)).map(normalizeOutboundTask)
|
||||
@@ -123,7 +136,7 @@ export function outboundPickPayload(item, position, packages, scannedCode = '')
|
||||
const skuScan = item.skuCode && scannedCode === item.skuCode
|
||||
return {
|
||||
outboundPalletItemId: item.outboundPalletItemId,
|
||||
inventoryPositionId: position.inventoryPositionId,
|
||||
inventoryPositionId: position.inventoryPositionId || null,
|
||||
packages: Number(packages),
|
||||
cartonMarks: skuScan ? [] : position.cartonMark ? [position.cartonMark] : [],
|
||||
...(skuScan ? { skuCode: item.skuCode } : {})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { logout, refresh, getPendingRefresh } from './auth.js'
|
||||
import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js'
|
||||
|
||||
function replacementForCapturedSession(captured, tokens) {
|
||||
if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) {
|
||||
throw new Error('退出刷新响应缺少会话凭据')
|
||||
}
|
||||
const previous = decodeAccessToken(captured.accessToken)
|
||||
const replacement = decodeAccessToken(tokens.accessToken)
|
||||
if (!Number.isFinite(Number(replacement?.exp)) || Number(replacement.exp) * 1000 <= Date.now()) {
|
||||
throw new Error('退出刷新凭据无效或已过期')
|
||||
}
|
||||
for (const name of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) {
|
||||
if (previous[name] == null || String(previous[name]) !== String(replacement[name])) {
|
||||
throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销')
|
||||
}
|
||||
}
|
||||
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }
|
||||
}
|
||||
|
||||
// Active user logout only. Ordinary authentication failures continue to use local signOut.
|
||||
export function logoutCurrentSession() {
|
||||
const captured = {
|
||||
accessToken: session.token, refreshToken: session.refreshToken,
|
||||
pendingRefresh: getPendingRefresh(session.refreshToken)
|
||||
}
|
||||
signOut()
|
||||
const clearedRevision = getSessionRevision()
|
||||
const revoke = async () => {
|
||||
if (!captured.accessToken || !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' }
|
||||
let credentials = captured
|
||||
let refreshed = false
|
||||
if (captured.pendingRefresh) {
|
||||
// Auth rotates access/refresh credentials. Revoke the captured refresh's result,
|
||||
// while the business refresh wrapper's revision guard prevents local restoration.
|
||||
refreshed = true
|
||||
credentials = replacementForCapturedSession(captured, await captured.pendingRefresh)
|
||||
}
|
||||
try {
|
||||
await logout(credentials.accessToken, credentials.refreshToken)
|
||||
} catch (error) {
|
||||
if (error.network || error.statusCode !== 401 || refreshed) throw error
|
||||
// Only an explicit expired-access HTTP 401 permits one refresh and one revoke.
|
||||
credentials = replacementForCapturedSession(captured, await refresh(captured.refreshToken))
|
||||
await logout(credentials.accessToken, credentials.refreshToken)
|
||||
}
|
||||
return { revoked: true }
|
||||
}
|
||||
return revoke().then(result => ({ ...result, clearedRevision }), error => {
|
||||
error.logoutRevision = clearedRevision
|
||||
throw error
|
||||
})
|
||||
}
|
||||
+40
-13
@@ -1,7 +1,18 @@
|
||||
import { refresh } from './auth.js'
|
||||
import { session, replaceTokens, signOut } from '../stores/session.js'
|
||||
import { session, replaceTokens, signOut, getSessionRevision } from '../stores/session.js'
|
||||
|
||||
let refreshPromise = null
|
||||
let refreshRevision = -1
|
||||
|
||||
function requireCurrentSession(revision) {
|
||||
if (revision !== getSessionRevision()) throw new Error('登录会话已变更,请重新操作')
|
||||
}
|
||||
|
||||
function expireSession(revision) {
|
||||
if (revision !== getSessionRevision()) return
|
||||
signOut()
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
}
|
||||
|
||||
function errorFrom(response, fallback) {
|
||||
const body = response?.data || {}
|
||||
@@ -21,23 +32,27 @@ function successful(response, body) {
|
||||
return response?.statusCode === 200 && ['00000', '200'].includes(String(body?.code))
|
||||
}
|
||||
|
||||
async function refreshOverseasToken() {
|
||||
async function refreshOverseasToken(revision) {
|
||||
requireCurrentSession(revision)
|
||||
if (!session.refreshToken) {
|
||||
signOut()
|
||||
expireSession(revision)
|
||||
throw new Error('登录状态已过期,请重新登录')
|
||||
}
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refresh(session.refreshToken)
|
||||
.then((tokens) => { replaceTokens(tokens); return session.token })
|
||||
.catch((error) => { signOut(); throw error })
|
||||
.finally(() => { refreshPromise = null })
|
||||
if (!refreshPromise || refreshRevision !== revision) {
|
||||
refreshRevision = revision
|
||||
const pending = refresh(session.refreshToken)
|
||||
.then((tokens) => { requireCurrentSession(revision); replaceTokens(tokens); return session.token })
|
||||
.catch((error) => { expireSession(revision); throw error })
|
||||
.finally(() => { if (refreshPromise === pending) refreshPromise = null })
|
||||
refreshPromise = pending
|
||||
}
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
async function tokenAfterUnauthorized(failedToken) {
|
||||
async function tokenAfterUnauthorized(failedToken, revision) {
|
||||
requireCurrentSession(revision)
|
||||
if (session.token && session.token !== failedToken) return session.token
|
||||
return refreshOverseasToken()
|
||||
return refreshOverseasToken(revision)
|
||||
}
|
||||
|
||||
function overseasUrl(path) {
|
||||
@@ -70,13 +85,17 @@ function rawRequest(method, path, data, token) {
|
||||
}
|
||||
|
||||
export async function overseasRequest(method, path, data, retry = true) {
|
||||
const revision = getSessionRevision()
|
||||
console.info('[Overseas PDA API] request', { method, path })
|
||||
const attemptedToken = session.token
|
||||
let response = await rawRequest(method, path, data, attemptedToken)
|
||||
requireCurrentSession(revision)
|
||||
if (retry && unauthorized(response)) {
|
||||
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
|
||||
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
|
||||
response = await rawRequest(method, path, data, replacementToken)
|
||||
}
|
||||
requireCurrentSession(revision)
|
||||
if (unauthorized(response)) expireSession(revision)
|
||||
const body = response?.data || {}
|
||||
console.info('[Overseas PDA API] response', { method, path, statusCode: response?.statusCode, code: body?.code,
|
||||
message: body?.message, requestId: body?.requestId || response?.header?.['x-request-id'] || response?.header?.['X-Request-Id'],
|
||||
@@ -149,27 +168,35 @@ function rawDownload(path, token) {
|
||||
}
|
||||
|
||||
export async function overseasUpload(path, filePath, name = 'file', retry = true) {
|
||||
const revision = getSessionRevision()
|
||||
const attemptedToken = session.token
|
||||
let response = await rawUpload(path, filePath, name, attemptedToken)
|
||||
requireCurrentSession(revision)
|
||||
let body = null
|
||||
try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ }
|
||||
if (retry && unauthorized({ ...response, data: body })) {
|
||||
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
|
||||
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
|
||||
response = await rawUpload(path, filePath, name, replacementToken)
|
||||
try { body = JSON.parse(response.data || '{}') } catch (_) { body = null }
|
||||
}
|
||||
requireCurrentSession(revision)
|
||||
if (unauthorized({ ...response, data: body })) expireSession(revision)
|
||||
if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '海外作业文件上传失败')
|
||||
return body.data
|
||||
}
|
||||
|
||||
export async function overseasDownload(path, options = {}) {
|
||||
const revision = getSessionRevision()
|
||||
const { retry = true, expectedContentTypePrefix = '' } = options
|
||||
const attemptedToken = session.token
|
||||
let response = await rawDownload(path, attemptedToken)
|
||||
requireCurrentSession(revision)
|
||||
if (retry && unauthorized(response)) {
|
||||
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
|
||||
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
|
||||
response = await rawDownload(path, replacementToken)
|
||||
}
|
||||
requireCurrentSession(revision)
|
||||
if (unauthorized(response)) expireSession(revision)
|
||||
if (response?.statusCode !== 200 || !response?.tempFilePath) {
|
||||
throw errorFrom({ ...response, data: {} }, '签收照片下载失败')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { overseasRequest } from './overseas.js'
|
||||
|
||||
const text = value => typeof value === 'string' ? value : ''
|
||||
const transport = value => ({ vehiclePlate: text(value?.vehiclePlate), driverName: text(value?.driverName), driverPhone: text(value?.driverPhone) })
|
||||
export function normalizeHandoverStatus(value) {
|
||||
if (!value?.dispatchNo || !value?.tripNo || !Array.isArray(value.deliveries)) throw new Error('交接状态缺少排车或派送依据')
|
||||
return {
|
||||
dispatchNo: text(value.dispatchNo), tripNo: text(value.tripNo), tripStatus: text(value.tripStatus),
|
||||
tripRecordVersion: value.tripRecordVersion == null ? '' : String(value.tripRecordVersion),
|
||||
originalDeparture: transport(value.originalDeparture),
|
||||
deliveries: value.deliveries.map(row => {
|
||||
const packages = Number(row.packages)
|
||||
if (!row.deliveryNo || row.packages == null || String(row.packages).trim() === '' || !Number.isSafeInteger(packages) || packages <= 0) throw new Error('交接状态中的派送箱数无效')
|
||||
const remainingPackages = Number(row.remainingPackages)
|
||||
if (row.remainingPackages == null || String(row.remainingPackages).trim() === '' || !Number.isSafeInteger(remainingPackages) || remainingPackages < 0 || remainingPackages > packages) throw new Error('交接状态中的剩余箱数无效')
|
||||
return { deliveryNo: text(row.deliveryNo), status: text(row.status), packages, remainingPackages, terminalAt: text(row.terminalAt), transport: transport(row.transport) }
|
||||
}),
|
||||
latestHandover: value.latestHandover ? { handoverNo: text(value.latestHandover.handoverNo), handedOverAt: text(value.latestHandover.handedOverAt),
|
||||
location: text(value.latestHandover.location), previous: transport(value.latestHandover.previous), replacement: transport(value.latestHandover.replacement) } : null
|
||||
}
|
||||
}
|
||||
export async function getDispatchHandoverStatus(dispatchNo) {
|
||||
const number = String(dispatchNo || '').trim()
|
||||
if (!number) throw new Error('请先打开排车任务')
|
||||
const result = normalizeHandoverStatus(await overseasRequest('GET', `/pda/v1/last-leg/dispatches/${encodeURIComponent(number)}/handover-status`))
|
||||
if (result.dispatchNo !== number) throw new Error('返回的交接记录不属于当前排车任务')
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<view class="handover-card">
|
||||
<view class="handover-heading"><text class="handover-title">发车与交接记录</text><button :disabled="loading" @tap="load">刷新</button></view>
|
||||
<text v-if="loading">正在读取当前运输资源…</text>
|
||||
<view v-else-if="error" class="handover-error" role="alert">
|
||||
<text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text>
|
||||
<button @tap="load">重新读取</button>
|
||||
</view>
|
||||
<template v-else-if="status">
|
||||
<text class="handover-line">车次 {{ status.tripNo }}</text>
|
||||
<text class="handover-line">原始发车:{{ status.originalDeparture.vehiclePlate || '—' }} · {{ status.originalDeparture.driverName || '—' }}</text>
|
||||
<view v-if="status.latestHandover" class="handover-record">
|
||||
<text class="handover-line">最近交接 {{ status.latestHandover.handoverNo || '—' }}</text>
|
||||
<text class="handover-line">{{ time(status.latestHandover.handedOverAt) }} · {{ status.latestHandover.location || '—' }}</text>
|
||||
<text class="handover-line">{{ status.latestHandover.previous.vehiclePlate || '—' }} → {{ status.latestHandover.replacement.vehiclePlate || '—' }}</text>
|
||||
<text class="handover-line">{{ status.latestHandover.previous.driverName || '—' }} → {{ status.latestHandover.replacement.driverName || '—' }}</text>
|
||||
</view>
|
||||
<text v-else class="handover-line">本排车暂无在途交接记录。</text>
|
||||
<view v-for="delivery in status.deliveries" :key="delivery.deliveryNo" class="handover-delivery">
|
||||
<text class="handover-line">{{ delivery.deliveryNo }} · 原实发 {{ delivery.packages }} 箱 · 剩余 {{ delivery.remainingPackages }} 箱 · {{ label(delivery) }}</text>
|
||||
<text class="handover-line">{{ delivery.terminalAt ? '完成时承运' : '当前承运' }}:{{ delivery.transport.vehiclePlate || '—' }} · {{ delivery.transport.driverName || '—' }}</text>
|
||||
<text v-if="delivery.transport.driverPhone" class="handover-line">联系电话 {{ delivery.transport.driverPhone }}</text>
|
||||
</view>
|
||||
<text class="handover-note">交接由后台确认。本页只读,不重复发车。</text>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
import { getDispatchHandoverStatus } from '@/api/trip-handover.js'
|
||||
import { toDispatchError } from '@/utils/dispatch-error.js'
|
||||
export default {
|
||||
props: { dispatchNo: { type: String, required: true }, recordVersion: { type: [String, Number], default: null } },
|
||||
data() { return { status: null, error: null, loading: false, generation: 0 } },
|
||||
computed: { scopeKey() { return `${this.dispatchNo}:${this.recordVersion ?? ''}` } },
|
||||
watch: { scopeKey: { immediate: true, handler() { this.status = null; this.load() } } },
|
||||
beforeUnmount() { this.generation++ },
|
||||
methods: {
|
||||
async load() {
|
||||
const generation = ++this.generation
|
||||
this.error = null; this.status = null
|
||||
if (!this.dispatchNo) { this.loading = false; return }
|
||||
this.loading = true
|
||||
try { const result = await getDispatchHandoverStatus(this.dispatchNo); if (generation === this.generation) this.status = result }
|
||||
catch (cause) { if (generation === this.generation) this.error = toDispatchError(cause, '交接记录读取失败') }
|
||||
finally { if (generation === this.generation) this.loading = false }
|
||||
},
|
||||
time(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' },
|
||||
label(delivery) {
|
||||
if (delivery.terminalAt) return delivery.status === 'RETURNED' ? '运输已完成' : '已签收'
|
||||
return { WAIT_DELIVERY: '派送中', RETURN_PENDING: '待退回' }[delivery.status] || '待核对'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.handover-card{padding:28rpx;background:#fff;border-radius:20rpx;margin-top:20rpx;color:#334155;font-size:26rpx}
|
||||
.handover-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx}.handover-title{font-size:30rpx;font-weight:600}
|
||||
.handover-heading button{margin:0;font-size:26rpx}.handover-line{display:block;margin-top:12rpx;overflow-wrap:anywhere}
|
||||
.handover-record,.handover-delivery{padding-top:16rpx;margin-top:16rpx;border-top:1px solid #e2e8f0}.handover-note{display:block;margin-top:20rpx;color:#64748b}
|
||||
.handover-error{color:#b91c1c}.handover-error text{display:block;margin-top:12rpx}
|
||||
</style>
|
||||
@@ -47,7 +47,7 @@
|
||||
<view class="card-head"><view><text class="card-title">国内装柜照片(只读)</text><text class="container-meta">现场对照国内装柜过程与封柜状态</text></view><text class="status-tag status-good">{{ domesticPhotoCount }} / 5</text></view>
|
||||
<view class="domestic-photo-grid">
|
||||
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap="previewDomesticPhoto(photo)">
|
||||
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" />
|
||||
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" @error="invalidatePhotoPreview(photo)" />
|
||||
<view v-else class="domestic-photo-placeholder"><uni-load-more v-if="photo.status === 'loading'" status="loading" :show-text="false" /><uni-icons v-else type="image" color="#8090a3" size="22" /><text>{{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }}</text></view>
|
||||
<text>{{ photo.label }}</text>
|
||||
</view>
|
||||
@@ -58,7 +58,7 @@
|
||||
<view class="photo-grid">
|
||||
<view v-for="(step, index) in photoSteps" :key="step.role" class="photo-role" :class="{ complete: photoSlots[index]?.fileId }">
|
||||
<view class="photo-preview" :class="{ failed: photoSlots[index]?.status === 'error' }" @tap="previewPhoto(index)">
|
||||
<image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" />
|
||||
<image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" @error="invalidatePhotoPreview(photoSlots[index])" />
|
||||
<view v-else class="photo-placeholder"><uni-icons type="camera" color="#8090a3" size="23" /><text>未添加</text></view>
|
||||
</view>
|
||||
<view class="photo-role-info">
|
||||
@@ -76,10 +76,10 @@
|
||||
<template v-else-if="phase === 'receive'">
|
||||
<ReceivingSessionSummary :session="current" />
|
||||
<view class="photo-card domestic-photo-card domestic-photo-collapsible">
|
||||
<view class="card-head" @tap="domesticPhotosExpanded = !domesticPhotosExpanded"><view><text class="card-title">国内装柜照片</text><text class="container-meta">已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照</text></view><text class="container-detail-link">{{ domesticPhotosExpanded ? '收起' : '查看' }} ›</text></view>
|
||||
<view class="card-head" @tap="toggleDomesticPhotos"><view><text class="card-title">国内装柜照片</text><text class="container-meta">已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照</text></view><text class="container-detail-link">{{ domesticPhotosExpanded ? '收起' : '查看' }} ›</text></view>
|
||||
<view v-if="domesticPhotosExpanded" class="domestic-photo-grid">
|
||||
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap.stop="previewDomesticPhoto(photo)">
|
||||
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" />
|
||||
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" @error="invalidatePhotoPreview(photo)" />
|
||||
<view v-else class="domestic-photo-placeholder"><uni-load-more v-if="photo.status === 'loading'" status="loading" :show-text="false" /><uni-icons v-else type="image" color="#8090a3" size="22" /><text>{{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }}</text></view>
|
||||
<text>{{ photo.label }}</text>
|
||||
</view>
|
||||
@@ -139,7 +139,7 @@
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { assertReceivingWarehouse, isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES, toReceivingError } from '@/api/receiving-adapter.js'
|
||||
import { applyWarehouseProfile, session, requireSession } from '@/stores/session.js'
|
||||
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, setCurrentReceiving } from '@/stores/receiving.js'
|
||||
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, restoreReceivingPhoto, setCurrentReceiving } from '@/stores/receiving.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { completeCurrentReceiving, downloadCurrentDomesticLoadingPhoto, downloadCurrentUnloadPhoto, getCurrentReceiving, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, receiveCurrentWholePallet, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
|
||||
@@ -167,8 +167,8 @@
|
||||
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
|
||||
},
|
||||
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标列表', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates')) this.bootstrap() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this._pageActive = true; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates' || this._resumeReceiving)) { if (!this.busy) { this._resumeReceiving = false; this.bootstrap() } } },
|
||||
onHide() { this.suspendReceiving() }, onUnload() { this.suspendReceiving() }, beforeUnmount() { this.suspendReceiving() },
|
||||
methods: {
|
||||
candidateKey(item) { return `${item?.sourceContainerBookingId || ''}:${item?.containerNo || ''}` },
|
||||
statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成', COMPLETED_WITH_DIFFERENCE: '差异待核平' })[status] || status },
|
||||
@@ -180,16 +180,19 @@
|
||||
async refreshBoundWarehouse() { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()); return this.user },
|
||||
photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error; this.showToast('硬件扫码接收器异常,请检查扫描服务', 'warning') } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
|
||||
handleHardwareScan(value) { const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
|
||||
dispatchHardwareScan(scan) { const palletWorkflowActive = Boolean(this.activeInbound || this.resolvedPallet); const target = palletWorkflowActive ? 'pallet' : scan.target; console.info('[Overseas PDA scan] dispatch', { value: scan.value, target, queuedTarget: scan.target }); if (target === 'pallet') { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
|
||||
drainScanQueue() { if (this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) },
|
||||
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; setTimeout(() => this.drainScanQueue(), 0) } },
|
||||
handleHardwareScan(value) { if (!this._pageActive || this.loading) return; const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
|
||||
dispatchHardwareScan(scan) { if (!this._pageActive || this.loading) return; const palletWorkflowActive = Boolean(this.activeInbound || this.resolvedPallet); const target = palletWorkflowActive ? 'pallet' : scan.target; console.info('[Overseas PDA scan] dispatch', { value: scan.value, target, queuedTarget: scan.target }); if (target === 'pallet') { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
|
||||
drainScanQueue() { if (!this._pageActive || this.loading || this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) },
|
||||
suspendReceiving() { this._pageActive = false; this._scanEpoch = (this._scanEpoch || 0) + 1; this._resumeReceiving = true; clearTimeout(this._drainTimer); clearTimeout(this._toastTimer); this.scanQueue = []; this.stopScanner() },
|
||||
scheduleScanDrain() { clearTimeout(this._drainTimer); if (!this._pageActive) return; const epoch = this._scanEpoch || 0; this._drainTimer = setTimeout(() => { if (this._pageActive && epoch === (this._scanEpoch || 0)) this.drainScanQueue() }, 0) },
|
||||
async run(action, work) { if (this.busy || !this._pageActive) throw new Error('当前页面未就绪,请返回作业页重试'); this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; if (this._pageActive && this._resumeReceiving) { this._resumeReceiving = false; this.bootstrap() } else this.scheduleScanDrain() } },
|
||||
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' },
|
||||
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { photo.status = 'restoring'; try { photo.localPath = await downloadCurrentUnloadPhoto(photo.downloadPath); photo.status = 'success' } catch (error) { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; this.error = error } })) },
|
||||
async restoreDomesticPhotoPreviews() { await Promise.all(this.domesticLoadingPhotos.filter((photo) => photo.downloadPath && !photo.localPath).map((photo) => this.loadDomesticPhoto(photo))) },
|
||||
async loadDomesticPhoto(photo) { if (!photo?.downloadPath || photo.status === 'loading') return; photo.status = 'loading'; try { photo.localPath = await downloadCurrentDomesticLoadingPhoto(photo.downloadPath); photo.status = 'success' } catch (_) { photo.status = 'error' } },
|
||||
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { try { await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto) } catch (error) { this.error = error } })) },
|
||||
async restoreDomesticPhotoPreviews() { if (this.phase !== 'sign' && !this.domesticPhotosExpanded) return; await Promise.all(this.domesticLoadingPhotos.filter((photo) => photo.downloadPath && !photo.localPath).map((photo) => this.loadDomesticPhoto(photo))) },
|
||||
async toggleDomesticPhotos() { this.domesticPhotosExpanded = !this.domesticPhotosExpanded; if (this.domesticPhotosExpanded) await this.restoreDomesticPhotoPreviews() },
|
||||
async loadDomesticPhoto(photo) { if (!photo?.downloadPath) return; try { await restoreReceivingPhoto(photo, downloadCurrentDomesticLoadingPhoto) } catch (_) { photo.status = 'error' } },
|
||||
async previewDomesticPhoto(photo) { if (!photo?.localPath) await this.loadDomesticPhoto(photo); if (photo?.localPath) { const urls = this.domesticLoadingPhotos.map((item) => item.localPath).filter(Boolean); uni.previewImage({ urls, current: photo.localPath }) } },
|
||||
async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value); await Promise.all([this.restorePhotoPreviews(), this.restoreDomesticPhotoPreviews()]); this.setScanTargetForCurrent(value) },
|
||||
async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value, JSON.stringify([session.tenantId, this.user?.id, this.user?.warehouseId])); if (!this._pageActive) return; await Promise.all([this.restorePhotoPreviews(), this.restoreDomesticPhotoPreviews()]); this.setScanTargetForCurrent(value) },
|
||||
async bootstrap() { if (this.loading) return; this.loading = true; this.error = null; this.detailOpen = false; try { await this.refreshBoundWarehouse(); try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.candidateError = null } catch (error) { if (!isNoCurrentReceiving(error)) throw error; resetReceivingDraft(); await this.loadCandidates(false, false) } this.bootstrapped = true } catch (error) { this.candidateError = error } finally { this.loading = false } },
|
||||
async recoverCurrentAfterConflict(error, preserveEvidence = false) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = preserveEvidence ? error : null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { if (isNoCurrentReceiving(recoveryError)) { this.detailOpen = false; receiving.selected = null; await this.loadCandidates(false); this.candidateError = preserveEvidence ? error : null; this.error = preserveEvidence ? error : null; this.showToast('柜状态已变化,列表已刷新', 'warning'); return true } this.error = error; return false } },
|
||||
async loadCandidates(feedback = true, refreshWarehouse = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { if (refreshWarehouse) await this.refreshBoundWarehouse(); const values = await listReceivingCandidates(); receiving.candidates = values.map((item) => this.validateReceivingWarehouse(item, `柜号 ${item.containerNo || '—'}`)); if (feedback) this.showToast('刷新待卸柜成功', 'success') } catch (error) { receiving.candidates = []; this.candidateError = error; if (feedback) { playErrorSound(); this.showToast(error.message || '刷新待卸柜失败', 'error') } } finally { this.candidateLoading = false } },
|
||||
@@ -198,10 +201,11 @@
|
||||
async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.sourceContainerBookingId || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { sourceContainerBookingId: this.selected.sourceContainerBookingId, containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, this.selected.sourceContainerBookingId, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } },
|
||||
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; const size = Number(tempFiles?.[0]?.size || 0); if (size > MAX_UNLOAD_PHOTO_BYTES) { const message = '照片不能超过10MB'; this.error = Object.assign(new Error(message), { code: 'A0400' }); this.showToast(message, 'error'); playErrorSound(); return } if (path) this.replacePhoto(index, path) } }) },
|
||||
async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '重拍失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; this.showToast(error.message || '照片上传失败', 'error'); playErrorSound() } finally { this.busy = false } },
|
||||
previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
|
||||
invalidatePhotoPreview(photo) { if (!photo) return; photo.localPath = ''; photo.status = 'error'; photo.errorMessage = '照片加载失败,点按重试' },
|
||||
async previewPhoto(index) { const photo = this.photoSlots[index]; if (!photo) return; try { const path = await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto); if (path) uni.previewImage({ urls: [path], current: path }) } catch (error) { this.error = error } },
|
||||
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', async () => this.validateReceivingWarehouse(await signCurrentReceiving({ photos, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} },
|
||||
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; setTimeout(() => this.drainScanQueue(), 0) },
|
||||
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
|
||||
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; this.scheduleScanDrain() },
|
||||
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || !this._pageActive) return; const scanEpoch = this._scanEpoch || 0; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; if (!this._pageActive || scanEpoch !== (this._scanEpoch || 0)) throw new Error('页面已离开,请返回后重新扫码确认收货'); const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
|
||||
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; this.packageCount = Number(value.currentReceivedPackages || 0) || ''; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); receiving.activeScanTarget = this.activeInbound.destinationType === 'FBA' ? 'quantity' : 'carton'; playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
|
||||
async submitReceipt() { const isFba = this.activeInbound?.destinationType === 'FBA'; if (isFba && Number(this.packageCount) > MAX_PACKAGES_PER_PALLET) { this.error = Object.assign(new Error(PALLET_CAPACITY_MESSAGE), { code: 'A0409' }); playErrorSound(); this.showToast(PALLET_CAPACITY_MESSAGE, 'error'); return } if (!this.canSubmitReceipt) return; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
|
||||
async completeContainer() { if (!this.canFinishOnsite || this.activeTarget === 'quantity' || this.scanQueue.length) return; const payload = { allowDifference: !this.allInboundsReconciled }; const key = idempotencyKey('complete', { containerNo: this.current.containerNo, ...payload }); try { const value = await this.run(this.allInboundsReconciled ? '完成入库' : '结束现场收货', async () => this.validateReceivingWarehouse(await completeCurrentReceiving({ ...payload, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value) } catch (_) {} },
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
<view class="fulfillment-card">
|
||||
<ScanTargetInput name="outbound" :model-value="scanCode" :label="discardCurrent ? '废弃订单托盘码' : '任务号、出库单号、排车单号、托盘码或箱码'" :placeholder="discardCurrent ? '扫描废弃订单对应托盘码' : '扫描任务号 / 出库单号 / 排车单号 / 托盘码 / 箱码'" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ discardCurrent ? '打开废弃托盘' : current ? '匹配出库货物' : '打开出库任务' }}</button>
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy || discardLoading || discardBusy" @tap="handleScan(scanCode)">{{ discardCurrent ? '打开废弃托盘' : current ? '匹配出库货物' : '打开出库任务' }}</button>
|
||||
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</text></view>
|
||||
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy" @tap="retry">重试</button></view>
|
||||
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy || discardLoading || discardBusy" @tap="retry">重试</button></view>
|
||||
|
||||
<template v-if="!loading && !current">
|
||||
<template v-if="!loading && !current && !discardCurrent">
|
||||
<view class="list-toolbar"><view><text class="list-title">待处理出库任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||||
<view v-if="tasks.length" class="task-list">
|
||||
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.taskNo || '—' }}</text><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">出库单 {{ task.outboundNo || '—' }} · {{ taskType(task) }}</text><text class="task-enter">查看并开始出库 ›</text></button>
|
||||
@@ -74,11 +74,12 @@
|
||||
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
|
||||
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
|
||||
selectedRemaining() { return Math.max(0, Math.min(Number(this.selectedItem?.plannedPackages || 0) - Number(this.selectedItem?.pickedPackages || 0), Number(this.selectedPosition?.availablePackages || 0))) },
|
||||
canPick() { return this.current?.status !== 'COMPLETED' && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
|
||||
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.current?.status !== 'COMPLETED' },
|
||||
taskOperable() { return ['PENDING', 'IN_PROGRESS'].includes(this.current?.status) },
|
||||
canPick() { return this.taskOperable && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
|
||||
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
|
||||
discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, palletCount: 0, pendingPalletCount: 0 }; current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
|
||||
discardOrderCount() { return this.discardOrders.length },
|
||||
canConfirmDiscard() { return Boolean(this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
|
||||
canConfirmDiscard() { return Boolean(!this.discardLoading && !this.discardBusy && this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
|
||||
},
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
|
||||
onHide() { this.stopScanner() },
|
||||
@@ -106,31 +107,45 @@
|
||||
}, true)
|
||||
},
|
||||
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() },
|
||||
openDiscardOrder(discardNo) { const code = String(discardNo || '').trim(); if (!code) return; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
|
||||
closeDiscard() { this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
|
||||
openDiscardOrder(discardNo) { const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
|
||||
closeDiscard() { if (this.discardBusy) return; this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
|
||||
updateDiscardPalletCode(value) { this.discardPalletCode = value },
|
||||
async resolveDiscardPallet(raw) { const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim(); if (!code || !this.discardCurrent || this.discardBusy) return; this.discardPalletCode = code; this.discardLoading = true; try { const task = await resolveCargoDiscardPallet(code); if (task.discardNo !== this.discardCurrent.discardNo) throw new Error('扫描的托盘不属于当前废弃订单'); this.discardTask = normalizeCargoDiscardTask(task); this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages])) } catch (error) { this.setError(error, '废弃托盘读取失败') } finally { this.discardLoading = false } },
|
||||
async resolveDiscardPallet(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim()
|
||||
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading) return
|
||||
const order = this.discardCurrent
|
||||
this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
|
||||
try {
|
||||
const task = await resolveCargoDiscardPallet(code)
|
||||
if (this.discardCurrent !== order) return
|
||||
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
|
||||
this.discardTask = normalizeCargoDiscardTask(task)
|
||||
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages]))
|
||||
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
|
||||
finally { if (this.discardCurrent === order) this.discardLoading = false }
|
||||
},
|
||||
updateDiscardActual(itemId, event) { const value = Math.max(0, Math.trunc(Number(event.detail.value || 0))); this.discardActuals = { ...this.discardActuals, [itemId]: value } },
|
||||
async confirmDiscard() { if (!this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines); this.successMessage = '废弃出库作业已完成,等待仓库确认'; this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
|
||||
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines); this.successMessage = '废弃出库作业已完成,等待仓库确认'; this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
|
||||
handleScan(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return
|
||||
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
|
||||
if (this.discardCurrent) return this.resolveDiscardPallet(code)
|
||||
if (!this.current) return this.openScannedTask(code)
|
||||
if (code === this.current.taskNo || code === this.current.outboundNo) return this.openTask(this.current.taskNo)
|
||||
if (!this.taskOperable) return
|
||||
if ((this.current.items || []).some((item) => item.palletNo === code)) return this.submitPalletPick(code)
|
||||
if (this.findAndSelectPosition(code)) return
|
||||
this.setError(new Error('扫码未匹配当前出库任务的托盘码、箱码、SKU 或入仓码'), '扫码未匹配')
|
||||
},
|
||||
findAndSelectPosition(code, feedback = true) { for (const item of this.current?.items || []) { if (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo) continue; const position = item.positions.find((value) => value.availablePackages > 0 && [value.palletNo, value.cartonMark, value.skuCode, value.inboundCode].filter(Boolean).includes(code)); if (position) { this.selectPosition(item, position, code); if (feedback) playSuccessSound(); return true } } return false },
|
||||
selectPosition(item, position, scannedCode = '') { if (!position || position.availablePackages <= 0 || (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo)) return; this.selectedItem = item; this.selectedPosition = position; this.selectedScanCode = scannedCode || this.lineBusinessCode(position); updateFulfillmentDraft('outbound', { scanCode: this.selectedScanCode, packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
|
||||
selectPosition(item, position, scannedCode = '') { if (!this.taskOperable || !position || position.availablePackages <= 0 || (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo)) return; this.selectedItem = item; this.selectedPosition = position; this.selectedScanCode = scannedCode || this.lineBusinessCode(position); updateFulfillmentDraft('outbound', { scanCode: this.selectedScanCode, packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
|
||||
async submitPick() {
|
||||
if (!this.canPick) return
|
||||
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
|
||||
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; this.selectedScanCode = ''; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
|
||||
},
|
||||
async submitPalletPick(palletNo) {
|
||||
const code = String(palletNo || '').trim(); if (!code || !this.current) return
|
||||
const code = String(palletNo || '').trim(); if (!code || !this.taskOperable) return
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'pallet-pick', { taskNo: this.current.taskNo, palletNo: code })
|
||||
await this.run('整托拣货', async () => { await pickOutboundPallet(this.current.taskNo, code, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = `${code} 整托拣货已完成`; playSuccessSound() })
|
||||
},
|
||||
@@ -139,7 +154,7 @@
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
|
||||
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.successMessage = '出库任务已完成'; playSuccessSound() })
|
||||
},
|
||||
async retry() { if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
|
||||
async retry() { if (this.discardCurrent) { if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
|
||||
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
||||
setError(error, fallback) { const forbidden = ['403', 'A0403'].includes(String(error?.code || '')); this.error = { message: forbidden ? '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' : error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId, network: Boolean(error?.network) }; console.error('[Overseas PDA outbound] failed', this.error); playErrorSound() },
|
||||
lineBusinessCode(position) { return position?.cartonMark || position?.palletNo || position?.skuCode || position?.inboundCode || '业务码缺失' },
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</view>
|
||||
<button class="logout-action" @tap="openLogout">退出登录</button>
|
||||
</view>
|
||||
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup>
|
||||
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出会撤销当前登录会话并清空本地作业草稿;未提交作业数据不得跨账号保留。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup>
|
||||
<view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -22,7 +22,8 @@
|
||||
import PopupHeader from '@/components/PopupHeader.vue'
|
||||
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
|
||||
import { currentLocale, LOCALE_OPTIONS, setLocale } from '@/i18n/index.js'
|
||||
import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js'
|
||||
import { applyWarehouseProfile, session, requireSession, getSessionRevision } from '@/stores/session.js'
|
||||
import { logoutCurrentSession } from '@/api/logout-session.js'
|
||||
import { checkForAppUpdate } from '@/utils/app-update.js'
|
||||
export default {
|
||||
components: { PopupHeader, PortalHeader },
|
||||
@@ -66,7 +67,19 @@
|
||||
async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } },
|
||||
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
|
||||
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
|
||||
logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) }
|
||||
logout() {
|
||||
if (this._logoutPromise) return this._logoutPromise
|
||||
this.closeLogout()
|
||||
const operation = logoutCurrentSession()
|
||||
this.user = null
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
this._logoutPromise = operation.catch(error => {
|
||||
if (getSessionRevision() === error.logoutRevision) {
|
||||
uni.showToast({ title: '已清除本地登录,服务端退出未确认', icon: 'none' })
|
||||
}
|
||||
})
|
||||
return this._logoutPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+31
-2
@@ -8,6 +8,28 @@ export const receiving = reactive({
|
||||
})
|
||||
|
||||
const idempotencyKeys = new Map()
|
||||
let previewOwner = ''
|
||||
const previewRequests = new WeakMap()
|
||||
|
||||
function photoOwner(value, owner) {
|
||||
if (!owner || !value?.containerNo || !value?.preparedAt) return ''
|
||||
return JSON.stringify([owner, value.containerNo, value.preparedAt, value.overseasWarehouseId,
|
||||
(value.inbounds || []).map(row => [row.customerId || '', row.inboundCode || '']).sort()])
|
||||
}
|
||||
|
||||
export async function restoreReceivingPhoto(photo, download) {
|
||||
if (!photo?.downloadPath || photo.localPath) return photo?.localPath || ''
|
||||
if (previewRequests.has(photo)) return previewRequests.get(photo)
|
||||
photo.status = 'loading'
|
||||
const request = Promise.resolve().then(() => download(photo.downloadPath)).then(path => {
|
||||
if (!path) throw new Error('照片加载失败')
|
||||
photo.localPath = path; photo.status = 'success'; photo.errorMessage = ''
|
||||
return path
|
||||
}).catch(error => { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; throw error })
|
||||
.finally(() => previewRequests.delete(photo))
|
||||
previewRequests.set(photo, request)
|
||||
return request
|
||||
}
|
||||
|
||||
export function receivingPhase(session = receiving.current) {
|
||||
if (!session) return 'candidates'
|
||||
@@ -23,16 +45,23 @@ export function receivingProgress(session = receiving.current) {
|
||||
return { total, received, pending: Math.max(0, Number(session?.pendingPackages ?? total - received)), percent: total ? Math.min(100, Math.round(received / total * 100)) : 0 }
|
||||
}
|
||||
|
||||
export function setCurrentReceiving(value) {
|
||||
export function setCurrentReceiving(value, owner = '') {
|
||||
const nextOwner = photoOwner(value, owner)
|
||||
const previous = previewOwner && previewOwner === nextOwner ? receiving.photoSlots : []
|
||||
previewOwner = nextOwner
|
||||
receiving.current = value || null
|
||||
receiving.selected = value || null
|
||||
receiving.photoSlots = RECEIVING_PHOTO_ROLES.map((role) => {
|
||||
const photo = (value?.photos || []).find((item) => item.role === role)
|
||||
return photo ? { ...photo, fileId: String(photo.fileId), role, uploaded: true, status: 'success' } : null
|
||||
if (!photo) return null
|
||||
const old = previous.find(item => item?.role === role && String(item.fileId) === String(photo.fileId)
|
||||
&& item.downloadPath === photo.downloadPath)
|
||||
return old || { ...photo, fileId: String(photo.fileId), role, localPath: '', uploaded: true, status: 'idle' }
|
||||
})
|
||||
}
|
||||
|
||||
export function resetReceivingDraft({ keepCurrent = false } = {}) {
|
||||
previewOwner = ''
|
||||
receiving.selected = keepCurrent ? receiving.current : null
|
||||
if (!keepCurrent) receiving.current = null
|
||||
receiving.photoSlots = [null, null, null]
|
||||
|
||||
@@ -5,6 +5,8 @@ import { resetFulfillmentSession } from './fulfillment.js'
|
||||
import { resetWarehouseWork } from './warehouse-work.js'
|
||||
|
||||
const SESSION_KEY = 'overseas-pda-auth-session'
|
||||
let sessionRevision = 0
|
||||
export function getSessionRevision() { return sessionRevision }
|
||||
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
|
||||
export const PDA_AUTHORITY = 'lastleg:pda:operate'
|
||||
|
||||
@@ -108,6 +110,7 @@ function persistSession() {
|
||||
}
|
||||
|
||||
export function signIn(account, warehouse, options = {}) {
|
||||
sessionRevision += 1
|
||||
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse,
|
||||
warehouseId: account.warehouseId || null, warehouseCode: account.warehouseCode || '', warehouseName: account.warehouseName || '' }
|
||||
session.token = options.token || account.token || ''
|
||||
@@ -184,6 +187,7 @@ export function restoreSession() {
|
||||
currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.claims, 'tenant_id'),
|
||||
apiBase: API_ROOT, authorization: pair.authorization
|
||||
})
|
||||
sessionRevision += 1
|
||||
return true
|
||||
} catch (_) {
|
||||
signOut()
|
||||
@@ -192,6 +196,7 @@ export function restoreSession() {
|
||||
}
|
||||
|
||||
export function signOut() {
|
||||
sessionRevision += 1
|
||||
session.currentUser = null
|
||||
session.token = ''
|
||||
session.refreshToken = ''
|
||||
|
||||
Reference in New Issue
Block a user