diff --git a/src/api/ProductAPI.ts b/src/api/ProductAPI.ts index 709e405..36402d3 100644 --- a/src/api/ProductAPI.ts +++ b/src/api/ProductAPI.ts @@ -62,6 +62,10 @@ async function disable(id: string): Promise { await axios.post(`${PRODUCT_URI}/disable`, undefined, { params: { id } }); } +async function remove(id: string): Promise { + await axios.post(`${PRODUCT_URI}/delete`, undefined, { params: { id } }); +} + async function skuDetail(id: string): Promise { return await axios.post(`${PRODUCT_URI}/sku/detail`, undefined, { params: { id } }); } @@ -86,10 +90,18 @@ async function specCreate(spec: ProductSpec): Promise { return await axios.post(`${PRODUCT_URI}/spec/create`, spec); } +async function specUpdate(spec: ProductSpec): Promise { + await axios.post(`${PRODUCT_URI}/spec/update`, spec); +} + async function specValueCreate(value: ProductSpecValue): Promise { return await axios.post(`${PRODUCT_URI}/spec/value/create`, value); } +async function specValueUpdate(value: ProductSpecValue): Promise { + await axios.post(`${PRODUCT_URI}/spec/value/update`, value); +} + async function sellableList(page: ProductPage): Promise { 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, diff --git a/src/pages/fulfillment/OrderFulfillmentCreate.vue b/src/pages/fulfillment/OrderFulfillmentCreate.vue index 1e211ce..34a5a3b 100644 --- a/src/pages/fulfillment/OrderFulfillmentCreate.vue +++ b/src/pages/fulfillment/OrderFulfillmentCreate.vue @@ -124,7 +124,15 @@ - + diff --git a/src/pages/product/ProductDetail.vue b/src/pages/product/ProductDetail.vue index 0965f41..a9abfa9 100644 --- a/src/pages/product/ProductDetail.vue +++ b/src/pages/product/ProductDetail.vue @@ -184,6 +184,7 @@ const actionList = computed(() => { 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); diff --git a/src/pages/product/ProductEdit.vue b/src/pages/product/ProductEdit.vue index 1d5ab01..573dad0 100644 --- a/src/pages/product/ProductEdit.vue +++ b/src/pages/product/ProductEdit.vue @@ -66,7 +66,6 @@ v-model="spec.name" borderless maxlength="32" - :readonly="!!spec.id" placeholder="如 颜色、尺寸" /> + > + + @@ -185,6 +185,7 @@ (); const batchUnitEditorVisible = ref(false); const batchUnitList = ref([]); +const batchBaseUnitMigrationRatioText = ref(""); const removedSkuKeySet = new Set(); const removedSkuMap = new Map(); @@ -326,6 +328,7 @@ function createUnit(unit?: Partial, defaultName = "件"): UnitFo function createSku(sku?: Partial): 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): 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): 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 { 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 { 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; diff --git a/src/pages/product/component/ProductSkuEditor.vue b/src/pages/product/component/ProductSkuEditor.vue index ec82d6f..33371a2 100644 --- a/src/pages/product/component/ProductSkuEditor.vue +++ b/src/pages/product/component/ProductSkuEditor.vue @@ -34,7 +34,7 @@ diff --git a/src/pages/product/component/ProductSkuUnitBatchEditor.vue b/src/pages/product/component/ProductSkuUnitBatchEditor.vue index 6b208b7..c8cdef1 100644 --- a/src/pages/product/component/ProductSkuUnitBatchEditor.vue +++ b/src/pages/product/component/ProductSkuUnitBatchEditor.vue @@ -14,6 +14,9 @@
批量配置销售单位 + + +
@@ -71,6 +74,7 @@ const props = defineProps<{ }>(); const unitList = defineModel("unitList", { required: true }); +const baseUnitMigrationRatioText = defineModel("baseUnitMigrationRatioText", { default: "" }); const emit = defineEmits<{ "update:modelValue": [value: boolean]; apply: []; diff --git a/src/pages/product/component/ProductSkuUnitEditor.vue b/src/pages/product/component/ProductSkuUnitEditor.vue index e9f268e..c3b00ec 100644 --- a/src/pages/product/component/ProductSkuUnitEditor.vue +++ b/src/pages/product/component/ProductSkuUnitEditor.vue @@ -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)" />
@@ -39,6 +38,9 @@ + + + @@ -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); diff --git a/src/types/Product.ts b/src/types/Product.ts index f0b8cf7..aa0ccbf 100644 --- a/src/types/Product.ts +++ b/src/types/Product.ts @@ -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; diff --git a/src/types/ProductEditor.ts b/src/types/ProductEditor.ts index 0e0cb96..28c2dd0 100644 --- a/src/types/ProductEditor.ts +++ b/src/types/ProductEditor.ts @@ -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 & { clientKey: string; + originalName: string; + originalSort?: number; valueList: ProductSpecValueForm[]; }; @@ -21,6 +25,8 @@ export type ProductSkuForm = Omit & { unitList: ProductSkuUnitForm[]; minStockQuantityText: string; photoItemList: PhotoItem[]; + originalBaseUnitId?: string; + baseUnitMigrationRatioText: string; }; export type ProductForm = Omit & {