Compare commits
4
Commits
016f0f8c43
...
58d99e52c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58d99e52c5 | ||
|
|
f643656eb4 | ||
|
|
c8e0c554b3 | ||
|
|
813bbbf324 |
@@ -62,6 +62,10 @@ async function disable(id: string): Promise<void> {
|
||||
await axios.post(`${PRODUCT_URI}/disable`, undefined, { params: { id } });
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
await axios.post(`${PRODUCT_URI}/delete`, undefined, { params: { id } });
|
||||
}
|
||||
|
||||
async function skuDetail(id: string): Promise<ProductSku> {
|
||||
return await axios.post(`${PRODUCT_URI}/sku/detail`, undefined, { params: { id } });
|
||||
}
|
||||
@@ -86,10 +90,18 @@ async function specCreate(spec: ProductSpec): Promise<string> {
|
||||
return await axios.post(`${PRODUCT_URI}/spec/create`, spec);
|
||||
}
|
||||
|
||||
async function specUpdate(spec: ProductSpec): Promise<void> {
|
||||
await axios.post(`${PRODUCT_URI}/spec/update`, spec);
|
||||
}
|
||||
|
||||
async function specValueCreate(value: ProductSpecValue): Promise<string> {
|
||||
return await axios.post(`${PRODUCT_URI}/spec/value/create`, value);
|
||||
}
|
||||
|
||||
async function specValueUpdate(value: ProductSpecValue): Promise<void> {
|
||||
await axios.post(`${PRODUCT_URI}/spec/value/update`, value);
|
||||
}
|
||||
|
||||
async function sellableList(page: ProductPage): Promise<Product[]> {
|
||||
return await axios.post(`${PRODUCT_URI}/sellable/list`, withCurrentStoreId(page));
|
||||
}
|
||||
@@ -155,13 +167,16 @@ export default {
|
||||
create,
|
||||
update,
|
||||
disable,
|
||||
remove,
|
||||
skuDetail,
|
||||
skuCreate,
|
||||
skuUpdate,
|
||||
skuDisable,
|
||||
specList,
|
||||
specCreate,
|
||||
specUpdate,
|
||||
specValueCreate,
|
||||
specValueUpdate,
|
||||
sellableList,
|
||||
stockList,
|
||||
stockProductList,
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
v-model:visible="photoViewerVisible"
|
||||
:images="photoViewerImages"
|
||||
:items="photoViewerItems"
|
||||
:viewer-z-index="props.viewerZIndex"
|
||||
:show-actions="showViewerActions === true"
|
||||
:submitting="isSubmitting"
|
||||
@change-type="changePhotoType"
|
||||
@@ -75,6 +76,7 @@ interface Props {
|
||||
showTypeLabel?: boolean;
|
||||
submit?: (photoItems: PhotoItem[]) => Promise<void>;
|
||||
title?: string;
|
||||
viewerZIndex?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<t-image-viewer
|
||||
v-model:visible="viewerVisible"
|
||||
:images="images"
|
||||
:style="{ zIndex: props.viewerZIndex }"
|
||||
show-index
|
||||
@close="handleClose"
|
||||
@index-change="handleIndexChange"
|
||||
@@ -103,6 +104,7 @@ interface Props {
|
||||
showActions: boolean;
|
||||
submitting: boolean;
|
||||
visible: boolean;
|
||||
viewerZIndex?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
@@ -342,7 +342,7 @@ onUnmounted(() => navBarStore.clearRight());
|
||||
|
||||
<style scoped lang="less">
|
||||
.shared-panel {
|
||||
padding-top: var(--app-nav-height);
|
||||
--ai-msg-padding-top: calc(var(--app-nav-height) + 1rem);
|
||||
|
||||
:deep(.welcome) {
|
||||
margin: auto 0;
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
variant="outline"
|
||||
size="extra-small"
|
||||
:content="deliveredAtText || '此刻'"
|
||||
@click="pickerVisible = true"
|
||||
@click="openDeliveredAtPicker"
|
||||
/>
|
||||
<t-button
|
||||
v-if="deliveredAtText"
|
||||
@@ -124,7 +124,15 @@
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<t-popup v-model="pickerVisible" placement="bottom">
|
||||
<t-date-time-picker v-model="draftTime" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="履约时间" @cancel="pickerVisible = false" @confirm="confirmTime" />
|
||||
<t-date-time-picker
|
||||
v-if="pickerVisible"
|
||||
v-model="draftTime"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
:mode="['date', 'minute']"
|
||||
title="履约时间"
|
||||
@cancel="pickerVisible = false"
|
||||
@confirm="confirmTime"
|
||||
/>
|
||||
</t-popup>
|
||||
<t-popup v-model="unitPickerVisible" placement="bottom">
|
||||
<t-picker v-model="unitPickerDraft" :columns="unitPickerOptions" title="选择实际履约单位" @cancel="unitPickerVisible = false" @confirm="confirmUnitPicker" />
|
||||
@@ -136,6 +144,7 @@
|
||||
import SalesOrderAPI from "@/api/SalesOrderAPI";
|
||||
import FulfillmentAPI from "@/api/FulfillmentAPI";
|
||||
import ProductAPI from "@/api/ProductAPI";
|
||||
import { Time } from "timi-web";
|
||||
import MediaSelector from "@/components/mediaSelector/MediaSelector.vue";
|
||||
import ProductSkuTags from "@/components/ProductSkuTags.vue";
|
||||
import ProductStockPreview from "@/components/ProductStockPreview.vue";
|
||||
@@ -383,6 +392,11 @@ function confirmTime(value: string | number): void {
|
||||
pickerVisible.value = false;
|
||||
}
|
||||
|
||||
function openDeliveredAtPicker(): void {
|
||||
draftTime.value = deliveredAtText.value || Time.formatDateTime(new Date());
|
||||
pickerVisible.value = true;
|
||||
}
|
||||
|
||||
function resetDeliveredAt(): void {
|
||||
deliveredAtText.value = "";
|
||||
draftTime.value = "";
|
||||
|
||||
@@ -458,7 +458,6 @@ onUnmounted(() => {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
min-height: 70vh;
|
||||
padding-top: 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
.items {
|
||||
|
||||
@@ -184,6 +184,7 @@ const actionList = computed<NavBarAction[]>(() => {
|
||||
if (product.value?.status === "ACTIVE") {
|
||||
list.push({ key: "disable", text: "停用产品", icon: "close", theme: "danger", action: disable });
|
||||
}
|
||||
list.push({ key: "remove", text: "移除产品", icon: "delete", theme: "danger", action: remove });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
@@ -271,6 +272,32 @@ function disable(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
const id = product.value?.id;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "移除产品",
|
||||
content: "仅可移除没有订单、履约、库存流水、配置引用和库存余额的产品。确定提交移除?",
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: { content: "移除", theme: "danger" },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await ProductAPI.remove(id);
|
||||
emitListDataChanged(listDataEventKeys.productChanged, id);
|
||||
emitListDataChanged(listDataEventKeys.productStockChanged, id);
|
||||
Message.success("产品已移除");
|
||||
await router.replace("/management/products");
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "产品移除失败"));
|
||||
} finally {
|
||||
dialog.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.clearRight();
|
||||
navBarStore.setCustomRightVisible(actionList.value.length > 0);
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
v-model="spec.name"
|
||||
borderless
|
||||
maxlength="32"
|
||||
:readonly="!!spec.id"
|
||||
placeholder="如 颜色、尺寸"
|
||||
/>
|
||||
<t-button
|
||||
@@ -85,10 +84,11 @@
|
||||
:key="value.clientKey"
|
||||
class="tag"
|
||||
variant="light-outline"
|
||||
:content="value.name"
|
||||
closable
|
||||
@close="removeSpecValue(spec, spec.valueList.indexOf(value))"
|
||||
/>
|
||||
>
|
||||
<t-input v-model.trim="value.name" class="value-name" borderless maxlength="32" />
|
||||
</t-tag>
|
||||
</div>
|
||||
</t-form-item>
|
||||
<t-form-item label="规格值" label-width="3.5rem">
|
||||
@@ -185,6 +185,7 @@
|
||||
<product-sku-unit-batch-editor
|
||||
v-model="batchUnitEditorVisible"
|
||||
v-model:unit-list="batchUnitList"
|
||||
v-model:base-unit-migration-ratio-text="batchBaseUnitMigrationRatioText"
|
||||
:product-type="form.productType"
|
||||
:sku-count="form.skuList.length"
|
||||
@apply="applyBatchUnitTemplate"
|
||||
@@ -306,6 +307,7 @@ const unitEditorVisible = ref(false);
|
||||
const editingSku = ref<SkuForm>();
|
||||
const batchUnitEditorVisible = ref(false);
|
||||
const batchUnitList = ref<UnitForm[]>([]);
|
||||
const batchBaseUnitMigrationRatioText = ref("");
|
||||
const removedSkuKeySet = new Set<string>();
|
||||
const removedSkuMap = new Map<string, SkuForm>();
|
||||
|
||||
@@ -326,6 +328,7 @@ function createUnit(unit?: Partial<ProductSkuUnit>, defaultName = "件"): UnitFo
|
||||
|
||||
function createSku(sku?: Partial<ProductSku>): SkuForm {
|
||||
const sourceUnitList = sku?.unitList || [];
|
||||
const originalBaseUnitId = sourceUnitList.find(unit => unit.isBase)?.id;
|
||||
const photoItemList = (sku?.attachmentList || []).filter(item => !!item.id).map(item => ({
|
||||
attachment: item,
|
||||
attachType: item.attachType || "PHOTO",
|
||||
@@ -344,6 +347,8 @@ function createSku(sku?: Partial<ProductSku>): SkuForm {
|
||||
status: sku?.status || "ACTIVE",
|
||||
minStockQuantityText: sku?.minStockQuantity === null || sku?.minStockQuantity === undefined ? "" : String(sku.minStockQuantity),
|
||||
unitList: sourceUnitList.length ? sourceUnitList.map(unit => createUnit(unit)) : [createUnit({ isBase: true, baseQuantityPerUnit: "1" })],
|
||||
originalBaseUnitId,
|
||||
baseUnitMigrationRatioText: "",
|
||||
photoItemList
|
||||
};
|
||||
}
|
||||
@@ -354,7 +359,17 @@ function createSpec(spec?: Partial<ProductSpec>): SpecForm {
|
||||
id: spec?.id,
|
||||
clientKey: window.crypto.randomUUID(),
|
||||
name: spec?.name || "",
|
||||
valueList: (spec?.valueList || []).map(value => ({ ...value, id: value.id, clientKey: window.crypto.randomUUID(), name: value.name || "", enabled: value.enabled ?? true }))
|
||||
originalName: spec?.name || "",
|
||||
originalSort: spec?.sort,
|
||||
valueList: (spec?.valueList || []).map(value => ({
|
||||
...value,
|
||||
id: value.id,
|
||||
clientKey: window.crypto.randomUUID(),
|
||||
name: value.name || "",
|
||||
originalName: value.name || "",
|
||||
originalSort: value.sort,
|
||||
enabled: value.enabled ?? true
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -385,7 +400,7 @@ function addSpecValue(spec: SpecForm): void {
|
||||
Message.warning("同一规格项的规格值不能重复");
|
||||
return;
|
||||
}
|
||||
spec.valueList.push({ clientKey: window.crypto.randomUUID(), name, enabled: true });
|
||||
spec.valueList.push({ clientKey: window.crypto.randomUUID(), name, originalName: name, originalSort: spec.valueList.length, enabled: true });
|
||||
specValueDraftMap[spec.clientKey] = "";
|
||||
}
|
||||
|
||||
@@ -417,6 +432,7 @@ function openBatchUnitEditor(): void {
|
||||
return;
|
||||
}
|
||||
batchUnitList.value = sourceSku.unitList.map(unit => ({ ...unit, id: undefined, skuId: undefined, clientKey: window.crypto.randomUUID() }));
|
||||
batchBaseUnitMigrationRatioText.value = "";
|
||||
batchUnitEditorVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -426,16 +442,10 @@ function closeBatchUnitEditor(): void {
|
||||
|
||||
function applyBatchUnitTemplate(): void {
|
||||
const templateList = batchUnitList.value;
|
||||
for (const sku of form.skuList) {
|
||||
const templateNameSet = new Set(templateList.map(unit => unit.name.trim()));
|
||||
if (sku.unitList.some(unit => !!unit.id && !templateNameSet.has(unit.name.trim()))) {
|
||||
Message.warning("统一配置不能删除或重命名已保存单位,请在 SKU 明细中停售");
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.skuList.forEach(sku => {
|
||||
const oldUnitList = sku.unitList;
|
||||
const oldUnitMap = new Map(oldUnitList.filter(unit => !!unit.id).map(unit => [unit.name.trim(), unit]));
|
||||
const oldBaseUnit = oldUnitList.find(unit => unit.isBase);
|
||||
sku.unitList = templateList.map((unit, unitIndex) => ({
|
||||
...unit,
|
||||
id: oldUnitMap.get(unit.name.trim())?.id,
|
||||
@@ -443,6 +453,10 @@ function applyBatchUnitTemplate(): void {
|
||||
clientKey: window.crypto.randomUUID(),
|
||||
sort: unitIndex
|
||||
}));
|
||||
const newBaseUnit = sku.unitList.find(unit => unit.isBase);
|
||||
if (oldBaseUnit?.id && newBaseUnit && oldBaseUnit.id !== newBaseUnit.id) {
|
||||
sku.baseUnitMigrationRatioText = batchBaseUnitMigrationRatioText.value;
|
||||
}
|
||||
});
|
||||
Message.success(`已为 ${form.skuList.length} 个 SKU 统一配置销售单位`);
|
||||
}
|
||||
@@ -452,7 +466,7 @@ function removeSku(index: number): void {
|
||||
if (!sku) {
|
||||
return;
|
||||
}
|
||||
if (form.skuList.length < 2) {
|
||||
if (isCreateMode.value && form.skuList.length < 2) {
|
||||
Message.warning("产品至少需要一个 SKU");
|
||||
return;
|
||||
}
|
||||
@@ -463,7 +477,7 @@ function removeSku(index: number): void {
|
||||
}
|
||||
form.skuList.splice(index, 1);
|
||||
if (sku.id) {
|
||||
Message.success("SKU 已移除,保存后将停用");
|
||||
Message.success("SKU 已移除,保存后由后端检查引用并处理");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +881,7 @@ function validate(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!form.skuList.length) {
|
||||
if (isCreateMode.value && !form.skuList.length) {
|
||||
Message.warning("至少需要一个 SKU");
|
||||
return false;
|
||||
}
|
||||
@@ -929,6 +943,16 @@ function validate(): boolean {
|
||||
Message.warning("每个 SKU 必须且只能有一个基础单位");
|
||||
return false;
|
||||
}
|
||||
const selectedBaseUnit = sku.unitList.find(unit => unit.isBase);
|
||||
const isBaseUnitChanged = !!sku.originalBaseUnitId && selectedBaseUnit?.id !== sku.originalBaseUnitId;
|
||||
if (isBaseUnitChanged && !/^\d+(?:\.\d{1,6})?$/.test(sku.baseUnitMigrationRatioText.trim())) {
|
||||
Message.warning("切换基础单位时,请填写有效的新旧基础单位换算比例,最多六位小数");
|
||||
return false;
|
||||
}
|
||||
if (isBaseUnitChanged && !(0 < Number(sku.baseUnitMigrationRatioText))) {
|
||||
Message.warning("新旧基础单位换算比例必须大于 0");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -958,7 +982,14 @@ function buildSkuPayload(sku: SkuForm, index: number): ProductSku {
|
||||
allowPurchase: form.productType === "SERVICE" ? false : !!unit.allowPurchase,
|
||||
allowSale: !!unit.allowSale,
|
||||
sort: unitIndex
|
||||
}))
|
||||
})),
|
||||
baseUnitMigrationRequest: sku.id && sku.originalBaseUnitId && sku.unitList.find(unit => unit.isBase)?.id !== sku.originalBaseUnitId
|
||||
? {
|
||||
targetUnitId: sku.unitList.find(unit => unit.isBase)?.id,
|
||||
targetUnitName: sku.unitList.find(unit => unit.isBase)?.name.trim(),
|
||||
oldToNewBaseRatio: sku.baseUnitMigrationRatioText.trim()
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -973,11 +1004,18 @@ function buildProductPayload(skuList?: ProductSku[], includeSpecList = false): P
|
||||
status: form.status,
|
||||
remark: form.remark?.trim() || null,
|
||||
attachmentList: buildAttachmentList(),
|
||||
specList: includeSpecList ? form.specList.map(spec => ({
|
||||
...spec,
|
||||
specList: includeSpecList ? form.specList.map((spec, specIndex) => ({
|
||||
id: spec.id,
|
||||
productId: form.id,
|
||||
name: spec.name.trim(),
|
||||
valueList: spec.valueList.map(value => ({ ...value, name: value.name.trim() }))
|
||||
sort: specIndex,
|
||||
valueList: spec.valueList.map((value, valueIndex) => ({
|
||||
id: value.id,
|
||||
specId: spec.id,
|
||||
name: value.name.trim(),
|
||||
enabled: value.enabled !== false,
|
||||
sort: valueIndex
|
||||
}))
|
||||
})) : undefined,
|
||||
skuList
|
||||
};
|
||||
@@ -987,11 +1025,23 @@ async function syncSpecs(productId: string): Promise<void> {
|
||||
for (const [specIndex, spec] of form.specList.entries()) {
|
||||
if (!spec.id) {
|
||||
spec.id = await ProductAPI.specCreate({ productId, name: spec.name.trim(), sort: specIndex });
|
||||
spec.originalName = spec.name.trim();
|
||||
spec.originalSort = specIndex;
|
||||
} else if (spec.name.trim() !== spec.originalName || specIndex !== spec.originalSort) {
|
||||
await ProductAPI.specUpdate({ id: spec.id, productId, name: spec.name.trim(), sort: specIndex });
|
||||
spec.originalName = spec.name.trim();
|
||||
spec.originalSort = specIndex;
|
||||
}
|
||||
for (const [valueIndex, value] of spec.valueList.entries()) {
|
||||
if (!value.id) {
|
||||
value.specId = spec.id;
|
||||
value.id = await ProductAPI.specValueCreate({ specId: spec.id, name: value.name.trim(), enabled: value.enabled !== false, sort: valueIndex });
|
||||
value.originalName = value.name.trim();
|
||||
value.originalSort = valueIndex;
|
||||
} else if (value.name.trim() !== value.originalName || valueIndex !== value.originalSort) {
|
||||
await ProductAPI.specValueUpdate({ id: value.id, specId: spec.id, name: value.name.trim(), enabled: value.enabled !== false, sort: valueIndex });
|
||||
value.originalName = value.name.trim();
|
||||
value.originalSort = valueIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1018,23 +1068,17 @@ async function save(): Promise<void> {
|
||||
await syncSpecs(productId!);
|
||||
normalizeSkuSpecValueIds();
|
||||
const latest = await ProductAPI.detail(productId!);
|
||||
const previousSkuIdSet = new Set((latest.skuList || []).map(sku => sku.id).filter((id): id is string => !!id));
|
||||
if (form.specList.length) {
|
||||
const defaultSku = latest.skuList?.find(sku => !sku.specValueIdList?.length);
|
||||
if (defaultSku?.id && form.skuList[0] && !form.skuList[0].id) {
|
||||
form.skuList[0].id = defaultSku.id;
|
||||
}
|
||||
generateSkuList(latest.skuList || [], true);
|
||||
} else if (!form.skuList.length) {
|
||||
form.skuList = [createSku(latest.skuList?.[0])];
|
||||
} else if (!form.skuList[0]?.id && latest.skuList?.[0]) {
|
||||
} else if (form.skuList[0] && !form.skuList[0].id && latest.skuList?.[0]) {
|
||||
form.skuList[0].id = latest.skuList[0].id;
|
||||
}
|
||||
const payload = buildProductPayload(form.skuList.map(buildSkuPayload));
|
||||
await ProductAPI.update(payload);
|
||||
const retainedSkuIdSet = new Set(form.skuList.map(sku => sku.id).filter((id): id is string => !!id));
|
||||
const disabledSkuIdList = [...previousSkuIdSet].filter(id => !retainedSkuIdSet.has(id));
|
||||
await Promise.all(disabledSkuIdList.map(async skuId => await ProductAPI.skuDisable(skuId)));
|
||||
emitListDataChanged(listDataEventKeys.productChanged, productId);
|
||||
emitListDataChanged(listDataEventKeys.productStockChanged, productId);
|
||||
Message.success(isCreateMode.value ? "产品已创建" : "产品已保存");
|
||||
@@ -1173,12 +1217,16 @@ onUnmounted(() => navBarStore.clearRight());
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.tags {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.value-name {
|
||||
width: 5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
--td-button-border-radius: 1rem;
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</t-cell>
|
||||
<product-stock-package-summary :sku="sku" :stock="stock" />
|
||||
<t-cell title="查看库存流水" arrow @click="openRecordList" />
|
||||
<t-cell v-if="0 < Number(stock.reservedQuantity || 0)" title="查看库存预占流水" arrow @click="openReservedRecordList" />
|
||||
</t-cell-group>
|
||||
@@ -100,6 +101,7 @@ import { shallowRef } from "vue";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import ProductAPI from "@/api/ProductAPI";
|
||||
import ProductProfile from "@/pages/product/component/ProductProfile.vue";
|
||||
import ProductStockPackageSummary from "@/pages/product/component/ProductStockPackageSummary.vue";
|
||||
import ProductStockOperationPopup from "@/pages/product/ProductStockOperationPopup.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { Product, ProductSku, ProductStock, ProductSkuUnit, StockOperationType } from "@/types/Product";
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<span v-text="getProductQuantityText(record.onHandBefore)" />
|
||||
<t-icon name="arrow-right" />
|
||||
<span :class="getDeltaClass(record.onHandDelta)" v-text="getProductQuantityText(record.onHandAfter)" />
|
||||
<span class="unit">基础单位</span>
|
||||
<span class="unit" v-text="baseUnitName || '基础单位'" />
|
||||
</span>
|
||||
</template>
|
||||
</t-cell>
|
||||
@@ -54,7 +54,7 @@
|
||||
<span v-text="getProductQuantityText(record.reservedBefore)" />
|
||||
<t-icon name="arrow-right" />
|
||||
<span :class="getDeltaClass(record.reservedDelta)" v-text="getProductQuantityText(record.reservedAfter)" />
|
||||
<span class="unit">基础单位</span>
|
||||
<span class="unit" v-text="baseUnitName || '基础单位'" />
|
||||
</span>
|
||||
</template>
|
||||
</t-cell>
|
||||
@@ -157,6 +157,7 @@ const navBarStore = useNavBarStore();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const record = ref<StockRecord>();
|
||||
const baseUnitName = ref("");
|
||||
const isLoading = ref(false);
|
||||
const isOperating = ref(false);
|
||||
const revokeVisible = ref(false);
|
||||
@@ -299,6 +300,15 @@ async function loadData(): Promise<void> {
|
||||
try {
|
||||
const result = await ProductAPI.recordDetail(id);
|
||||
record.value = result;
|
||||
baseUnitName.value = "";
|
||||
if (result.skuId) {
|
||||
try {
|
||||
const sku = await ProductAPI.skuDetail(result.skuId);
|
||||
baseUnitName.value = sku.unitList?.find(unit => unit.isBase)?.name || sku.unitList?.[0]?.name || "";
|
||||
} catch {
|
||||
baseUnitName.value = "";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "库存流水加载失败"));
|
||||
} finally {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</t-cell>
|
||||
<template #right>
|
||||
<t-button class="delete-action swipe-cell-button" theme="danger" @click.stop="requestRemove">
|
||||
停用
|
||||
移除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-swipe-cell>
|
||||
@@ -82,6 +82,7 @@
|
||||
v-model:photo-items="sku.photoItemList"
|
||||
title="SKU 图片(选填)"
|
||||
:edit-mode="true"
|
||||
:viewer-z-index="skuEditorZIndex + 1"
|
||||
:upload="uploadPhotoList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
<div class="editor-context">
|
||||
<strong>批量配置销售单位</strong>
|
||||
<span v-text="`以下单位将应用到全部 ${props.skuCount} 个 SKU 明细`" />
|
||||
<t-form-item label="基础单位换算比例" help="切换已有 SKU 的基础单位时填写:1 个旧基础单位等于多少个新基础单位;最多六位小数">
|
||||
<t-input v-model.trim="baseUnitMigrationRatioText" type="number" align="right" borderless placeholder="无需切换时留空" />
|
||||
</t-form-item>
|
||||
</div>
|
||||
|
||||
<div class="unit-list">
|
||||
@@ -71,6 +74,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const unitList = defineModel<ProductSkuUnitForm[]>("unitList", { required: true });
|
||||
const baseUnitMigrationRatioText = defineModel<string>("baseUnitMigrationRatioText", { default: "" });
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
apply: [];
|
||||
|
||||
@@ -27,8 +27,7 @@
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="extra-small"
|
||||
:content="unit.id ? '停售' : '删除'"
|
||||
:disabled="!!unit.id && unit.allowSale === false && unit.allowPurchase === false"
|
||||
content="移除"
|
||||
@click="removeUnit(unitIndex)"
|
||||
/>
|
||||
</div>
|
||||
@@ -39,6 +38,9 @@
|
||||
<t-form-item label="基础单位" content-align="right">
|
||||
<t-switch v-model="unit.isBase" size="small" :disabled="sku.unitList.length === 1" @change="setBaseUnit(unitIndex)" />
|
||||
</t-form-item>
|
||||
<t-form-item v-if="isBaseUnitMigrationRequired" label="新旧基础单位比例" help="填写 1 个旧基础单位等于多少个新基础单位;最多六位小数" required-mark>
|
||||
<t-input v-model.trim="sku.baseUnitMigrationRatioText" type="number" align="right" borderless placeholder="例如 30" />
|
||||
</t-form-item>
|
||||
<t-form-item label="换算数量" help="1 个当前单位对应的基础单位数量" required-mark>
|
||||
<t-input v-model.trim="unit.baseQuantityPerUnit" type="number" align="right" borderless :disabled="unit.isBase" placeholder="1" />
|
||||
</t-form-item>
|
||||
@@ -91,6 +93,12 @@ const visible = computed({
|
||||
set: value => handleVisibleChange(value)
|
||||
});
|
||||
const unitEditorZIndex = 12100;
|
||||
const isBaseUnitMigrationRequired = computed(() => {
|
||||
if (!sku.value?.originalBaseUnitId) {
|
||||
return false;
|
||||
}
|
||||
return sku.value.unitList.find(unit => unit.isBase)?.id !== sku.value.originalBaseUnitId;
|
||||
});
|
||||
|
||||
function handleVisibleChange(value: boolean): void {
|
||||
emit("update:modelValue", value);
|
||||
@@ -135,10 +143,7 @@ function removeUnit(index: number): void {
|
||||
}
|
||||
const unit = sku.value.unitList[index];
|
||||
if (unit?.id) {
|
||||
unit.allowSale = false;
|
||||
unit.allowPurchase = false;
|
||||
Message.success("单位已停售并禁止采购,历史订单仍可履约");
|
||||
return;
|
||||
Message.info("已移除单位将在保存时由后端检查引用后处理");
|
||||
}
|
||||
const wasBase = sku.value.unitList[index]?.isBase;
|
||||
sku.value.unitList.splice(index, 1);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<template v-if="packageUnits.length">
|
||||
<t-cell class="packages" title="整包装换算">
|
||||
<template #note>
|
||||
<div class="options" role="group" aria-label="选择整包装换算单位">
|
||||
<t-button
|
||||
v-for="unit in packageUnits"
|
||||
:key="unit.id"
|
||||
size="extra-small"
|
||||
:theme="selectedUnit?.id === unit.id ? 'primary' : 'default'"
|
||||
:variant="selectedUnit?.id === unit.id ? 'base' : 'outline'"
|
||||
:aria-label="`按${unit.name || '未命名单位'}换算`"
|
||||
:aria-pressed="selectedUnit?.id === unit.id"
|
||||
@click="selectedUnitId = unit.id || ''"
|
||||
>
|
||||
<span v-text="`按${unit.name || '未命名单位'}`" />
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
</t-cell>
|
||||
<t-cell title="库存折合" :note="summary.onHand" />
|
||||
<t-cell title="可用折合" :note="summary.available" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProductSku, ProductStock } from "@/types/Product";
|
||||
import { splitBaseQuantityIntoPackages } from "@/utils/package";
|
||||
|
||||
const props = defineProps<{ sku: ProductSku; stock: ProductStock }>();
|
||||
|
||||
// ---------- 整包装换算 ----------
|
||||
|
||||
const selectedUnitId = ref("");
|
||||
const baseUnits = computed(() => (props.sku.unitList || []).filter(unit => unit.isBase));
|
||||
const packageUnits = computed(() => (props.sku.unitList || [])
|
||||
.filter(unit => unit.id && !unit.isBase && 1 < Number(unit.baseQuantityPerUnit))
|
||||
.sort((left, right) => Number(right.baseQuantityPerUnit) - Number(left.baseQuantityPerUnit)));
|
||||
const selectedUnit = computed(() => packageUnits.value.find(unit => unit.id === selectedUnitId.value) || packageUnits.value[0]);
|
||||
const summary = computed(() => ({
|
||||
onHand: getSummaryText(props.stock.onHandQuantity),
|
||||
available: getSummaryText(props.stock.availableQuantity)
|
||||
}));
|
||||
|
||||
/** 使用原始基础库存换算,保留基础单位余数,异常数据不按零库存展示。 */
|
||||
function getSummaryText(quantity?: number | string): string {
|
||||
const baseUnit = baseUnits.value[0];
|
||||
const unit = selectedUnit.value;
|
||||
if (quantity === undefined || baseUnits.value.length !== 1 || !baseUnit?.name
|
||||
|| Number(baseUnit.baseQuantityPerUnit) !== 1 || !unit?.name) {
|
||||
return "无法换算";
|
||||
}
|
||||
const result = splitBaseQuantityIntoPackages(quantity, unit.baseQuantityPerUnit);
|
||||
if (!result) {
|
||||
return "无法换算";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (result.whole !== "0") {
|
||||
parts.push(`${result.whole} ${unit.name}`);
|
||||
}
|
||||
if (result.remainder !== "0" || !parts.length) {
|
||||
parts.push(`${result.remainder} ${baseUnit.name}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.packages {
|
||||
.options {
|
||||
--td-button-border-radius: .5rem;
|
||||
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -577,7 +577,21 @@ async function handleCustomerChange(): Promise<void> {
|
||||
}
|
||||
|
||||
async function toDetail(item: SalesOrder): Promise<void> {
|
||||
if (item.id) await router.push(`/management/sales-orders/${item.id}`);
|
||||
if (item.id) await router.push({
|
||||
path: `/management/sales-orders/${item.id}`,
|
||||
query: { 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);
|
||||
}
|
||||
|
||||
async function toPayment(item: SalesOrder): Promise<void> {
|
||||
|
||||
@@ -277,7 +277,7 @@ onUnmounted(() => {
|
||||
.filters {
|
||||
top: var(--app-nav-height);
|
||||
z-index: 10;
|
||||
padding: .5rem;
|
||||
padding: .5rem 1rem;
|
||||
position: sticky;
|
||||
|
||||
.search {
|
||||
|
||||
@@ -51,6 +51,12 @@ export type ProductSkuUnit = {
|
||||
sort?: number;
|
||||
} & Model;
|
||||
|
||||
export type ProductSkuBaseUnitMigrationRequest = {
|
||||
targetUnitId?: string;
|
||||
targetUnitName?: string;
|
||||
oldToNewBaseRatio: number | string;
|
||||
};
|
||||
|
||||
export type ProductSku = {
|
||||
storeId?: string;
|
||||
productId?: string;
|
||||
@@ -67,6 +73,7 @@ export type ProductSku = {
|
||||
updatedBy?: string;
|
||||
specValueIdList?: string[];
|
||||
unitList?: ProductSkuUnit[];
|
||||
baseUnitMigrationRequest?: ProductSkuBaseUnitMigrationRequest;
|
||||
attachmentList?: Attachment[];
|
||||
} & Model;
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@ import type { Product, ProductAttachmentType, ProductSku, ProductSkuUnit, Produc
|
||||
|
||||
export type ProductSpecValueForm = ProductSpecValue & {
|
||||
clientKey: string;
|
||||
originalName: string;
|
||||
originalSort?: number;
|
||||
};
|
||||
|
||||
export type ProductSpecForm = Omit<ProductSpec, "valueList"> & {
|
||||
clientKey: string;
|
||||
originalName: string;
|
||||
originalSort?: number;
|
||||
valueList: ProductSpecValueForm[];
|
||||
};
|
||||
|
||||
@@ -21,6 +25,8 @@ export type ProductSkuForm = Omit<ProductSku, "unitList"> & {
|
||||
unitList: ProductSkuUnitForm[];
|
||||
minStockQuantityText: string;
|
||||
photoItemList: PhotoItem[];
|
||||
originalBaseUnitId?: string;
|
||||
baseUnitMigrationRatioText: string;
|
||||
};
|
||||
|
||||
export type ProductForm = Omit<Product, "specList" | "skuList"> & {
|
||||
|
||||
@@ -100,6 +100,29 @@ export function convertBaseQuantityToPackageQuantity(baseQuantity: number | stri
|
||||
return formatDecimal(rounded, 3, 3);
|
||||
}
|
||||
|
||||
/** 将非负基础数量精确拆分为整包装和基础单位余数,不对余数取整。 */
|
||||
export function splitBaseQuantityIntoPackages(baseQuantity: number | string, baseQuantityPerUnit: number | string): { whole: string; remainder: string } | undefined {
|
||||
const quantityParts = toDecimalParts(baseQuantity, 3);
|
||||
const unitParts = toDecimalParts(baseQuantityPerUnit, 6);
|
||||
if (!quantityParts || !unitParts) {
|
||||
return undefined;
|
||||
}
|
||||
const scale = Math.max(quantityParts.scale, unitParts.scale);
|
||||
const factor = 10n ** BigInt(scale);
|
||||
const quantity = quantityParts.value * 10n ** BigInt(scale - quantityParts.scale);
|
||||
const unit = unitParts.value * 10n ** BigInt(scale - unitParts.scale);
|
||||
if (unit <= factor) {
|
||||
return undefined;
|
||||
}
|
||||
const remainder = quantity % unit;
|
||||
const integer = (remainder / factor).toString();
|
||||
const fraction = (remainder % factor).toString().padStart(scale, "0").replace(/0+$/, "");
|
||||
return {
|
||||
whole: (quantity / unit).toString(),
|
||||
remainder: fraction ? `${integer}.${fraction}` : integer
|
||||
};
|
||||
}
|
||||
|
||||
/** 精确相加两个最多三位小数的数量。 */
|
||||
export function addPackageQuantity(left: number | string, right: number | string): string | undefined {
|
||||
const leftParts = toQuantityParts(left);
|
||||
|
||||
Reference in New Issue
Block a user