feat:智能柜手动结算
This commit is contained in:
1 parent
44b2df144c
commit
5071dcbade
3 files changed
+493
-1
No files matched your search
@@ -117,6 +117,34 @@ export function offlinePay(data: any) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 智能柜手动结算提交的商品项;productId 为 hard_spu 主键。 */
|
||||
export interface HardDoorManualSettleItem {
|
||||
productId: string | number
|
||||
productCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能柜订单手动结算(第三方识别不可用时,人工核对视频后结算)
|
||||
*/
|
||||
export function manualSettle(data: { orderId: string | number; items: HardDoorManualSettleItem[] }) {
|
||||
return request({
|
||||
url: "/hard/hard-order/manual-settle",
|
||||
method: "post",
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能柜订单手动取消(用户未拿商品)
|
||||
*/
|
||||
export function manualCancel(data: { orderId: string | number }) {
|
||||
return request({
|
||||
url: "/hard/hard-order/manual-cancel",
|
||||
method: "post",
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function pageJoin(query: any) {
|
||||
return request({
|
||||
url: "/hard/hard-order/page-join",
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="manual-settlement">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="第三方商品识别服务不可用,请核对开门视频,人工选择用户拿走的商品后手动结算;若用户未拿商品请取消订单。"
|
||||
/>
|
||||
|
||||
<div class="settle-layout">
|
||||
<!-- 左侧:按柜层选择商品 -->
|
||||
<div class="settle-goods">
|
||||
<div class="settle-block__title">柜内商品({{ cabinetNoLabel }})</div>
|
||||
<template v-if="floors.length">
|
||||
<div v-for="floor in floors" :key="floor.floorNo" class="settle-floor">
|
||||
<div class="settle-floor__title">第 {{ floor.floorNo }} 层</div>
|
||||
<div class="settle-floor__list">
|
||||
<div v-for="product in floor.products" :key="product.productId" class="settle-product">
|
||||
<preview-image width="64px" height="56px" :src="product.pic" />
|
||||
<div class="settle-product__info">
|
||||
<el-tooltip effect="dark" :content="product.name">
|
||||
<div class="settle-product__name">{{ product.name }}</div>
|
||||
</el-tooltip>
|
||||
<div class="settle-product__meta">
|
||||
<span class="color-primary">{{ displayPrice(product.price) }}</span>
|
||||
<span class="settle-product__stock">库存 {{ product.stock }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-input-number
|
||||
v-model="cartCount[product.productId]"
|
||||
:min="0"
|
||||
:max="Math.max(product.stock, 1)"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else-if="!loading" description="未找到该柜的商品配置" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧:已选购物车 + 视频 + 操作 -->
|
||||
<div class="settle-panel">
|
||||
<div class="settle-cart">
|
||||
<div class="settle-block__title">已选商品</div>
|
||||
<template v-if="cartItems.length">
|
||||
<div class="settle-cart__list">
|
||||
<div v-for="item in cartItems" :key="item.productId" class="settle-cart__item">
|
||||
<span class="settle-cart__name">{{ item.name }}</span>
|
||||
<span class="settle-cart__count">× {{ item.count }}</span>
|
||||
<span class="color-primary">{{ displayPrice(item.price * item.count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settle-cart__total">
|
||||
合计:<span class="color-primary">{{ displayPrice(cartTotalPrice) }}</span>
|
||||
<span class="settle-cart__tip">(金额仅供参考,以实际结算结果为准)</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="请从左侧选择用户拿走的商品" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<div class="settle-video">
|
||||
<div class="settle-video__head">
|
||||
<div class="settle-block__title">开门视频</div>
|
||||
<el-radio-group v-if="videos.length > 1" v-model="currentVideoIndex" size="small">
|
||||
<el-radio-button v-for="(video, index) in videos" :key="index" :label="index">
|
||||
{{ video.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<video-player v-if="currentVideoUrl" :src="currentVideoUrl" width="100%" height="240px" />
|
||||
<el-empty v-else description="无开门视频(视频未回调或回调失败)" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<div class="settle-actions">
|
||||
<el-button
|
||||
v-auth="'hard_hardwareOrder_manualSettle'"
|
||||
type="primary"
|
||||
:disabled="!cartItems.length"
|
||||
:loading="submitting"
|
||||
@click="onSettle"
|
||||
>
|
||||
手动结算
|
||||
</el-button>
|
||||
<el-button
|
||||
v-auth="'hard_hardwareOrder_manualSettle'"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="cancelling"
|
||||
@click="onCancel"
|
||||
>
|
||||
用户未拿商品,取消订单
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="manual-settlement">
|
||||
import { h } from "vue"
|
||||
import { ElMessageBox } from "element-plus"
|
||||
import * as hardFlowManager from "/@/api/hard/hard-door-flow"
|
||||
import { getByCabinetNo } from "/@/api/hard/hard-spu"
|
||||
import { manualCancel, manualSettle } from "/@/api/hard/hard-order"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
|
||||
const VideoPlayer = defineAsyncComponent(() => import("/@/components/VideoPlayer/index.vue"))
|
||||
|
||||
type Props = {
|
||||
orderId?: string
|
||||
hardId?: string
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), { orderId: "", hardId: "" })
|
||||
const emit = defineEmits<{ (e: "refresh"): void }>()
|
||||
|
||||
/** 柜内单层中一个可选商品(同层多槽位同商品已合并)。 */
|
||||
interface FloorProduct {
|
||||
productId: string
|
||||
name: string
|
||||
pic?: string
|
||||
price: number
|
||||
stock: number
|
||||
}
|
||||
|
||||
/** 一层商品。 */
|
||||
interface Floor {
|
||||
floorNo: number
|
||||
products: FloorProduct[]
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const floors = ref<Floor[]>([])
|
||||
const cabinetNoLabel = ref("")
|
||||
/** 商品 id -> 手动选择的数量。 */
|
||||
const cartCount = reactive<Record<string, number>>({})
|
||||
const videos = ref<{ label: string; url: string }[]>([])
|
||||
const currentVideoIndex = ref(0)
|
||||
|
||||
const currentVideoUrl = computed(() => videos.value[currentVideoIndex.value]?.url || "")
|
||||
|
||||
const cartItems = computed(() => {
|
||||
const items: { productId: string; name: string; price: number; count: number }[] = []
|
||||
for (const floor of floors.value) {
|
||||
for (const product of floor.products) {
|
||||
const count = Number(cartCount[product.productId] || 0)
|
||||
if (count > 0) items.push({ productId: product.productId, name: product.name, price: product.price, count })
|
||||
}
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const cartTotalPrice = computed(() => cartItems.value.reduce((sum, item) => sum + item.price * item.count, 0))
|
||||
|
||||
watch(
|
||||
() => [props.orderId, props.hardId],
|
||||
() => load(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const load = async () => {
|
||||
if (!props.orderId || !props.hardId) return
|
||||
loading.value = true
|
||||
floors.value = []
|
||||
videos.value = []
|
||||
currentVideoIndex.value = 0
|
||||
cabinetNoLabel.value = ""
|
||||
Object.keys(cartCount).forEach((key) => delete cartCount[key])
|
||||
try {
|
||||
const { data: flow } = await hardFlowManager.getByOrderId({ orderId: props.orderId })
|
||||
if (flow?.cabinetNo != null) {
|
||||
cabinetNoLabel.value = `${flow.cabinetNo} 号柜`
|
||||
const res = await getByCabinetNo({ hardId: props.hardId, cabinetNo: flow.cabinetNo })
|
||||
floors.value = parseFloors(res.data)
|
||||
}
|
||||
videos.value = parseVideos(flow?.videoCallbackVo)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 /hard-spu/get-by-cabinet-no 的层结构转成层数组,同层同商品多槽位合并、库存求和。 */
|
||||
const parseFloors = (cabinetData: any): Floor[] => {
|
||||
if (!Array.isArray(cabinetData)) return []
|
||||
const result: Floor[] = []
|
||||
cabinetData.forEach((floorItem: any, floorIndex: number) => {
|
||||
const floorNo = floorIndex + 1
|
||||
const slots: any[] = floorItem?.[floorNo] || floorItem?.[String(floorNo)] || []
|
||||
const productMap = new Map<string, FloorProduct>()
|
||||
for (const slot of slots) {
|
||||
if (!slot?.spuId || !slot?.spuDetail) continue
|
||||
const spu = slot.spuDetail
|
||||
const productId = String(spu.id ?? slot.spuId)
|
||||
const joinStock = Number(slot.repertoryCount ?? 0)
|
||||
const spuStock = Number(spu.repertoryCount ?? 0)
|
||||
const exist = productMap.get(productId)
|
||||
if (exist) {
|
||||
exist.stock += joinStock
|
||||
} else {
|
||||
productMap.set(productId, {
|
||||
productId,
|
||||
name: spu.name || "未知商品",
|
||||
pic: spu.pic,
|
||||
price: Number(spu.price ?? 0),
|
||||
// 槽位库存是本柜的可拿数量;未初始化时回落到商品总库存
|
||||
stock: joinStock > 0 ? joinStock : spuStock,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (productMap.size) result.push({ floorNo, products: [...productMap.values()] })
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/** 从视频回调报文 videoCallbackVo 中解析 videoList 的视频地址。 */
|
||||
const parseVideos = (raw: unknown): { label: string; url: string }[] => {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const vo = typeof raw === "string" ? JSON.parse(raw) : raw
|
||||
const list = Array.isArray(vo?.videoList) ? vo.videoList : []
|
||||
return list
|
||||
.filter((video: any) => video?.videoUrl)
|
||||
.map((video: any, index: number) => ({
|
||||
label: Number(video.cameraNo) === 1 ? "主摄" : Number(video.cameraNo) === 2 ? "辅摄" : `视频${index + 1}`,
|
||||
url: String(video.videoUrl),
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error("解析视频回调报文失败", e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const onSettle = async () => {
|
||||
const items = cartItems.value
|
||||
if (!items.length || submitting.value) return
|
||||
const summary = h("div", { style: "margin:8px 0;line-height:22px" }, [
|
||||
h("div", null, `已选商品:${items.map((item) => `${item.name}×${item.count}`).join(";")}`),
|
||||
h("div", null, `合计金额:${displayPrice(cartTotalPrice.value)}(实际金额以结算结果为准)`),
|
||||
h("div", { style: "color:var(--el-color-danger)" }, "确认后将从用户钱包强制扣款或通过支付宝免密扣款,请谨慎操作。"),
|
||||
])
|
||||
try {
|
||||
await ElMessageBox.confirm(summary, "手动结算确认", {
|
||||
confirmButtonText: "确认结算",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await manualSettle({
|
||||
orderId: props.orderId,
|
||||
items: items.map((item) => ({ productId: item.productId, productCount: item.count })),
|
||||
})
|
||||
useMessage().success("手动结算成功")
|
||||
emit("refresh")
|
||||
} catch (e: any) {
|
||||
useMessage().error(e?.msg || "手动结算失败")
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = async () => {
|
||||
if (cancelling.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm("确认该订单用户未拿任何商品?取消后订单状态置为已取消,不可恢复。", "取消订单确认", {
|
||||
confirmButtonText: "确认取消订单",
|
||||
cancelButtonText: "再想想",
|
||||
type: "warning",
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
cancelling.value = true
|
||||
try {
|
||||
await manualCancel({ orderId: props.orderId })
|
||||
useMessage().success("订单已取消")
|
||||
emit("refresh")
|
||||
} catch (e: any) {
|
||||
useMessage().error(e?.msg || "取消订单失败")
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.manual-settlement {
|
||||
.settle-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settle-block__title {
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.settle-goods {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.settle-floor {
|
||||
&:not(:first-child) {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.settle-product {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__stock {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.settle-panel {
|
||||
display: flex;
|
||||
flex: 0 0 360px;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settle-cart {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__count {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__total {
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
&__tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.settle-video {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.settle-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -781,6 +781,20 @@
|
||||
<section v-if="isSmartCabinetOrder" id="section-cabinet" class="detail-section detail-anchor">
|
||||
<hard-door-log v-auth="'dzc_hardDoorFlow_view'" :order-id="String(globalData.detail.id)" />
|
||||
</section>
|
||||
|
||||
<section v-if="showManualSettlementSection" id="section-manual-settlement" class="detail-section detail-anchor">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>手动结算</h2>
|
||||
<p>人工核对开门视频,选择用户拿走的商品后手动完成订单</p>
|
||||
</div>
|
||||
</div>
|
||||
<manual-settlement
|
||||
:order-id="String(globalData.detail.id)"
|
||||
:hard-id="String(globalData.detail.hardId || '')"
|
||||
@refresh="getDetail"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -802,7 +816,7 @@ import * as hardAfterSaleApi from "/@/api/hard/hard-after-sale"
|
||||
import * as hardInvoiceApi from "/@/api/hard/hard-invoice"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
import { getAutoRefundTaskStatus } from "/@/constants/autoRefund"
|
||||
import { Box, Calendar, Check, CreditCard, Document, List, Location, Money, Picture, Printer, ShoppingCart, Timer } from "@element-plus/icons-vue"
|
||||
import { Box, Calendar, Check, Coin, CreditCard, Document, List, Location, Money, Picture, Printer, ShoppingCart, Timer } from "@element-plus/icons-vue"
|
||||
import type { Component } from "vue"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
@@ -810,6 +824,8 @@ import dayjs from "dayjs"
|
||||
const RefundRejectForm = defineAsyncComponent(() => import("./refund-reject.vue"))
|
||||
/** 智能柜订单流程日志,仅设备类型为生鲜柜时加载。 */
|
||||
const HardDoorLog = defineAsyncComponent(() => import("../components/hard-door-log.vue"))
|
||||
/** 智能柜免密订单手动结算,第三方识别不可用时人工核对视频结算。 */
|
||||
const ManualSettlement = defineAsyncComponent(() => import("../components/manual-settlement.vue"))
|
||||
/** 当前订单的全部售后记录与日志入口。 */
|
||||
const SaleTable = defineAsyncComponent(() => import("../components/sale-table.vue"))
|
||||
|
||||
@@ -817,6 +833,10 @@ const SaleTable = defineAsyncComponent(() => import("../components/sale-table.vu
|
||||
const AFTER_SALE_APPLY_STATUS = "10"
|
||||
/** 设备类型 4 表示生鲜智能柜,只有该类型存在开门与识别流程日志。 */
|
||||
const SMART_CABINET_HARD_TYPE = "4"
|
||||
/** 支付方式 9 表示智能柜免密支付,订单依赖第三方识别后自动结算。 */
|
||||
const HARD_DOOR_FREE_PAY_TYPE = "9"
|
||||
/** 智能柜免密订单待结算(未支付)状态编码,仅该状态允许手动结算/取消。 */
|
||||
const DOOR_ORDER_PENDING_SETTLE_STATUS = "1"
|
||||
/** 消费机支付订单类型编码,后端允许该类型没有购物车明细。 */
|
||||
const SIMPLE_PAYMENT_ORDER_TYPE = "9"
|
||||
/** 商城订单类型编码,使用发货、收货履约状态。 */
|
||||
@@ -1224,6 +1244,13 @@ const isSimplePaymentOrder = computed(() => orderTypeCode.value === SIMPLE_PAYME
|
||||
const isSmartCabinetOrder = computed(
|
||||
() => String(globalData.detail.hardType || globalData.detail.sysType || "") === SMART_CABINET_HARD_TYPE,
|
||||
)
|
||||
/** 是否为待结算的智能柜免密订单;第三方识别不可用时提供人工核对视频后的手动结算入口。 */
|
||||
const showManualSettlementSection = computed(
|
||||
() =>
|
||||
String(globalData.detail.payType || "") === HARD_DOOR_FREE_PAY_TYPE &&
|
||||
isSmartCabinetOrder.value &&
|
||||
orderStatusCode.value === DOOR_ORDER_PENDING_SETTLE_STATUS,
|
||||
)
|
||||
|
||||
/** 当前订单在管理端的业务流程摘要。 */
|
||||
const orderScenarioLabel = computed(() => getOrderScenarioLabel(orderTypeCode.value))
|
||||
@@ -1451,6 +1478,9 @@ const sectionNavItems = computed<SectionNavItem[]>(() => {
|
||||
if (isSmartCabinetOrder.value) {
|
||||
items.push({ key: "cabinet", label: "智能柜日志", targetId: "section-cabinet", icon: Box })
|
||||
}
|
||||
if (showManualSettlementSection.value) {
|
||||
items.push({ key: "manual-settlement", label: "手动结算", targetId: "section-manual-settlement", icon: Coin })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user