chore: remove kbx-foundation-v36 reference (superseded by v4 implementation)
Removed entire kbx-foundation-v36 directory as it's been replaced by the new KBX Foundation v4 patterns implemented in this session: - Registry-driven screen definitions - Density-aware UI adapter components - Feature module templates (ShadowRun, Models) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+217
@@ -0,0 +1,217 @@
|
||||
import type { AxiosAdapter, AxiosRequestConfig, 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 },
|
||||
]
|
||||
|
||||
const orderDetails = new Map<string, any>([
|
||||
['20000000-0000-4000-8000-000000000001', {
|
||||
orderId:'20000000-0000-4000-8000-000000000001', orderNo:'ORD-260811-0001', version:4, status:'출고대기', 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:'신규', 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:'출고대기', 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:'출고대기', exceptionCount:1, status:'READY' },
|
||||
{ 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:'신규', exceptionCount:0, status:'NEW' },
|
||||
{ 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:'출고대기', exceptionCount:0, status:'READY' },
|
||||
{ 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:'보류', exceptionCount:1, status:'HOLD' },
|
||||
{ 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:'출고완료', exceptionCount:0, status:'SHIPPED' },
|
||||
]
|
||||
|
||||
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 = [
|
||||
{ id:'op-1', module:'OMS', category:'재고', code:'STOCK_SHORTAGE', title:'주문 재고 부족', detail:'ABC001 가용재고 부족', severity:'warning', status:'open', owner:'', occurredAt:now(), screenId:'OMS-ORD-001', entityId:orderRows[0].id },
|
||||
{ id:'op-2', module:'WMS', category:'연계', code:'SHIPMENT_SYNC', title:'출고 연계 지연', detail:'WMS 전송 재시도 대기', severity:'error', status:'open', owner:'', occurredAt:now(), screenId:'COMMON-OPS-001', entityId:'sync-2' },
|
||||
]
|
||||
const reconcileItems = [
|
||||
{ id:'rec-1', reconcileType:'OMS_WMS_SHIPMENT', referenceNo:'ORD-260811-0001', expected:2, actual:1, difference:-1, reason:'피킹부족', status:'mismatch', resolutionStatus:'미처리' },
|
||||
{ id:'rec-2', reconcileType:'OMS_WMS_SHIPMENT', referenceNo:'ORD-260811-0003', expected:1, actual:1, difference:0, reason:'', status:'matched', resolutionStatus:'정상' },
|
||||
]
|
||||
|
||||
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 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.status===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.status==='NEW').length,readyToShip:orderRows.filter(x=>x.status==='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: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.status==='READY').map(row=>row.id)
|
||||
let accepted=0; for(const id of ids){const row=orderRows.find(x=>x.id===id);if(row&&row.status==='READY'){row.shipmentStatus='출고지시';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:'작성',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:'작성',exceptionCount:0,status:'NEW'});audit(id,'주문 등록');return ok(config,{orderId:id,orderNo,version:1,status:'작성'})
|
||||
}
|
||||
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??'작성',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){existing.version=Number(existing.version)+1;existing.status='확정';audit(id,'주문 확정');return ok(config,{orderId:id,orderNo:existing.orderNo,version:existing.version,status:'확정'})}return ok(config,{orderId:id,orderNo:'',version:1,status:'확정'})}
|
||||
|
||||
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'){return ok(config,{items:clone(workItems),totalCount:workItems.length,counters:{all:workItems.length,open:workItems.filter(x=>x.status==='open').length,critical:workItems.filter(x=>x.severity==='error').length}})}
|
||||
if(method==='POST'&&url==='/api/operations/work-items/claim'){for(const id of data.ids??[]){const item=workItems.find(x=>x.id===id);if(item)item.owner='QA 사용자'}return ok(config,{updated:(data.ids??[]).length})}
|
||||
if(method==='POST'&&url==='/api/operations/work-items/resolve'){for(const id of data.ids??[]){const item=workItems.find(x=>x.id===id);if(item)item.status='resolved'}return ok(config,{updated:(data.ids??[]).length})}
|
||||
if(method==='POST'&&/^\/api\/operations\/work-items\/[^/]+\/retry$/.test(url))return ok(config,{accepted:true})
|
||||
if(method==='GET'&&url==='/api/reconcile/items'){return ok(config,{items:clone(reconcileItems),totalCount:reconcileItems.length,mismatchCount:reconcileItems.filter(x=>x.difference!==0).length})}
|
||||
if(method==='POST'&&url==='/api/reconcile/items/create-exceptions')return ok(config,{created:(data.ids??[]).length})
|
||||
|
||||
if(method==='GET'&&url==='/api/oms/claims')return ok(config,{items:[{id:'claim-1',claimNo:'CLM-260811-01',orderNo:'ORD-260811-0001',type:'RETURN',reason:'사이즈 교환',status:'접수',customerName:'홍길동',requestedAt:now(),version:1}],totalCount:1})
|
||||
if(method==='POST'&&/^\/api\/oms\/claims\/[^/]+\/(approve|hold|start|complete)$/.test(url)){const transition=url.split('/').at(-1)!;return ok(config,{id:url.split('/').at(-2),status:{approve:'승인',hold:'보류',start:'처리중',complete:'완료'}[transition],version:2})}
|
||||
|
||||
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 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:['주문번호','상품코드','수량','구매자'],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
|
||||
}
|
||||
+38
@@ -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,
|
||||
}
|
||||
+63
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
+29
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user