365 lines
10 KiB
Vue
365 lines
10 KiB
Vue
<template>
|
|
<div v-can="permissionCodes.productStock.read" class="product-stock-detail">
|
|
<t-loading v-if="isLoading" class="loading" size="32px" />
|
|
<template v-else-if="product && sku && stock">
|
|
<product-profile :product="product" v-entrance-transition>
|
|
<template #tags>
|
|
<t-tag :content="sku?.specSummary || sku?.name || '默认 SKU'" variant="light-outline" theme="primary" />
|
|
<t-tag :content="getStockStatusText(stock)" variant="light-outline" :theme="getStockTheme(stock)" />
|
|
</template>
|
|
</product-profile>
|
|
<t-cell-group theme="card" title="库存" v-entrance-transition="70">
|
|
<t-cell
|
|
v-if="1 < unitList.length"
|
|
title="显示单位"
|
|
:note="selectedUnitName"
|
|
arrow
|
|
@click="openUnitPicker"
|
|
/>
|
|
<t-cell class="empty-cell-title-margin">
|
|
<template #title>
|
|
<span></span>
|
|
</template>
|
|
<template #description>
|
|
<div class="stock">
|
|
<div class="main">
|
|
<span class="label">库存</span>
|
|
<div class="value">
|
|
<strong class="text" v-text="displayStock.onHandQuantity" />
|
|
<span class="unit" v-text="selectedUnitName" />
|
|
</div>
|
|
</div>
|
|
<div class="footer">
|
|
<div class="available">
|
|
<span class="label">可用</span>
|
|
<strong class="value" v-text="displayStock.availableQuantity" />
|
|
<span class="unit" v-text="selectedUnitName" />
|
|
</div>
|
|
<div class="reserved">
|
|
<span class="label">预占</span>
|
|
<strong class="value" v-text="displayStock.reservedQuantity" />
|
|
<span class="unit" v-text="selectedUnitName" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</t-cell>
|
|
<t-cell v-if="0 < Number(stock.reservedQuantity || 0)" title="查看预占流水" arrow @click="openReservedRecordList" />
|
|
<t-cell title="查看该产品 SKU 流水" arrow @click="openRecordList" />
|
|
</t-cell-group>
|
|
<card-container
|
|
v-if="canOperate && sku?.status === 'ACTIVE' && sku.stockEnabled"
|
|
class="actions-wrap"
|
|
title="库存操作"
|
|
description="所有操作必须选择业务单位、上传照片并填写备注"
|
|
v-entrance-transition="140"
|
|
>
|
|
<div class="actions">
|
|
<t-button
|
|
v-for="item in operationList"
|
|
:key="item.type"
|
|
class="action"
|
|
:theme="item.theme"
|
|
block
|
|
@click="openOperation(item.type)"
|
|
>
|
|
<template #icon>
|
|
<t-icon :name="item.icon" size="1.25rem" />
|
|
</template>
|
|
<span v-text="item.text" />
|
|
</t-button>
|
|
</div>
|
|
</card-container>
|
|
</template>
|
|
<t-empty v-else description="库存或产品不存在" />
|
|
<product-stock-operation-popup
|
|
v-if="product && sku && stock"
|
|
v-model:visible="operationVisible"
|
|
:product="product"
|
|
:sku="sku"
|
|
:stock="stock"
|
|
:operation="operationType"
|
|
@success="loadAll"
|
|
/>
|
|
<t-popup v-model="unitPickerVisible" placement="bottom">
|
|
<t-picker
|
|
v-model="unitPickerDraft"
|
|
:columns="unitPickerOptions"
|
|
title="选择库存展示单位"
|
|
@cancel="unitPickerVisible = false"
|
|
@confirm="confirmUnit"
|
|
/>
|
|
</t-popup>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { shallowRef } from "vue";
|
|
import { useUserStore } from "timi-web-api";
|
|
import ProductAPI from "@/api/ProductAPI";
|
|
import ProductProfile from "@/pages/product/component/ProductProfile.vue";
|
|
import ProductStockOperationPopup from "@/pages/product/ProductStockOperationPopup.vue";
|
|
import { useNavBarStore } from "@/store/navBarStore";
|
|
import type { Product, ProductSku, ProductStock, ProductSkuUnit, StockOperationType } from "@/types/Product";
|
|
import { getErrorMessage } from "@/utils/error";
|
|
import { convertBaseQuantityToPackageQuantity } from "@/utils/package";
|
|
import { Message } from "@/utils/message";
|
|
import { permissionCodes } from "@/utils/permission";
|
|
|
|
type ManualStockOperation = Extract<StockOperationType, "MANUAL_IN" | "MANUAL_OUT" | "STOCKTAKE_GAIN" | "STOCKTAKE_LOSS">;
|
|
type PickerValue = string | number;
|
|
|
|
defineOptions({ name: "ProductStockDetail" });
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const navBarStore = useNavBarStore();
|
|
const userStore = useUserStore();
|
|
const product = ref<Product>();
|
|
const sku = ref<ProductSku>();
|
|
const stock = ref<ProductStock>();
|
|
const isLoading = ref(false);
|
|
const operationVisible = ref(false);
|
|
const operationType = ref<ManualStockOperation>("MANUAL_IN");
|
|
const selectedUnitId = shallowRef("");
|
|
const unitPickerVisible = shallowRef(false);
|
|
const unitPickerDraft = ref<PickerValue[]>([]);
|
|
const canOperate = computed(() => userStore.hasPermissions(permissionCodes.productStock.create));
|
|
const unitList = computed(() => (sku.value?.unitList || []).filter(unit => !!unit.id));
|
|
const selectedUnit = computed<ProductSkuUnit | undefined>(() => unitList.value.find(unit => unit.id === selectedUnitId.value)
|
|
|| unitList.value.find(unit => unit.isBase)
|
|
|| unitList.value[0]);
|
|
const selectedUnitName = computed(() => selectedUnit.value?.name || "基础单位");
|
|
const unitPickerOptions = computed(() => [unitList.value.map(unit => ({ label: unit.name || "未命名单位", value: unit.id || "" }))]);
|
|
const displayStock = computed(() => ({
|
|
onHandQuantity: getStockQuantityText(stock.value?.onHandQuantity),
|
|
availableQuantity: getStockQuantityText(stock.value?.availableQuantity),
|
|
reservedQuantity: getStockQuantityText(stock.value?.reservedQuantity)
|
|
}));
|
|
const operationList: Array<{ text: string; icon: string; type: ManualStockOperation; theme: "primary" | "danger" }> = [
|
|
{ text: "手工入库", icon: "import", type: "MANUAL_IN", theme: "primary" },
|
|
{ text: "手工出库", icon: "export", type: "MANUAL_OUT", theme: "danger" },
|
|
{ text: "盘盈", icon: "arrow-up", type: "STOCKTAKE_GAIN", theme: "primary" },
|
|
{ text: "盘亏", icon: "arrow-down", type: "STOCKTAKE_LOSS", theme: "danger" }
|
|
];
|
|
|
|
function getStockStatusText(item: ProductStock): string {
|
|
if (!item.sku?.stockEnabled) {
|
|
return "不管理库存";
|
|
}
|
|
const available = Number(item.availableQuantity || 0);
|
|
const min = Number(item.sku.minStockQuantity || 0);
|
|
return 0 < min && available < min ? "库存不足" : "库存正常";
|
|
}
|
|
|
|
function getStockTheme(item: ProductStock): "danger" | "success" | "default" {
|
|
if (!item.sku?.stockEnabled) {
|
|
return "default";
|
|
}
|
|
const available = Number(item.availableQuantity || 0);
|
|
const min = Number(item.sku.minStockQuantity || 0);
|
|
return 0 < min && available < min ? "danger" : "success";
|
|
}
|
|
|
|
function getStockQuantityText(value?: number | string): string {
|
|
return convertBaseQuantityToPackageQuantity(value ?? "0", selectedUnit.value?.baseQuantityPerUnit ?? "1") || "0";
|
|
}
|
|
|
|
function setDefaultUnit(nextSku: ProductSku): void {
|
|
const nextUnitList = nextSku.unitList || [];
|
|
if (selectedUnitId.value && nextUnitList.some(unit => unit.id === selectedUnitId.value)) {
|
|
return;
|
|
}
|
|
selectedUnitId.value = nextUnitList.find(unit => unit.isBase)?.id || nextUnitList[0]?.id || "";
|
|
}
|
|
|
|
function openUnitPicker(): void {
|
|
if (1 >= unitList.value.length) {
|
|
return;
|
|
}
|
|
unitPickerDraft.value = [selectedUnit.value?.id || ""];
|
|
unitPickerVisible.value = true;
|
|
}
|
|
|
|
function confirmUnit(value: PickerValue[]): void {
|
|
const unitId = String(value[0] || "");
|
|
if (unitList.value.some(unit => unit.id === unitId)) {
|
|
selectedUnitId.value = unitId;
|
|
}
|
|
unitPickerVisible.value = false;
|
|
}
|
|
|
|
async function loadAll(): Promise<void> {
|
|
const skuId = String(route.params.skuId || "");
|
|
if (!skuId) {
|
|
return;
|
|
}
|
|
isLoading.value = true;
|
|
try {
|
|
const skuResult = await ProductAPI.skuDetail(skuId);
|
|
const [productResult, stockResult] = await Promise.all([
|
|
ProductAPI.detail(skuResult.productId || ""),
|
|
ProductAPI.stockDetail(skuId)
|
|
]);
|
|
product.value = productResult;
|
|
sku.value = skuResult;
|
|
setDefaultUnit(skuResult);
|
|
stock.value = { ...stockResult, sku: skuResult };
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function openRecordList(): Promise<void> {
|
|
if (product.value?.id) {
|
|
await router.push({
|
|
name: "ProductStockRecordList",
|
|
query: {
|
|
productId: product.value.id,
|
|
transitionDepth: String(getCurrentRouteDepth() + 1)
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async function openReservedRecordList(): Promise<void> {
|
|
if (product.value?.id) {
|
|
await router.push({
|
|
name: "ProductStockRecordList",
|
|
query: {
|
|
productId: product.value.id,
|
|
operationType: "ORDER_RESERVE",
|
|
activeReservationOnly: "true",
|
|
transitionDepth: String(getCurrentRouteDepth() + 1)
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function getCurrentRouteDepth(): number {
|
|
const transitionDepth = Array.isArray(route.query.transitionDepth)
|
|
? route.query.transitionDepth[0]
|
|
: route.query.transitionDepth;
|
|
const depth = Number(transitionDepth);
|
|
if (transitionDepth && Number.isInteger(depth) && 0 <= depth && depth <= 20) {
|
|
return depth;
|
|
}
|
|
return Number(route.meta.depth ?? 0);
|
|
}
|
|
|
|
function openOperation(type: ManualStockOperation): void {
|
|
operationType.value = type;
|
|
operationVisible.value = true;
|
|
}
|
|
|
|
onMounted(async () => {
|
|
navBarStore.clearRight();
|
|
try {
|
|
await loadAll();
|
|
} catch (error) {
|
|
Message.error(getErrorMessage(error, "库存详情加载失败"));
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => navBarStore.clearRight());
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.product-stock-detail {
|
|
gap: 1rem;
|
|
display: flex;
|
|
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
|
|
flex-direction: column;
|
|
|
|
.loading {
|
|
margin: 3rem auto;
|
|
}
|
|
|
|
.stock {
|
|
|
|
.label {
|
|
color: var(--td-text-color-secondary);
|
|
display: block;
|
|
font-size: .75rem;
|
|
}
|
|
|
|
.value {
|
|
color: var(--td-brand-color);
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.unit {
|
|
color: var(--td-text-color-secondary);
|
|
font-size: .75rem;
|
|
}
|
|
|
|
.main {
|
|
padding: .75rem;
|
|
background: var(--td-brand-color-light);
|
|
border-radius: .875rem;
|
|
|
|
.value {
|
|
gap: .5rem;
|
|
display: flex;
|
|
font-size: 2.25rem;
|
|
margin-top: .25rem;
|
|
align-items: baseline;
|
|
}
|
|
}
|
|
|
|
.footer {
|
|
gap: .75rem;
|
|
display: grid;
|
|
margin-top: .75rem;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
.available,
|
|
.reserved {
|
|
padding: .75rem;
|
|
background: var(--td-bg-color-secondarycontainer);
|
|
border-radius: .75rem;
|
|
}
|
|
|
|
.value {
|
|
display: block;
|
|
font-size: 1.375rem;
|
|
line-height: 1.25;
|
|
}
|
|
|
|
.available {
|
|
|
|
.value {
|
|
color: var(--td-success-color);
|
|
}
|
|
}
|
|
|
|
.reserved {
|
|
.value {
|
|
color: var(--td-warning-color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.actions-wrap {
|
|
|
|
:deep(.content) {
|
|
background: transparent;
|
|
}
|
|
|
|
.actions {
|
|
gap: .75rem;
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
.action {
|
|
--td-button-border-radius: 1rem;
|
|
font-weight: 600;
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
</style>
|