add product stock preview
This commit is contained in:
@@ -9,6 +9,7 @@ import type {
|
|||||||
ProductSpec,
|
ProductSpec,
|
||||||
ProductSpecValue,
|
ProductSpecValue,
|
||||||
ProductStock,
|
ProductStock,
|
||||||
|
ProductStockBatchRequest,
|
||||||
ProductStockProduct,
|
ProductStockProduct,
|
||||||
ProductStockChangeRequest,
|
ProductStockChangeRequest,
|
||||||
ProductStockManualRevokeRequest,
|
ProductStockManualRevokeRequest,
|
||||||
@@ -107,6 +108,10 @@ async function stockDetail(skuId: string, storeId?: string): Promise<ProductStoc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function stockBatchDetail(request: ProductStockBatchRequest): Promise<ProductStock[]> {
|
||||||
|
return await axios.post(`${STOCK_URI}/batch-detail`, withCurrentStoreId(request));
|
||||||
|
}
|
||||||
|
|
||||||
async function initialIn(request: ProductStockChangeRequest): Promise<ProductStock> {
|
async function initialIn(request: ProductStockChangeRequest): Promise<ProductStock> {
|
||||||
return await axios.post(`${STOCK_URI}/initial-in`, withCurrentStoreId(request));
|
return await axios.post(`${STOCK_URI}/initial-in`, withCurrentStoreId(request));
|
||||||
}
|
}
|
||||||
@@ -161,6 +166,7 @@ export default {
|
|||||||
stockList,
|
stockList,
|
||||||
stockProductList,
|
stockProductList,
|
||||||
stockDetail,
|
stockDetail,
|
||||||
|
stockBatchDetail,
|
||||||
initialIn,
|
initialIn,
|
||||||
manualIn,
|
manualIn,
|
||||||
manualOut,
|
manualOut,
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BottomPopup } from "timi-tdesign-mobile";
|
import { BottomPopup } from "timi-tdesign-mobile";
|
||||||
import ProductAPI from "@/api/ProductAPI";
|
import ProductAPI from "@/api/ProductAPI";
|
||||||
import type { Product, ProductSku, ProductSkuUnit, ProductSpec, ProductSpecValue } from "@/types/Product";
|
import type { Product, ProductSelection, ProductSku, ProductSkuUnit, ProductSpec, ProductSpecValue } from "@/types/Product";
|
||||||
import { getErrorMessage } from "@/utils/error";
|
import { getErrorMessage } from "@/utils/error";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
import { formatProductAmount, getProductPhotoURL, getProductSkuSpecEntries } from "@/utils/product";
|
import { formatProductAmount, getProductPhotoURL, getProductSkuSpecEntries } from "@/utils/product";
|
||||||
@@ -137,7 +137,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), { label: "产品", requiredMark: false, placeholder: "请选择产品 SKU" });
|
const props = withDefaults(defineProps<Props>(), { label: "产品", requiredMark: false, placeholder: "请选择产品 SKU" });
|
||||||
const emit = defineEmits<{ "update:modelValue": [value: Props["modelValue"]] }>();
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: Props["modelValue"]];
|
||||||
|
select: [selection: ProductSelection];
|
||||||
|
}>();
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
@@ -242,6 +245,11 @@ function selectUnit(sku: ProductSku, skuUnitId: string): void {
|
|||||||
if (!sku.id || !skuUnitId) {
|
if (!sku.id || !skuUnitId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const product = activeProduct.value;
|
||||||
|
const unit = sku.unitList?.find(item => item.id === skuUnitId);
|
||||||
|
if (product && unit) {
|
||||||
|
emit("select", { product, sku, unit });
|
||||||
|
}
|
||||||
emit("update:modelValue", { skuId: sku.id, skuUnitId });
|
emit("update:modelValue", { skuId: sku.id, skuUnitId });
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<t-table
|
||||||
|
class="product-stock-preview"
|
||||||
|
:columns="columns"
|
||||||
|
:data="items"
|
||||||
|
row-key="skuId"
|
||||||
|
table-layout="fixed"
|
||||||
|
vertical-align="middle"
|
||||||
|
>
|
||||||
|
<template #product="{ row }">
|
||||||
|
<span class="product clip-text" v-text="row.productName || '未命名产品'" />
|
||||||
|
</template>
|
||||||
|
<template #required="{ row }">
|
||||||
|
<span v-text="row.baseQuantity" />
|
||||||
|
</template>
|
||||||
|
<template #available="{ row }">
|
||||||
|
<span v-text="row.availableQuantity" />
|
||||||
|
</template>
|
||||||
|
<template #check="{ row }">
|
||||||
|
<strong :class="row.shortageQuantity ? 'shortage' : 'sufficient'" v-text="row.shortageQuantity ? '缺少' : '充足'" />
|
||||||
|
</template>
|
||||||
|
<template #footerSummary>
|
||||||
|
<div class="orange tips">仅供参考,以实际提交为准</div>
|
||||||
|
</template>
|
||||||
|
</t-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProductStockPreviewItem } from "@/utils/productStock";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ProductStockPreview"
|
||||||
|
});
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items: ProductStockPreviewItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ colKey: "product", title: "产品", width: "40%", ellipsis: true },
|
||||||
|
{ colKey: "required", title: "需求", width: "20%", align: "right", cell: "required" },
|
||||||
|
{ colKey: "available", title: "可用", width: "20%", align: "right", cell: "available" },
|
||||||
|
{ colKey: "check", title: "校验", width: "20%", align: "center", cell: "check" }
|
||||||
|
];
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.product-stock-preview {
|
||||||
|
--td-component-border: transparent;
|
||||||
|
|
||||||
|
.product {
|
||||||
|
min-width: 0;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sufficient {
|
||||||
|
color: var(--td-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortage {
|
||||||
|
color: var(--td-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-table__content) {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(th),
|
||||||
|
:deep(td) {
|
||||||
|
padding: .25rem .375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
placeholder="请选择产品"
|
placeholder="请选择产品"
|
||||||
required-mark
|
required-mark
|
||||||
@update:model-value="applyProduct(index, $event)"
|
@update:model-value="applyProduct(index, $event)"
|
||||||
|
@select="applyProductSelection(index, $event)"
|
||||||
/>
|
/>
|
||||||
<t-form-item v-if="item.skuId" label="SKU 单位" content-align="right">
|
<t-form-item v-if="item.skuId" label="SKU 单位" content-align="right">
|
||||||
<span v-text="getUnit(item)?.name || '未选择单位'" />
|
<span v-text="getUnit(item)?.name || '未选择单位'" />
|
||||||
@@ -111,7 +112,7 @@ import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
|||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
|
import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
|
||||||
import ProductAPI from "@/api/ProductAPI";
|
import ProductAPI from "@/api/ProductAPI";
|
||||||
import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
|
import type { Product, ProductSelection, ProductSku, ProductSkuUnit } from "@/types/Product";
|
||||||
import { getErrorMessage } from "@/utils/error";
|
import { getErrorMessage } from "@/utils/error";
|
||||||
import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
|
import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
@@ -204,23 +205,27 @@ const estimatedOriginalAmount = computed<number | undefined>(() => {
|
|||||||
});
|
});
|
||||||
const estimatedOriginalAmountText = computed(() => estimatedOriginalAmount.value === undefined ? "待完善" : `¥${formatProductAmount(estimatedOriginalAmount.value)}`);
|
const estimatedOriginalAmountText = computed(() => estimatedOriginalAmount.value === undefined ? "待完善" : `¥${formatProductAmount(estimatedOriginalAmount.value)}`);
|
||||||
|
|
||||||
async function applyProduct(index: number, value?: { skuId: string; skuUnitId: string }): Promise<void> {
|
function applyProduct(index: number, value?: { skuId: string; skuUnitId: string }): void {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sku = skuCache.get(value.skuId) || await ProductAPI.skuDetail(value.skuId);
|
|
||||||
skuCache.set(value.skuId, sku);
|
|
||||||
const product = productCache.get(sku.productId || "") || await ProductAPI.detail(sku.productId || "");
|
|
||||||
productCache.set(sku.productId || "", product);
|
|
||||||
const unit = (sku.unitList || []).find(item => item.id === value.skuUnitId);
|
|
||||||
Object.assign(form.itemList[index], {
|
Object.assign(form.itemList[index], {
|
||||||
skuId: value.skuId,
|
skuId: value.skuId,
|
||||||
skuUnitId: value.skuUnitId,
|
skuUnitId: value.skuUnitId
|
||||||
productNameSnapshot: product.name,
|
});
|
||||||
skuNameSnapshot: sku.name,
|
}
|
||||||
specSummarySnapshot: sku.specSummary,
|
|
||||||
unitSnapshot: unit?.name || "",
|
function applyProductSelection(index: number, selection: ProductSelection): void {
|
||||||
baseQuantityPerUnitSnapshot: unit?.baseQuantityPerUnit
|
skuCache.set(selection.sku.id || "", selection.sku);
|
||||||
|
productCache.set(selection.product.id || "", selection.product);
|
||||||
|
Object.assign(form.itemList[index], {
|
||||||
|
productNameSnapshot: selection.product.name,
|
||||||
|
productIdSnapshot: selection.product.id,
|
||||||
|
skuCodeSnapshot: selection.sku.code,
|
||||||
|
skuNameSnapshot: selection.sku.name,
|
||||||
|
specSummarySnapshot: selection.sku.specSummary,
|
||||||
|
unitSnapshot: selection.unit.name,
|
||||||
|
baseQuantityPerUnitSnapshot: selection.unit.baseQuantityPerUnit
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,13 +401,16 @@ async function loadData(): Promise<void> {
|
|||||||
}
|
}
|
||||||
const result = await PackageAPI.configDetail(routeId.value);
|
const result = await PackageAPI.configDetail(routeId.value);
|
||||||
const itemList = (result.itemList || []).map(createItem);
|
const itemList = (result.itemList || []).map(createItem);
|
||||||
const skuIdList = [...new Set(itemList.map(item => item.skuId).filter((id): id is string => !!id))];
|
for (const item of itemList) {
|
||||||
await Promise.all(skuIdList.map(async skuId => {
|
if (item.skuId && item.sku) {
|
||||||
const sku = itemList.find(item => item.skuId === skuId)?.sku || await ProductAPI.skuDetail(skuId);
|
skuCache.set(item.skuId, item.sku);
|
||||||
skuCache.set(skuId, sku);
|
|
||||||
if (sku.productId) {
|
|
||||||
productCache.set(sku.productId, await ProductAPI.detail(sku.productId));
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const productIdList = [...new Set(itemList
|
||||||
|
.map(item => item.sku?.productId || item.productIdSnapshot)
|
||||||
|
.filter((id): id is string => !!id))];
|
||||||
|
await Promise.all(productIdList.map(async productId => {
|
||||||
|
productCache.set(productId, await ProductAPI.detail(productId));
|
||||||
}));
|
}));
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
id: result.id || routeId.value,
|
id: result.id || routeId.value,
|
||||||
|
|||||||
@@ -199,11 +199,14 @@
|
|||||||
<t-switch
|
<t-switch
|
||||||
v-model="fulfillAll"
|
v-model="fulfillAll"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="!canFulfillImmediately"
|
:disabled="!canFulfillImmediately || isStockChecking"
|
||||||
/>
|
/>
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
<t-form-item label-width="0">
|
<t-form-item label-width="0">
|
||||||
<div class="red">全部履约会同步启用全额收款;请先准备全部产品库存,任意一步失败,整张订单都会回滚。</div>
|
<div class="stock-tips">
|
||||||
|
<div class="tips red">全部履约会同步启用全额收款;请先准备充足的库存,任意一步失败都会下单失败。</div>
|
||||||
|
<product-stock-preview v-if="stockPreviewList.length" :items="stockPreviewList" />
|
||||||
|
</div>
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
<t-form-item v-if="(paymentVerified && parsedPaymentAmount) || (receiveFullPayment && parsedSaleAmount)" label-width="0">
|
<t-form-item v-if="(paymentVerified && parsedPaymentAmount) || (receiveFullPayment && parsedSaleAmount)" label-width="0">
|
||||||
<photo-scroller
|
<photo-scroller
|
||||||
@@ -337,6 +340,7 @@ import CustomerList from "@/components/CustomerList.vue";
|
|||||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||||
|
import ProductStockPreview from "@/components/ProductStockPreview.vue";
|
||||||
import { usePackagePurchase } from "@/pages/package/usePackagePurchase";
|
import { usePackagePurchase } from "@/pages/package/usePackagePurchase";
|
||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import { useSettingStore } from "@/store/settingStore";
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
@@ -351,10 +355,13 @@ import {
|
|||||||
formatPackageAmount,
|
formatPackageAmount,
|
||||||
getPackageQuantityText,
|
getPackageQuantityText,
|
||||||
normalizePackageQuantity,
|
normalizePackageQuantity,
|
||||||
parsePackageAmount
|
parsePackageAmount,
|
||||||
|
toPackageBaseQuantity
|
||||||
} from "@/utils/package";
|
} from "@/utils/package";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
import { permissionCodes } from "@/utils/permission";
|
import { permissionCodes } from "@/utils/permission";
|
||||||
|
import { buildProductStockPreview, getProductStockShortageMessage, mergeProductStockRequirements } from "@/utils/productStock";
|
||||||
|
import type { ProductStockPreviewItem } from "@/utils/productStock";
|
||||||
import { uploadPhotoList } from "@/utils/photo";
|
import { uploadPhotoList } from "@/utils/photo";
|
||||||
import { getProductPhotoURL, getProductSkuSnapshotText } from "@/utils/product";
|
import { getProductPhotoURL, getProductSkuSnapshotText } from "@/utils/product";
|
||||||
|
|
||||||
@@ -510,6 +517,9 @@ const paymentPhotoUrlList = computed(() => paymentPhotoItems.value.map(item => i
|
|||||||
const fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
|
const fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
|
||||||
const receiveFullPayment = ref(false);
|
const receiveFullPayment = ref(false);
|
||||||
const fulfillAll = ref(false);
|
const fulfillAll = ref(false);
|
||||||
|
const isStockChecking = ref(false);
|
||||||
|
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
|
||||||
|
let stockCheckToken = 0;
|
||||||
const parsedPaymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
|
const parsedPaymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
|
||||||
const paymentVerified = computed(() => !receiveFullPayment.value
|
const paymentVerified = computed(() => !receiveFullPayment.value
|
||||||
&& 0 < paymentAmountText.value.trim().length
|
&& 0 < paymentAmountText.value.trim().length
|
||||||
@@ -553,11 +563,29 @@ const canReceiveImmediately = computed(() => userStore.hasPermissions(permission
|
|||||||
&& userStore.hasPermissions(permissionCodes.fundReceive.verify));
|
&& userStore.hasPermissions(permissionCodes.fundReceive.verify));
|
||||||
const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)
|
const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)
|
||||||
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
|
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
|
||||||
|
&& userStore.hasPermissions(permissionCodes.productStock.read)
|
||||||
&& (parsedSaleAmount.value === 0 || canReceiveImmediately.value));
|
&& (parsedSaleAmount.value === 0 || canReceiveImmediately.value));
|
||||||
|
|
||||||
watch(fulfillAll, value => {
|
watch(fulfillAll, async value => {
|
||||||
if (value && parsedSaleAmount.value !== 0) {
|
stockCheckToken += 1;
|
||||||
receiveFullPayment.value = true;
|
const token = stockCheckToken;
|
||||||
|
if (!value) {
|
||||||
|
isStockChecking.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isStockChecking.value = true;
|
||||||
|
try {
|
||||||
|
if (!await validateDirectStock() && token === stockCheckToken && fulfillAll.value) {
|
||||||
|
fulfillAll.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token === stockCheckToken && fulfillAll.value && parsedSaleAmount.value !== 0) {
|
||||||
|
receiveFullPayment.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (token === stockCheckToken) {
|
||||||
|
isStockChecking.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -603,6 +631,14 @@ function getItemSku(item: PackagePurchaseItem): ProductSku | undefined {
|
|||||||
return skuMap[item.skuId] || config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.sku;
|
return skuMap[item.skuId] || config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.sku;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncConfigSkuMap(value?: GaoPackageConfig): void {
|
||||||
|
for (const item of value?.itemList || []) {
|
||||||
|
if (item.skuId && item.sku) {
|
||||||
|
skuMap[item.skuId] = item.sku;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getItemUnit(item: PackagePurchaseItem): ProductSkuUnit | undefined {
|
function getItemUnit(item: PackagePurchaseItem): ProductSkuUnit | undefined {
|
||||||
return getItemSku(item)?.unitList?.find(unit => unit.id === item.skuUnitId);
|
return getItemSku(item)?.unitList?.find(unit => unit.id === item.skuUnitId);
|
||||||
}
|
}
|
||||||
@@ -762,10 +798,70 @@ function validateItemDecimalQuantity(): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateDirectStock(): Promise<boolean> {
|
||||||
|
stockPreviewList.value = [];
|
||||||
|
const selected = packagePurchaseItemList.value.filter(item => {
|
||||||
|
const quantity = normalizePackageQuantity(item.totalQuantity);
|
||||||
|
return !!quantity && quantity !== "0";
|
||||||
|
});
|
||||||
|
const skuIdList = [...new Set(selected.map(item => item.skuId).filter(Boolean))];
|
||||||
|
await Promise.all(skuIdList.map(async skuId => {
|
||||||
|
if (skuMap[skuId]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
skuMap[skuId] = await ProductAPI.skuDetail(skuId);
|
||||||
|
} catch {
|
||||||
|
// 统一在下方提示,避免部分 SKU 查询失败时静默跳过库存校验
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
for (const item of selected) {
|
||||||
|
if (!skuMap[item.skuId]) {
|
||||||
|
Message.warning(`${getItemName(item)} 的库存信息加载失败,请刷新后重试`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!getItemUnit(item)) {
|
||||||
|
Message.warning(`${getItemName(item)} 的权益单位不存在,请刷新后重试`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const requirementList = mergeProductStockRequirements(selected.flatMap(item => {
|
||||||
|
const sku = skuMap[item.skuId];
|
||||||
|
const unit = getItemUnit(item);
|
||||||
|
const quantity = normalizePackageQuantity(item.totalQuantity);
|
||||||
|
const baseQuantity = quantity && unit ? toPackageBaseQuantity(quantity, unit.baseQuantityPerUnit) : undefined;
|
||||||
|
if (!sku?.stockEnabled || !baseQuantity) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{ skuId: item.skuId, baseQuantity, productName: getItemName(item), unitName: "基础单位", stockEnabled: sku.stockEnabled }];
|
||||||
|
}));
|
||||||
|
if (!requirementList.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stockList = await ProductAPI.stockBatchDetail({ skuIdList: requirementList.map(item => item.skuId) });
|
||||||
|
stockPreviewList.value = buildProductStockPreview(stockList, requirementList);
|
||||||
|
const shortageMessage = getProductStockShortageMessage(stockList, requirementList);
|
||||||
|
if (shortageMessage) {
|
||||||
|
Message.warning(`${shortageMessage},不能直接履约`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
stockPreviewList.value = [];
|
||||||
|
Message.error(getErrorMessage(error, "库存查询失败,暂不能直接履约"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitRecord(): Promise<void> {
|
async function submitRecord(): Promise<void> {
|
||||||
if (isSaving.value) {
|
if (isSaving.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isStockChecking.value) {
|
||||||
|
Message.warning("库存正在预检,请稍候再提交");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
|
if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -796,6 +892,7 @@ async function purchasePackage(): Promise<void> {
|
|||||||
|
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
try {
|
try {
|
||||||
|
if (fulfillAll.value && !await validateDirectStock()) return;
|
||||||
const result = await submitPurchase();
|
const result = await submitPurchase();
|
||||||
if (!result?.salesOrderId) {
|
if (!result?.salesOrderId) {
|
||||||
Message.error("套餐购买成功但未返回完整订单关联,请联系管理员");
|
Message.error("套餐购买成功但未返回完整订单关联,请联系管理员");
|
||||||
@@ -823,8 +920,8 @@ async function loadPageData(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
config.value = await PackageAPI.configDetail(configId.value);
|
config.value = await PackageAPI.configDetail(configId.value);
|
||||||
|
syncConfigSkuMap(config.value);
|
||||||
applyConfig(config.value);
|
applyConfig(config.value);
|
||||||
await loadConfigSkuList(config.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectConfig(item: GaoPackageConfig): Promise<void> {
|
async function selectConfig(item: GaoPackageConfig): Promise<void> {
|
||||||
@@ -832,21 +929,11 @@ async function selectConfig(item: GaoPackageConfig): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
config.value = await PackageAPI.configDetail(item.id);
|
config.value = await PackageAPI.configDetail(item.id);
|
||||||
await loadConfigSkuList(config.value);
|
syncConfigSkuMap(config.value);
|
||||||
|
applyConfig(config.value);
|
||||||
configSelectorVisible.value = false;
|
configSelectorVisible.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfigSkuList(value?: GaoPackageConfig): Promise<void> {
|
|
||||||
const skuIdList = [...new Set((value?.itemList || []).map(item => item.skuId).filter(Boolean))];
|
|
||||||
await Promise.all(skuIdList.map(async skuId => {
|
|
||||||
try {
|
|
||||||
skuMap[skuId] = await ProductAPI.skuDetail(skuId);
|
|
||||||
} catch {
|
|
||||||
// 后端会校验最终权益单位,单个 SKU 的单位加载失败不影响套餐查看
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
navBarStore.clearRight();
|
navBarStore.clearRight();
|
||||||
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
|
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
|
||||||
@@ -895,23 +982,8 @@ watch(config, value => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.straight-through {
|
.tips {
|
||||||
width: 100%;
|
font-size: .875rem;
|
||||||
padding: .75rem;
|
|
||||||
background: var(--td-bg-color-secondarycontainer);
|
|
||||||
border-radius: .875rem;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: .9375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips {
|
|
||||||
color: var(--td-text-color-secondary);
|
|
||||||
margin: .5rem .5rem 0;
|
|
||||||
font-size: .75rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-profile {
|
.customer-profile {
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BottomPopup } from "timi-tdesign-mobile";
|
import { BottomPopup } from "timi-tdesign-mobile";
|
||||||
import type { ColorObject } from "tdesign-mobile-vue";
|
import { DialogPlugin, type ColorObject } from "tdesign-mobile-vue";
|
||||||
import { type Attachment, AttachmentAPI, useUserStore } from "timi-web-api";
|
import { type Attachment, AttachmentAPI, useUserStore } from "timi-web-api";
|
||||||
import ProductAPI from "@/api/ProductAPI";
|
import ProductAPI from "@/api/ProductAPI";
|
||||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||||
@@ -212,7 +212,7 @@ import ProductSkuUnitEditor from "@/pages/product/component/ProductSkuUnitEditor
|
|||||||
import ProductSkuUnitBatchEditor from "@/pages/product/component/ProductSkuUnitBatchEditor.vue";
|
import ProductSkuUnitBatchEditor from "@/pages/product/component/ProductSkuUnitBatchEditor.vue";
|
||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import type { PhotoItem } from "@/types/Photo";
|
import type { PhotoItem } from "@/types/Photo";
|
||||||
import type { Product, ProductAttachmentType, ProductCategory, ProductSku, ProductSkuUnit, ProductSpec } from "@/types/Product";
|
import type { Product, ProductAttachmentType, ProductCategory, ProductSku, ProductSkuUnit, ProductSpec, ProductType } from "@/types/Product";
|
||||||
import type { ProductAttachmentItem, ProductForm, ProductSkuForm as SkuForm, ProductSkuUnitForm as UnitForm, ProductSpecForm as SpecForm, ProductSpecValueForm as SpecValueForm } from "@/types/ProductEditor";
|
import type { ProductAttachmentItem, ProductForm, ProductSkuForm as SkuForm, ProductSkuUnitForm as UnitForm, ProductSpecForm as SpecForm, ProductSpecValueForm as SpecValueForm } from "@/types/ProductEditor";
|
||||||
import { compressUploadImage } from "@/utils/imageCompression";
|
import { compressUploadImage } from "@/utils/imageCompression";
|
||||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||||
@@ -464,19 +464,64 @@ function openTypePicker(): void {
|
|||||||
typePickerVisible.value = true;
|
typePickerVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmType(value: Array<string | number>): void {
|
async function confirmType(value: Array<string | number>): Promise<void> {
|
||||||
const type = String(value[0] || "");
|
const type = String(value[0] || "");
|
||||||
if (type === "SERVICE" || type === "GOODS" || type === "CONSUMABLE") {
|
if (type !== "SERVICE" && type !== "GOODS" && type !== "CONSUMABLE") {
|
||||||
form.productType = type;
|
typePickerVisible.value = false;
|
||||||
if (type === "SERVICE") {
|
return;
|
||||||
form.stockEnabled = false;
|
|
||||||
form.skuList.forEach(sku => {
|
|
||||||
sku.stockEnabled = false;
|
|
||||||
sku.unitList.forEach(unit => { unit.allowPurchase = false; });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
typePickerVisible.value = false;
|
typePickerVisible.value = false;
|
||||||
|
if (type !== "SERVICE" || form.productType === "SERVICE") {
|
||||||
|
applyProductType(type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await canChangeToService()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
title: "切换为服务产品",
|
||||||
|
content: "切换后将关闭产品及全部 SKU 的库存管理,并清空最低库存设置;已有库存流水会保留。确定继续吗?",
|
||||||
|
cancelBtn: "取消",
|
||||||
|
confirmBtn: "确定切换",
|
||||||
|
onCancel() {
|
||||||
|
dialog.destroy();
|
||||||
|
},
|
||||||
|
onConfirm() {
|
||||||
|
dialog.destroy();
|
||||||
|
applyProductType("SERVICE");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProductType(type: ProductType): void {
|
||||||
|
form.productType = type;
|
||||||
|
if (type !== "SERVICE") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.stockEnabled = false;
|
||||||
|
form.skuList.forEach(sku => {
|
||||||
|
sku.stockEnabled = false;
|
||||||
|
sku.minStockQuantityText = "";
|
||||||
|
sku.unitList.forEach(unit => { unit.allowPurchase = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canChangeToService(): Promise<boolean> {
|
||||||
|
const skuIdList = form.skuList.map(sku => sku.id).filter((id): id is string => !!id);
|
||||||
|
if (!skuIdList.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stockList = await ProductAPI.stockBatchDetail({ skuIdList });
|
||||||
|
if (stockList.some(stock => 0 < Number(stock.reservedQuantity || 0))) {
|
||||||
|
Message.warning("当前产品存在预占库存,不能切换为服务产品,请先完成或取消相关订单");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
Message.error(getErrorMessage(error, "无法确认预占库存,暂不能切换为服务产品"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCategoryPicker(): void {
|
function openCategoryPicker(): void {
|
||||||
|
|||||||
@@ -29,7 +29,14 @@
|
|||||||
@click="toSkuList(item)"
|
@click="toSkuList(item)"
|
||||||
>
|
>
|
||||||
<template #leftIcon>
|
<template #leftIcon>
|
||||||
<div class="avatar fallback" v-text="(item.productName || '产').slice(0, 1)" />
|
<t-image
|
||||||
|
v-if="getProductPhotoURL(item)"
|
||||||
|
:src="getProductPhotoURL(item)"
|
||||||
|
:alt="item.productName || '产品图片'"
|
||||||
|
fit="cover"
|
||||||
|
class="avatar"
|
||||||
|
/>
|
||||||
|
<div v-else class="avatar fallback" v-text="(item.productName || '产').slice(0, 1)" />
|
||||||
</template>
|
</template>
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="title">
|
<div class="title">
|
||||||
@@ -67,7 +74,7 @@ import ProductAPI from "@/api/ProductAPI";
|
|||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import type { ProductStockProduct } from "@/types/Product";
|
import type { ProductStockProduct } from "@/types/Product";
|
||||||
import { getErrorMessage } from "@/utils/error";
|
import { getErrorMessage } from "@/utils/error";
|
||||||
import { getProductQuantityText } from "@/utils/product";
|
import { getProductPhotoURL, getProductQuantityText } from "@/utils/product";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
import { permissionCodes } from "@/utils/permission";
|
import { permissionCodes } from "@/utils/permission";
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,14 @@
|
|||||||
@click="toDetail(item)"
|
@click="toDetail(item)"
|
||||||
>
|
>
|
||||||
<template #leftIcon>
|
<template #leftIcon>
|
||||||
<div class="avatar fallback" v-text="(item.sku?.name || 'S').slice(0, 1)" />
|
<t-image
|
||||||
|
v-if="getSkuPhotoURL(item)"
|
||||||
|
:src="getSkuPhotoURL(item)"
|
||||||
|
:alt="item.sku?.name || 'SKU 图片'"
|
||||||
|
fit="cover"
|
||||||
|
class="avatar"
|
||||||
|
/>
|
||||||
|
<div v-else class="avatar fallback" v-text="(item.sku?.name || 'S').slice(0, 1)" />
|
||||||
</template>
|
</template>
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="title">
|
<div class="title">
|
||||||
@@ -71,7 +78,7 @@ import ProductAPI from "@/api/ProductAPI";
|
|||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import type { Product, ProductStock } from "@/types/Product";
|
import type { Product, ProductStock } from "@/types/Product";
|
||||||
import { getErrorMessage } from "@/utils/error";
|
import { getErrorMessage } from "@/utils/error";
|
||||||
import { getProductQuantityText } from "@/utils/product";
|
import { getProductPhotoURL, getProductQuantityText } from "@/utils/product";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
import { permissionCodes } from "@/utils/permission";
|
import { permissionCodes } from "@/utils/permission";
|
||||||
|
|
||||||
@@ -102,6 +109,11 @@ const emptyText = computed(() => {
|
|||||||
}
|
}
|
||||||
return "该产品暂无启用库存管理的 SKU";
|
return "该产品暂无启用库存管理的 SKU";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getSkuPhotoURL(item: ProductStock): string {
|
||||||
|
return item.sku ? getProductPhotoURL(item.sku) : "";
|
||||||
|
}
|
||||||
|
|
||||||
let keywordTimer: number | undefined;
|
let keywordTimer: number | undefined;
|
||||||
let requestToken = 0;
|
let requestToken = 0;
|
||||||
let suppressKeywordFetch = false;
|
let suppressKeywordFetch = false;
|
||||||
|
|||||||
@@ -165,11 +165,14 @@
|
|||||||
<t-switch
|
<t-switch
|
||||||
v-model="fulfillAll"
|
v-model="fulfillAll"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="!canFulfillImmediately"
|
:disabled="!canFulfillImmediately || isStockChecking"
|
||||||
/>
|
/>
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
<t-form-item label-width="0">
|
<t-form-item label-width="0">
|
||||||
<div class="red">全部履约会同步启用全额收款;请先准备全部产品库存,任意一步失败,整张订单都会回滚。</div>
|
<div class="stock-tips">
|
||||||
|
<div class="tips red">全部履约会同步启用全额收款;请先准备充足的库存,任意一步失败都会下单失败。</div>
|
||||||
|
<product-stock-preview v-if="stockPreviewList.length" :items="stockPreviewList" />
|
||||||
|
</div>
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
<t-form-item v-if="(paymentVerified && paymentAmount) || (receiveFullPayment && totalAmount)" label-width="0">
|
<t-form-item v-if="(paymentVerified && paymentAmount) || (receiveFullPayment && totalAmount)" label-width="0">
|
||||||
<photo-scroller
|
<photo-scroller
|
||||||
@@ -230,6 +233,7 @@ import CodeScanner from "@/components/CodeScanner.vue";
|
|||||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||||
|
import ProductStockPreview from "@/components/ProductStockPreview.vue";
|
||||||
import type { Customer } from "@/types/Customer";
|
import type { Customer } from "@/types/Customer";
|
||||||
import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
|
import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
|
||||||
import type { PhotoItem } from "@/types/Photo";
|
import type { PhotoItem } from "@/types/Photo";
|
||||||
@@ -239,7 +243,8 @@ import { formatProductAmount, getProductQuantityStep } from "@/utils/product";
|
|||||||
import { getErrorMessage } from "@/utils/error";
|
import { getErrorMessage } from "@/utils/error";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
import { permissionCodes } from "@/utils/permission";
|
import { permissionCodes } from "@/utils/permission";
|
||||||
import { getProductStockShortageMessage, mergeProductStockRequirements } from "@/utils/productStock";
|
import { buildProductStockPreview, getProductStockShortageMessage, mergeProductStockRequirements } from "@/utils/productStock";
|
||||||
|
import type { ProductStockPreviewItem } from "@/utils/productStock";
|
||||||
import { uploadPhotoList } from "@/utils/photo";
|
import { uploadPhotoList } from "@/utils/photo";
|
||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import { useSettingStore } from "@/store/settingStore";
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
@@ -269,6 +274,9 @@ const paymentPhotoUrlList = computed(() => paymentPhotoItems.value.map(item => i
|
|||||||
const fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
|
const fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
|
||||||
const receiveFullPayment = ref(false);
|
const receiveFullPayment = ref(false);
|
||||||
const fulfillAll = ref(false);
|
const fulfillAll = ref(false);
|
||||||
|
const isStockChecking = ref(false);
|
||||||
|
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
|
||||||
|
let stockCheckToken = 0;
|
||||||
const paymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
|
const paymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
|
||||||
const paymentVerified = computed(() => !receiveFullPayment.value
|
const paymentVerified = computed(() => !receiveFullPayment.value
|
||||||
&& 0 < paymentAmountText.value.trim().length
|
&& 0 < paymentAmountText.value.trim().length
|
||||||
@@ -293,9 +301,26 @@ const canFulfillImmediately = computed(() => userStore.hasPermissions(permission
|
|||||||
&& userStore.hasPermissions(permissionCodes.productStock.read)
|
&& userStore.hasPermissions(permissionCodes.productStock.read)
|
||||||
&& (totalAmount.value === 0 || canReceiveImmediately.value));
|
&& (totalAmount.value === 0 || canReceiveImmediately.value));
|
||||||
|
|
||||||
watch(fulfillAll, value => {
|
watch(fulfillAll, async value => {
|
||||||
if (value && totalAmount.value !== 0) {
|
stockCheckToken += 1;
|
||||||
receiveFullPayment.value = true;
|
const token = stockCheckToken;
|
||||||
|
if (!value) {
|
||||||
|
isStockChecking.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isStockChecking.value = true;
|
||||||
|
try {
|
||||||
|
if (!await validateDirectStock() && token === stockCheckToken && fulfillAll.value) {
|
||||||
|
fulfillAll.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token === stockCheckToken && fulfillAll.value && totalAmount.value !== 0) {
|
||||||
|
receiveFullPayment.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (token === stockCheckToken) {
|
||||||
|
isStockChecking.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -469,6 +494,7 @@ function buildAttachments(source: PhotoItem[] = photoItems.value): Partial<Attac
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function validateDirectStock(): Promise<boolean> {
|
async function validateDirectStock(): Promise<boolean> {
|
||||||
|
stockPreviewList.value = [];
|
||||||
const requirementList = mergeProductStockRequirements(items.value.flatMap(item => {
|
const requirementList = mergeProductStockRequirements(items.value.flatMap(item => {
|
||||||
const sku = getSku(item);
|
const sku = getSku(item);
|
||||||
const unit = getUnit(item);
|
const unit = getUnit(item);
|
||||||
@@ -477,13 +503,14 @@ async function validateDirectStock(): Promise<boolean> {
|
|||||||
if (!item.skuId || !sku?.stockEnabled || !baseQuantity) {
|
if (!item.skuId || !sku?.stockEnabled || !baseQuantity) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [{ skuId: item.skuId, baseQuantity, productName: getProduct(item)?.name || item.productName || "未命名产品", unitName: "基础单位" }];
|
return [{ skuId: item.skuId, baseQuantity, productName: getProduct(item)?.name || item.productName || "未命名产品", unitName: "基础单位", stockEnabled: sku.stockEnabled }];
|
||||||
}));
|
}));
|
||||||
if (!requirementList.length) {
|
if (!requirementList.length) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const stockList = await ProductAPI.stockBatchDetail({ skuIdList: requirementList.map(item => item.skuId) });
|
const stockList = await ProductAPI.stockBatchDetail({ skuIdList: requirementList.map(item => item.skuId) });
|
||||||
|
stockPreviewList.value = buildProductStockPreview(stockList, requirementList);
|
||||||
const shortageMessage = getProductStockShortageMessage(stockList, requirementList);
|
const shortageMessage = getProductStockShortageMessage(stockList, requirementList);
|
||||||
if (shortageMessage) {
|
if (shortageMessage) {
|
||||||
Message.warning(`${shortageMessage},不能直接履约`);
|
Message.warning(`${shortageMessage},不能直接履约`);
|
||||||
@@ -491,6 +518,7 @@ async function validateDirectStock(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
stockPreviewList.value = [];
|
||||||
Message.error(getErrorMessage(error, "库存查询失败,暂不能直接履约"));
|
Message.error(getErrorMessage(error, "库存查询失败,暂不能直接履约"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -518,7 +546,12 @@ function validate(): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
async function save(): Promise<void> {
|
async function save(): Promise<void> {
|
||||||
if (isSaving.value || !validate()) return;
|
if (isSaving.value) return;
|
||||||
|
if (isStockChecking.value) {
|
||||||
|
Message.warning("库存正在预检,请稍候再提交");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validate()) return;
|
||||||
if (paymentVerified.value || receiveFullPayment.value || fulfillAll.value) {
|
if (paymentVerified.value || receiveFullPayment.value || fulfillAll.value) {
|
||||||
const dialog = DialogPlugin.confirm({
|
const dialog = DialogPlugin.confirm({
|
||||||
title: "确认直通完成交易",
|
title: "确认直通完成交易",
|
||||||
@@ -631,6 +664,10 @@ onUnmounted(() => navBarStore.clearRight());
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
.product-part {
|
.product-part {
|
||||||
|
|
||||||
> .header {
|
> .header {
|
||||||
@@ -738,10 +775,6 @@ onUnmounted(() => navBarStore.clearRight());
|
|||||||
.textarea {
|
.textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-part {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-wrap {
|
.submit-wrap {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
<t-form-item v-if="canDirectVerify" label="直接核销收款" content-align="right">
|
<t-form-item v-if="canDirectVerify" label="直接核销收款" content-align="right">
|
||||||
<t-switch v-model="directVerify" size="small" />
|
<t-switch v-model="directVerify" size="small" />
|
||||||
<template #help v-if="directVerify">
|
<template #help v-if="directVerify">
|
||||||
<span class="red">店长直接核销后,收款会立即计入订单已收款,不再进入待核销队列。</span>
|
<span class="red">直接核销后,收款会立即计入订单已收款,不再进入待核销队列。</span>
|
||||||
</template>
|
</template>
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
<t-form-item label="备注">
|
<t-form-item label="备注">
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ export type Product = {
|
|||||||
attachmentList?: Attachment[];
|
attachmentList?: Attachment[];
|
||||||
} & Model;
|
} & Model;
|
||||||
|
|
||||||
|
export type ProductSelection = {
|
||||||
|
product: Product;
|
||||||
|
sku: ProductSku;
|
||||||
|
unit: ProductSkuUnit;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProductCategoryPage = {
|
export type ProductCategoryPage = {
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
status?: ProductCategoryStatus;
|
status?: ProductCategoryStatus;
|
||||||
@@ -126,6 +132,7 @@ export type ProductStockProduct = {
|
|||||||
productId: string;
|
productId: string;
|
||||||
productCode: string;
|
productCode: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
|
attachmentList?: Attachment[];
|
||||||
productType?: ProductType;
|
productType?: ProductType;
|
||||||
skuCount?: number;
|
skuCount?: number;
|
||||||
onHandQuantity?: number | string;
|
onHandQuantity?: number | string;
|
||||||
@@ -141,6 +148,11 @@ export type ProductStockPage = {
|
|||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProductStockBatchRequest = {
|
||||||
|
storeId?: string;
|
||||||
|
skuIdList: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type StockRecord = {
|
export type StockRecord = {
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
skuId?: string;
|
skuId?: string;
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { ProductStock } from "@/types/Product";
|
||||||
|
import { addPackageQuantity, comparePackageQuantity } from "@/utils/package";
|
||||||
|
|
||||||
|
export type ProductStockRequirement = {
|
||||||
|
skuId: string;
|
||||||
|
baseQuantity: string;
|
||||||
|
productName: string;
|
||||||
|
unitName: string;
|
||||||
|
stockEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductStockPreviewItem = ProductStockRequirement & {
|
||||||
|
availableQuantity: string;
|
||||||
|
shortageQuantity?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 合并同一 SKU 的基础库存需求,避免多单位订单拆开校验造成误判。 */
|
||||||
|
export function mergeProductStockRequirements(requirementList: ProductStockRequirement[]): ProductStockRequirement[] {
|
||||||
|
const requirementMap = new Map<string, ProductStockRequirement>();
|
||||||
|
for (const requirement of requirementList) {
|
||||||
|
const existing = requirementMap.get(requirement.skuId);
|
||||||
|
if (!existing) {
|
||||||
|
requirementMap.set(requirement.skuId, { ...requirement });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const baseQuantity = addPackageQuantity(existing.baseQuantity, requirement.baseQuantity);
|
||||||
|
if (baseQuantity) {
|
||||||
|
existing.baseQuantity = baseQuantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...requirementMap.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回库存不足提示;库存单位统一为 SKU 基础单位。 */
|
||||||
|
export function getProductStockShortageMessage(stockList: ProductStock[], requirementList: ProductStockRequirement[]): string | undefined {
|
||||||
|
const stockMap = new Map(stockList.map(stock => [stock.skuId, stock]));
|
||||||
|
const shortageList: string[] = [];
|
||||||
|
for (const requirement of requirementList) {
|
||||||
|
if (!requirement.stockEnabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const availableQuantity = stockMap.get(requirement.skuId)?.availableQuantity ?? "0";
|
||||||
|
if (!(0 < comparePackageQuantity(requirement.baseQuantity, availableQuantity))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const shortageQuantity = subtractPackageQuantity(requirement.baseQuantity, availableQuantity);
|
||||||
|
shortageList.push(`${requirement.productName} 可用 ${availableQuantity} ${requirement.unitName},缺少 ${shortageQuantity} ${requirement.unitName}`);
|
||||||
|
}
|
||||||
|
return shortageList.length ? `库存不足:${shortageList.join(";")}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成启用库存管理 SKU 的库存预检明细。 */
|
||||||
|
export function buildProductStockPreview(stockList: ProductStock[], requirementList: ProductStockRequirement[]): ProductStockPreviewItem[] {
|
||||||
|
const stockMap = new Map(stockList.map(stock => [stock.skuId, stock]));
|
||||||
|
return requirementList.filter(requirement => requirement.stockEnabled).map(requirement => {
|
||||||
|
const availableQuantity = String(stockMap.get(requirement.skuId)?.availableQuantity ?? "0");
|
||||||
|
const shortageQuantity = 0 < comparePackageQuantity(requirement.baseQuantity, availableQuantity)
|
||||||
|
? subtractPackageQuantity(requirement.baseQuantity, availableQuantity)
|
||||||
|
: undefined;
|
||||||
|
return { ...requirement, availableQuantity, shortageQuantity };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractPackageQuantity(left: string, right: number | string): string {
|
||||||
|
const leftParts = toQuantityParts(left);
|
||||||
|
const rightParts = toQuantityParts(right);
|
||||||
|
if (!leftParts || !rightParts) {
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
const scale = Math.max(leftParts.scale, rightParts.scale);
|
||||||
|
const result = leftParts.value * 10n ** BigInt(scale - leftParts.scale)
|
||||||
|
- rightParts.value * 10n ** BigInt(scale - rightParts.scale);
|
||||||
|
const text = result.toString().padStart(scale + 1, "0");
|
||||||
|
const integerText = scale ? text.slice(0, -scale) : text;
|
||||||
|
const fractionText = scale ? text.slice(-scale).replace(/0+$/, "") : "";
|
||||||
|
return fractionText ? `${integerText}.${fractionText}` : integerText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toQuantityParts(value: number | string): { value: bigint; scale: number } | undefined {
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (!/^\d+(?:\.\d{1,3})?$/.test(text)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const [integerText, fractionText = ""] = text.split(".");
|
||||||
|
return { value: BigInt(`${integerText}${fractionText}`), scale: fractionText.length };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user