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
@@ -84,30 +84,28 @@
</template>
<script setup lang="ts">
import PackageAPI from "@/api/PackageAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import FulfillmentAPI from "@/api/FulfillmentAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { CustomerPackageItem, GaoCustomerPackage } from "@/types/Package";
import type { OrderFulfillmentItemRequest } from "@/types/Fulfillment";
import type { SalesOrder, SalesOrderItem } from "@/types/SalesOrder";
import { useUserStore } from "timi-web-api";
import type { GaoOrderFulfillmentItemRequest } from "@/types/Fulfillment";
import type { GaoSalesOrderItem, SalesOrder } from "@/types/SalesOrder";
import type { PhotoItem } from "@/types/Photo";
import type { Attachment } from "timi-web-api";
import { createPackageRequestNo, getPackageQuantityText, normalizePackageQuantity } from "@/utils/package";
import { comparePackageQuantity, createPackageRequestNo, getPackageQuantityText, normalizePackageQuantity } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL } from "@/utils/product";
type SourceItem = SalesOrderItem | CustomerPackageItem;
defineOptions({ name: "OrderFulfillmentCreate" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const order = ref<SalesOrder>();
const customerPackage = ref<GaoCustomerPackage>();
const quantityMap = reactive<Record<string, string>>({});
const deliveredAtText = ref("");
const draftTime = ref("");
@@ -117,12 +115,14 @@ const requestNo = ref("");
const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
const isPackage = computed(() => !!customerPackage.value);
const sourceItems = computed<SourceItem[]>(() => customerPackage.value?.itemList || order.value?.itemList || []);
const isPackage = computed(() => order.value?.orderType === "PACKAGE");
const expectedItemType = computed(() => isPackage.value ? "PACKAGE_ENTITLEMENT" : "DIRECT_PRODUCT");
const sourceItems = computed<GaoSalesOrderItem[]>(() => (order.value?.itemList || []).filter(item => item.itemType === expectedItemType.value));
const canOperateStock = computed(() => userStore.hasPermissions(permissionCodes.productStock.create));
const canFulfillByPayment = computed(() => order.value?.paymentStatus === "PAID" || (order.value?.paymentStatus === "PARTIALLY_PAID" && canOperateStock.value));
async function loadData(): Promise<void> {
const salesOrderId = String(route.query.salesOrderId || "");
const customerPackageId = String(route.query.customerPackageId || "");
if (!salesOrderId) {
Message.warning("请从订单详情或客户套餐详情进入履约创建");
await router.replace("/management/order-fulfillments");
@@ -130,26 +130,20 @@ async function loadData(): Promise<void> {
}
try {
order.value = await SalesOrderAPI.detail(salesOrderId);
if (customerPackageId) customerPackage.value = await PackageAPI.customerPackageDetail(customerPackageId);
} catch (error) {
Message.error(getErrorMessage(error, "履约来源加载失败"));
}
}
function getAvailableQuantity(item: SourceItem): string {
if (isPackage.value) {
return getPackageQuantityText((item as CustomerPackageItem).availableQuantity);
}
const orderItem = item as SalesOrderItem;
const availableQuantity = Math.max(0, Number(orderItem.quantity || 0) - Number(orderItem.reservedQuantity || 0) - Number(orderItem.fulfilledQuantity || 0));
return getPackageQuantityText(availableQuantity.toFixed(3));
function getAvailableQuantity(item: GaoSalesOrderItem): string {
return getPackageQuantityText(item.availableQuantity);
}
function getItemDescription(item: SourceItem): string {
function getItemDescription(item: GaoSalesOrderItem): string {
return ` · 剩余 ${getAvailableQuantity(item)}`;
}
function fillAllQuantity(item: SourceItem): void {
function fillAllQuantity(item: GaoSalesOrderItem): void {
if (item.id) {
quantityMap[item.id] = getAvailableQuantity(item);
}
@@ -178,6 +172,14 @@ function validate(): boolean {
Message.warning("请从订单详情或客户套餐详情进入履约创建");
return false;
}
if (order.value.status !== "ACTIVE") {
Message.warning("当前订单不是进行中状态,不能创建履约");
return false;
}
if (!canFulfillByPayment.value) {
Message.warning("订单尚未满足履约收款条件");
return false;
}
if (!buildAttachments().length) {
Message.warning("履约必须上传至少一张照片");
return false;
@@ -188,10 +190,15 @@ function validate(): boolean {
return false;
}
for (const item of selected) {
if (!normalizePackageQuantity(quantityMap[item.id || ""])) {
const quantity = normalizePackageQuantity(quantityMap[item.id || ""]);
if (!quantity) {
Message.warning("履约数量必须是正数,最多三位小数");
return false;
}
if (0 < comparePackageQuantity(quantity, item.availableQuantity ?? "0")) {
Message.warning(`${item.productNameSnapshot || "产品"} 的可履约数量不足`);
return false;
}
}
return true;
}
@@ -201,10 +208,10 @@ async function save(): Promise<void> {
if (!requestNo.value) requestNo.value = createPackageRequestNo();
isSaving.value = true;
try {
const itemList: OrderFulfillmentItemRequest[] = sourceItems.value
const itemList: GaoOrderFulfillmentItemRequest[] = sourceItems.value
.filter(item => !!item.id && quantityMap[item.id])
.map(item => ({
...(isPackage.value ? { sourceCustomerPackageItemId: item.id } : { sourceOrderItemId: item.id }),
sourceOrderItemId: item.id!,
quantity: normalizePackageQuantity(quantityMap[item.id || ""])!
}));
const result = await FulfillmentAPI.create({
@@ -3,7 +3,7 @@
<template v-if="fulfillment">
<t-cell-group title="履约单信息" theme="card" v-entrance-transition>
<t-cell title="销售订单" arrow @click="toOrder" />
<t-cell title="履约类型" :note="getFulfillmentTypeText(fulfillment.fulfillmentType)" />
<t-cell title="订单项类型" :note="getSalesOrderItemTypeText(sourceItemType)" />
<t-cell title="状态"><template #note><t-tag :content="getFulfillmentStatusText(fulfillment.status)" variant="light-outline" :theme="getStatusTheme(fulfillment.status)" /></template></t-cell>
<t-cell title="履约时间">
<template #note>
@@ -52,22 +52,22 @@
<t-cell v-if="!fulfillment.itemList?.length" title="暂无履约明细" />
</t-cell-group>
<section
v-if="(isWaitingStock && canVerify) || (isPending && (canVerify || canCreate)) || (isVerified && canRevoke)"
v-if="(!isOrderCancelled && isWaitingStock && canVerify) || (!isOrderCancelled && isPending && (canVerify || canCreate)) || (isVerified && canRevoke)"
class="actions"
v-entrance-transition="140"
>
<t-button
v-if="isWaitingStock && canVerify"
v-if="!isOrderCancelled && isWaitingStock && canVerify"
theme="primary"
content="准备库存"
@click="prepareStock"
/>
<div v-if="isPending && canVerify" class="pair">
<div v-if="!isOrderCancelled && isPending && canVerify" class="pair">
<t-button theme="primary" content="核销履约" @click="verify" />
<t-button theme="danger" content="拒绝履约" @click="openReason('reject')" />
</div>
<t-button
v-if="isPending && canCreate"
v-if="!isOrderCancelled && isPending && canCreate"
theme="default"
content="撤销履约"
@click="openReason('cancel')"
@@ -119,16 +119,18 @@ import { BottomPopup } from "timi-tdesign-mobile";
import { PassTimeLabel } from "timi-web";
import { useUserStore } from "timi-web-api";
import FulfillmentAPI from "@/api/FulfillmentAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { OrderFulfillment } from "@/types/Fulfillment";
import type { GaoSalesOrderItemType, SalesOrder } from "@/types/SalesOrder";
import type { PhotoItem } from "@/types/Photo";
import type { Attachment } from "timi-web-api";
import { createPackageRequestNo } from "@/utils/package";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error";
import { getProductPhotoURL } from "@/utils/product";
import { getFulfillmentStatusText, getFulfillmentTypeText, getStatusTheme } from "@/utils/salesOrder";
import { getFulfillmentStatusText, getSalesOrderItemTypeText, getStatusTheme } from "@/utils/salesOrder";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo";
@@ -140,6 +142,7 @@ const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const fulfillment = ref<OrderFulfillment>();
const order = ref<SalesOrder>();
const loading = ref(false);
const operating = ref(false);
const reasonVisible = ref(false);
@@ -163,14 +166,25 @@ const fulfillmentPhotoURLList = computed(() => fulfillmentPhotoItems.value.map(i
const isWaitingStock = computed(() => fulfillment.value?.status === "WAITING_STOCK");
const isPending = computed(() => fulfillment.value?.status === "PENDING");
const isVerified = computed(() => fulfillment.value?.status === "VERIFIED");
const isOrderCancelled = computed(() => order.value?.status === "CANCELLED");
const canVerify = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.verify));
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create));
const canRevoke = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.revoke));
const sourceItemType = computed<GaoSalesOrderItemType | undefined>(() => {
const sourceOrderItemId = fulfillment.value?.itemList?.[0]?.sourceOrderItemId;
return order.value?.itemList?.find(item => item.id === sourceOrderItemId)?.itemType;
});
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) return;
loading.value = true;
try { fulfillment.value = await FulfillmentAPI.detail(id); }
try {
const result = await FulfillmentAPI.detail(id);
fulfillment.value = result;
order.value = result.salesOrderId && userStore.hasPermissions(permissionCodes.salesOrder.read)
? await SalesOrderAPI.detail(result.salesOrderId)
: undefined;
}
catch (error) { Message.error(getErrorMessage(error, "履约单加载失败")); }
finally { loading.value = false; }
}
@@ -194,9 +208,10 @@ function getCurrentRouteDepth(): number {
}
async function prepareStock(): Promise<void> {
if (!fulfillment.value?.id || operating.value) return;
if (isOrderCancelled.value) { Message.warning("已取消订单不能准备库存"); return; }
operating.value = true;
try {
await FulfillmentAPI.prepareStock({ fulfillmentId: fulfillment.value.id });
fulfillment.value = await FulfillmentAPI.prepareStock({ fulfillmentId: fulfillment.value.id, reason: "库存已备齐" });
Message.success("库存准备已提交");
await loadData();
} catch (error) { Message.error(getErrorMessage(error, "库存准备失败")); }
@@ -204,9 +219,10 @@ async function prepareStock(): Promise<void> {
}
async function verify(): Promise<void> {
if (!fulfillment.value?.id || operating.value) return;
if (isOrderCancelled.value) { Message.warning("已取消订单不能核销履约"); return; }
operating.value = true;
try {
await FulfillmentAPI.verify({ fulfillmentId: fulfillment.value.id, reason: "" });
fulfillment.value = await FulfillmentAPI.verify({ fulfillmentId: fulfillment.value.id, reason: "" });
Message.success("履约已核销");
await loadData();
} catch (error) { Message.error(getErrorMessage(error, "履约核销失败")); }
@@ -218,8 +234,9 @@ async function submitReason(): Promise<void> {
operating.value = true;
try {
const payload = { fulfillmentId: fulfillment.value.id, reason: reason.value.trim() };
if (reasonAction.value === "reject") await FulfillmentAPI.reject(payload);
else await FulfillmentAPI.cancel(payload);
fulfillment.value = reasonAction.value === "reject"
? await FulfillmentAPI.reject(payload)
: await FulfillmentAPI.cancel(payload);
reasonVisible.value = false;
Message.success(reasonAction.value === "reject" ? "履约已拒绝" : "履约已撤销");
await loadData();
@@ -236,7 +253,7 @@ async function submitRevoke(): Promise<void> {
if (!revokeRequestNo.value) revokeRequestNo.value = createPackageRequestNo();
operating.value = true;
try {
await FulfillmentAPI.revoke({
fulfillment.value = await FulfillmentAPI.revoke({
fulfillmentId: fulfillment.value.id,
requestNo: revokeRequestNo.value,
reason: revokeReason.value.trim(),
+5 -25
View File
@@ -2,12 +2,6 @@
<div v-can="permissionCodes.orderFulfillment.read" class="fulfillment-list">
<div class="filters">
<t-dropdown-menu class="menus">
<t-dropdown-item
v-model="type"
label="履约类型"
:options="typeOptions"
@change="selectType"
/>
<t-dropdown-item
v-model="status"
label="履约状态"
@@ -47,7 +41,7 @@ import FulfillmentAPI from "@/api/FulfillmentAPI";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import OrderFulfillmentListCell from "@/pages/fulfillment/components/OrderFulfillmentListCell.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { FulfillmentStatus, FulfillmentType, OrderFulfillment } from "@/types/Fulfillment";
import type { GaoFulfillmentStatus, OrderFulfillment } from "@/types/Fulfillment";
import type { NavBarAction } from "@/types/NavBarAction";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
@@ -76,8 +70,7 @@ const actions = computed<NavBarAction[]>(() => [
action: toCustomerPackageList
}
]);
const type = ref<"ALL" | FulfillmentType>("ALL");
const status = ref<"ALL" | FulfillmentStatus>(getInitialStatus(route.query.status));
const status = ref<"ALL" | GaoFulfillmentStatus>(getInitialStatus(route.query.status));
const list = ref<OrderFulfillment[]>([]);
const pageIndex = ref(0);
const total = ref(0);
@@ -85,11 +78,6 @@ const loading = ref(false);
const refreshing = ref(false);
const noMore = ref(false);
const pageSize = 15;
const typeOptions = [
{ label: "全部类型", value: "ALL" },
{ label: "套餐权益", value: "PACKAGE_ENTITLEMENT" },
{ label: "直接产品", value: "DIRECT_PRODUCT" }
];
const statusOptions = [
{ label: "全部状态", value: "ALL" },
{ label: "等待库存", value: "WAITING_STOCK" },
@@ -104,10 +92,10 @@ function getQueryText(value: unknown): string {
return Array.isArray(value) ? "" : typeof value === "string" ? value : "";
}
function getInitialStatus(value: unknown): "ALL" | FulfillmentStatus {
function getInitialStatus(value: unknown): "ALL" | GaoFulfillmentStatus {
const statusValue = getQueryText(value);
if (["WAITING_STOCK", "PENDING", "VERIFIED", "REJECTED", "CANCELLED", "REVOKED"].includes(statusValue)) {
return statusValue as FulfillmentStatus;
return statusValue as GaoFulfillmentStatus;
}
return "ALL";
}
@@ -128,7 +116,6 @@ async function fetchList(reset: boolean): Promise<void> {
index: pageIndex.value,
size: pageSize,
salesOrderId: getQueryText(route.query.salesOrderId) || undefined,
fulfillmentType: type.value === "ALL" ? undefined : type.value,
status: status.value === "ALL" ? undefined : status.value
});
const rows = result.list || [];
@@ -159,16 +146,9 @@ async function loadMore(): Promise<void> {
}
}
async function selectType(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
type.value = String(value) as "ALL" | FulfillmentType;
await fetchList(true);
}
}
async function selectStatus(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
status.value = String(value) as "ALL" | FulfillmentStatus;
status.value = String(value) as "ALL" | GaoFulfillmentStatus;
await fetchList(true);
}
}