update customer package

This commit is contained in:
Timi
2026-09-06 15:49:20 +08:00
parent b8b3ecbf5d
commit 4c6920be85
19 changed files with 383 additions and 285 deletions
+1 -1
View File
@@ -216,7 +216,7 @@ async function save(): Promise<void> {
if (!requestNo.value) {
requestNo.value = createPackageRequestNo();
}
const result = await SalesOrderAPI.fundRecordRevoke({
const result = await SalesOrderAPI.revokeFund({
requestNo: requestNo.value,
fundRecordId: record.value.id,
occurredAt: buildOccurredAt(),
+26 -3
View File
@@ -1,6 +1,10 @@
<template>
<div v-can="permissionCodes.salesOrder.cancel" class="sales-order-operation">
<t-cell-group v-if="order" title="销售订单" theme="card"><t-cell title="订单号" :note="order.orderNo || order.id" /><t-cell title="当前状态" :note="order.status || '未知'" /></t-cell-group>
<t-cell-group v-if="order" title="销售订单" theme="card">
<t-cell title="订单号" :note="order.orderNo || order.id" />
<t-cell title="当前状态" :note="getSalesOrderStatusText(order.status)" />
<t-cell v-if="hasPendingFulfillment" title="履约提示" note="存在待处理履约,暂不能取消订单" />
</t-cell-group>
<div v-if="order" class="revoke-tip">取消订单会立即撤销本单有效消费;受影响的订单积分和签到消费条件积分将由系统自动追加撤销流水。退款仍需按资金流程处理。</div>
<t-form v-if="order" class="form" label-align="left" label-width="6rem">
<t-form-item label="取消原因" required-mark><t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入取消原因" /></t-form-item>
@@ -11,26 +15,45 @@
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import FulfillmentAPI from "@/api/FulfillmentAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { SalesOrder } from "@/types/SalesOrder";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { getSalesOrderStatusText } from "@/utils/salesOrder";
defineOptions({ name: "SalesOrderCancel" });
const route = useRoute(); const router = useRouter(); const navBarStore = useNavBarStore(); const userStore = useUserStore();
const order = ref<SalesOrder>(); const loading = ref(false); const saving = ref(false); const reason = ref("");
const order = ref<SalesOrder>(); const loading = ref(false); const saving = ref(false); const reason = ref(""); const hasPendingFulfillment = ref(false);
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) return;
loading.value = true;
try { order.value = await SalesOrderAPI.detail(id); }
try {
order.value = await SalesOrderAPI.detail(id);
if (userStore.hasPermissions(permissionCodes.orderFulfillment.read)) {
const [pendingResult, waitingStockResult] = await Promise.all([
FulfillmentAPI.list({ salesOrderId: id, status: "PENDING", index: 0, size: 1 }),
FulfillmentAPI.list({ salesOrderId: id, status: "WAITING_STOCK", index: 0, size: 1 })
]);
hasPendingFulfillment.value = !!pendingResult.list?.length || !!waitingStockResult.list?.length;
}
}
catch (error) { Message.error(getErrorMessage(error, "订单加载失败")); }
finally { loading.value = false; }
}
async function save(): Promise<void> {
if (!order.value?.id || !reason.value || saving.value) return;
if (order.value.status !== "ACTIVE") {
Message.warning("只有进行中的订单可以取消");
return;
}
if (hasPendingFulfillment.value) {
Message.warning("存在待处理履约,暂不能取消订单");
return;
}
saving.value = true;
try {
await SalesOrderAPI.cancel({ salesOrderId: order.value.id, reason: reason.value.trim() });
+169 -97
View File
@@ -10,7 +10,7 @@
<h1 class="no" v-text="order.orderNo" />
<div class="meta">
<pass-time-label class="time" :timestamp="order.purchasedAt" underline />
<div class="amount" :class="getPaymentAmountClass(order.paymentStatus)">
<div class="amount red text-bold">
<span>¥</span>
<strong class="value" v-text="formatPackageAmount(order.saleAmount)" />
</div>
@@ -66,63 +66,81 @@
</div>
</t-form-item>
</t-form>
<t-cell-group
<t-form
v-if="order.itemList?.length"
class="product empty-cell-title-margin"
class="form product"
title="订单明细"
theme="card"
label-width="0"
v-entrance-transition="140"
>
<t-cell
class="item"
v-for="item in order.itemList"
:key="item.id"
>
<template #leftIcon>
<t-image
v-if="getProductPhotoURL(item)"
:src="getProductPhotoURL(item)"
:alt="item.productNameSnapshot || '产品图片'"
fit="cover"
class="avatar"
/>
<div
v-else
class="avatar fallback"
v-text="(item.productNameSnapshot || '产').slice(0, 1)"
/>
</template>
<template #title>
<div class="title">
<span v-text="getItemTitle(item)"></span>
<t-tag
:content="getItemFulfillmentStatus(item).text"
:theme="getItemFulfillmentStatus(item).theme"
variant="light-outline"
/>
<t-form-item class="form-item" v-for="item in order.itemList" :key="item.id">
<div class="item">
<div class="header">
<div class="left">
<t-image
v-if="getProductPhotoURL(item)"
:src="getProductPhotoURL(item)"
:alt="item.productNameSnapshot || '产品图片'"
fit="cover"
class="avatar"
/>
<div
v-else
class="avatar fallback"
v-text="(item.productNameSnapshot || '产').slice(0, 1)"
/>
</div>
<div class="main">
<div class="title">
<span class="name text-bold" v-text="item.productNameSnapshot" />
<t-tag
:content="getItemFulfillmentStatus(item).text"
:theme="getItemFulfillmentStatus(item).theme"
variant="light-outline"
/>
</div>
<div class="description">
<strong
v-if="item.lineAmount !== null && item.lineAmount !== undefined"
class="red"
v-text="`¥${formatPackageAmount(item.lineAmount)}`"
/>
<t-tag v-else size="small" content="套餐权益" />
<t-tag size="small" theme="primary" variant="light" :content="item.unitSnapshot" />
</div>
</div>
</div>
</template>
<template #description>
<div class="description">
<span>
<span>¥</span>
<strong class="red" v-text="formatPackageAmount(item.lineAmount)" />
</span>
<span>
<span>已履约</span>
<div class="content">
<div class="metric">
<strong v-text="getPackageQuantityText(item.quantity)" />
<span>总数</span>
</div>
<div class="metric">
<strong :class="getAvailableQuantityClass(item)" v-text="getPackageQuantityText(item.availableQuantity)" />
<span>可履约</span>
</div>
<div class="metric">
<strong v-text="getPackageQuantityText(item.pendingQuantity)" />
<span>待核销</span>
</div>
<div class="metric">
<strong :class="getFulfilledQuantityClass(item)" v-text="getPackageQuantityText(item.fulfilledQuantity)" />
</span>
<span>
<span>剩余履约</span>
<strong :class="getRemainingQuantityClass(item)" v-text="getRemainingQuantityText(item)" />
</span>
<span>已履约</span>
</div>
<div class="metric">
<strong :class="getRemainingQuantityClass(item)" v-text="getPackageQuantityText(item.remainingQuantity)" />
<span>剩余</span>
</div>
</div>
</template>
</t-cell>
<t-cell title="履约记录" note="查看本单所有履约单" arrow @click="toFulfillmentList" />
</t-cell-group>
</div>
</t-form-item>
<t-form-item label="履约记录" arrow @click="toFulfillmentList">
查看本单所有履约单
</t-form-item>
</t-form>
<t-button
v-if="canCreateFulfillment && canFulfillByPayment && order.orderType === 'DIRECT_PRODUCT' && order.status === 'ACTIVE'"
v-if="canCreateFulfillment && canFulfillByPayment && order.status === 'ACTIVE'"
class="cell-button"
theme="primary"
content="履约产品"
@@ -149,6 +167,11 @@
<t-cell title="退款状态" :note="getSalesRefundStatusText(order.refundStatus)" />
<t-cell v-if="canReadFund" title="查看全部资金流水" arrow @click="toFundRecords" />
</t-cell-group>
<t-cell-group v-if="order.status === 'CANCELLED'" class="cancel-info" title="取消记录" theme="card">
<t-cell title="取消时间" :note="order.cancelledAt ? Time.toDateTime(order.cancelledAt) : '未知'" />
<t-cell title="取消人" :note="order.cancelOperator?.name || order.cancelOperatorUserId || '未知'" />
<t-cell title="取消原因" :note="order.cancelReason || '未填写'" />
</t-cell-group>
<section class="actions" v-entrance-transition="280">
<t-button
v-if="canReceive"
@@ -179,11 +202,12 @@
<script setup lang="ts">
import { PassTimeLabel, Text, Time } from "timi-web";
import { type Attachment, useUserStore } from "timi-web-api";
import FulfillmentAPI from "@/api/FulfillmentAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { SalesOrder, SalesOrderItem, SalesPaymentStatus } from "@/types/SalesOrder";
import type { GaoSalesOrderItem, GaoSalesPaymentStatus, SalesOrder } from "@/types/SalesOrder";
import type { PhotoItem } from "@/types/Photo";
import { formatPackageAmount, getPackageQuantityText } from "@/utils/package";
import { getAttachmentReadURL } from "@/utils/attachment";
@@ -205,6 +229,7 @@ const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const order = ref<SalesOrder>();
const hasPendingFulfillment = ref(false);
const loading = ref(false);
const isEditing = ref(false);
const isSaving = ref(false);
@@ -217,7 +242,7 @@ const canOperateStock = computed(() => userStore.hasPermissions(permissionCodes.
const canReceive = computed(() => !!order.value && order.value.status !== "CANCELLED" && order.value.paymentStatus !== "PAID" && userStore.hasPermissions(permissionCodes.fundReceive.create));
const canRefund = computed(() => !!order.value && order.value.status === "CANCELLED" && order.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund));
const canConfirmNoRefund = computed(() => !!order.value && order.value.status === "CANCELLED" && order.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund));
const canCancel = computed(() => !!order.value && order.value.status === "ACTIVE" && userStore.hasPermissions(permissionCodes.salesOrder.cancel));
const canCancel = computed(() => !!order.value && order.value.status === "ACTIVE" && !hasPendingFulfillment.value && userStore.hasPermissions(permissionCodes.salesOrder.cancel));
const canFulfillByPayment = computed(() => order.value?.paymentStatus === "PAID" || (order.value?.paymentStatus === "PARTIALLY_PAID" && canOperateStock.value));
const photoURLList = computed(() => photoItems.value.map(item => item.url));
@@ -246,7 +271,7 @@ function getPaidAtText(paidAt?: number | null): string {
return paidAt ? Time.toDateTime(paidAt) : "待后端回填";
}
function getPaymentAmountClass(status?: SalesPaymentStatus): "green" | "orange" | "red" | "gray" {
function getPaymentAmountClass(status?: GaoSalesPaymentStatus): "green" | "orange" | "red" | "gray" {
if (status === "PAID") return "green";
if (status === "PARTIALLY_PAID") return "orange";
if (status === "UNPAID") return "red";
@@ -263,37 +288,22 @@ function getRemainingAmountClass(amount?: number | null): "green" | "red" | "gra
return amount === 0 ? "green" : "red";
}
function getItemTitle(item: SalesOrderItem): string {
const productName = item.productNameSnapshot || "未命名产品";
const quantity = getPackageQuantityText(item.quantity);
const unit = item.unitSnapshot ? ` ${item.unitSnapshot}` : "";
return `${productName} ${quantity}${unit}`;
function getAvailableQuantityClass(item: GaoSalesOrderItem): "green" | "orange" {
return Number(item.availableQuantity || 0) === 0 ? "green" : "orange";
}
function getRemainingQuantity(item: SalesOrderItem): number {
const quantity = Number(item.quantity || 0);
function getFulfilledQuantityClass(item: GaoSalesOrderItem): "green" | "orange" {
return Number(item.remainingQuantity || 0) === 0 ? "green" : "orange";
}
function getRemainingQuantityClass(item: GaoSalesOrderItem): "green" | "orange" {
return Number(item.remainingQuantity || 0) === 0 ? "green" : "orange";
}
function getItemFulfillmentStatus(item: GaoSalesOrderItem): { text: string; theme: "success" | "warning" | "danger" } {
const fulfilledQuantity = Number(item.fulfilledQuantity || 0);
return Math.max(0, quantity - fulfilledQuantity);
}
function getRemainingQuantityText(item: SalesOrderItem): string {
return getPackageQuantityText(getRemainingQuantity(item).toFixed(3));
}
function getFulfilledQuantityClass(item: SalesOrderItem): "green" | "orange" {
const quantity = Number(item.quantity || 0);
const fulfilledQuantity = Number(item.fulfilledQuantity || 0);
return fulfilledQuantity < quantity ? "orange" : "green";
}
function getRemainingQuantityClass(item: SalesOrderItem): "green" | "orange" {
return getRemainingQuantity(item) === 0 ? "green" : "orange";
}
function getItemFulfillmentStatus(item: SalesOrderItem): { text: string; theme: "success" | "warning" | "danger" } {
const quantity = Number(item.quantity || 0);
const fulfilledQuantity = Number(item.fulfilledQuantity || 0);
if (0 < quantity && (fulfilledQuantity === quantity || quantity < fulfilledQuantity)) {
const remainingQuantity = Number(item.remainingQuantity || 0);
if (0 < fulfilledQuantity && remainingQuantity === 0) {
return { text: "已完成", theme: "success" };
}
if (0 < fulfilledQuantity) {
@@ -308,12 +318,25 @@ async function loadData(): Promise<void> {
navBarStore.clearRight();
if (!id) {
order.value = undefined;
hasPendingFulfillment.value = false;
photoItems.value = [];
return;
}
loading.value = true;
try {
order.value = await SalesOrderAPI.detail(id);
hasPendingFulfillment.value = false;
if (userStore.hasPermissions(permissionCodes.orderFulfillment.read)) {
try {
const [pendingResult, waitingStockResult] = await Promise.all([
FulfillmentAPI.list({ salesOrderId: id, status: "PENDING", index: 0, size: 1 }),
FulfillmentAPI.list({ salesOrderId: id, status: "WAITING_STOCK", index: 0, size: 1 })
]);
hasPendingFulfillment.value = !!pendingResult.list?.length || !!waitingStockResult.list?.length;
} catch {
// 取消接口仍会在后端校验待处理履约,查询失败时不阻断订单详情展示。
}
}
editRemark.value = order.value.remark || "";
photoItems.value = toPhotoItems(order.value.attachmentList);
} catch (error) {
@@ -464,7 +487,6 @@ onUnmounted(() => navBarStore.clearRight());
gap: .75rem;
display: flex;
margin-top: .6rem;
color: var(--td-text-color-secondary);
align-items: end;
justify-content: space-between;
@@ -513,27 +535,77 @@ onUnmounted(() => navBarStore.clearRight());
.product {
.item {
.form-item {
padding: 10px;
.avatar {
width: 3.5rem;
height: 3.5rem;
}
.title {
flex: 1;
.item {
width: 100%;
display: flex;
justify-content: space-between;
}
flex-direction: column;
.description {
gap: .75rem;
display: flex;
> span {
gap: .25rem;
.header {
width: 100%;
display: flex;
padding-bottom: .5rem;
border-bottom: 1px solid rgba(155, 155, 155, .2);
.left {
.avatar {
width: 3.5rem;
height: 3.5rem;
}
}
.main {
flex: 1;
display: flex;
padding-left: .5rem;
flex-direction: column;
justify-content: space-between;
.title {
display: flex;
align-items: center;
justify-content: space-between;
}
.description {
gap: .5rem;
display: flex;
}
}
}
.content {
display: flex;
padding-top: 5px;
justify-content: space-around;
.metric {
gap: .25rem;
flex: 1;
display: flex;
position: relative;
align-items: center;
flex-direction: column;
&:not(:last-child):after {
content: "";
top: 50%;
right: 0;
width: 1px;
height: 50%;
transform: translateY(calc(-50% + 2.5px));
position: absolute;
background: rgba(155, 155, 155, .2);
}
span {
color: var(--td-text-color-secondary);
font-size: .65rem;
}
}
}
}
}
+7 -7
View File
@@ -70,7 +70,7 @@ import SalesOrderAPI from "@/api/SalesOrderAPI";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import SalesOrderListCell from "@/pages/salesOrder/components/SalesOrderListCell.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { SalesOrder, SalesOrderPage, SalesOrderStatus, SalesOrderType, SalesPaymentStatus, SalesRefundStatus } from "@/types/SalesOrder";
import type { GaoSalesOrderStatus, GaoSalesOrderType, GaoSalesPaymentStatus, GaoSalesRefundStatus, SalesOrder, SalesOrderPage } from "@/types/SalesOrder";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
@@ -93,8 +93,8 @@ const actions = computed(() => canCreate.value ? [{
}] : []);
const keyword = ref("");
const orderType = ref("ALL");
type PaymentFilter = "ALL" | SalesPaymentStatus | "PENDING_RECEIVE";
type RefundFilter = "ALL" | SalesRefundStatus;
type PaymentFilter = "ALL" | GaoSalesPaymentStatus | "PENDING_RECEIVE";
type RefundFilter = "ALL" | GaoSalesRefundStatus;
const status = ref("ALL");
const paymentStatus = ref<PaymentFilter>("ALL");
@@ -135,10 +135,10 @@ function buildPageFilter(): SalesOrderPage {
const pendingReceive = paymentStatus.value === "PENDING_RECEIVE";
return {
keyword: keyword.value || undefined,
orderType: orderType.value === "ALL" ? undefined : orderType.value as SalesOrderType,
status: status.value === "ALL" ? undefined : status.value as SalesOrderStatus,
paymentStatus: pendingReceive || paymentStatus.value === "ALL" ? undefined : paymentStatus.value as SalesPaymentStatus,
refundStatus: refundStatus.value === "ALL" ? undefined : refundStatus.value as SalesRefundStatus,
orderType: orderType.value === "ALL" ? undefined : orderType.value as GaoSalesOrderType,
status: status.value === "ALL" ? undefined : status.value as GaoSalesOrderStatus,
paymentStatus: pendingReceive || paymentStatus.value === "ALL" ? undefined : paymentStatus.value as GaoSalesPaymentStatus,
refundStatus: refundStatus.value === "ALL" ? undefined : refundStatus.value as GaoSalesRefundStatus,
pendingReceive: pendingReceive ? true : undefined,
index: pageIndex.value,
size: pageSize
+3 -5
View File
@@ -13,14 +13,13 @@ import { useUserStore } from "timi-web-api";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { SalesOrder } from "@/types/SalesOrder";
import { createPackageRequestNo } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "SalesOrderNoRefund" });
const route = useRoute(); const router = useRouter(); const navBarStore = useNavBarStore(); const userStore = useUserStore();
const order = ref<SalesOrder>(); const loading = ref(false); const saving = ref(false); const reason = ref(""); const requestNo = ref("");
const order = ref<SalesOrder>(); const loading = ref(false); const saving = ref(false); const reason = ref("");
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) return;
@@ -31,13 +30,12 @@ async function loadData(): Promise<void> {
}
async function save(): Promise<void> {
if (!order.value?.id || !reason.value || saving.value) return;
if (!requestNo.value) requestNo.value = createPackageRequestNo();
saving.value = true;
try {
await SalesOrderAPI.confirmNoRefund({ salesOrderId: order.value.id, requestNo: requestNo.value, reason: reason.value.trim() });
await SalesOrderAPI.confirmNoRefund({ salesOrderId: order.value.id, reason: reason.value.trim() });
Message.success("已确认无需退款");
await router.replace(`/management/sales-orders/${order.value.id}`);
} catch (error) { Message.error(getErrorMessage(error, "确认失败,重试会复用同一请求号")); }
} catch (error) { Message.error(getErrorMessage(error, "确认失败,请刷新后重试")); }
finally { saving.value = false; }
}
onMounted(async () => {
@@ -15,6 +15,12 @@
<div class="customer-status">
<strong class="customer" v-text="customerName" />
<div class="status">
<t-tag
:content="getSalesOrderTypeText(props.order.orderType)"
theme="primary"
size="small"
variant="light-outline"
/>
<t-tag
:content="getSalesOrderStatusText(props.order.status)"
size="small"
@@ -82,10 +88,10 @@
<script setup lang="ts">
import { PassTimeLabel } from "timi-web";
import type { SalesOrder, SalesPaymentStatus } from "@/types/SalesOrder";
import type { GaoSalesPaymentStatus, SalesOrder } from "@/types/SalesOrder";
import { getCustomerPhotoURL } from "@/utils/customer";
import { formatPackageAmount, getPackageQuantityText } from "@/utils/package";
import { getSalesOrderStatusText, getSalesPaymentStatusText, getStatusTheme } from "@/utils/salesOrder";
import { getSalesOrderStatusText, getSalesOrderTypeText, getSalesPaymentStatusText, getStatusTheme } from "@/utils/salesOrder";
defineOptions({ name: "SalesOrderListCell" });
@@ -110,10 +116,10 @@ const fulfilledProductQuantity = computed(() => sumProductQuantity("fulfilledQua
const productSummaryText = computed(() => `共 ${itemList.value.length} 项 · 总数量 ${totalProductQuantity.value} · 已履约 ${fulfilledProductQuantity.value}`);
const showReceiveAction = computed(() => props.canReceive && props.order.status !== "CANCELLED" && props.order.paymentStatus !== "PAID");
const canFulfillByPayment = computed(() => props.order.paymentStatus === "PAID" || (props.order.paymentStatus === "PARTIALLY_PAID" && props.canOperateStock));
const showFulfillAction = computed(() => props.canFulfill && canFulfillByPayment.value && props.order.orderType === "DIRECT_PRODUCT" && props.order.status === "ACTIVE");
const showFulfillAction = computed(() => props.canFulfill && canFulfillByPayment.value && props.order.status === "ACTIVE");
const showActions = computed(() => showReceiveAction.value || showFulfillAction.value);
function getPaymentStatusTheme(status?: SalesPaymentStatus): "success" | "warning" | "danger" | "default" {
function getPaymentStatusTheme(status?: GaoSalesPaymentStatus): "success" | "warning" | "danger" | "default" {
if (status === "PAID") {
return "success";
}