feat:智能柜手动结算

This commit is contained in:
dzc committed 2026-09-08 15:58:03 +08:00
1 parent 44b2df144c
commit 5071dcbade
3 files changed
+493 -1

No files matched your search

+28
View File
@@ -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
})