From 39f7ea00dba114b47a47e9412aa6aaec06c109d5 Mon Sep 17 00:00:00 2001 From: adonis Date: Wed, 10 Jun 2026 10:50:53 +0330 Subject: [PATCH] add price into product --- src/components/BasicSelect.vue | 15 +- src/model/product.ts | 3 +- .../base/components/ProductEditorFormBody.vue | 111 +++++++++++++- src/pages/menuPages/products/base/index.vue | 72 +++++++-- src/utils/productEditorPrice.ts | 141 ++++++++++++++++++ 5 files changed, 321 insertions(+), 21 deletions(-) create mode 100644 src/utils/productEditorPrice.ts diff --git a/src/components/BasicSelect.vue b/src/components/BasicSelect.vue index 3a6a9cd..90e4c1a 100644 --- a/src/components/BasicSelect.vue +++ b/src/components/BasicSelect.vue @@ -12,7 +12,9 @@ :mode="props.mode" :style="props.style" :multiple="props.multiple" - :dropdown-style="dropdownStyle" + :dropdown-style="mergedDropdownStyle" + :popup-class-name="props.popupClassName" + :dropdown-match-select-width="props.dropdownMatchSelectWidth" @change="handleChange" :placeholder="props.placeholder" :allowClear="props.allowClear" @@ -60,13 +62,22 @@ interface Prop { allowClear?: boolean hasTooltip?: boolean noData?: string + popupClassName?: string + dropdownMatchSelectWidth?: boolean + dropdownStyle?: Record } const props = withDefaults(defineProps(), { allowClear: true, - hasTooltip: true + hasTooltip: true, + dropdownMatchSelectWidth: true }) +const mergedDropdownStyle = computed(() => ({ + ...dropdownStyle, + ...(props.dropdownStyle ?? {}) +})) + // console.log('props.items ====>', props.items) //define emits const emit = defineEmits(['update:value', 'change']) diff --git a/src/model/product.ts b/src/model/product.ts index 220c92e..cae1cc8 100644 --- a/src/model/product.ts +++ b/src/model/product.ts @@ -43,7 +43,8 @@ export interface Product{ } export interface MethodePrices { - id: string + key?: string + id?: string name: string price: number profit: number diff --git a/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue b/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue index 6e28e63..229e013 100644 --- a/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue +++ b/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue @@ -18,10 +18,21 @@ قیمت ثابت : -
-
-
مرجع قیمت :
- +
+
+
مرجع قیمت :
+
تعدیل نسبت به قیمت مرجع (٪) :
@@ -74,10 +85,21 @@
-
-
-
قیمت :
- +
+
+
قیمت :
+
تعدیل نسبت به قیمت مرجع (٪) :
@@ -95,6 +117,30 @@
+
+
+
قیمت لحظه‌ای
+ + بروزرسانی + +
+
+ {{ usePrice(livePricePreview.amount) }} +
+
+ {{ livePricePreview.message }} +
+
+ {{ livePricePreview.message }} +
+
+ مبنا: {{ livePricePreview.source }} + + — تعدیل {{ form.price_offset_percent }}٪ + +
+
+
+ pricesData: MethodePrices[] + pricesLoading: boolean + appLastPrice: (last & { price?: number }) | null + appPriceLoading: boolean categoryItems: any[] metalItems: any[] priceItems: any[] @@ -208,6 +262,18 @@ const props = defineProps<{ const tradeWeightUnit = ref('گرم') const isMeltedCategory = computed(() => isMeltedTradeGroup(props.form.group_type)) +const livePricePreview = computed(() => + computeProductEditorPricePreview({ + form: props.form, + methodOption: props.methodOption, + meltedPriceOption: props.meltedPriceOption as 'app' | 'fixed' | 'reference', + pricesData: props.pricesData, + appLastPrice: props.appLastPrice, + pricesLoading: props.pricesLoading, + appPriceLoading: props.appPriceLoading + }) +) + watch( () => props.meltedPriceOption, (option) => { @@ -263,5 +329,34 @@ defineEmits<{ (e: 'update:productIndexImage', value: any[]): void (e: 'uploadedIndexFile', id: number, response?: Record): void (e: 'deleteIndexFile'): void + (e: 'refreshPrices'): void }>() + + + + diff --git a/src/pages/menuPages/products/base/index.vue b/src/pages/menuPages/products/base/index.vue index 2c41280..7ea6684 100644 --- a/src/pages/menuPages/products/base/index.vue +++ b/src/pages/menuPages/products/base/index.vue @@ -23,6 +23,10 @@ :weightOption="weightOption" :activeKey="activeKey_" :pricesList="pricesList" + :pricesData="pricesData" + :pricesLoading="pricesLoading" + :appLastPrice="appLastPrice" + :appPriceLoading="appPriceLoading" :categoryItems="categorySelectItems" :metalItems="metalItems" :priceItems="priceItems" @@ -40,6 +44,7 @@ @minusPrice="minusPrice" @uploadedIndexFile="uploadedIndexFile" @deleteIndexFile="deleteIndexFile" + @refreshPrices="refreshLivePrices" />
@@ -52,10 +57,11 @@ import locale from 'ant-design-vue/es/date-picker/locale/fa_IR' import { cloneDeep } from 'lodash' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' -import { computed, nextTick, onMounted, ref } from 'vue' +import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { createProduct, getCustomerById, updateCustomer } from '@/api/product' import { fetchProductImageUrl } from '@/api/file' -import { methodePrices } from '@/api/price' +import { getLastPrice, methodePrices } from '@/api/price' +import type { last } from '@/model/price' import BasicActionSheet from '@/components/BasicActionSheet.vue' import type { Item } from '@/model/Item' import type { MethodePrices, Product } from '@/model/product' @@ -123,23 +129,74 @@ const priceItems: Array = [ ] const pricesData = ref([]) +const pricesLoading = ref(false) +const appLastPrice = ref<(last & { price?: number }) | null>(null) +const appPriceLoading = ref(false) +let pricePollTimer: ReturnType | undefined + const pricesList = computed(() => pricesData.value.map((x) => ({ - id: `${x.name}`, - name: `${x.name}(${usePrice(x.price)})` + id: `${x.key ?? x.id ?? x.name}`, + name: `${x.name} (${usePrice(x.price)})` })) ) async function fetchPrices() { + pricesLoading.value = true try { pricesData.value = await methodePrices() } catch (err) { console.error(err) + } finally { + pricesLoading.value = false } } +async function fetchAppPrice() { + appPriceLoading.value = true + try { + appLastPrice.value = await getLastPrice() + } catch (err) { + console.error(err) + appLastPrice.value = null + } finally { + appPriceLoading.value = false + } +} + +async function refreshLivePrices() { + await Promise.all([fetchPrices(), fetchAppPrice()]) +} + +function startPricePolling() { + stopPricePolling() + void refreshLivePrices() + pricePollTimer = setInterval(() => { + void refreshLivePrices() + }, 10000) +} + +function stopPricePolling() { + if (pricePollTimer) { + clearInterval(pricePollTimer) + pricePollTimer = undefined + } +} + +watch(addUserVisible, (visible) => { + if (visible) { + startPricePolling() + } else { + stopPricePolling() + } +}) + onMounted(() => { - fetchPrices() + void refreshLivePrices() +}) + +onUnmounted(() => { + stopPricePolling() }) function uploadedIndexFile(id: number, response?: Record) { @@ -372,8 +429,3 @@ const minusPrice = () => { } - diff --git a/src/utils/productEditorPrice.ts b/src/utils/productEditorPrice.ts new file mode 100644 index 0000000..43ce29a --- /dev/null +++ b/src/utils/productEditorPrice.ts @@ -0,0 +1,141 @@ +import type { last } from '@/model/price' +import type { MethodePrices } from '@/model/product' +import type { MeltedPriceSource } from '@/utils/productCategories' +import { isMeltedTradeGroup } from '@/utils/productCategories' + +const GOLD_DOT_REFERENCE_NAME = 'گلد دات ای از آبشده - رسمی - نقد فردا' + +export function findMethodPriceEntry( + prices: MethodePrices[], + readingMethod?: string | null +): MethodePrices | undefined { + if (!readingMethod) return undefined + + return prices.find( + (entry) => + entry.key === readingMethod || + entry.id === readingMethod || + entry.name === readingMethod + ) +} + +export function normalizeReferenceBase(entry: MethodePrices): { base: number; profit: number } { + let base = Number(entry.price) || 0 + const profit = Number(entry.profit) || 0 + + if (entry.name === GOLD_DOT_REFERENCE_NAME) { + base = base / 4.3318 + } + + return { base, profit } +} + +export function adjustedProductPrice( + basePrice: number, + baseProfit: number, + offsetPercent: number +): number { + const reference = basePrice + baseProfit + return Math.round(reference * (1 + (Number(offsetPercent) || 0) / 100)) +} + +export function resolveAppBasePrice(lastPrice?: Partial & { price?: number } | null): number { + if (!lastPrice) return 0 + + const rawPrice = Number(lastPrice.price) + if (rawPrice > 0) return rawPrice + + const buy = Number(lastPrice.buy) || 0 + const sell = Number(lastPrice.sell) || 0 + if (buy > 0 && sell > 0) return Math.round((buy + sell) / 2) + + return Number(lastPrice.price_last) || 0 +} + +export type ProductEditorPricePreview = + | { status: 'ready'; amount: number; source: string } + | { status: 'empty'; message: string } + | { status: 'loading'; message: string } + +export function computeProductEditorPricePreview(input: { + form: { + group_type?: string + price?: number | null + reading_method?: string | null + price_offset_percent?: number | null + } + methodOption: string + meltedPriceOption: MeltedPriceSource + pricesData: MethodePrices[] + appLastPrice?: (Partial & { price?: number }) | null + pricesLoading?: boolean + appPriceLoading?: boolean +}): ProductEditorPricePreview { + const offsetPercent = Number(input.form.price_offset_percent ?? 0) + const melted = isMeltedTradeGroup(input.form.group_type) + + if (melted) { + if (input.meltedPriceOption === 'fixed') { + const amount = Number(input.form.price) || 0 + return amount > 0 + ? { status: 'ready', amount, source: 'قیمت ثابت' } + : { status: 'empty', message: 'قیمت ثابت را وارد کنید' } + } + + if (input.meltedPriceOption === 'app') { + if (input.appPriceLoading) { + return { status: 'loading', message: 'در حال دریافت قیمت اپ...' } + } + const base = resolveAppBasePrice(input.appLastPrice) + if (base <= 0) { + return { status: 'empty', message: 'قیمت اپ در دسترس نیست' } + } + return { + status: 'ready', + amount: adjustedProductPrice(base, 0, offsetPercent), + source: 'قیمت صفحه اصلی اپ' + } + } + + if (!input.form.reading_method) { + return { status: 'empty', message: 'مرجع قیمت را انتخاب کنید' } + } + if (input.pricesLoading) { + return { status: 'loading', message: 'در حال دریافت قیمت مرجع...' } + } + const entry = findMethodPriceEntry(input.pricesData, input.form.reading_method) + if (!entry) { + return { status: 'empty', message: 'مرجع قیمت یافت نشد' } + } + const { base, profit } = normalizeReferenceBase(entry) + return { + status: 'ready', + amount: adjustedProductPrice(base, profit, offsetPercent), + source: entry.name + } + } + + if (input.methodOption === '1') { + const amount = Number(input.form.price) || 0 + return amount > 0 + ? { status: 'ready', amount, source: 'قیمت دستی' } + : { status: 'empty', message: 'قیمت وسط را وارد کنید' } + } + + if (!input.form.reading_method) { + return { status: 'empty', message: 'مرجع قیمت را انتخاب کنید' } + } + if (input.pricesLoading) { + return { status: 'loading', message: 'در حال دریافت قیمت مرجع...' } + } + const entry = findMethodPriceEntry(input.pricesData, input.form.reading_method) + if (!entry) { + return { status: 'empty', message: 'مرجع قیمت یافت نشد' } + } + const { base, profit } = normalizeReferenceBase(entry) + return { + status: 'ready', + amount: adjustedProductPrice(base, profit, offsetPercent), + source: entry.name + } +}