V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KbxBarcodeCapture,KbxWmsActionButton,KbxWmsMobilePage } from '@kbx/ui'
|
||||
import { countingScreen } from './counting.definition'
|
||||
const online=ref(true);const stage=ref<'location'|'item'|'counted'>('location');const location=ref('');const item=ref('');const counted=ref(0);const bookQty=ref(12);const msg=ref('실사 위치를 스캔하세요.')
|
||||
function scan(v:string){if(stage.value==='location'){location.value=v;stage.value='item';msg.value='상품을 스캔하세요.';return}if(stage.value==='item'){item.value=v;counted.value++;msg.value='계속 스캔하거나 수량 확정하세요.'}}
|
||||
function finish(){stage.value='counted';msg.value=counted.value===bookQty.value?'장부수량과 일치합니다.':'차이가 있어 관리자 확인 대상으로 등록합니다.'}
|
||||
</script>
|
||||
<template><KbxWmsMobilePage :screen="countingScreen" :progress="stage==='counted'?'실사완료':'실사중'" :online="online"><p class="msg">{{msg}}</p><div v-if="location" class="box"><small>LOCATION</small><strong>{{location}}</strong></div><div v-if="item" class="box"><small>상품</small><strong>{{item}}</strong><p>실사 {{counted}} · 장부 {{bookQty}}</p></div><KbxBarcodeCapture v-if="stage!=='counted'" :enabled="online" :label="stage==='location'?'위치 스캔':'상품 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='item'" label="수량 확정" @click="finish"/><KbxWmsActionButton v-if="stage==='counted'" label="다음 위치" @click="stage='location';location='';item='';counted=0;msg='실사 위치를 스캔하세요.'"/></template></KbxWmsMobilePage></template>
|
||||
<style scoped>.msg{font-weight:650}.box{padding:16px;border:1px solid var(--kbx-color-border);border-radius:8px;margin-bottom:12px}.box small{display:block;color:var(--kbx-color-text-muted)}.box strong{font-size:28px}.box p{font-size:20px;font-weight:650}</style>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const countingScreen=defineKbxScreen({id:'WMS-COUNT-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'재고실사',helpKey:'WMS-COUNT-001',permissions:['wms.inventory.count'],telemetry:{enabled:true}})
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsCountingRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/counting/:taskId', name:'wms-counting', component:()=>import('./WmsCountingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-COUNT-001' } },
|
||||
]
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { KbxWmsExceptionType } from '@kbx/contracts'
|
||||
import { KbxWmsActionButton } from '@kbx/ui'
|
||||
|
||||
defineProps<{ busy?: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [KbxWmsExceptionType, string]
|
||||
}>()
|
||||
|
||||
const type = ref<KbxWmsExceptionType>('no-stock')
|
||||
const memo = ref('')
|
||||
|
||||
const options: { value: KbxWmsExceptionType; label: string }[] = [
|
||||
{ value: 'no-stock', label: '실물 없음' },
|
||||
{ value: 'short-quantity', label: '수량 부족' },
|
||||
{ value: 'wrong-location', label: '위치 오류' },
|
||||
{ value: 'damaged-item', label: '상품 이상' },
|
||||
{ value: 'barcode-issue', label: '바코드 문제' },
|
||||
{ value: 'other', label: '기타' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="backdrop" @click.self="$emit('close')">
|
||||
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="exception-title">
|
||||
<h2 id="exception-title">피킹 문제 신고</h2>
|
||||
<p>가장 가까운 사유 하나만 선택하세요.</p>
|
||||
<label v-for="option in options" :key="option.value" class="reason">
|
||||
<input v-model="type" type="radio" :value="option.value">
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
<label class="memo">메모 <small>선택 입력</small>
|
||||
<textarea v-model="memo" rows="3" maxlength="300" />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
|
||||
<KbxWmsActionButton label="문제 등록" :busy="busy" @click="$emit('submit', type, memo)" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:30; }
|
||||
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; max-height:85dvh; overflow:auto; }
|
||||
h2 { margin:0 0 4px; font-size:20px; }
|
||||
p { margin:0 0 14px; color:var(--kbx-color-text-muted); }
|
||||
.reason { min-height:48px; display:flex; align-items:center; gap:10px; border-bottom:1px solid var(--kbx-color-border); }
|
||||
.reason input { width:20px; height:20px; }
|
||||
.memo { display:block; margin-top:16px; font-weight:600; }
|
||||
.memo small { font-weight:400; color:var(--kbx-color-text-muted); }
|
||||
textarea { width:100%; margin-top:6px; border:1px solid var(--kbx-color-border); border-radius:6px; padding:10px; font:inherit; box-sizing:border-box; }
|
||||
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
</style>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KbxWmsActionButton } from '@kbx/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
current: number
|
||||
required: number
|
||||
busy?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; submit: [number] }>()
|
||||
const quantity = ref(props.current)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="backdrop" @click.self="$emit('close')">
|
||||
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="quantity-title">
|
||||
<h2 id="quantity-title">피킹 수량 입력</h2>
|
||||
<p>상품 바코드를 최소 1회 확인한 뒤 사용합니다.</p>
|
||||
<label>
|
||||
피킹 수량
|
||||
<input v-model.number="quantity" type="number" inputmode="decimal" :min="1" :max="required">
|
||||
</label>
|
||||
<div class="hint">필요수량 {{ required }}</div>
|
||||
<div class="actions">
|
||||
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
|
||||
<KbxWmsActionButton
|
||||
label="수량 적용"
|
||||
:busy="busy"
|
||||
:disabled="quantity <= 0 || quantity > required"
|
||||
@click="$emit('submit', quantity)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:31; }
|
||||
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; }
|
||||
h2 { margin:0 0 4px; font-size:20px; }
|
||||
p { margin:0 0 16px; color:var(--kbx-color-text-muted); }
|
||||
label { display:block; font-weight:600; }
|
||||
input { width:100%; min-height:52px; margin-top:6px; padding:0 12px; box-sizing:border-box; font:inherit; font-size:22px; text-align:right; border:1px solid var(--kbx-color-border-strong); border-radius:6px; }
|
||||
.hint { margin-top:6px; text-align:right; color:var(--kbx-color-text-muted); font-size:13px; }
|
||||
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:18px; }
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
KbxBarcodeCapture,
|
||||
KbxWmsActionButton,
|
||||
KbxWmsMobilePage,
|
||||
} from '@kbx/ui'
|
||||
import PickingExceptionSheet from './PickingExceptionSheet.vue'
|
||||
import PickingQuantitySheet from './PickingQuantitySheet.vue'
|
||||
import { useWmsPicking } from './useWmsPicking'
|
||||
import { pickingScreen } from './picking.definition'
|
||||
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
const vm = useWmsPicking(props.taskId)
|
||||
|
||||
const progress = computed(() => vm.task.value
|
||||
? `${vm.task.value.completedLines} / ${vm.task.value.totalLines}`
|
||||
: undefined)
|
||||
|
||||
const current = computed(() => vm.task.value?.currentLine ?? null)
|
||||
const stageTitle = computed(() => {
|
||||
switch (vm.task.value?.stage) {
|
||||
case 'ready': return '작업을 시작하세요.'
|
||||
case 'await-location': return '위치를 스캔하세요.'
|
||||
case 'await-item': return '상품을 스캔하세요.'
|
||||
case 'processing': return '처리 중입니다.'
|
||||
case 'completed': return '피킹을 완료했습니다.'
|
||||
case 'blocked': return '관리자 확인이 필요합니다.'
|
||||
default: return '작업을 불러오는 중입니다.'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxWmsMobilePage
|
||||
:screen="pickingScreen"
|
||||
:progress="progress"
|
||||
:online="vm.online.value"
|
||||
:pending-commands="vm.pendingCommands.value"
|
||||
:syncing="vm.syncing.value"
|
||||
>
|
||||
<p v-if="vm.message.value" class="message" role="status">{{ vm.message.value }}</p>
|
||||
|
||||
<section v-if="vm.task.value" class="instruction">
|
||||
<p class="eyebrow">{{ stageTitle }}</p>
|
||||
|
||||
<template v-if="current">
|
||||
<div class="location">
|
||||
<span>LOCATION</span>
|
||||
<strong>{{ current.locationCode }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<span>상품</span>
|
||||
<strong>{{ current.itemName }}</strong>
|
||||
<small>{{ current.itemCode }}<template v-if="current.itemOption"> · {{ current.itemOption }}</template></small>
|
||||
<small>바코드 {{ current.barcode }}</small>
|
||||
</div>
|
||||
|
||||
<div class="qty">
|
||||
<div><span>필요</span><strong>{{ current.requiredQty }}</strong></div>
|
||||
<div><span>피킹</span><strong>{{ current.pickedQty }}</strong></div>
|
||||
<div><span>남음</span><strong>{{ current.remainingQty }}</strong></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<KbxWmsActionButton
|
||||
v-if="current && vm.task.value.stage === 'await-item' && current.pickedQty > 0 && current.remainingQty > 0"
|
||||
class="quantity-action"
|
||||
label="수량 직접 입력"
|
||||
variant="secondary"
|
||||
@click="vm.openQuantity"
|
||||
/>
|
||||
|
||||
<div v-else-if="vm.task.value.stage === 'ready'" class="ready">
|
||||
<strong>{{ vm.task.value.taskNo }}</strong>
|
||||
<span>총 {{ vm.task.value.totalLines }}개 라인 · {{ vm.task.value.totalQty }}개</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<KbxBarcodeCapture
|
||||
v-if="vm.task.value && !['ready','completed','blocked'].includes(vm.task.value.stage)"
|
||||
:enabled="vm.scanEnabled.value"
|
||||
:label="vm.scanLabel.value"
|
||||
@scan="vm.handleScan"
|
||||
/>
|
||||
|
||||
<template #actions>
|
||||
<KbxWmsActionButton
|
||||
v-if="vm.task.value?.stage === 'ready'"
|
||||
label="작업 시작"
|
||||
:busy="vm.starting.value"
|
||||
:disabled="!vm.online.value"
|
||||
@click="vm.start"
|
||||
/>
|
||||
|
||||
<KbxWmsActionButton
|
||||
v-else-if="vm.task.value && ['await-location','await-item'].includes(vm.task.value.stage)"
|
||||
label="문제 신고"
|
||||
variant="secondary"
|
||||
:disabled="!vm.online.value || vm.pendingCommands.value > 0"
|
||||
@click="vm.openException"
|
||||
/>
|
||||
|
||||
<KbxWmsActionButton
|
||||
v-else-if="vm.task.value?.stage === 'completed'"
|
||||
label="작업 목록으로"
|
||||
@click="$router.push('/wms/picking')"
|
||||
/>
|
||||
</template>
|
||||
</KbxWmsMobilePage>
|
||||
|
||||
<PickingQuantitySheet
|
||||
v-if="vm.quantityOpen.value && current"
|
||||
:current="current.pickedQty"
|
||||
:required="current.requiredQty"
|
||||
:busy="vm.settingQuantity.value"
|
||||
@close="vm.closeQuantity"
|
||||
@submit="vm.setQuantity"
|
||||
/>
|
||||
|
||||
<PickingExceptionSheet
|
||||
v-if="vm.exceptionOpen.value"
|
||||
:busy="vm.reportingException.value"
|
||||
@close="vm.closeException"
|
||||
@submit="(type, memo) => vm.reportException(type, memo)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message { margin:0 0 12px; padding:10px 12px; background:var(--kbx-color-surface-subtle); border-radius:6px; font-size:14px; }
|
||||
.instruction { text-align:center; }
|
||||
.eyebrow { margin:0 0 12px; font-size:16px; font-weight:650; }
|
||||
.location { padding:16px; background:var(--kbx-color-surface-muted); border:1px solid var(--kbx-color-border); border-radius:8px; }
|
||||
.location span, .item span { display:block; color:var(--kbx-color-text-muted); font-size:12px; font-weight:600; }
|
||||
.location strong { display:block; margin-top:3px; font-size:32px; letter-spacing:.03em; }
|
||||
.item { padding:20px 4px 8px; }
|
||||
.item strong { display:block; margin-top:4px; font-size:22px; }
|
||||
.item small { display:block; margin-top:4px; color:var(--kbx-color-text-muted); font-size:13px; }
|
||||
.qty { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:12px; }
|
||||
.qty div { border:1px solid var(--kbx-color-border); border-radius:8px; padding:12px 4px; }
|
||||
.qty span { display:block; font-size:13px; color:var(--kbx-color-text-muted); }
|
||||
.qty strong { display:block; margin-top:2px; font-size:28px; }
|
||||
.quantity-action { margin-top:var(--kbx-space-3); }
|
||||
.ready { min-height:260px; display:flex; flex-direction:column; justify-content:center; gap:8px; }
|
||||
.ready strong { font-size:26px; }
|
||||
.ready span { color:var(--kbx-color-text-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
|
||||
export const pickingScreen = defineKbxScreen({
|
||||
id: 'WMS-PICK-001',
|
||||
version: '1.0.0',
|
||||
module: 'WMS',
|
||||
type: 'wms-mobile', templateCode:'T09',
|
||||
title: '출고 피킹',
|
||||
helpKey: 'WMS-PICK-001',
|
||||
permissions: ['wms.picking.execute'],
|
||||
telemetry: { enabled: true },
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
KbxWmsExceptionCommand,
|
||||
KbxWmsPickingTask,
|
||||
KbxWmsScanCommand,
|
||||
KbxWmsScanResult,
|
||||
KbxWmsSetQuantityCommand,
|
||||
} from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export function getPickingTask(taskId: string) {
|
||||
return kbxApi.request<KbxWmsPickingTask>('wms.picking.getTask', { path: { taskId } })
|
||||
}
|
||||
|
||||
export function startPickingTask(taskId: string, expectedVersion: number, idempotencyKey = crypto.randomUUID()) {
|
||||
return kbxApi.request<KbxWmsPickingTask, { expectedVersion: number }>('wms.picking.start', {
|
||||
path: { taskId }, body: { expectedVersion }, idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function scanPickingBarcode(command: KbxWmsScanCommand) {
|
||||
return kbxApi.request<KbxWmsScanResult, KbxWmsScanCommand>('wms.picking.scan', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function reportPickingException(command: KbxWmsExceptionCommand) {
|
||||
return kbxApi.request<KbxWmsPickingTask, KbxWmsExceptionCommand>('wms.picking.reportException', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function setPickingQuantity(command: KbxWmsSetQuantityCommand) {
|
||||
return kbxApi.request<KbxWmsScanResult, KbxWmsSetQuantityCommand>('wms.picking.setQuantity', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const wmsPickingRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/wms/picking/:taskId',
|
||||
name: 'wms-picking-task',
|
||||
component: () => import('./WmsPickingPage.vue'),
|
||||
props: route => ({ taskId: String(route.params.taskId) }),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,234 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import type {
|
||||
KbxBarcodeEvent,
|
||||
KbxWmsExceptionType,
|
||||
KbxWmsPickingTask,
|
||||
KbxWmsScanCommand,
|
||||
KbxWmsSetQuantityCommand,
|
||||
} from '@kbx/contracts'
|
||||
import { isKbxProblem, isRetryableKbxProblem } from '@kbx/contracts'
|
||||
import { playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
|
||||
import {
|
||||
getPickingTask,
|
||||
reportPickingException,
|
||||
scanPickingBarcode,
|
||||
setPickingQuantity,
|
||||
startPickingTask,
|
||||
} from './pickingApi'
|
||||
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
|
||||
import { pickingScreen } from './picking.definition'
|
||||
import { queuePickingScan, readPickingRetryQueue, removePickingScan } from './wmsRetryQueue'
|
||||
|
||||
function newIdempotencyKey(taskId: string) {
|
||||
return `${taskId}:${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
export function useWmsPicking(taskId: string) {
|
||||
const { online } = useKbxNetworkState()
|
||||
const task = ref<KbxWmsPickingTask | null>(null)
|
||||
const message = ref('')
|
||||
const pendingCommands = ref(0)
|
||||
const syncing = ref(false)
|
||||
const exceptionOpen = ref(false)
|
||||
const quantityOpen = ref(false)
|
||||
let taskTelemetry:ReturnType<typeof startKbxTask>|undefined
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['wms-picking-task', taskId],
|
||||
queryFn: () => getPickingTask(taskId),
|
||||
})
|
||||
|
||||
watch(() => query.data.value, value => {
|
||||
if (value) task.value = value
|
||||
}, { immediate: true })
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => startPickingTask(taskId, task.value?.version ?? 0),
|
||||
onSuccess(result) {
|
||||
taskTelemetry=startKbxTask(pickingScreen.id,pickingScreen.version,'wms-picking')
|
||||
task.value = result
|
||||
message.value = result.message ?? '피킹을 시작합니다.'
|
||||
playKbxWmsFeedback('neutral')
|
||||
},
|
||||
})
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: (command: KbxWmsScanCommand) => scanPickingBarcode(command),
|
||||
onSuccess(result, command) {
|
||||
removePickingScan(command.idempotencyKey)
|
||||
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
|
||||
task.value = result.task
|
||||
message.value = result.message
|
||||
playKbxWmsFeedback(result.feedback)
|
||||
if(result.task.stage==='completed'){taskTelemetry?.complete('success');taskTelemetry=undefined}
|
||||
},
|
||||
onError(error, command) {
|
||||
// 4xx is an authoritative server rejection. Only network/5xx ambiguity is safe-retried.
|
||||
if (isKbxProblem(error) && !isRetryableKbxProblem(error)) {
|
||||
message.value = error.title ?? '현재 작업 상태를 다시 확인하세요.'
|
||||
playKbxWmsFeedback('error')
|
||||
void query.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
// The request may have committed even if the response was lost. Reuse the exact idempotency key.
|
||||
queuePickingScan(command)
|
||||
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
|
||||
message.value = '서버 확인이 필요합니다. 연결되면 같은 작업을 안전하게 재전송합니다.'
|
||||
playKbxWmsFeedback('warning')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const quantityMutation = useMutation({
|
||||
mutationFn: (pickedQty: number) => {
|
||||
if (!task.value?.currentLine) throw new Error('Current line not loaded')
|
||||
const command: KbxWmsSetQuantityCommand = {
|
||||
taskId,
|
||||
lineId: task.value.currentLine.lineId,
|
||||
pickedQty,
|
||||
idempotencyKey: newIdempotencyKey(taskId),
|
||||
expectedVersion: task.value.version,
|
||||
}
|
||||
return setPickingQuantity(command)
|
||||
},
|
||||
onSuccess(result) {
|
||||
task.value = result.task
|
||||
message.value = result.message
|
||||
quantityOpen.value = false
|
||||
playKbxWmsFeedback(result.feedback)
|
||||
},
|
||||
onError(error) {
|
||||
message.value = isKbxProblem(error)
|
||||
? error.title
|
||||
: '수량을 반영하지 못했습니다.'
|
||||
playKbxWmsFeedback('error')
|
||||
void query.refetch()
|
||||
},
|
||||
})
|
||||
|
||||
const exceptionMutation = useMutation({
|
||||
mutationFn: ({ type, memo }: { type: KbxWmsExceptionType; memo?: string }) => {
|
||||
if (!task.value) throw new Error('Task not loaded')
|
||||
return reportPickingException({
|
||||
taskId,
|
||||
lineId: task.value.currentLine?.lineId,
|
||||
type,
|
||||
memo,
|
||||
idempotencyKey: newIdempotencyKey(taskId),
|
||||
expectedVersion: task.value.version,
|
||||
})
|
||||
},
|
||||
onSuccess(result, input) {
|
||||
task.value = result
|
||||
kbxTelemetry.track('manual.intervention',{screenId:pickingScreen.id,screenVersion:pickingScreen.version,taskSessionId:taskTelemetry?.id,attributes:{workType:'wms-picking',reasonCode:input.type,exceptionType:input.type}})
|
||||
exceptionOpen.value = false
|
||||
message.value = result.message ?? '예외를 등록했습니다.'
|
||||
playKbxWmsFeedback('warning')
|
||||
},
|
||||
})
|
||||
|
||||
const scanEnabled = computed(() =>
|
||||
Boolean(
|
||||
task.value &&
|
||||
online.value &&
|
||||
pendingCommands.value === 0 &&
|
||||
!scanMutation.isPending.value &&
|
||||
['await-location', 'await-item'].includes(task.value.stage),
|
||||
),
|
||||
)
|
||||
|
||||
const scanLabel = computed(() => {
|
||||
switch (task.value?.stage) {
|
||||
case 'await-location': return 'LOCATION SCAN'
|
||||
case 'await-item': return 'ITEM SCAN'
|
||||
case 'processing': return 'PROCESSING'
|
||||
case 'completed': return 'COMPLETED'
|
||||
default: return 'SCAN READY'
|
||||
}
|
||||
})
|
||||
|
||||
async function handleScan(event: KbxBarcodeEvent) {
|
||||
if (!task.value || !scanEnabled.value) return
|
||||
taskTelemetry?.interaction('scan')
|
||||
kbxTelemetry.track('command.execute',{screenId:pickingScreen.id,screenVersion:pickingScreen.version,taskSessionId:taskTelemetry?.id,attributes:{commandId:'scan',operationKind:'command'}})
|
||||
|
||||
const command: KbxWmsScanCommand = {
|
||||
taskId,
|
||||
barcode: event.normalizedValue,
|
||||
source: event.source,
|
||||
idempotencyKey: newIdempotencyKey(taskId),
|
||||
expectedVersion: task.value.version,
|
||||
occurredAt: new Date(event.occurredAt).toISOString(),
|
||||
}
|
||||
|
||||
await scanMutation.mutateAsync(command).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function flushRetryQueue() {
|
||||
if (!online.value || syncing.value) return
|
||||
const queued = readPickingRetryQueue()
|
||||
.filter(x => x.command.taskId === taskId)
|
||||
.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt))
|
||||
if (!queued.length) return
|
||||
|
||||
syncing.value = true
|
||||
try {
|
||||
// Authoritative picking is sequence-sensitive. Replay one-by-one and stop at first failure.
|
||||
for (const item of queued) {
|
||||
try {
|
||||
const result = await scanPickingBarcode(item.command)
|
||||
task.value = result.task
|
||||
message.value = result.message
|
||||
removePickingScan(item.command.idempotencyKey)
|
||||
} catch (error) {
|
||||
if (isKbxProblem(error) && !isRetryableKbxProblem(error)) {
|
||||
removePickingScan(item.command.idempotencyKey)
|
||||
message.value = error.title ?? '작업 상태가 변경되었습니다. 최신 상태를 확인하세요.'
|
||||
playKbxWmsFeedback('error')
|
||||
await query.refetch()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(online, value => {
|
||||
if (value) void flushRetryQueue()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
|
||||
if (online.value) void flushRetryQueue()
|
||||
})
|
||||
|
||||
return {
|
||||
task,
|
||||
message,
|
||||
online,
|
||||
pendingCommands,
|
||||
syncing,
|
||||
scanEnabled,
|
||||
scanLabel,
|
||||
exceptionOpen,
|
||||
quantityOpen,
|
||||
loading: query.isLoading,
|
||||
starting: startMutation.isPending,
|
||||
scanning: scanMutation.isPending,
|
||||
reportingException: exceptionMutation.isPending,
|
||||
settingQuantity: quantityMutation.isPending,
|
||||
start: () => startMutation.mutateAsync(),
|
||||
handleScan,
|
||||
openException: () => { exceptionOpen.value = true },
|
||||
openQuantity: () => { quantityOpen.value = true },
|
||||
closeQuantity: () => { quantityOpen.value = false },
|
||||
setQuantity: (quantity: number) => quantityMutation.mutateAsync(quantity),
|
||||
closeException: () => { exceptionOpen.value = false },
|
||||
reportException: (type: KbxWmsExceptionType, memo?: string) => exceptionMutation.mutateAsync({ type, memo }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { KbxWmsScanCommand } from '@kbx/contracts'
|
||||
|
||||
const STORAGE_KEY = 'kbx:wms:safe-retry:v1'
|
||||
|
||||
export interface QueuedPickingScan {
|
||||
command: KbxWmsScanCommand
|
||||
queuedAt: string
|
||||
}
|
||||
|
||||
export function readPickingRetryQueue(): QueuedPickingScan[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as QueuedPickingScan[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function queuePickingScan(command: KbxWmsScanCommand) {
|
||||
// Safety-first queue: only unacknowledged commands are retained for replay.
|
||||
// UI stops accepting the next authoritative scan until the server confirms this one.
|
||||
const queue = readPickingRetryQueue()
|
||||
if (!queue.some(x => x.command.idempotencyKey === command.idempotencyKey)) {
|
||||
queue.push({ command, queuedAt: new Date().toISOString() })
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(queue))
|
||||
}
|
||||
}
|
||||
|
||||
export function removePickingScan(idempotencyKey: string) {
|
||||
const next = readPickingRetryQueue().filter(x => x.command.idempotencyKey !== idempotencyKey)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KbxBarcodeCapture,KbxWmsActionButton,KbxWmsMobilePage } from '@kbx/ui'
|
||||
import { putawayScreen } from './putaway.definition'
|
||||
const online=ref(true);const stage=ref<'item'|'location'|'done'>('item');const item=ref('');const suggested=ref('A-01-03');const location=ref('');const msg=ref('적치할 상품을 스캔하세요.')
|
||||
function scan(v:string){if(stage.value==='item'){item.value=v;stage.value='location';msg.value=`추천 위치 ${suggested.value}를 스캔하세요.`;return}if(stage.value==='location'){if(v!==suggested.value){msg.value=`다른 위치입니다. ${suggested.value}로 이동하세요.`;return}location.value=v;stage.value='done';msg.value='적치가 완료되었습니다.'}}
|
||||
</script>
|
||||
<template><KbxWmsMobilePage :screen="putawayScreen" :progress="stage==='done'?'완료':'적치중'" :online="online"><p class="msg">{{msg}}</p><div v-if="item" class="item"><small>상품</small><strong>{{item}}</strong></div><div v-if="stage!=='item'" class="location"><small>추천 LOCATION</small><strong>{{suggested}}</strong></div><KbxBarcodeCapture v-if="stage!=='done'" :enabled="online" :label="stage==='item'?'상품 스캔':'위치 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='done'" label="다음 상품" @click="stage='item';item='';location='';msg='적치할 상품을 스캔하세요.'"/></template></KbxWmsMobilePage></template>
|
||||
<style scoped>.msg{font-weight:650}.item,.location{padding:16px;border:1px solid var(--kbx-color-border);border-radius:8px;margin-bottom:12px}.item small,.location small{display:block;color:var(--kbx-color-text-muted)}.item strong{font-size:22px}.location strong{font-size:32px}</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const putawayScreen=defineKbxScreen({id:'WMS-PUT-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 적치',helpKey:'WMS-PUT-001',permissions:['wms.putaway.execute'],telemetry:{enabled:true}})
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsPutawayRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/putaway/:taskId', name:'wms-putaway', component:()=>import('./WmsPutawayPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-PUT-001' } },
|
||||
]
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { KbxBarcodeCapture, KbxWmsActionButton, KbxWmsMobilePage } from '@kbx/ui'
|
||||
import { receivingScreen } from './receiving.definition'
|
||||
const props=defineProps<{taskId:string}>(); const online=ref(true); const stage=ref<'po'|'item'|'qty'|'completed'>('po'); const poNo=ref(''); const item=ref({code:'',name:'',expected:0,received:0}); const message=ref('입고예정번호 또는 ASN을 스캔하세요.')
|
||||
const progress=computed(()=>stage.value==='completed'?'완료':stage.value==='po'?'입고대기':'검수중')
|
||||
function scan(value:string){ if(stage.value==='po'){poNo.value=value; stage.value='item'; message.value='상품을 스캔하세요.'; return} if(stage.value==='item'){item.value={code:value,name:'스캔 품목',expected:10,received:1}; stage.value='qty'; message.value='수량을 확인하세요.'}}
|
||||
function complete(){stage.value='completed';message.value='입고 검수가 완료되었습니다.'}
|
||||
</script>
|
||||
<template><KbxWmsMobilePage :screen="receivingScreen" :progress="progress" :online="online"><p class="msg">{{message}}</p><section v-if="poNo" class="card"><small>입고예정</small><strong>{{poNo}}</strong></section><section v-if="item.code" class="card"><small>상품</small><strong>{{item.name}}</strong><span>{{item.code}}</span><div class="qty">예정 {{item.expected}} · 검수 {{item.received}}</div></section><KbxBarcodeCapture v-if="stage==='po'||stage==='item'" :enabled="online" :label="stage==='po'?'입고예정 스캔':'상품 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='qty'" label="검수 완료" @click="complete"/><KbxWmsActionButton v-else-if="stage==='completed'" label="다음 입고" @click="stage='po';poNo='';item={code:'',name:'',expected:0,received:0}"/></template></KbxWmsMobilePage></template>
|
||||
<style scoped>.msg{font-weight:650}.card{display:flex;flex-direction:column;gap:5px;border:1px solid var(--kbx-color-border);border-radius:8px;padding:16px;margin-bottom:12px}.card small,.card span{color:var(--kbx-color-text-muted)}.card strong{font-size:24px}.qty{font-size:20px;font-weight:650}</style>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const receivingScreen=defineKbxScreen({id:'WMS-REC-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 검수',helpKey:'WMS-REC-001',permissions:['wms.receiving.execute'],telemetry:{enabled:true}})
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsReceivingRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/receiving/:taskId', name:'wms-receiving', component:()=>import('./WmsReceivingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-REC-001' } },
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { KbxDataGrid, KbxQueuePage, KbxSearchPanel } from '@kbx/ui'
|
||||
import { routeForWmsTask, searchWmsWork } from './workApi'
|
||||
import { wmsWorkColumns, wmsWorkScreen, wmsWorkSearchFields, type WmsWorkRow } from './work.definition'
|
||||
const router=useRouter();const search=reactive({taskType:'',status:'READY',owner:'mine',keyword:''});const rows=ref<WmsWorkRow[]>([]);const selection=ref<WmsWorkRow[]>([]);const loading=ref(false);const searched=ref(false);const error=ref<unknown>(null);const activeExceptionKey=ref<string|null>(null)
|
||||
async function executeSearch(){loading.value=true;error.value=null;try{rows.value=await searchWmsWork(search);searched.value=true}catch(cause){error.value=cause;searched.value=true}finally{loading.value=false}}
|
||||
function executeCommand(id:string){if(id==='search')return executeSearch();if(id==='start'&&selection.value[0])return router.push(routeForWmsTask(selection.value[0]))}
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'total',label:'전체 작업',value:rows.value.length},
|
||||
{key:'ready',label:'대기',value:rows.value.filter(x=>x.status==='READY').length},
|
||||
{key:'blocked',label:'예외',value:rows.value.filter(x=>x.status==='BLOCKED').length,emphasis:true},
|
||||
])
|
||||
const exceptionCounters=computed(()=>[{key:'blocked',label:'확인 필요한 작업',count:rows.value.filter(x=>x.status==='BLOCKED').length,severity:'warning' as const}])
|
||||
async function filterException(key:string|null){activeExceptionKey.value=key;search.status=key==='blocked'?'BLOCKED':'READY';await executeSearch()}
|
||||
</script>
|
||||
<template><KbxQueuePage :screen="wmsWorkScreen" :selection-count="selection.length" :content-state="error?'error':loading&&!searched?'loading':!searched?'idle':rows.length===0?'empty':'ready'" :refreshing="loading&&searched" :summary-items="summaryItems" :exception-counters="exceptionCounters" :active-exception-key="activeExceptionKey" breadcrumb="WMS > 작업관리" @command="executeCommand" @exception-filter="filterException"><template #search><KbxSearchPanel v-model="search" :fields="wmsWorkSearchFields" @search="executeSearch"/></template><template #content><KbxDataGrid :rows="rows" :columns="wmsWorkColumns" row-key="taskId" selection="single" :loading="loading" @selection-changed="selection=$event" @row-double-clicked="row=>router.push(routeForWmsTask(row))"/></template></KbxQueuePage></template>
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsWorkRoutes: RouteRecordRaw[] = [{ path:'/wms/work', name:'wms-work', component:()=>import('./WmsWorkPage.vue'), meta:{ screenId:'WMS-WORK-001' } }]
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
|
||||
|
||||
export interface WmsWorkRow {
|
||||
taskId: string
|
||||
taskNo: string
|
||||
taskType: 'RECEIVING' | 'PUTAWAY' | 'PICKING' | 'CHECKING' | 'COUNTING'
|
||||
waveNo?: string
|
||||
ownerName?: string
|
||||
progress: number
|
||||
totalLines: number
|
||||
completedLines: number
|
||||
slaAt?: string
|
||||
status: string
|
||||
exceptionCount: number
|
||||
}
|
||||
|
||||
export const wmsWorkScreen = defineKbxScreen({
|
||||
id: 'WMS-WORK-001', version: '1.0.0', module: 'WMS', type: 'queue', templateCode:'T06', title: '물류 작업',
|
||||
description: '입고·적치·피킹·검수·실사 작업과 예외를 작업자 관점에서 조회합니다.',
|
||||
helpKey: 'WMS-WORK-001', permissions: ['wms.work.read'], telemetry: { enabled: true },
|
||||
commands: [
|
||||
{ id:'search', label:'조회', group:'query', shortcut:'F3' },
|
||||
{ id:'start', label:'작업 시작', group:'workflow', variant:'primary', requiresSelection:true, minSelection:1, maxSelection:1, permission:'wms.work.execute' },
|
||||
],
|
||||
})
|
||||
|
||||
export const wmsWorkSearchFields: KbxSearchField[] = [
|
||||
{ key:'taskType', label:'작업유형', type:'select', primary:true, options:[
|
||||
{value:'RECEIVING',label:'입고검수'},{value:'PUTAWAY',label:'적치'},{value:'PICKING',label:'피킹'},{value:'CHECKING',label:'검수'},{value:'COUNTING',label:'실사'},
|
||||
] },
|
||||
{ key:'status', label:'상태', type:'select', primary:true, options:[{value:'READY',label:'대기'},{value:'IN_PROGRESS',label:'진행'},{value:'BLOCKED',label:'예외'}] },
|
||||
{ key:'owner', label:'작업자', type:'select', primary:true, options:[{value:'mine',label:'내 작업'},{value:'unassigned',label:'미배정'}] },
|
||||
{ key:'keyword', label:'검색', type:'text', primary:true, width:'lg', placeholder:'작업번호 / Wave / 주문번호' },
|
||||
]
|
||||
|
||||
export const wmsWorkColumns: KbxGridColumn<WmsWorkRow>[] = [
|
||||
{ field:'taskNo', header:'작업번호', type:'code', width:150, pinned:'left' },
|
||||
{ field:'taskType', header:'작업유형', width:100 },
|
||||
{ field:'waveNo', header:'Wave', type:'code', width:130 },
|
||||
{ field:'ownerName', header:'작업자', width:100 },
|
||||
{ field:'progress', header:'진행률(%)', type:'percent', width:100 },
|
||||
{ field:'completedLines', header:'완료', type:'integer', width:80 },
|
||||
{ field:'totalLines', header:'전체', type:'integer', width:80 },
|
||||
{ field:'slaAt', header:'SLA', type:'datetime', width:155 },
|
||||
{ field:'status', header:'상태', type:'status', width:100 },
|
||||
{ field:'exceptionCount', header:'예외', type:'integer', width:80 },
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { WmsWorkRow } from './work.definition'
|
||||
export interface WmsWorkSearch { taskType?: string; status?: string; owner?: string; keyword?: string }
|
||||
export async function searchWmsWork(_search: WmsWorkSearch): Promise<WmsWorkRow[]> { return [] }
|
||||
export function routeForWmsTask(row: WmsWorkRow): string {
|
||||
switch (row.taskType) {
|
||||
case 'RECEIVING': return `/wms/receiving/${row.taskId}`
|
||||
case 'PUTAWAY': return `/wms/putaway/${row.taskId}`
|
||||
case 'PICKING': return `/wms/picking/${row.taskId}`
|
||||
case 'COUNTING': return `/wms/counting/${row.taskId}`
|
||||
default: return `/wms/work?task=${encodeURIComponent(row.taskId)}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user