index.vue 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865
  1. <template>
  2. <div class="merge-container">
  3. <van-nav-bar
  4. title="海康并库" left-arrow fixed placeholder @click-left="goBack" @click-right="onDetailsClick">
  5. <template #left>
  6. <van-icon name="arrow-left" size="25" />
  7. <div style="color: #fff">返回</div>
  8. </template>
  9. <template #right>
  10. <div style="color: #fff">详情</div>
  11. </template>
  12. </van-nav-bar>
  13. <!-- 扫描输入框区域 -->
  14. <div class="scan-section">
  15. <van-search
  16. ref="boxCodeInputRef"
  17. v-model.lazy="boxCode"
  18. placeholder="请扫描料箱号"
  19. left-icon=""
  20. :class="['scan-input', scanType === 1 ? 'scan-input-active' : '']"
  21. @search="onBoxCodeEnter"
  22. @focus="onBoxCodeClick"
  23. autocomplete="off"
  24. />
  25. </div>
  26. <!-- 信息展示表格 -->
  27. <div class="info-table">
  28. <div class="table-row">
  29. <div class="cell label">源库位</div>
  30. <div class="cell value input-cell source-location-cell">
  31. <van-search
  32. ref="sourceLocationInputRef"
  33. v-model.lazy="sourceLocation"
  34. placeholder="请扫描源库位"
  35. left-icon=""
  36. class="table-search-input"
  37. @search="onSourceLocationEnter"
  38. @focus="onSourceLocationClick"
  39. autocomplete="off"
  40. />
  41. </div>
  42. <div class="cell label">库存数量</div>
  43. <div class="cell value">{{ productInfo.stockQty }}</div>
  44. </div>
  45. <div class="table-row">
  46. <div class="cell label">商品名称</div>
  47. <div class="cell value span-2">{{ productInfo.productName }}</div>
  48. </div>
  49. <div class="table-row">
  50. <div class="cell label">商品条码</div>
  51. <div class="cell value span-2 input-cell">
  52. <van-search
  53. ref="barcodeInputRef"
  54. v-model.lazy="scanBarcode"
  55. placeholder="请扫描商品条码"
  56. left-icon=""
  57. class="table-search-input"
  58. @search="onBarcodeEnter"
  59. @focus="onBarcodeClick"
  60. autocomplete="off"
  61. />
  62. </div>
  63. </div>
  64. <div class="table-row row-small">
  65. <div class="cell label">质量状态</div>
  66. <div class="cell value value-small">{{ productInfo.qualityStatus }}</div>
  67. <div class="cell label label-small">属性仓</div>
  68. <div class="cell value value-large">{{ productInfo.warehouseType }}</div>
  69. <div class="cell label label-small">批号</div>
  70. <div class="cell value value-small">{{ productInfo.lotNumber }}</div>
  71. </div>
  72. <div class="table-row">
  73. <div class="cell label">生产日期</div>
  74. <div class="cell value">{{ productInfo.productionDate }}</div>
  75. <div class="cell label">失效日期</div>
  76. <div class="cell value">{{ productInfo.expiryDate }}</div>
  77. </div>
  78. <div class="table-row">
  79. <div class="cell label">目标库位</div>
  80. <div class="cell value input-cell input-wide source-location-cell">
  81. <van-search
  82. ref="targetLocationInputRef"
  83. v-model.lazy="productInfo.targetLocationNew"
  84. placeholder="请扫描目标库位"
  85. left-icon=""
  86. class="table-search-input"
  87. @search="onTargetLocationEnter"
  88. @focus="onTargetLocationClick"
  89. autocomplete="off"
  90. />
  91. </div>
  92. <div class="cell label">移库数量</div>
  93. <div class="cell value editable" @dblclick="editMoveQty">
  94. <template v-if="isEditingMoveQty">
  95. <van-field
  96. v-model="productInfo.actualMoveQty"
  97. type="number"
  98. autofocus
  99. @blur="confirmMoveQty"
  100. @keyup.enter="confirmMoveQty"
  101. />
  102. </template>
  103. <template v-else>
  104. <span>{{ productInfo.actualMoveQty }}</span>
  105. <span class="placeholder">双击编辑</span>
  106. </template>
  107. </div>
  108. </div>
  109. </div>
  110. <!-- 站点类型选择和料箱选择区域 -->
  111. <van-tabs v-model:active="selectedStationType" @change="handleStationTypeChange" class="station-tabs">
  112. <van-tab title="上架站点" name="shelf">
  113. <div class="grid-section">
  114. <div class="grid-container">
  115. <div
  116. v-for="station in stationList"
  117. :key="station.id"
  118. class="box-wrapper"
  119. >
  120. <!-- 站台序号在上方 -->
  121. <div class="box-number">{{ station.displayNumber }}</div>
  122. <div
  123. class="box-item"
  124. :class="{
  125. 'box-filled': station.status === 'filled' && !station.splitCount,
  126. 'box-empty-box': station.status === 'emptyBox',
  127. 'box-waiting': station.status === 'waiting',
  128. 'box-error': station.status === 'error',
  129. 'box-selected': selectedBox === station.stationCode,
  130. 'box-split': station.splitCount && ['offline', 'filled', 'emptyBox'].includes(station.status)
  131. }"
  132. @click="handleStationClick(station)"
  133. >
  134. <!-- 分割的料箱(异常/等待调箱状态不渲染分割) -->
  135. <template v-if="station.splitCount && station.subLocations && ['offline', 'filled', 'emptyBox'].includes(station.status)">
  136. <div class="sub-grid" :style="getSubGridStyle(station.splitCount)">
  137. <div
  138. v-for="sub in station.subLocations"
  139. :key="sub.id"
  140. class="sub-location"
  141. :class="{
  142. 'sub-filled': sub.status === 'filled',
  143. 'sub-selected': selectedBox === sub.locationCode,
  144. 'sub-disabled': !isLocationClickable(sub.locationCode)
  145. }"
  146. @click.stop="isLocationClickable(sub.locationCode) && selectSubLocation(station, sub)"
  147. ></div>
  148. </div>
  149. </template>
  150. <!-- 普通站台或异常状态 -->
  151. <template v-else>
  152. <span v-if="station.label" class="box-label">{{ station.label }}</span>
  153. </template>
  154. </div>
  155. </div>
  156. </div>
  157. </div>
  158. </van-tab>
  159. <van-tab title="退货缓存站点" name="return">
  160. <div class="grid-section">
  161. <div class="grid-container">
  162. <div
  163. v-for="station in stationList"
  164. :key="station.id"
  165. class="box-wrapper"
  166. >
  167. <!-- 站台序号在上方 -->
  168. <div class="box-number">{{ station.displayNumber }}</div>
  169. <div
  170. class="box-item"
  171. :class="{
  172. 'box-filled': station.status === 'filled' && !station.splitCount,
  173. 'box-empty-box': station.status === 'emptyBox',
  174. 'box-waiting': station.status === 'waiting',
  175. 'box-error': station.status === 'error',
  176. 'box-selected': selectedBox === station.stationCode,
  177. 'box-split': station.splitCount && ['offline', 'filled', 'emptyBox'].includes(station.status)
  178. }"
  179. @click="handleStationClick(station)"
  180. >
  181. <!-- 分割的料箱(异常/等待调箱状态不渲染分割) -->
  182. <template v-if="station.splitCount && station.subLocations && ['offline', 'filled', 'emptyBox'].includes(station.status)">
  183. <div class="sub-grid" :style="getSubGridStyle(station.splitCount)">
  184. <div
  185. v-for="sub in station.subLocations"
  186. :key="sub.id"
  187. class="sub-location"
  188. :class="{
  189. 'sub-filled': sub.status === 'filled',
  190. 'sub-selected': selectedBox === sub.locationCode,
  191. 'sub-disabled': !isLocationClickable(sub.locationCode)
  192. }"
  193. @click.stop="isLocationClickable(sub.locationCode) && selectSubLocation(station, sub)"
  194. ></div>
  195. </div>
  196. </template>
  197. <!-- 普通站台或异常状态 -->
  198. <template v-else>
  199. <span v-if="station.label" class="box-label">{{ station.label }}</span>
  200. </template>
  201. </div>
  202. </div>
  203. </div>
  204. </div>
  205. </van-tab>
  206. </van-tabs>
  207. <!-- 底部按钮 -->
  208. <div class="footer-buttons">
  209. <van-button class="btn-robot" size="small" @click="callRobot">呼唤机器人</van-button>
  210. <div class="btn-right">
  211. <van-button type="primary" class="btn-reset" size="small" @click="resetAllData">重新输入</van-button>
  212. <van-button class="btn-submit" size="small" @click="submitMove">提交移库</van-button>
  213. </div>
  214. </div>
  215. <!-- 库位信息弹窗 -->
  216. <van-popup
  217. v-model:show="showLocationPopup"
  218. position="bottom"
  219. round
  220. :style="{ maxHeight: '70%' }"
  221. >
  222. <div class="location-popup">
  223. <div class="popup-content">
  224. <!-- 推荐类型信息 -->
  225. <div class="recommend-type-section">
  226. <div class="recommend-type">
  227. {{ currentLocation.recommendType === 'clear' ? '此库位推荐清空' : '此库位推荐保留' }}
  228. </div>
  229. <van-icon name="cross" @click="showLocationPopup = false" />
  230. </div>
  231. <div class="info-row">
  232. <span class="info-label">库位编号</span>
  233. <span class="info-value">{{ currentLocation.id }}</span>
  234. </div>
  235. <div class="info-row">
  236. <span class="info-label">SKU</span>
  237. <span class="info-value">{{ currentLocation.sku || '-' }}</span>
  238. </div>
  239. <!-- 推荐库位列表 -->
  240. <div class="recommend-section">
  241. <div class="recommend-title">
  242. {{ currentLocation.recommendType === 'clear' ? '推荐目标库位列表' : '推荐来源库位列表' }}
  243. </div>
  244. <div v-if="currentLocation.relatedLocations.length > 0" class="recommend-list">
  245. <div
  246. v-for="(item, index) in currentLocation.relatedLocations"
  247. :key="index"
  248. class="recommend-item"
  249. >
  250. <span class="recommend-location">{{ currentLocation.recommendType === 'clear' ? '保留库位' : '清空库位' }}: {{ item.location }}</span>
  251. <span class="recommend-qty">推荐移库数量: {{ item.quantity }}</span>
  252. </div>
  253. </div>
  254. <div v-else class="recommend-empty">暂无推荐库位</div>
  255. </div>
  256. </div>
  257. <div class="popup-footer">
  258. <van-button type="primary" block @click="confirmSelectLocation">选择此库位</van-button>
  259. </div>
  260. </div>
  261. </van-popup>
  262. <!-- 并库任务详情弹框 -->
  263. <MergeTaskDetailsDialog ref="taskDetailsDialogRef"/>
  264. <!-- 料箱选择弹框 -->
  265. <BoxSelectionDialog ref="boxSelectionDialogRef" :box-list="currentBoxList" :warehouse="warehouse" @success="onBoxSelectionSuccess" />
  266. </div>
  267. </template>
  268. <script setup lang="ts">
  269. import { closeListener, openListener, scanInit } from '@/utils/keydownListener'
  270. import { onMounted, onUnmounted, ref, reactive, nextTick } from 'vue'
  271. import { showToast, showLoadingToast, closeToast } from 'vant'
  272. import { useStore } from '@/store/modules/user'
  273. import { getWorkingDetailsByBox, getBoxSplitCode, boxAndStationUnbindTask, reissueTask, getBoxStatus, forceCompleteBoxTask, type BoxRelatedMergeDetailsVO, type LocationMergeDetails } from '@/api/location/merge'
  274. import { getInventory, inventoryMovement } from '@/api/inventory'
  275. import { showConfirmDialog } from 'vant'
  276. import { getHeader, androidFocus, goBack, scanError, scanSuccess } from '@/utils/android'
  277. import MergeTaskDetailsDialog from './components/MergeTaskDetailsDialog.vue'
  278. import BoxSelectionDialog from './components/BoxSelectionDialog.vue'
  279. try {
  280. getHeader()
  281. androidFocus()
  282. } catch (error) {
  283. }
  284. const store = useStore()
  285. const warehouse = store.warehouse
  286. // 扫描类型: 1=料箱号, 2=源库位, 3=商品条码, 4=目标库位
  287. const scanType = ref(1)
  288. // 输入框引用
  289. const boxCodeInputRef = ref<any>(null)
  290. const sourceLocationInputRef = ref<any>(null)
  291. const barcodeInputRef = ref<any>(null)
  292. const targetLocationInputRef = ref<any>(null)
  293. // 扫描料箱号
  294. const boxCode = ref('')
  295. // 源库位
  296. const sourceLocation = ref('')
  297. // 商品条码
  298. const scanBarcode = ref('')
  299. // 选中的站点类型:'return'(退货缓存站点)或 'shelf'(上架站点)
  300. const selectedStationType = ref('return')
  301. // 轮询定时器
  302. let pollingTimer: ReturnType<typeof setInterval> | null = null
  303. // 页面初始化
  304. onMounted(() => {
  305. // 检查仓库是否已选择
  306. if (!warehouse) {
  307. showToast('请先选择仓库')
  308. return
  309. }
  310. openListener()
  311. scanInit(_handlerScan)
  312. // 获取焦点
  313. nextTick(() => {
  314. focusBoxCodeInput()
  315. })
  316. // 防止键盘弹出时底部按钮上移
  317. if (window.visualViewport) {
  318. window.visualViewport.addEventListener('resize', handleViewportResize)
  319. }
  320. })
  321. onUnmounted(() => {
  322. closeListener()
  323. stopPolling()
  324. // 移除viewport监听
  325. if (window.visualViewport) {
  326. window.visualViewport.removeEventListener('resize', handleViewportResize)
  327. }
  328. })
  329. // 处理viewport变化(键盘弹出/收起)
  330. const handleViewportResize = () => {
  331. // 保持页面滚动位置,防止键盘弹出时页面跳动
  332. const scrollTop = document.documentElement.scrollTop || document.body.scrollTop
  333. requestAnimationFrame(() => {
  334. window.scrollTo(0, scrollTop)
  335. })
  336. }
  337. // 设置料箱号输入框焦点
  338. const focusBoxCodeInput = () => {
  339. nextTick(() => {
  340. boxCodeInputRef.value?.focus()
  341. })
  342. }
  343. // 设置源库位输入框焦点
  344. const focusSourceLocationInput = () => {
  345. nextTick(() => {
  346. sourceLocationInputRef.value?.focus()
  347. })
  348. }
  349. // 设置商品条码输入框焦点
  350. const focusBarcodeInput = () => {
  351. nextTick(() => {
  352. barcodeInputRef.value?.focus()
  353. })
  354. }
  355. // 设置目标库位输入框焦点
  356. const focusTargetLocationInput = () => {
  357. nextTick(() => {
  358. targetLocationInputRef.value?.focus()
  359. })
  360. }
  361. // 扫描监听
  362. const _handlerScan = (code: string) => {
  363. if (!code) return
  364. if (scanType.value === 1) {
  365. // 扫描料箱号
  366. boxCode.value = code
  367. loadBoxData(code)
  368. } else if (scanType.value === 2) {
  369. // 扫描源库位
  370. sourceLocation.value = code
  371. onSourceLocationEnter()
  372. } else if (scanType.value === 3) {
  373. // 扫描商品条码
  374. scanBarcode.value = code
  375. onBarcodeEnter()
  376. } else if (scanType.value === 4) {
  377. // 扫描目标库位
  378. productInfo.targetLocationNew = code
  379. onTargetLocationEnter()
  380. }
  381. }
  382. // 料箱号输入框点击 - 重置所有数据
  383. const onBoxCodeClick = () => {
  384. scanType.value = 1
  385. }
  386. // 料箱号回车
  387. const onBoxCodeEnter = () => {
  388. scanType.value = 1
  389. if (boxCode.value && boxCode.value.length > 5) {
  390. loadBoxData(boxCode.value)
  391. }
  392. }
  393. // 源库位输入框点击 - 重置除料箱号外的数据
  394. const onSourceLocationClick = () => {
  395. scanType.value = 2
  396. }
  397. // 源库位回车
  398. const onSourceLocationEnter = () => {
  399. if (!sourceLocation.value) return
  400. // 清空商品信息
  401. resetProductInfo()
  402. // 切换到扫描商品条码
  403. scanType.value = 3
  404. focusBarcodeInput()
  405. }
  406. // 商品条码输入框点击 - 只重置商品信息
  407. const onBarcodeClick = () => {
  408. scanType.value = 3
  409. }
  410. // 目标库位输入框点击
  411. const onTargetLocationClick = () => {
  412. scanType.value = 4
  413. }
  414. // 目标库位回车
  415. const onTargetLocationEnter = () => {
  416. if (!productInfo.targetLocationNew) return
  417. showToast(`已输入目标库位: ${productInfo.targetLocationNew}`)
  418. }
  419. // 处理站点类型切换
  420. const handleStationTypeChange = () => {
  421. // 重新初始化站点(显示不同类型的站点)
  422. initStations()
  423. // 重新加载站点数据
  424. if (boxCode.value) {
  425. refreshBoxData()
  426. }
  427. }
  428. // 当前选中的库存数据(用于提交移库)
  429. const currentInventoryData = ref<any>(null)
  430. // 商品条码回车 - 调用getInventory获取商品信息
  431. const onBarcodeEnter = async () => {
  432. if (!scanBarcode.value) return
  433. if (!sourceLocation.value) {
  434. showToast('请先扫描源库位')
  435. return
  436. }
  437. try {
  438. showLoadingToast({ message: '查询中...', forbidClick: true })
  439. const params = {
  440. warehouse,
  441. barcode: scanBarcode.value,
  442. location: sourceLocation.value
  443. }
  444. const res = await getInventory(params)
  445. closeToast()
  446. if (res.data && res.data.length > 0) {
  447. const inventoryData = res.data[0]
  448. // 保存完整的库存数据
  449. currentInventoryData.value = inventoryData
  450. // 填充商品信息
  451. productInfo.targetLocation = sourceLocation.value
  452. productInfo.stockQty = inventoryData.quantity
  453. productInfo.productName = inventoryData.productName
  454. productInfo.barcode = inventoryData.barcode || inventoryData.barcode2
  455. productInfo.qualityStatus = inventoryData.lotAtt08
  456. productInfo.warehouseType = inventoryData.lotAtt05
  457. productInfo.lotNumber = inventoryData.lotAtt04 || ''
  458. productInfo.productionDate = inventoryData.lotAtt01
  459. productInfo.expiryDate = inventoryData.lotAtt02
  460. // 可移库数量
  461. const availableQty = inventoryData.quantityAvailable + inventoryData.quantityVirtual
  462. productInfo.moveQty = availableQty
  463. // 计算推荐移库数量:取可移库数量和任务推荐数量的最小值
  464. const taskRecommendQty = getTaskRecommendQty(sourceLocation.value)
  465. productInfo.recommendMoveQty = taskRecommendQty > 0 ? Math.min(availableQty, taskRecommendQty) : availableQty
  466. productInfo.actualMoveQty = '' // 用户实际填写数量初始为空
  467. scanSuccess()
  468. showToast('商品信息获取成功')
  469. // 切换到扫描目标库位
  470. scanType.value = 4
  471. focusTargetLocationInput()
  472. } else {
  473. scanError()
  474. showToast('未找到库存信息')
  475. currentInventoryData.value = null
  476. resetProductInfo()
  477. }
  478. } catch (error: any) {
  479. closeToast()
  480. scanError()
  481. showToast(error.message || '查询失败')
  482. }
  483. }
  484. // 重置所有数据(回到最开始状态)
  485. const resetAllData = () => {
  486. boxCode.value = ''
  487. sourceLocation.value = ''
  488. scanBarcode.value = ''
  489. selectedBox.value = null
  490. mergeDataList.value = []
  491. clickableLocationsMap.value = new Map()
  492. resetProductInfo()
  493. productInfo.targetLocationNew = '' // 清空目标库位
  494. initStations()
  495. scanType.value = 1
  496. focusBoxCodeInput()
  497. }
  498. // 重置除料箱号外的数据
  499. const resetExceptBoxCode = () => {
  500. sourceLocation.value = ''
  501. scanBarcode.value = ''
  502. selectedBox.value = null
  503. resetProductInfo()
  504. productInfo.targetLocationNew = '' // 清空目标库位
  505. scanType.value = 2
  506. focusSourceLocationInput()
  507. }
  508. // 重置商品信息
  509. const resetProductInfo = () => {
  510. scanBarcode.value = ''
  511. currentInventoryData.value = null
  512. productInfo.targetLocation = ''
  513. productInfo.stockQty = ''
  514. productInfo.productName = ''
  515. productInfo.barcode = ''
  516. productInfo.qualityStatus = ''
  517. productInfo.warehouseType = ''
  518. productInfo.lotNumber = ''
  519. productInfo.productionDate = ''
  520. productInfo.expiryDate = ''
  521. productInfo.moveQty = ''
  522. productInfo.recommendMoveQty = ''
  523. productInfo.actualMoveQty = ''
  524. }
  525. // 加载料箱数据
  526. const loadBoxData = async (code: string) => {
  527. if (!code) return
  528. // 检查是否为站台编码(以RLOCHK开头)
  529. if (code.startsWith('RLOCHK')) {
  530. handleStationCodeForceComplete(code)
  531. return
  532. }
  533. try {
  534. showLoadingToast({ message: '加载中...', forbidClick: true })
  535. // 1. 调用 getWorkingDetailsByBox 获取任务详情
  536. const res = await getWorkingDetailsByBox(code, warehouse)
  537. const boxDetailsList: BoxRelatedMergeDetailsVO[] = res.data || []
  538. if (boxDetailsList.length === 0) {
  539. closeToast()
  540. scanError()
  541. showToast('未找到相关任务')
  542. return
  543. }
  544. // 保存返回的数据
  545. mergeDataList.value = boxDetailsList
  546. // 2. 遍历 mergeDetails 取源库位和目标库位,去重
  547. const locationSet = new Set<string>()
  548. boxDetailsList.forEach(boxDetail => {
  549. boxDetail.mergeDetails?.forEach((detail: LocationMergeDetails) => {
  550. if (detail.sourceLocation) locationSet.add(detail.sourceLocation)
  551. if (detail.targetLocation) locationSet.add(detail.targetLocation)
  552. })
  553. })
  554. const locations = Array.from(locationSet)
  555. if (locations.length > 0) {
  556. // 3. 调用 getBoxSplitCode 获取库位分割信息
  557. const splitRes = await getBoxSplitCode(warehouse, locations)
  558. const splitMap: Record<string, number> = splitRes.data || {}
  559. // 4. 构建可点击库位信息
  560. buildClickableLocationsMap(boxDetailsList)
  561. // 5. 初始化站台区域
  562. updateStationList(boxDetailsList, splitMap)
  563. }
  564. closeToast()
  565. scanSuccess()
  566. // 加载完成后检查是否需要启动轮询
  567. if (hasWaitingBox()) {
  568. startPolling()
  569. }
  570. // 加载完成后focus到源库位输入框
  571. scanType.value = 2
  572. focusSourceLocationInput()
  573. } catch (error: any) {
  574. closeToast()
  575. scanError()
  576. showToast(error.message || '加载失败')
  577. }
  578. }
  579. // 合并数据列表
  580. const mergeDataList = ref<BoxRelatedMergeDetailsVO[]>([])
  581. // 构建可点击库位信息Map
  582. const buildClickableLocationsMap = (boxDetailsList: BoxRelatedMergeDetailsVO[]) => {
  583. const map = new Map<string, ClickableLocationInfo>()
  584. // 先对所有移库任务去重,避免同一条任务被处理多次导致数量翻倍
  585. const uniqueTaskMap = new Map<string, LocationMergeDetails>()
  586. boxDetailsList.forEach(boxDetail => {
  587. boxDetail.mergeDetails?.forEach((detail: LocationMergeDetails) => {
  588. // 使用 sourceLocation + targetLocation + sku 作为唯一键
  589. const key = `${detail.sourceLocation || ''}_${detail.targetLocation || ''}_${detail.sku || ''}_${detail.lotNum || ''}`
  590. if (!uniqueTaskMap.has(key)) {
  591. uniqueTaskMap.set(key, detail)
  592. }
  593. })
  594. })
  595. // 遍历去重后的任务
  596. uniqueTaskMap.forEach((detail) => {
  597. const sourceLocation = detail.sourceLocation
  598. const targetLocation = detail.targetLocation
  599. const moveQty = detail.moveQty || 0
  600. // 处理源库位(推荐清空库位)
  601. if (sourceLocation) {
  602. if (!map.has(sourceLocation)) {
  603. map.set(sourceLocation, {
  604. recommendType: 'clear',
  605. relatedLocations: [],
  606. sku: detail.sku || ''
  607. })
  608. }
  609. const sourceInfo = map.get(sourceLocation)!
  610. // 添加对应的保留库位
  611. if (targetLocation) {
  612. const existingIdx = sourceInfo.relatedLocations.findIndex(r => r.location === targetLocation)
  613. if (existingIdx === -1) {
  614. sourceInfo.relatedLocations.push({ location: targetLocation, quantity: moveQty })
  615. }
  616. }
  617. }
  618. // 处理目标库位(推荐保留库位)
  619. if (targetLocation) {
  620. if (!map.has(targetLocation)) {
  621. map.set(targetLocation, {
  622. recommendType: 'keep',
  623. relatedLocations: [],
  624. sku: detail.sku || ''
  625. })
  626. }
  627. const targetInfo = map.get(targetLocation)!
  628. // 添加对应的清空库位
  629. if (sourceLocation) {
  630. const existingIdx = targetInfo.relatedLocations.findIndex(r => r.location === sourceLocation)
  631. if (existingIdx === -1) {
  632. targetInfo.relatedLocations.push({ location: sourceLocation, quantity: moveQty })
  633. }
  634. }
  635. }
  636. })
  637. clickableLocationsMap.value = map
  638. }
  639. // 商品信息
  640. const productInfo = reactive({
  641. targetLocation: '',
  642. stockQty: '',
  643. productName: '',
  644. barcode: '',
  645. qualityStatus: '',
  646. warehouseType: '',
  647. lotNumber: '', // 批号,取lotAtt04
  648. productionDate: '',
  649. expiryDate: '',
  650. moveQty: '', // 可移库数量(库存可用数量)
  651. recommendMoveQty: '', // 推荐移库数量(取可移库数量和任务推荐数量的最小值)
  652. actualMoveQty: '', // 用户实际填写的移库数量
  653. targetLocationNew: ''
  654. })
  655. // 移库数量编辑
  656. const isEditingMoveQty = ref(false)
  657. const editMoveQty = () => {
  658. isEditingMoveQty.value = true
  659. }
  660. const confirmMoveQty = () => {
  661. isEditingMoveQty.value = false
  662. }
  663. // 站台数据结构
  664. interface SubLocation {
  665. id: string
  666. status: 'empty' | 'filled' | 'selected'
  667. locationCode: string // 实际库位编码
  668. }
  669. interface StationItem {
  670. id: number
  671. stationCode: string // 站台编码 RLOCHK13A01011
  672. displayNumber: string // 显示序号 13-24
  673. status: 'offline' | 'waiting' | 'filled' | 'emptyBox' | 'error'
  674. label?: string
  675. splitCount?: number
  676. subLocations?: SubLocation[]
  677. boxCode?: string
  678. inventoryLocations?: string[]
  679. }
  680. // 站台列表
  681. const stationList = ref<StationItem[]>([])
  682. // 初始化站台列表(根据站点类型显示不同范围的站台)
  683. const initStations = () => {
  684. const stationRange = selectedStationType.value === 'return'
  685. ? { start: 13, end: 24 } // 退货缓存站点
  686. : { start: 1, end: 12 } // 上架站点
  687. const { start, end } = stationRange
  688. const length = end - start + 1
  689. stationList.value = Array.from({ length }, (_, i) => {
  690. const num = String(start + i).padStart(2, '0')
  691. return {
  692. id: start + i,
  693. stationCode: `RLOCHK${num}A01011`,
  694. displayNumber: num,
  695. status: 'offline' as const
  696. }
  697. })
  698. }
  699. // 判断库位是否可点击
  700. const isLocationClickable = (locationCode: string): boolean => {
  701. return clickableLocationsMap.value.has(locationCode)
  702. }
  703. // 生成子库位
  704. const generateSubLocations = (boxCode: string, splitCount: number, inventoryLocations: string[]): SubLocation[] => {
  705. return Array.from({ length: splitCount }, (_, i) => {
  706. // 库位编码: 分割数为1时库位=料箱编码;否则为 料箱编码-序号
  707. const locationCode = splitCount === 1 ? boxCode : `${boxCode}-${i + 1}`
  708. return {
  709. id: `${boxCode}-${i + 1}`,
  710. locationCode: locationCode,
  711. status: inventoryLocations.includes(locationCode) ? 'filled' : 'empty'
  712. }
  713. })
  714. }
  715. // 根据接口数据更新站台列表
  716. const updateStationList = (boxDetailsList: BoxRelatedMergeDetailsVO[], splitMap: Record<string, number>) => {
  717. const stationToBoxMap = new Map<string, BoxRelatedMergeDetailsVO>()
  718. boxDetailsList.forEach(boxDetail => {
  719. if (boxDetail.station) {
  720. stationToBoxMap.set(boxDetail.station, boxDetail)
  721. }
  722. })
  723. stationList.value = stationList.value.map(station => {
  724. const boxDetail = stationToBoxMap.get(station.stationCode)
  725. if (!boxDetail) {
  726. return { ...station, status: 'offline' as const, label: undefined, splitCount: undefined, subLocations: undefined, boxCode: undefined }
  727. }
  728. const boxCode = boxDetail.boxCode
  729. const boxStatus = boxDetail.boxStatus
  730. const inventoryLocations = boxDetail.inventoryLocations || []
  731. let status: StationItem['status'] = 'offline'
  732. let label: string | undefined = undefined
  733. if (boxStatus === 0 || boxStatus === 10) {
  734. status = 'waiting'
  735. label = '等待调箱'
  736. } else if (boxStatus === 20) {
  737. if (inventoryLocations.length === 0) {
  738. status = 'emptyBox'
  739. label = '空箱'
  740. } else {
  741. status = 'filled'
  742. }
  743. } else if (boxStatus === 30) {
  744. status = 'offline'
  745. } else if (boxStatus === 40) {
  746. status = 'error'
  747. label = '调库异常'
  748. }
  749. // 获取分割数量
  750. let splitCount = 1
  751. if (splitMap[boxCode]) {
  752. splitCount = splitMap[boxCode]
  753. } else {
  754. boxDetail.mergeDetails?.forEach(detail => {
  755. if (detail.sourceLocation && splitMap[detail.sourceLocation]) {
  756. splitCount = Math.max(splitCount, splitMap[detail.sourceLocation])
  757. }
  758. if (detail.targetLocation && splitMap[detail.targetLocation]) {
  759. splitCount = Math.max(splitCount, splitMap[detail.targetLocation])
  760. }
  761. })
  762. }
  763. const updatedStation: StationItem = { ...station, status, label, boxCode, inventoryLocations }
  764. if (splitCount > 1) {
  765. updatedStation.splitCount = splitCount
  766. updatedStation.subLocations = generateSubLocations(boxCode, splitCount, inventoryLocations)
  767. }
  768. return updatedStation
  769. })
  770. }
  771. initStations()
  772. // 获取子库位的grid样式
  773. const getSubGridStyle = (splitCount: number) => {
  774. switch (splitCount) {
  775. case 2:
  776. // 2分割:横着分割(上下分割)
  777. return { gridTemplateColumns: '1fr', gridTemplateRows: 'repeat(2, 1fr)' }
  778. case 4:
  779. return { gridTemplateColumns: 'repeat(2, 1fr)', gridTemplateRows: 'repeat(2, 1fr)' }
  780. case 6:
  781. return { gridTemplateColumns: 'repeat(2, 1fr)', gridTemplateRows: 'repeat(3, 1fr)' }
  782. case 8:
  783. return { gridTemplateColumns: 'repeat(2, 1fr)', gridTemplateRows: 'repeat(4, 1fr)' }
  784. default:
  785. return { gridTemplateColumns: 'repeat(2, 1fr)', gridTemplateRows: 'repeat(2, 1fr)' }
  786. }
  787. }
  788. // 选中的料箱/库位
  789. const selectedBox = ref<string | number | null>(null)
  790. // 可点击库位信息(来自mergeDetails的sourceLocation和targetLocation)
  791. interface LocationRecommendInfo {
  792. location: string
  793. quantity: number
  794. }
  795. interface ClickableLocationInfo {
  796. recommendType: 'clear' | 'keep' // clear=推荐清空库位(源), keep=推荐保留库位(目标)
  797. relatedLocations: LocationRecommendInfo[] // 对应的推荐库位列表
  798. sku: string
  799. }
  800. // 可点击库位集合 Map<库位编码, 库位推荐信息>
  801. const clickableLocationsMap = ref<Map<string, ClickableLocationInfo>>(new Map())
  802. // 库位信息弹窗
  803. const showLocationPopup = ref(false)
  804. const currentLocation = reactive({
  805. id: '',
  806. boxId: '',
  807. status: '' as 'empty' | 'filled',
  808. recommendType: '' as 'clear' | 'keep',
  809. sku: '',
  810. relatedLocations: [] as LocationRecommendInfo[]
  811. })
  812. // 点击站台处理
  813. const handleStationClick = (station: StationItem) => {
  814. // 离线状态不可点击
  815. if (station.status === 'offline') return
  816. // 异常状态弹出重新下发确认框
  817. if (station.status === 'error') {
  818. showReissueConfirm(station)
  819. return
  820. }
  821. // 空料箱状态弹出解绑确认框(仅当料箱无分割,即料箱即库位时才能触发)
  822. if (station.status === 'emptyBox') {
  823. showUnbindConfirm(station)
  824. return
  825. }
  826. // 分割的料箱不处理,由子库位处理
  827. if (station.splitCount) return
  828. // 其他状态正常选择
  829. selectStation(station)
  830. }
  831. // 处理站台编码强制完成任务
  832. const handleStationCodeForceComplete = async (stationCode: string) => {
  833. showConfirmDialog({
  834. title: '强制完成任务',
  835. message: `检测到站台编码:${stationCode},是否强制完成该站台料箱任务?`
  836. }).then(async () => {
  837. try {
  838. showLoadingToast({ message: '正在强制完成任务...', forbidClick: true })
  839. await forceCompleteBoxTask(warehouse, stationCode, null)
  840. closeToast()
  841. scanSuccess()
  842. showToast('强制完成任务成功')
  843. // 重新加载数据
  844. refreshBoxData()
  845. } catch (error: any) {
  846. closeToast()
  847. scanError()
  848. showToast(error.message || '强制完成任务失败')
  849. }
  850. }).catch(() => {
  851. // 用户取消
  852. })
  853. }
  854. // 显示重新下发确认框
  855. const showReissueConfirm = (station: StationItem) => {
  856. showConfirmDialog({
  857. title: '重新下发任务',
  858. message: `料箱${station.boxCode}调用海康异常,是否重新下发任务?`
  859. })
  860. .then(() => {
  861. doReissueTask(station)
  862. })
  863. .catch(() => {
  864. // 用户取消
  865. })
  866. }
  867. // 显示解绑确认框
  868. const showUnbindConfirm = (station: StationItem) => {
  869. showConfirmDialog({
  870. title: '解绑确认',
  871. message: '您正在进行解绑操作,是否继续?'
  872. })
  873. .then(() => {
  874. doUnbindTask(station)
  875. })
  876. .catch(() => {
  877. // 用户取消
  878. })
  879. }
  880. // 执行解绑任务
  881. const doUnbindTask = async (station: StationItem) => {
  882. try {
  883. showLoadingToast({ message: '正在解绑...', forbidClick: true })
  884. const data = {
  885. warehouse,
  886. boxCode: station.boxCode,
  887. stationCode: station.stationCode
  888. }
  889. await boxAndStationUnbindTask(data)
  890. closeToast()
  891. scanSuccess()
  892. showToast('料箱解绑成功')
  893. // 局部刷新料箱状态
  894. setTimeout(() => {
  895. refreshBoxStatus()
  896. }, 500)
  897. } catch (error: any) {
  898. closeToast()
  899. scanError()
  900. showToast(error.message || '解绑失败')
  901. }
  902. }
  903. // 获取当前所有料箱编码列表
  904. const getBoxCodeList = (): string[] => {
  905. return stationList.value
  906. .filter(s => s.boxCode && s.status !== 'offline')
  907. .map(s => s.boxCode!)
  908. }
  909. // 检查是否存在等待调箱的料箱
  910. const hasWaitingBox = (): boolean => {
  911. return stationList.value.some(s => s.status === 'waiting')
  912. }
  913. // 局部更新料箱状态
  914. const refreshBoxStatus = async () => {
  915. const boxCodeList = getBoxCodeList()
  916. if (boxCodeList.length === 0) return
  917. try {
  918. const res = await getBoxStatus(warehouse, boxCodeList)
  919. const statusMap = res.data || {}
  920. // 更新站台列表中的状态
  921. stationList.value = stationList.value.map(station => {
  922. if (!station.boxCode || !statusMap.hasOwnProperty(station.boxCode)) {
  923. return station
  924. }
  925. const newBoxStatus = statusMap[station.boxCode]
  926. const inventoryLocations = station.inventoryLocations || []
  927. let status: StationItem['status'] = 'offline'
  928. let label: string | undefined = undefined
  929. if (newBoxStatus === 0 || newBoxStatus === 10) {
  930. status = 'waiting'
  931. label = '等待调箱'
  932. } else if (newBoxStatus === 20) {
  933. if (inventoryLocations.length === 0) {
  934. status = 'emptyBox'
  935. label = '空箱'
  936. } else {
  937. status = 'filled'
  938. }
  939. } else if (newBoxStatus === 30) {
  940. status = 'offline'
  941. } else if (newBoxStatus === 40) {
  942. status = 'error'
  943. label = '调库异常'
  944. }
  945. return { ...station, status, label }
  946. })
  947. // 检查是否需要继续轮询
  948. if (hasWaitingBox()) {
  949. startPolling()
  950. } else {
  951. stopPolling()
  952. }
  953. } catch (error) {
  954. console.error('刷新料箱状态失败', error)
  955. }
  956. }
  957. // 启动轮询
  958. const startPolling = () => {
  959. if (pollingTimer) return // 已经在轮询中
  960. pollingTimer = setInterval(() => {
  961. refreshBoxStatus()
  962. }, 10000) // 10秒
  963. }
  964. // 停止轮询
  965. const stopPolling = () => {
  966. if (pollingTimer) {
  967. clearInterval(pollingTimer)
  968. pollingTimer = null
  969. }
  970. }
  971. // 静默刷新盒子数据(调用getWorkingDetailsByBox更新库存信息)
  972. const refreshBoxData = async () => {
  973. if (!boxCode.value) return
  974. try {
  975. // 调用 getWorkingDetailsByBox 获取最新任务详情
  976. const res = await getWorkingDetailsByBox(boxCode.value, warehouse)
  977. const boxDetailsList: BoxRelatedMergeDetailsVO[] = res.data || []
  978. if (boxDetailsList.length === 0) {
  979. return
  980. }
  981. // 保存返回的数据
  982. mergeDataList.value = boxDetailsList
  983. // 遍历 mergeDetails 取源库位和目标库位,去重
  984. const locationSet = new Set<string>()
  985. boxDetailsList.forEach(boxDetail => {
  986. boxDetail.mergeDetails?.forEach((detail: LocationMergeDetails) => {
  987. if (detail.sourceLocation) locationSet.add(detail.sourceLocation)
  988. if (detail.targetLocation) locationSet.add(detail.targetLocation)
  989. })
  990. })
  991. const locations = Array.from(locationSet)
  992. if (locations.length > 0) {
  993. // 调用 getBoxSplitCode 获取库位分割信息
  994. const splitRes = await getBoxSplitCode(warehouse, locations)
  995. const splitMap: Record<string, number> = splitRes.data || {}
  996. // 构建可点击库位信息
  997. buildClickableLocationsMap(boxDetailsList)
  998. // 更新站台区域
  999. updateStationList(boxDetailsList, splitMap)
  1000. }
  1001. // 检查是否需要启动轮询
  1002. if (hasWaitingBox()) {
  1003. startPolling()
  1004. } else {
  1005. stopPolling()
  1006. }
  1007. } catch (error) {
  1008. console.error('刷新盒子数据失败', error)
  1009. }
  1010. }
  1011. // 执行重新下发任务
  1012. const doReissueTask = async (station: StationItem) => {
  1013. if (!station.boxCode) {
  1014. scanError()
  1015. showToast('料箱编码不存在')
  1016. return
  1017. }
  1018. try {
  1019. showLoadingToast({ message: '正在重新下发...', forbidClick: true })
  1020. await reissueTask(warehouse, station.stationCode, station.boxCode)
  1021. closeToast()
  1022. scanSuccess()
  1023. showToast('重新下发成功')
  1024. // 局部刷新料箱状态
  1025. setTimeout(() => {
  1026. refreshBoxStatus()
  1027. }, 500)
  1028. } catch (error: any) {
  1029. closeToast()
  1030. scanError()
  1031. showToast(error.message || '重新下发失败')
  1032. }
  1033. }
  1034. // 选择站台(对于未分割的料箱,库位编码等于料箱编码)
  1035. const selectStation = (station: StationItem) => {
  1036. if (station.status === 'waiting' || station.status === 'emptyBox' || station.status === 'offline') return
  1037. const locationCode = station.boxCode || ''
  1038. // 检查是否可点击
  1039. if (!isLocationClickable(locationCode)) {
  1040. scanError()
  1041. showToast('该库位不在合并任务中')
  1042. return
  1043. }
  1044. selectedBox.value = station.stationCode
  1045. // 获取库位推荐信息
  1046. const locationInfo = clickableLocationsMap.value.get(locationCode)
  1047. // 显示库位信息弹窗
  1048. currentLocation.id = locationCode
  1049. currentLocation.boxId = locationCode
  1050. currentLocation.status = station.status === 'filled' ? 'filled' : 'empty'
  1051. currentLocation.recommendType = locationInfo?.recommendType || 'keep'
  1052. currentLocation.sku = locationInfo?.sku || ''
  1053. currentLocation.relatedLocations = locationInfo?.relatedLocations || []
  1054. showLocationPopup.value = true
  1055. }
  1056. // 选择子库位
  1057. const selectSubLocation = (station: StationItem, sub: SubLocation) => {
  1058. // 检查是否可点击
  1059. if (!isLocationClickable(sub.locationCode)) {
  1060. return
  1061. }
  1062. if (station.status === 'emptyBox') {
  1063. showUnbindConfirm(station)
  1064. return
  1065. }
  1066. selectedBox.value = sub.locationCode
  1067. // 获取库位推荐信息
  1068. const locationInfo = clickableLocationsMap.value.get(sub.locationCode)
  1069. // 显示库位信息弹窗
  1070. currentLocation.id = sub.locationCode
  1071. currentLocation.boxId = station.boxCode || ''
  1072. currentLocation.status = sub.status === 'filled' ? 'filled' : 'empty'
  1073. currentLocation.recommendType = locationInfo?.recommendType || 'keep'
  1074. currentLocation.sku = locationInfo?.sku || ''
  1075. currentLocation.relatedLocations = locationInfo?.relatedLocations || []
  1076. showLocationPopup.value = true
  1077. }
  1078. // 确认选择库位
  1079. const confirmSelectLocation = async () => {
  1080. if (currentLocation.recommendType === 'clear') {
  1081. // 推荐清空库位,填入源库位
  1082. sourceLocation.value = currentLocation.id
  1083. showLocationPopup.value = false
  1084. showToast(`已选择源库位: ${currentLocation.id}`)
  1085. // 如果有SKU,自动查询库存信息
  1086. if (currentLocation.sku) {
  1087. scanBarcode.value = currentLocation.sku
  1088. await queryInventoryBySku(currentLocation.sku, currentLocation.id)
  1089. } else {
  1090. // 切换到扫描商品条码
  1091. scanType.value = 3
  1092. focusBarcodeInput()
  1093. }
  1094. } else {
  1095. // 推荐保留库位,填入目标库位
  1096. productInfo.targetLocationNew = currentLocation.id
  1097. showLocationPopup.value = false
  1098. showToast(`已选择目标库位: ${currentLocation.id}`)
  1099. }
  1100. }
  1101. // 并库任务详情弹框
  1102. const taskDetailsDialogRef = ref<any>(null)
  1103. // 点击详情按钮
  1104. const onDetailsClick = () => {
  1105. taskDetailsDialogRef.value?.showDialog(warehouse)
  1106. }
  1107. // 料箱选择弹框
  1108. const boxSelectionDialogRef = ref<any>(null)
  1109. // 当前可选择的料箱列表
  1110. const currentBoxList = ref<string[]>([])
  1111. // 料箱选择成功
  1112. const onBoxSelectionSuccess = () => {
  1113. // 重置页面数据并聚焦到料箱输入框
  1114. refreshBoxData()
  1115. }
  1116. // 根据SKU查询库存信息
  1117. const queryInventoryBySku = async (sku: string, location: string) => {
  1118. try {
  1119. showLoadingToast({ message: '查询中...', forbidClick: true })
  1120. const params = {
  1121. warehouse,
  1122. barcode: sku,
  1123. location: location
  1124. }
  1125. const res = await getInventory(params)
  1126. closeToast()
  1127. if (res.data && res.data.length > 0) {
  1128. const inventoryData = res.data[0]
  1129. // 保存完整的库存数据
  1130. currentInventoryData.value = inventoryData
  1131. // 填充商品信息
  1132. productInfo.targetLocation = location
  1133. productInfo.stockQty = inventoryData.quantity
  1134. productInfo.productName = inventoryData.productName
  1135. productInfo.barcode = inventoryData.barcode || inventoryData.barcode2
  1136. productInfo.qualityStatus = inventoryData.lotAtt08
  1137. productInfo.warehouseType = inventoryData.lotAtt05
  1138. productInfo.lotNumber = inventoryData.lotAtt04 || ''
  1139. productInfo.productionDate = inventoryData.lotAtt01
  1140. productInfo.expiryDate = inventoryData.lotAtt02
  1141. // 可移库数量
  1142. const availableQty = inventoryData.quantityAvailable + inventoryData.quantityVirtual
  1143. productInfo.moveQty = availableQty
  1144. // 计算推荐移库数量:取可移库数量和任务推荐数量的最小值
  1145. const taskRecommendQty = getTaskRecommendQty(location)
  1146. productInfo.recommendMoveQty = taskRecommendQty > 0 ? Math.min(availableQty, taskRecommendQty) : availableQty
  1147. productInfo.actualMoveQty = '' // 用户实际填写数量初始为空
  1148. scanSuccess()
  1149. showToast('商品信息获取成功')
  1150. // 切换到扫描目标库位
  1151. scanType.value = 4
  1152. focusTargetLocationInput()
  1153. } else {
  1154. scanError()
  1155. showToast('未找到库存信息')
  1156. currentInventoryData.value = null
  1157. resetProductInfo()
  1158. // 切换到扫描商品条码
  1159. scanType.value = 3
  1160. focusBarcodeInput()
  1161. }
  1162. } catch (error: any) {
  1163. closeToast()
  1164. scanError()
  1165. showToast(error.message || '查询失败')
  1166. // 切换到扫描商品条码
  1167. scanType.value = 3
  1168. focusBarcodeInput()
  1169. }
  1170. }
  1171. // 获取任务推荐移库数量(从mergeDetails中获取对应源库位的moveQty)
  1172. const getTaskRecommendQty = (location: string): number => {
  1173. let totalQty = 0
  1174. mergeDataList.value.forEach(boxDetail => {
  1175. boxDetail.mergeDetails?.forEach((detail: LocationMergeDetails) => {
  1176. if (detail.sourceLocation === location && detail.moveQty) {
  1177. totalQty += detail.moveQty
  1178. }
  1179. })
  1180. })
  1181. return totalQty
  1182. }
  1183. // 呼唤机器人
  1184. const callRobot = () => {
  1185. // 获取当前所有料箱列表
  1186. currentBoxList.value = getBoxCodeList()
  1187. if (currentBoxList.value.length === 0) {
  1188. showToast('暂无需要回库的料箱')
  1189. return
  1190. }
  1191. // 显示选择Dialog
  1192. boxSelectionDialogRef.value?.showDialog()
  1193. }
  1194. // 提交移库
  1195. const submitMove = () => {
  1196. if (!boxCode.value) {
  1197. scanError()
  1198. showToast('请先扫描料箱号')
  1199. return
  1200. }
  1201. if (!sourceLocation.value) {
  1202. scanError()
  1203. showToast('请先扫描源库位')
  1204. return
  1205. }
  1206. if (!productInfo.barcode) {
  1207. scanError()
  1208. showToast('请先扫描商品条码')
  1209. return
  1210. }
  1211. if (!productInfo.targetLocationNew) {
  1212. scanError()
  1213. showToast('请先选择目标库位')
  1214. return
  1215. }
  1216. if (!productInfo.actualMoveQty || Number(productInfo.actualMoveQty) <= 0) {
  1217. scanError()
  1218. showToast('请输入有效的移库数量')
  1219. return
  1220. }
  1221. if (Number(productInfo.actualMoveQty) > Number(productInfo.moveQty)) {
  1222. scanError()
  1223. showToast('移库数量不能大于可移库数量')
  1224. return
  1225. }
  1226. showConfirmDialog({
  1227. title: '移库确认',
  1228. message: `${productInfo.barcode}从"${sourceLocation.value}"移动至"${productInfo.targetLocationNew}"共:${productInfo.actualMoveQty}件`
  1229. })
  1230. .then(() => {
  1231. const { traceId, lotNumber, ownerCode, owner, sku } = currentInventoryData.value || {}
  1232. console.log(currentInventoryData.value)
  1233. const data = {
  1234. fmLocation: sourceLocation.value,
  1235. fmContainer: traceId || boxCode.value,
  1236. owner: ownerCode || owner || '',
  1237. sku: sku || productInfo.barcode,
  1238. lotNum: lotNumber,
  1239. warehouse,
  1240. quantity: Number(productInfo.actualMoveQty),
  1241. toLocation: productInfo.targetLocationNew,
  1242. transactionType: 'MERGE_TRANSFER'
  1243. }
  1244. showLoadingToast({ message: '提交中...', forbidClick: true })
  1245. // 保存移库前的数据用于判断是否清空
  1246. const movedQty = Number(productInfo.actualMoveQty)
  1247. const availableQty = Number(productInfo.moveQty)
  1248. inventoryMovement(data)
  1249. .then(() => {
  1250. closeToast()
  1251. scanSuccess()
  1252. showToast('提交移库成功')
  1253. // 如果源库位被清空,更新盒子信息
  1254. if (movedQty >= availableQty) {
  1255. setTimeout(() => {
  1256. refreshBoxData()
  1257. }, 500)
  1258. }
  1259. // 重置除料箱号外的数据,继续下一个移库
  1260. resetExceptBoxCode()
  1261. })
  1262. .catch((err: any) => {
  1263. closeToast()
  1264. scanError()
  1265. showToast(err.message || '提交移库失败')
  1266. })
  1267. })
  1268. .catch(() => {
  1269. // 用户取消
  1270. })
  1271. }
  1272. </script>
  1273. <style scoped lang="scss">
  1274. .merge-container {
  1275. min-height: 100vh;
  1276. background: #f5f5f5;
  1277. padding: 12px;
  1278. box-sizing: border-box;
  1279. }
  1280. .scan-section {
  1281. margin-bottom: 12px;
  1282. .scan-input {
  1283. padding: 0;
  1284. background: #fff;
  1285. border-radius: 4px;
  1286. :deep(.van-search__content) {
  1287. background: #fff;
  1288. padding-left: 12px;
  1289. }
  1290. :deep(.van-field__control) {
  1291. font-size: 14px;
  1292. }
  1293. &.scan-input-active {
  1294. :deep(.van-search__content) {
  1295. border-bottom: 2px solid #1989fa;
  1296. }
  1297. }
  1298. }
  1299. }
  1300. .station-tabs {
  1301. margin-bottom: 50px;
  1302. background: #fff;
  1303. border-radius: 4px;
  1304. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  1305. :deep(.van-tabs__nav) {
  1306. background: transparent;
  1307. }
  1308. :deep(.van-tab) {
  1309. font-size: 14px;
  1310. font-weight: 500;
  1311. }
  1312. }
  1313. .info-table {
  1314. background: #fff;
  1315. border: 1px solid #ddd;
  1316. margin-bottom: 12px;
  1317. .table-row {
  1318. display: flex;
  1319. border-bottom: 1px solid #ddd;
  1320. &:last-child {
  1321. border-bottom: none;
  1322. }
  1323. &.row-small .cell {
  1324. font-size: 11px;
  1325. padding: 6px 6px;
  1326. }
  1327. }
  1328. .cell {
  1329. padding: 8px 6px;
  1330. font-size: 13px;
  1331. border-right: 1px solid #ddd;
  1332. flex: 1;
  1333. display: flex;
  1334. align-items: center;
  1335. &:last-child {
  1336. border-right: none;
  1337. }
  1338. &.label {
  1339. background: #f9f9f9;
  1340. color: #666;
  1341. flex: 0 0 55px;
  1342. }
  1343. &.label-small {
  1344. flex: 0 0 40px;
  1345. }
  1346. &.value {
  1347. color: #333;
  1348. &.input-cell {
  1349. padding: 0;
  1350. .table-search-input {
  1351. padding: 0;
  1352. background: transparent;
  1353. :deep(.van-search__content) {
  1354. background: transparent;
  1355. padding-left: 6px;
  1356. }
  1357. :deep(.van-field__control) {
  1358. font-size: 13px;
  1359. }
  1360. }
  1361. }
  1362. &.source-location-cell {
  1363. flex: 2;
  1364. }
  1365. &.editable {
  1366. cursor: pointer;
  1367. min-height: 20px;
  1368. .placeholder {
  1369. color: #ccc;
  1370. font-size: 12px;
  1371. }
  1372. :deep(.van-field) {
  1373. padding: 0;
  1374. .van-field__body {
  1375. height: 20px;
  1376. }
  1377. .van-field__control {
  1378. font-size: 13px;
  1379. }
  1380. }
  1381. }
  1382. }
  1383. &.span-2 {
  1384. flex: 2;
  1385. }
  1386. &.value-small {
  1387. flex: 0.9;
  1388. }
  1389. &.value-large {
  1390. flex: 1.4;
  1391. }
  1392. &.input-wide {
  1393. flex: 1.13;
  1394. }
  1395. }
  1396. }
  1397. .grid-section {
  1398. background: #fff;
  1399. padding: 12px;
  1400. margin-bottom: 12px;
  1401. border-radius: 4px;
  1402. .grid-container {
  1403. display: grid;
  1404. grid-template-columns: repeat(6, 1fr);
  1405. gap: 8px;
  1406. }
  1407. .box-wrapper {
  1408. display: flex;
  1409. flex-direction: column;
  1410. align-items: center;
  1411. .box-number {
  1412. font-size: 12px;
  1413. color: #333;
  1414. margin-bottom: 2px;
  1415. }
  1416. }
  1417. .box-item {
  1418. width: 100%;
  1419. border: 1px solid #ddd;
  1420. font-size: 14px;
  1421. color: #333;
  1422. background: #fff;
  1423. cursor: pointer;
  1424. border-radius: 4px;
  1425. position: relative;
  1426. box-sizing: border-box;
  1427. overflow: hidden;
  1428. /* 使用伪元素实现宽高比,兼容安卓WebView */
  1429. &::before {
  1430. content: '';
  1431. display: block;
  1432. padding-top: 125%; /* 0.8的宽高比 = 1/0.8 = 125% */
  1433. }
  1434. .box-label {
  1435. position: absolute;
  1436. top: 50%;
  1437. left: 50%;
  1438. transform: translate(-50%, -50%);
  1439. font-size: 11px;
  1440. color: #666;
  1441. text-align: center;
  1442. line-height: 1.2;
  1443. }
  1444. &.box-filled {
  1445. background: #e8d4f7;
  1446. border-color: #c9a0dc;
  1447. }
  1448. &.box-empty-box {
  1449. background: #d4f7e0;
  1450. border-color: #a0dcb0;
  1451. }
  1452. &.box-waiting {
  1453. background: #f5d4d4;
  1454. border-color: #e0b0b0;
  1455. }
  1456. &.box-error {
  1457. border-color: #ff6666;
  1458. cursor: pointer;
  1459. .box-label {
  1460. color: #ff6666;
  1461. }
  1462. }
  1463. &.box-selected {
  1464. border: 2px solid #1989fa;
  1465. }
  1466. &.box-split {
  1467. padding: 3px;
  1468. cursor: default;
  1469. .sub-grid {
  1470. position: absolute;
  1471. top: 3px;
  1472. left: 3px;
  1473. right: 3px;
  1474. bottom: 3px;
  1475. display: grid;
  1476. gap: 2px;
  1477. }
  1478. .sub-location {
  1479. border: 1px solid #ddd;
  1480. background: #fff;
  1481. cursor: pointer;
  1482. border-radius: 2px;
  1483. &.sub-filled {
  1484. background: #e8d4f7;
  1485. border-color: #c9a0dc;
  1486. }
  1487. &.sub-selected {
  1488. border: 2px solid #1989fa;
  1489. }
  1490. &.sub-disabled {
  1491. background: #f0f0f0;
  1492. border-color: #ddd;
  1493. cursor: not-allowed;
  1494. opacity: 0.6;
  1495. }
  1496. }
  1497. }
  1498. }
  1499. }
  1500. .footer-buttons {
  1501. position: fixed;
  1502. bottom: 0;
  1503. left: 0;
  1504. right: 0;
  1505. display: flex;
  1506. justify-content: space-between;
  1507. align-items: center;
  1508. padding: 10px 12px;
  1509. padding-bottom: calc(10px + env(safe-area-inset-bottom));
  1510. background: #fff;
  1511. box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
  1512. z-index: 100;
  1513. /* 防止键盘弹出时上移 */
  1514. transform: translate3d(0, 0, 0);
  1515. backface-visibility: hidden;
  1516. -webkit-backface-visibility: hidden;
  1517. .btn-right {
  1518. display: flex;
  1519. /* 使用 margin 替代 gap,兼容安卓 WebView */
  1520. .van-button + .van-button {
  1521. margin-left: 12px;
  1522. }
  1523. }
  1524. .van-button {
  1525. height: 32px;
  1526. font-size: 13px;
  1527. padding: 0 14px;
  1528. min-width: 75px;
  1529. }
  1530. .btn-robot {
  1531. background: #fff;
  1532. border: 1px solid #ff9800;
  1533. color: #ff9800;
  1534. }
  1535. .btn-reset {
  1536. background: #5b9bd5;
  1537. border-color: #5b9bd5;
  1538. }
  1539. .btn-submit {
  1540. background: #ff9800;
  1541. border-color: #ff9800;
  1542. color: #fff;
  1543. }
  1544. }
  1545. .location-popup {
  1546. padding: 16px;
  1547. .popup-header {
  1548. display: flex;
  1549. justify-content: space-between;
  1550. align-items: center;
  1551. padding-bottom: 12px;
  1552. border-bottom: 1px solid #eee;
  1553. .popup-title {
  1554. font-size: 16px;
  1555. font-weight: 500;
  1556. color: #333;
  1557. }
  1558. }
  1559. .popup-content {
  1560. padding: 6px 0;
  1561. .recommend-type-section {
  1562. display: flex;
  1563. justify-content: space-between;
  1564. align-items: center;
  1565. margin-bottom: 10px;
  1566. .recommend-type {
  1567. font-size: 16px;
  1568. font-weight: 500;
  1569. color: #1989fa;
  1570. text-align: left;
  1571. }
  1572. .van-icon {
  1573. font-size: 18px;
  1574. color: #666;
  1575. cursor: pointer;
  1576. }
  1577. }
  1578. .info-row {
  1579. display: flex;
  1580. justify-content: space-between;
  1581. align-items: center;
  1582. padding: 10px 0;
  1583. border-bottom: 1px solid #f5f5f5;
  1584. &:last-child {
  1585. border-bottom: none;
  1586. }
  1587. .info-label {
  1588. font-size: 14px;
  1589. color: #666;
  1590. }
  1591. .info-value {
  1592. font-size: 14px;
  1593. color: #333;
  1594. }
  1595. }
  1596. }
  1597. .popup-footer {
  1598. padding-top: 12px;
  1599. }
  1600. .recommend-section {
  1601. margin-top: 6px;
  1602. padding-top: 6px;
  1603. .recommend-title {
  1604. font-size: 14px;
  1605. font-weight: 500;
  1606. color: #333;
  1607. margin-bottom: 8px;
  1608. text-align: left;
  1609. }
  1610. .recommend-list {
  1611. max-height: 150px;
  1612. overflow-y: auto;
  1613. }
  1614. .recommend-item {
  1615. display: flex;
  1616. justify-content: space-between;
  1617. align-items: center;
  1618. padding: 8px 12px;
  1619. background: #f9f9f9;
  1620. border-radius: 4px;
  1621. margin-bottom: 6px;
  1622. &:last-child {
  1623. margin-bottom: 0;
  1624. }
  1625. .recommend-location {
  1626. font-size: 13px;
  1627. color: #333;
  1628. font-weight: 500;
  1629. }
  1630. .recommend-qty {
  1631. font-size: 12px;
  1632. color: #666;
  1633. }
  1634. }
  1635. .recommend-empty {
  1636. font-size: 13px;
  1637. color: #999;
  1638. text-align: center;
  1639. padding: 12px 0;
  1640. }
  1641. }
  1642. }
  1643. </style>