V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
@@ -0,0 +1,277 @@
import { AxiosError, type AxiosAdapter, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { kbxHttp } from '../http/kbxHttpClient'
const now = () => new Date().toISOString()
const clone = <T>(value: T): T => structuredClone(value)
const uid = () => crypto.randomUUID()
const customers = [
{ id:'11111111-1111-4111-8111-111111111111', code:'10001', displayName:'대한상사', secondaryText:'서울 · 정상', status:'active', metadata:{ businessNumber:'123-45-67890', representative:'김대표' } },
{ id:'22222222-2222-4222-8222-222222222222', code:'10002', displayName:'한빛유통', secondaryText:'경기 · 정상', status:'active', metadata:{ businessNumber:'234-56-78901', representative:'이대표' } },
{ id:'33333333-3333-4333-8333-333333333333', code:'10003', displayName:'새봄물류', secondaryText:'인천 · 정상', status:'active', metadata:{ businessNumber:'345-67-89012', representative:'박대표' } },
]
const warehouses = [
{ id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', code:'WH01', displayName:'서울물류센터', secondaryText:'수도권', status:'active' },
{ id:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', code:'WH02', displayName:'인천물류센터', secondaryText:'수도권', status:'active' },
{ id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc', code:'WH03', displayName:'부산물류센터', secondaryText:'영남권', status:'active' },
]
const items = [
{ id:'10000000-0000-4000-8000-000000000001', code:'ABC001', name:'운동화 BLACK 270', displayName:'운동화 BLACK 270', categoryName:'신발', specification:'BLACK / 270', unit:'EA', barcode:'8801234567001', defaultWarehouseId:warehouses[0].id, defaultWarehouseName:warehouses[0].displayName, lotManaged:false, expiryManaged:false, active:true, version:3 },
{ id:'10000000-0000-4000-8000-000000000002', code:'ABC002', name:'운동화 WHITE 265', displayName:'운동화 WHITE 265', categoryName:'신발', specification:'WHITE / 265', unit:'EA', barcode:'8801234567002', defaultWarehouseId:warehouses[0].id, defaultWarehouseName:warehouses[0].displayName, lotManaged:false, expiryManaged:false, active:true, version:2 },
{ id:'10000000-0000-4000-8000-000000000003', code:'DEF001', name:'기능성 양말', displayName:'기능성 양말', categoryName:'의류', specification:'FREE / BLACK', unit:'EA', barcode:'8801234567003', defaultWarehouseId:warehouses[1].id, defaultWarehouseName:warehouses[1].displayName, lotManaged:false, expiryManaged:false, active:true, version:5 },
{ id:'10000000-0000-4000-8000-000000000004', code:'FOOD01', name:'에너지바 40g', displayName:'에너지바 40g', categoryName:'식품', specification:'40g', unit:'EA', barcode:'8801234567004', defaultWarehouseId:warehouses[1].id, defaultWarehouseName:warehouses[1].displayName, lotManaged:true, expiryManaged:true, active:true, version:1 },
]
function demoOrderLifecycleFromShipment(value:string){
return ({NEW:'DRAFT',READY:'CONFIRMED',CONFIRMED:'CONFIRMED',HOLD:'HOLD',SHIPPED:'SHIPPED',ERROR:'ERROR'} as Record<string,string>)[value] ?? value
}
const orderDetails = new Map<string, any>([
['20000000-0000-4000-8000-000000000001', {
orderId:'20000000-0000-4000-8000-000000000001', orderNo:'ORD-260811-0001', version:4, status:'CONFIRMED', orderDate:'2026-08-11', customerId:customers[0].id, warehouseId:warehouses[0].id,
receiverName:'홍길동', phone:'010-1234-5678', postalCode:'06236', address1:'서울특별시 강남구 테헤란로 1', address2:'101호',
lines:[{id:'line-1',clientId:'line-1',itemId:items[0].id,itemCode:'ABC001',itemName:items[0].name,orderQty:2,unitPrice:89000,amount:178000,remark:''}],
}],
['20000000-0000-4000-8000-000000000002', {
orderId:'20000000-0000-4000-8000-000000000002', orderNo:'ORD-260811-0002', version:2, status:'DRAFT', orderDate:'2026-08-11', customerId:customers[1].id, warehouseId:warehouses[0].id,
receiverName:'김하나', phone:'010-2345-6789', postalCode:'04524', address1:'서울특별시 중구 세종대로 10', address2:'',
lines:[{id:'line-2',clientId:'line-2',itemId:items[2].id,itemCode:'DEF001',itemName:items[2].name,orderQty:5,unitPrice:12000,amount:60000,remark:'선물포장'}],
}],
['20000000-0000-4000-8000-000000000003', {
orderId:'20000000-0000-4000-8000-000000000003', orderNo:'ORD-260811-0003', version:6, status:'CONFIRMED', orderDate:'2026-08-11', customerId:customers[2].id, warehouseId:warehouses[1].id,
receiverName:'박현장', phone:'010-3456-7890', postalCode:'22382', address1:'인천광역시 중구 공항동로 1', address2:'물류동',
lines:[{id:'line-3',clientId:'line-3',itemId:items[1].id,itemCode:'ABC002',itemName:items[1].name,orderQty:1,unitPrice:92000,amount:92000,remark:''}],
}],
])
let orderRows = [
{ id:'20000000-0000-4000-8000-000000000001', orderNo:'ORD-260811-0001', channelName:'쿠팡', orderedAt:'2026-08-11T09:15:00+09:00', customerName:'홍길동', itemSummary:'운동화 BLACK 270', totalQty:2, amount:178000, allocationStatus:'부족', shipmentStatus:'READY', exceptionCount:1 },
{ id:'20000000-0000-4000-8000-000000000002', orderNo:'ORD-260811-0002', channelName:'스마트스토어', orderedAt:'2026-08-11T09:38:00+09:00', customerName:'김하나', itemSummary:'기능성 양말', totalQty:5, amount:60000, allocationStatus:'정상', shipmentStatus:'NEW', exceptionCount:0 },
{ id:'20000000-0000-4000-8000-000000000003', orderNo:'ORD-260811-0003', channelName:'자사몰', orderedAt:'2026-08-11T10:02:00+09:00', customerName:'박현장', itemSummary:'운동화 WHITE 265', totalQty:1, amount:92000, allocationStatus:'정상', shipmentStatus:'READY', exceptionCount:0 },
{ id:'20000000-0000-4000-8000-000000000004', orderNo:'ORD-260811-0004', channelName:'쿠팡', orderedAt:'2026-08-11T10:17:00+09:00', customerName:'이소라', itemSummary:'에너지바 40g', totalQty:12, amount:36000, allocationStatus:'정상', shipmentStatus:'HOLD', exceptionCount:1 },
{ id:'20000000-0000-4000-8000-000000000005', orderNo:'ORD-260811-0005', channelName:'스마트스토어', orderedAt:'2026-08-11T11:05:00+09:00', customerName:'정우진', itemSummary:'기능성 양말 외 1종', totalQty:4, amount:84000, allocationStatus:'정상', shipmentStatus:'SHIPPED', exceptionCount:0 },
]
const auditByEntity = new Map<string, any[]>()
function audit(id:string, action:string, changes:any[] = []) {
const list = auditByEntity.get(id) ?? []
list.unshift({ id:uid(), occurredAt:now(), actor:{type:'user',displayName:'QA 사용자'}, action, changes })
auditByEntity.set(id, list)
}
for (const id of orderDetails.keys()) audit(id, '주문 조회용 데모 데이터 생성')
for (const item of items) audit(item.id, '품목 기준정보 생성')
let notifications:any[] = [
{ id:'noti-1', severity:'warning', title:'재고부족 주문 7건', message:'출고 전 확인이 필요합니다.', createdAt:now(), screenId:'OMS-ORD-001', action:{id:'open',label:'주문 보기',route:'/oms/orders?exceptionOnly=true'} },
{ id:'noti-2', severity:'error', title:'OMS ↔ WMS 대사 불일치 3건', message:'원인 확인 후 재처리하세요.', createdAt:now(), screenId:'COMMON-REC-001', action:{id:'open',label:'대사 보기',route:'/operations/reconcile'} },
]
let operations:any[] = [
{ id:'job-1', type:'excel-import', title:'주문 Excel Import', sourceScreenId:'OMS-ORD-003', status:'running', requestedAt:now(), startedAt:now(), processed:13421, total:17894, succeeded:13218, failed:203, resultMessage:'75% 처리 중' },
]
const workItems:any[] = [
{ id:'op-1', sourceModule:'OMS', sourceType:'order', sourceId:orderRows[0].id, referenceNo:'ORD-260811-0001', sourceScreenId:'OMS-ORD-001', code:'STOCK_SHORTAGE', title:'주문 재고 부족', detail:'ABC001 가용재고 부족', severity:'warning', status:'open', ownerId:null, ownerName:null, occurredAt:'2026-08-11T09:15:00+09:00', dueAt:null, ageMinutes:75, version:1, context:{ itemCode:'ABC001' }, retryActionKey:null, allowManualResolution:true },
{ id:'op-2', sourceModule:'WMS', sourceType:'integration', sourceId:'sync-2', referenceNo:'SYNC-260811-0002', sourceScreenId:'COMMON-OPS-001', code:'SHIPMENT_SYNC', title:'출고 연계 지연', detail:'WMS 전송 재시도 대기', severity:'critical', status:'open', ownerId:null, ownerName:null, occurredAt:'2026-08-11T08:10:00+09:00', dueAt:null, ageMinutes:140, version:2, context:{ integration:'WMS' }, retryActionKey:'shipment-sync', allowManualResolution:false },
{ id:'op-3', sourceModule:'ERP', sourceType:'inventory', sourceId:'inventory-3', referenceNo:'INV-260811-0003', sourceScreenId:'ERP-INV-001', code:'INVENTORY_REVIEW', title:'재고 확인 필요', detail:'실사 차이 검토가 필요합니다.', severity:'info', status:'claimed', ownerId:'demo-user', ownerName:'QA 사용자', occurredAt:'2026-08-11T10:30:00+09:00', dueAt:null, ageMinutes:32, version:1, context:{}, retryActionKey:null, allowManualResolution:true },
]
function demoWorkItemActions(item:any){
const actions:any[]=[{id:'navigate',label:'원 업무 보기',kind:'navigate'}]
if(item.status==='open')actions.push({id:'claim',label:'내가 처리',kind:'claim',permission:'common.operations.claim'})
if(['open','claimed'].includes(item.status)&&item.retryActionKey)actions.push({id:'retry',label:'재처리',kind:'retry',permission:'common.operations.retry'})
if(['open','claimed'].includes(item.status)&&item.allowManualResolution)actions.push({id:'resolve',label:'해결 처리',kind:'resolve',permission:'common.operations.resolve'})
return actions
}
const reconcileItems:any[] = [
{ id:'rec-1', reconcileType:'OMS_WMS_OUTBOUND_QTY', referenceNo:'ORD-260811-0001', sourceLabel:'OMS 주문수량', targetLabel:'WMS 피킹수량', expectedValue:'2', actualValue:'1', differenceValue:'-1', reasonCode:'PICK_SHORT', reasonText:'피킹부족', status:'mismatch', occurredAt:'2026-08-11T10:42:00+09:00', sourceId:orderRows[0].id, targetId:'pick-line-1', version:3 },
{ id:'rec-2', reconcileType:'OMS_WMS_OUTBOUND_QTY', referenceNo:'ORD-260811-0003', sourceLabel:'OMS 주문수량', targetLabel:'WMS 피킹수량', expectedValue:'1', actualValue:'1', differenceValue:'0', reasonCode:null, reasonText:null, status:'matched', occurredAt:'2026-08-11T10:38:00+09:00', sourceId:orderRows[2].id, targetId:'pick-line-3', version:1 },
{ id:'rec-3', reconcileType:'OMS_WMS_OUTBOUND_QTY', referenceNo:'ORD-260811-0004', sourceLabel:'OMS 주문수량', targetLabel:'WMS 피킹수량', expectedValue:'12', actualValue:'10', differenceValue:'-2', reasonCode:'SYNC_PENDING', reasonText:'연계 결과 안정화 대기', status:'pending', occurredAt:'2026-08-11T10:47:00+09:00', sourceId:orderRows[3].id, targetId:'pick-line-4', version:2 },
]
let claimRows:any[] = [
{id:'50000000-0000-4000-8000-000000000001',claimNo:'CLM-260811-01',orderNo:'ORD-260811-0001',channelName:'쿠팡',type:'RETURN',reason:'사이즈 교환',requestedQty:1,status:'REQUESTED',ownerName:'QA 사용자',requestedAt:'2026-08-11T13:20:00+09:00',version:1},
{id:'50000000-0000-4000-8000-000000000002',claimNo:'CLM-260811-02',orderNo:'ORD-260811-0003',channelName:'자사몰',type:'EXCHANGE',reason:'색상 교환',requestedQty:1,status:'APPROVED',ownerName:'QA 사용자',requestedAt:'2026-08-11T13:35:00+09:00',version:2},
{id:'50000000-0000-4000-8000-000000000003',claimNo:'CLM-260811-03',orderNo:'ORD-260811-0004',channelName:'쿠팡',type:'CANCEL',reason:'고객 요청',requestedQty:2,status:'IN_PROGRESS',ownerName:'QA 사용자',requestedAt:'2026-08-11T14:10:00+09:00',version:3},
{id:'50000000-0000-4000-8000-000000000004',claimNo:'CLM-260811-04',orderNo:'ORD-260811-0002',channelName:'스마트스토어',type:'RETURN',reason:'검수 필요',requestedQty:1,status:'HOLD',ownerName:'QA 사용자',requestedAt:'2026-08-11T14:30:00+09:00',version:2},
]
const pickingTask:any = {
taskId:'30000000-0000-4000-8000-000000000001', taskNo:'PICK-260811-001', status:'ready', stage:'ready', version:1,
currentLine:{ lineId:'pick-line-1', lineNo:23, locationCode:'A-03-02', itemId:items[0].id, itemCode:items[0].code, itemName:items[0].name, itemOption:items[0].specification, barcode:items[0].barcode, requiredQty:6, pickedQty:4, remainingQty:2 },
completedLines:22, totalLines:138, completedQty:71, totalQty:427, message:'작업을 시작하세요.',
}
const imports = new Map<string, any>()
function ok(config: AxiosRequestConfig, data: unknown, status = 200): AxiosResponse {
return { data, status, statusText: status === 204 ? 'No Content' : 'OK', headers:{}, config:config as any, request:{} }
}
function fail(config:AxiosRequestConfig,data:unknown,status:number):never {
const response=ok(config,data,status)
throw new AxiosError(typeof data==='object'&&data&&'title' in data?String((data as any).title):`Demo HTTP ${status}`,AxiosError.ERR_BAD_REQUEST,config as any,{},response)
}
function text(v:unknown){ return String(v ?? '').trim().toLowerCase() }
function body(config:AxiosRequestConfig):any {
if (typeof config.data === 'string') { try { return JSON.parse(config.data) } catch { return config.data } }
return config.data ?? {}
}
function params(config:AxiosRequestConfig):Record<string,any>{ return (config.params ?? {}) as Record<string,any> }
function path(config:AxiosRequestConfig){ return String(config.url ?? '').split('?')[0] }
function findLookup(list:any[], codeOrId:string){ return list.find(x=>x.id===codeOrId||x.code.toLowerCase()===codeOrId.toLowerCase()) ?? null }
function lookupResponse(list:any[], q:any){
const keyword=text(q.query)
const filtered=keyword?list.filter(x=>`${x.code} ${x.displayName} ${x.secondaryText??''}`.toLowerCase().includes(keyword)):list
return { items:clone(filtered.slice(0,Number(q.pageSize??20))), totalCount:filtered.length }
}
const demoAdapter:AxiosAdapter = async config => {
await new Promise(resolve => setTimeout(resolve, 90))
const method=String(config.method??'get').toUpperCase(); const url=path(config); const q=params(config); const data=body(config)
if(method==='GET'&&url==='/api/kbx/runtime/notice') return ok(config,null)
if(method==='GET'&&url==='/api/kbx/runtime/operations') return ok(config,clone(operations))
if(method==='GET'&&url==='/api/kbx/runtime/notifications') return ok(config,clone(notifications))
if(method==='POST'&&/^\/api\/kbx\/runtime\/notifications\/[^/]+\/read$/.test(url)){const id=url.split('/').at(-2)!;const n=notifications.find(x=>x.id===id);if(n)n.readAt=now();return ok(config,undefined,204)}
if(method==='POST'&&url==='/api/kbx/ux/events') return ok(config,undefined,204)
if(method==='POST'&&url==='/api/common/suggestions') return ok(config,{id:uid(),receivedAt:now()})
if(method==='POST'&&url==='/api/common/ai/ask') return ok(config,{answer:'현재 화면의 예외와 선택 상태를 기준으로 확인할 항목을 정리했습니다.',actions:[]})
if(method==='GET'&&url==='/api/oms/orders'){
let rows=[...orderRows]; const keyword=text(q.keyword)
if(q.status) rows=rows.filter(x=>x.shipmentStatus===q.status)
if(String(q.exceptionOnly)==='true'||q.exceptionOnly===true) rows=rows.filter(x=>x.exceptionCount>0)
if(keyword) rows=rows.filter(x=>`${x.orderNo} ${x.customerName} ${x.itemSummary} ${x.channelName}`.toLowerCase().includes(keyword))
const counters={all:orderRows.length,new:orderRows.filter(x=>x.shipmentStatus==='NEW').length,readyToShip:orderRows.filter(x=>x.shipmentStatus==='READY').length,exceptions:orderRows.filter(x=>x.exceptionCount>0).length}
const pageSize=Number(q.pageSize??100); const page=Math.max(Number(q.page??1),1); const slice=rows.slice((page-1)*pageSize,page*pageSize)
return ok(config,{items:clone(slice),totalCount:rows.length,totalQty:rows.reduce((s,x)=>s+x.totalQty,0),totalAmount:rows.reduce((s,x)=>s+x.amount,0),counters})
}
if(method==='GET'&&/^\/api\/oms\/orders\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;const existing=orderDetails.get(id);if(existing)return ok(config,clone(existing));const row=orderRows.find(x=>x.id===id);if(row)return ok(config,{orderId:id,orderNo:row.orderNo,version:1,status:demoOrderLifecycleFromShipment(row.shipmentStatus),orderDate:'2026-08-11',customerId:customers[0].id,warehouseId:warehouses[0].id,receiverName:row.customerName,phone:'010-0000-0000',address1:'서울특별시',lines:[]});return ok(config,null)}
if(method==='GET'&&/^\/api\/oms\/orders\/[^/]+\/audit$/.test(url)){const id=url.split('/').at(-2)!;return ok(config,clone(auditByEntity.get(id)??[]))}
if(method==='POST'&&url==='/api/oms/orders/ship'){
const ids:Array<string>=data.mode==='ids'?(data.ids??[]):orderRows.filter(row=>row.shipmentStatus==='READY').map(row=>row.id)
let accepted=0; for(const id of ids){const row=orderRows.find(x=>x.id===id);if(row&&row.shipmentStatus==='READY'){row.shipmentStatus='CONFIRMED';accepted++}}
return ok(config,{requested:ids.length,accepted,rejected:ids.length-accepted})
}
if(method==='POST'&&url==='/api/oms/orders/register'){
const id=uid();const orderNo=`ORD-260811-${String(orderRows.length+1).padStart(4,'0')}`;const detail={...data,orderId:id,orderNo,version:1,status:'DRAFT',lines:(data.lines??[]).map((line:any,index:number)=>{const item=items.find(x=>x.id===line.itemId);return{id:`${id}-${index+1}`,clientId:line.clientId,itemId:line.itemId,itemCode:item?.code??'',itemName:item?.name??'',orderQty:Number(line.orderQty),unitPrice:Number(line.unitPrice),amount:Number(line.orderQty)*Number(line.unitPrice),remark:line.remark??''}})};orderDetails.set(id,detail);orderRows.unshift({id,orderNo,channelName:'직접주문',orderedAt:now(),customerName:data.receiverName,itemSummary:detail.lines[0]?.itemName??'상품 없음',totalQty:detail.lines.reduce((s:number,x:any)=>s+x.orderQty,0),amount:detail.lines.reduce((s:number,x:any)=>s+x.amount,0),allocationStatus:'미확인',shipmentStatus:'NEW',exceptionCount:0});audit(id,'주문 등록');return ok(config,{orderId:id,orderNo,version:1,status:'DRAFT'})
}
if(method==='PUT'&&/^\/api\/oms\/orders\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;const existing=orderDetails.get(id);const version=Number(existing?.version??0)+1;const next={...(existing??{}),...data,orderId:id,orderNo:existing?.orderNo??`ORD-${id.slice(0,8)}`,version,status:existing?.status??'DRAFT',lines:(data.lines??existing?.lines??[]).map((line:any,index:number)=>{const item=items.find(x=>x.id===line.itemId);return{...line,id:line.id??`${id}-${index+1}`,itemCode:item?.code??line.itemCode??'',itemName:item?.name??line.itemName??'',amount:Number(line.orderQty)*Number(line.unitPrice)}})};orderDetails.set(id,next);audit(id,'주문 수정');return ok(config,{orderId:id,orderNo:next.orderNo,version,status:next.status})}
if(method==='POST'&&/^\/api\/oms\/orders\/[^/]+\/confirm$/.test(url)){const id=url.split('/').at(-2)!;const existing=orderDetails.get(id);if(!existing)return fail(config,{type:'not-found',code:'ORDER_NOT_FOUND',title:'주문을 찾을 수 없습니다.'},404);if(existing.status!=='DRAFT')return fail(config,{type:'business-rule',code:'ORDER_NOT_CONFIRMABLE',title:'주문을 확정할 수 없습니다.',detail:'작성 상태의 주문만 확정할 수 있습니다.'},409);existing.version=Number(existing.version)+1;existing.status='CONFIRMED';audit(id,'주문 확정');return ok(config,{orderId:id,orderNo:existing.orderNo,version:existing.version,status:'CONFIRMED'})}
if(url.startsWith('/api/lookups/customers')){
if(method==='GET'&&url==='/api/lookups/customers')return ok(config,lookupResponse(customers,q))
const key=decodeURIComponent(url.split('/').at(-1)!);return ok(config,clone(findLookup(customers,key)))
}
if(url.startsWith('/api/lookups/warehouses')){
if(method==='GET'&&url==='/api/lookups/warehouses')return ok(config,lookupResponse(warehouses,q))
const key=decodeURIComponent(url.split('/').at(-1)!);return ok(config,clone(findLookup(warehouses,key)))
}
if(url.startsWith('/api/lookups/items')){
const lookupItems=items.map(x=>({id:x.id,code:x.code,displayName:x.name,secondaryText:`${x.specification} · ${x.unit}`,status:x.active?'active':'disabled',metadata:{availableQty:x.code==='ABC001'?8:120}}))
if(method==='GET'&&url==='/api/lookups/items')return ok(config,lookupResponse(lookupItems,q))
const key=decodeURIComponent(url.split('/').at(-1)!);return ok(config,clone(findLookup(lookupItems,key)))
}
if(method==='GET'&&url==='/api/erp/items'){const keyword=text(q.keyword);const found=keyword?items.filter(x=>`${x.code} ${x.name}`.toLowerCase().includes(keyword)):items;return ok(config,{items:clone(found),totalCount:found.length})}
if(method==='GET'&&/^\/api\/erp\/items\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;return ok(config,clone(items.find(x=>x.id===id)??null))}
if(method==='GET'&&/^\/api\/erp\/items\/[^/]+\/audit$/.test(url)){const id=url.split('/').at(-2)!;return ok(config,clone(auditByEntity.get(id)??[]))}
if(method==='POST'&&url==='/api/erp/items'){const id=uid();const created={id,version:1,active:true,defaultWarehouseName:warehouses.find(x=>x.id===data.defaultWarehouseId)?.displayName??'',...data};items.push(created);audit(id,'품목 등록');return ok(config,{id,version:1,status:'사용'})}
if(method==='PUT'&&/^\/api\/erp\/items\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;const target=items.find(x=>x.id===id);if(target){Object.assign(target,data,{version:Number(target.version)+1,defaultWarehouseName:warehouses.find(x=>x.id===data.defaultWarehouseId)?.displayName??target.defaultWarehouseName});audit(id,'품목 수정');return ok(config,{id,version:target.version,status:target.active?'사용':'사용중지'})}return ok(config,{id,version:1,status:'사용'})}
if(method==='POST'&&/^\/api\/erp\/items\/[^/]+\/deactivate$/.test(url)){const id=url.split('/').at(-2)!;const target=items.find(x=>x.id===id);if(target){target.active=false;target.version=Number(target.version)+1;audit(id,'품목 사용중지');return ok(config,{id,version:target.version,status:'사용중지'})}return ok(config,{id,version:1,status:'사용중지'})}
if(method==='GET'&&url==='/api/erp/inventory'){
const inv=items.map((x,index)=>({itemId:x.id,itemCode:x.code,itemName:x.name,specification:x.specification,unit:x.unit,totalQty:[1280,721,312,96][index]??0,availableQty:[830,650,290,80][index]??0,allocatedQty:[430,61,12,8][index]??0,holdQty:[20,10,10,8][index]??0}))
const keyword=text(q.keyword);const found=keyword?inv.filter(x=>`${x.itemCode} ${x.itemName}`.toLowerCase().includes(keyword)):inv;return ok(config,{items:found,totalCount:found.length})
}
if(method==='GET'&&/^\/api\/erp\/inventory\/[^/]+\/locations$/.test(url)){return ok(config,{items:[{warehouseCode:'WH01',warehouseName:'서울물류센터',locationCode:'A-01-01',onHandQty:300,availableQty:260},{warehouseCode:'WH01',warehouseName:'서울물류센터',locationCode:'A-01-02',onHandQty:530,availableQty:500}]})}
if(method==='GET'&&/^\/api\/erp\/inventory\/[^/]+\/history$/.test(url)){return ok(config,{items:[{occurredAt:'2026-08-11T10:21:00+09:00',businessType:'출고',inQty:0,outQty:2,balanceQty:830,referenceNo:'ORD-260811-0001'},{occurredAt:'2026-08-11T08:40:00+09:00',businessType:'입고',inQty:100,outQty:0,balanceQty:832,referenceNo:'GR-260811-001'}]})}
if(method==='POST'&&url==='/api/erp/item-prices/bulk'){return ok(config,{requested:data.rows?.length??0,saved:data.rows?.length??0,created:data.rows?.length??0,updated:0})}
if(method==='GET'&&url==='/api/operations/work-items'){
let rows=[...workItems];const keyword=text(q.keyword)
if(q.module)rows=rows.filter(x=>x.sourceModule===q.module)
if(q.severity)rows=rows.filter(x=>x.severity===q.severity)
if(q.status)rows=rows.filter(x=>x.status===q.status)
if(q.code)rows=rows.filter(x=>x.code===q.code)
if(q.owner==='mine')rows=rows.filter(x=>x.ownerId==='demo-user')
if(q.owner==='unassigned')rows=rows.filter(x=>!x.ownerId)
if(keyword)rows=rows.filter(x=>`${x.title} ${x.detail??''} ${x.referenceNo} ${x.sourceId}`.toLowerCase().includes(keyword))
const counters=Object.values(workItems.filter(x=>['open','claimed'].includes(x.status)).reduce((acc:any,item:any)=>{const current=acc[item.code]??{key:item.code,label:item.title,count:0,severity:item.severity};current.count++;if(item.severity==='critical')current.severity='critical';else if(item.severity==='warning'&&current.severity!=='critical')current.severity='warning';acc[item.code]=current;return acc},{}))
return ok(config,{items:clone(rows.map(x=>({...x,actions:demoWorkItemActions(x)}))),totalCount:rows.length,counters:clone(counters)})
}
if(method==='POST'&&url==='/api/operations/work-items/claim'){let claimedCount=0;for(const id of data.ids??[]){const item=workItems.find(x=>x.id===id);if(item&&item.status==='open'){item.status='claimed';item.ownerId='demo-user';item.ownerName='QA 사용자';item.version++;claimedCount++}}return ok(config,{claimedCount,skippedCount:Math.max((data.ids??[]).length-claimedCount,0)})}
if(method==='POST'&&url==='/api/operations/work-items/resolve'){let resolvedCount=0;for(const id of data.ids??[]){const item=workItems.find(x=>x.id===id);if(item&&['open','claimed'].includes(item.status)){item.status='resolved';item.version++;resolvedCount++}}return ok(config,{resolvedCount,rejectedCount:Math.max((data.ids??[]).length-resolvedCount,0)})}
if(method==='POST'&&/^\/api\/operations\/work-items\/[^/]+\/retry$/.test(url))return ok(config,{accepted:true})
if(method==='GET'&&url==='/api/reconcile/items'){
const keyword=text(q.keyword);let rows=[...reconcileItems]
if(q.reconcileType)rows=rows.filter(x=>x.reconcileType===q.reconcileType)
if(q.status)rows=rows.filter(x=>x.status===q.status)
if(keyword)rows=rows.filter(x=>`${x.referenceNo} ${x.reasonText??''}`.toLowerCase().includes(keyword))
const summaryBase=reconcileItems.filter(x=>(!q.reconcileType||x.reconcileType===q.reconcileType)&&(!keyword||`${x.referenceNo} ${x.reasonText??''}`.toLowerCase().includes(keyword)))
const summary={totalCount:summaryBase.length,matchedCount:summaryBase.filter(x=>x.status==='matched').length,mismatchCount:summaryBase.filter(x=>x.status==='mismatch').length,pendingCount:summaryBase.filter(x=>x.status==='pending').length,resolvedCount:summaryBase.filter(x=>x.status==='resolved').length}
return ok(config,{items:clone(rows),summary})
}
if(method==='POST'&&url==='/api/reconcile/items/create-exceptions'){
const requested=[...new Set(data.ids??[])].slice(0,500);let createdOrUpdatedCount=0
for(const id of requested){const row=reconcileItems.find(x=>x.id===id);if(!row||!['mismatch','pending'].includes(row.status))continue;createdOrUpdatedCount++;const workId=`reconcile-${row.id}`;if(!workItems.some(x=>x.id===workId))workItems.push({id:workId,sourceModule:'COMMON',sourceType:'reconcile',sourceId:row.id,referenceNo:row.referenceNo,sourceScreenId:'COMMON-REC-001',code:'RECONCILE_MISMATCH',title:`대사 불일치 · ${row.referenceNo}`,detail:row.reasonText??'대사 근거 확인 필요',severity:'warning',status:'open',ownerId:null,ownerName:null,occurredAt:row.occurredAt,dueAt:null,ageMinutes:0,version:row.version,context:{reconcileType:row.reconcileType,expectedValue:row.expectedValue,actualValue:row.actualValue,differenceValue:row.differenceValue},retryActionKey:null,allowManualResolution:true})}
return ok(config,{createdOrUpdatedCount,skippedCount:requested.length-createdOrUpdatedCount})
}
if(method==='GET'&&url==='/api/oms/claims'){
let rows=[...claimRows];const keyword=text(q.keyword)
if(q.type)rows=rows.filter(x=>x.type===q.type)
if(q.status)rows=rows.filter(x=>x.status===q.status)
if(keyword)rows=rows.filter(x=>`${x.claimNo} ${x.orderNo} ${x.reason} ${x.channelName}`.toLowerCase().includes(keyword))
return ok(config,{items:clone(rows),totalCount:rows.length})
}
if(method==='POST'&&/^\/api\/oms\/claims\/[^/]+\/(approve|hold|start|complete)$/.test(url)){
const transition=url.split('/').at(-1)!;const id=url.split('/').at(-2)!;const row=claimRows.find(x=>x.id===id)
const policy:any={approve:{from:'REQUESTED',to:'APPROVED'},hold:{from:'REQUESTED',to:'HOLD'},start:{from:'APPROVED',to:'IN_PROGRESS'},complete:{from:'IN_PROGRESS',to:'COMPLETED'}}
const rule=policy[transition]
if(row&&rule&&row.status===rule.from){row.status=rule.to;row.version++;return ok(config,{id:row.id,status:row.status,version:row.version})}
return fail(config,{type:'business-rule',code:'CLAIM_TRANSITION_NOT_ALLOWED',title:'현재 상태에서는 요청한 클레임 전이를 실행할 수 없습니다.',detail:`${rule?.from??'허용 상태'} 상태를 확인하세요.`},409)
}
if(method==='GET'&&/^\/api\/wms\/picking\/tasks\/[^/]+$/.test(url))return ok(config,clone(pickingTask))
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/start$/.test(url)){pickingTask.status='picking';pickingTask.stage='await-location';pickingTask.message='A-03-02 위치를 스캔하세요.';pickingTask.version++;return ok(config,clone(pickingTask))}
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/scan$/.test(url)){
const raw=String(data.normalizedValue??data.rawValue??data.barcode??'')
if(pickingTask.stage==='await-location'){
const accepted=raw===pickingTask.currentLine?.locationCode
if(accepted){pickingTask.stage='await-item';pickingTask.message='상품을 스캔하세요.';pickingTask.version++}
return ok(config,{accepted,duplicate:false,task:clone(pickingTask),feedback:accepted?'success':'error',message:accepted?'위치를 확인했습니다.':`잘못된 위치입니다. ${pickingTask.currentLine?.locationCode}로 이동하세요.`})
}
const expected=pickingTask.currentLine?.barcode
if(raw===expected&&pickingTask.currentLine){pickingTask.currentLine.pickedQty++;pickingTask.currentLine.remainingQty=Math.max(pickingTask.currentLine.requiredQty-pickingTask.currentLine.pickedQty,0);pickingTask.completedQty++;pickingTask.version++;pickingTask.message=pickingTask.currentLine.remainingQty===0?'현재 상품 피킹을 완료했습니다.':'수량을 반영했습니다.';return ok(config,{accepted:true,duplicate:false,task:clone(pickingTask),feedback:'success',message:pickingTask.message})}
return ok(config,{accepted:false,duplicate:false,task:clone(pickingTask),feedback:'error',message:'다른 상품입니다.'})
}
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/quantity$/.test(url)){if(pickingTask.currentLine){const before=pickingTask.currentLine.pickedQty;pickingTask.currentLine.pickedQty=Number(data.pickedQty??0);pickingTask.currentLine.remainingQty=Math.max(pickingTask.currentLine.requiredQty-pickingTask.currentLine.pickedQty,0);pickingTask.completedQty+=pickingTask.currentLine.pickedQty-before;pickingTask.version++;pickingTask.message='피킹 수량을 변경했습니다.'}return ok(config,{accepted:true,duplicate:false,task:clone(pickingTask),feedback:'success',message:pickingTask.message})}
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/exceptions$/.test(url)){pickingTask.status='exception';pickingTask.version++;return ok(config,clone(pickingTask))}
if(method==='POST'&&url==='/api/imports/sessions'){
const id=uid();const mapping=[{sourceColumn:'주문번호',targetField:'orderNo',source:'exact',confidence:1,reason:'정확 일치'},{sourceColumn:'주문일자',targetField:'orderDate',source:'alias',confidence:1,reason:'Alias 일치'},{sourceColumn:'업체코드',targetField:'customerCode',source:'alias',confidence:1,reason:'Alias 일치'},{sourceColumn:'출고창고',targetField:'warehouseCode',source:'alias',confidence:1,reason:'Alias 일치'},{sourceColumn:'받는분',targetField:'receiverName',source:'alias',confidence:1,reason:'Alias 일치'},{sourceColumn:'상품코드',targetField:'itemCode',source:'alias',confidence:1,reason:'Alias 일치'},{sourceColumn:'수량',targetField:'orderQty',source:'alias',confidence:1,reason:'Alias 일치'}];const session={id,importType:'oms.orders.v1',screenId:'OMS-ORD-003',fileName:'demo-orders.xlsx',status:'mapping-required',totalRows:128,validRows:0,invalidRows:0,warningRows:0,createdRows:0,updatedRows:0,progressPercent:10,mapping,sourceColumns:mapping.map(x=>x.sourceColumn),createdAt:now()};imports.set(id,session);operations=[{id:`import:${id}`,type:'excel-import',title:'주문 Excel Import',sourceScreenId:'OMS-ORD-003',status:'running',requestedAt:now(),startedAt:now(),processed:0,total:128,succeeded:0,failed:0},...operations];return ok(config,clone(session))
}
if(method==='GET'&&/^\/api\/imports\/sessions\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;return ok(config,clone(imports.get(id)??null))}
if(method==='PUT'&&/^\/api\/imports\/sessions\/[^/]+\/mapping$/.test(url)){const id=url.split('/').at(-2)!;const s=imports.get(id);if(s){s.mapping=data.mappings??[];s.status='uploaded';s.progressPercent=20}return ok(config,clone(s))}
if(method==='POST'&&/^\/api\/imports\/sessions\/[^/]+\/validate$/.test(url)){const id=url.split('/').at(-2)!;const s=imports.get(id);if(s){Object.assign(s,{status:'validated',validRows:125,invalidRows:3,warningRows:2,progressPercent:60,errors:[{rowNumber:8,field:'itemCode',sourceColumn:'상품코드',code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다.',severity:'error'}]})}return ok(config,clone(s))}
if(method==='POST'&&/^\/api\/imports\/sessions\/[^/]+\/commit$/.test(url)){const id=url.split('/').at(-2)!;const s=imports.get(id);if(s){Object.assign(s,{status:'partially-completed',createdRows:120,updatedRows:5,progressPercent:100,completedAt:now()});const op=operations.find(x=>x.id===`import:${id}`);if(op)Object.assign(op,{status:'partially-completed',processed:128,succeeded:125,failed:3,completedAt:now(),resultMessage:'125건 반영 / 3건 오류'})}return ok(config,clone(s))}
if(method==='POST'&&/^\/api\/imports\/sessions\/[^/]+\/saved-mappings$/.test(url))return ok(config,undefined,204)
if(method==='GET'&&/^\/api\/imports\/oms.orders.v1\/template$/.test(url))return ok(config,new Blob(['KBX demo workbook'],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}))
if(method==='GET'&&/^\/api\/imports\/sessions\/[^/]+\/errors.xlsx$/.test(url))return ok(config,new Blob(['KBX demo errors'],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}))
if(method==='GET'&&url==='/api/kbx/external-data/status')return ok(config,{items:[{datasetId:'krx.daily',name:'KRX 일별 시세',provider:'KRX OPEN API',status:'healthy',lastSucceededAt:now(),freshness:{observedAt:now(),source:'KRX'}},{datasetId:'dart.corp',name:'DART 기업정보',provider:'OPENDART',status:'healthy',lastSucceededAt:now(),freshness:{observedAt:now(),source:'DART'}}]})
if(method==='POST'&&/^\/api\/kbx\/external-data\/[^/]+\/refresh$/.test(url))return ok(config,{accepted:true})
if(method==='GET'&&url==='/api/kbx/ux/metrics')return ok(config,{summary:{taskCompletionRate:0.96,manualInterventionRate:0.0318,validationFailureRate:0.012},screens:[]})
if(method==='GET'&&url==='/api/kbx/experiments')return ok(config,{items:[]})
if(method==='GET'&&url==='/api/kbx/experiments/assignments')return ok(config,{assignments:[]})
// Frontend demo mode never fabricates a successful write for an unknown operation.
// Returning a visible unsupported response makes missing FE/API coverage explicit during QA.
console.warn(`[KBX demo API] unsupported ${method} ${url}`)
return ok(config,{ type:'system', code:'DEMO_OPERATION_UNSUPPORTED', title:'이 화면의 Demo API가 아직 준비되지 않았습니다.', detail:`${method} ${url}` },501)
}
export function installFrontendDemoApi() {
kbxHttp.defaults.adapter = demoAdapter
}
@@ -0,0 +1,38 @@
import { kbxApiCatalog, type KbxApiOperationDefinition, type KbxApiOperationId, type KbxApiRequestOptions } from '@kbx/contracts'
import { kbxHttp } from '../kbxHttpClient'
function buildPath(template: string, values: Record<string, string | number> = {}) {
return template.replace(/\{([^}:]+)(?::[^}]+)?\}/g, (_, name: string) => {
const key = Object.keys(values).find(x => x.toLowerCase() === name.toLowerCase())
if (!key) throw new Error(`API path parameter '${name}' is required.`)
return encodeURIComponent(String(values[key]))
})
}
export async function requestKbxApi<TResponse = unknown, TBody = unknown>(
operationId: KbxApiOperationId,
options: KbxApiRequestOptions<TBody> = {},
): Promise<TResponse> {
const operation: KbxApiOperationDefinition = kbxApiCatalog[operationId]
if (operation.idempotency === 'required' && !options.idempotencyKey) {
throw new Error(`Operation '${operationId}' requires a stable idempotency key.`)
}
const headers: Record<string, string> = { ...(options.headers ?? {}) }
if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey
const response = await kbxHttp.request<TResponse>({
method: operation.method,
url: buildPath(operation.path, options.path),
params: options.query,
data: options.body,
headers,
responseType: options.responseType ?? operation.responseType ?? 'json',
validateStatus: status => operation.successStatuses.some(value => value === status),
})
return response.data
}
export const kbxApi = {
request: requestKbxApi,
}
@@ -0,0 +1,63 @@
import axios, { AxiosError, type AxiosRequestConfig } from 'axios'
import { isKbxProblem, kbxUnexpectedProblem, type KbxProblem, type KbxSystemProblem } from '@kbx/contracts'
export const kbxHttp = axios.create({
timeout: 30_000,
headers: { 'X-KBX-Client': 'web' },
})
kbxHttp.interceptors.request.use(config => {
config.headers['X-Correlation-Id'] ??= crypto.randomUUID()
return config
})
kbxHttp.interceptors.response.use(
response => response,
(error: AxiosError) => {
const correlationId = String(error.config?.headers?.['X-Correlation-Id'] ?? crypto.randomUUID())
if (!error.response) {
const problem: KbxSystemProblem = {
type: 'system',
code: 'NETWORK_UNAVAILABLE',
title: '서버와 연결할 수 없습니다.',
detail: '입력한 내용은 유지됩니다. 네트워크 상태를 확인한 후 다시 시도하세요.',
correlationId,
retryable: true,
}
return Promise.reject(problem)
}
const data = error.response.data
if (isKbxProblem(data)) {
const problem: KbxProblem = {
...data,
correlationId: data.correlationId ?? correlationId,
}
return Promise.reject(problem)
}
if (error.response.status === 403) {
return Promise.reject({
type: 'permission', code: 'PERMISSION_DENIED', title: '이 작업을 수행할 권한이 없습니다.', correlationId,
} satisfies KbxProblem)
}
if (error.response.status === 404) {
return Promise.reject({
type: 'not-found', code: 'RESOURCE_NOT_FOUND', title: '요청한 데이터를 찾을 수 없습니다.', correlationId,
} satisfies KbxProblem)
}
return Promise.reject(kbxUnexpectedProblem(correlationId, `HTTP ${error.response.status}`, error.response.status >= 500))
},
)
export function withIdempotency(config: AxiosRequestConfig = {}, key = crypto.randomUUID()): AxiosRequestConfig {
return {
...config,
headers: {
...(config.headers ?? {}),
'Idempotency-Key': key,
},
}
}
@@ -0,0 +1,29 @@
import { ref } from 'vue'
import type { KbxConflictSnapshot, KbxProblem, KbxSystemProblem } from '@kbx/contracts'
export function useKbxProblemHandler() {
const conflict = ref<KbxConflictSnapshot | null>(null)
const systemProblem = ref<KbxSystemProblem | null>(null)
function handle(problem: KbxProblem) {
if (problem.type === 'conflict') {
conflict.value = {
code: problem.code,
title: problem.title,
currentVersion: problem.currentVersion,
correlationId: problem.correlationId,
}
return
}
if (problem.type === 'system') {
systemProblem.value = problem
}
}
function clear() {
conflict.value = null
systemProblem.value = null
}
return { conflict, systemProblem, handle, clear }
}