add price into product

This commit is contained in:
2026-06-10 10:50:53 +03:30
parent cf3dbbceb1
commit 39f7ea00db
5 changed files with 321 additions and 21 deletions
+13 -2
View File
@@ -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<string, string | number>
}
const props = withDefaults(defineProps<Prop>(), {
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'])
+2 -1
View File
@@ -43,7 +43,8 @@ export interface Product{
}
export interface MethodePrices {
id: string
key?: string
id?: string
name: string
price: number
profit: number
@@ -18,10 +18,21 @@
<span>قیمت ثابت :</span>
<BasicInput class="md:w-[20rem]" addon-before="ریال" type="price" v-model:value="form.price" />
</div>
<div v-if="meltedPriceOption === 'reference'" class="flex flex-col gap-2">
<div class="md:flex justify-between items-center">
<div class="ml-4">مرجع قیمت :</div>
<BasicSelect class="w-11rem method_style" allowClear :items="pricesList" valueName="name" keyName="id" v-model:value="form.reading_method" />
<div v-if="meltedPriceOption === 'reference'" class="flex flex-col gap-2 md:min-w-[28rem]">
<div class="flex flex-col gap-1">
<div>مرجع قیمت :</div>
<BasicSelect
class="price-reference-select"
allowClear
:hasTooltip="false"
popup-class-name="price-reference-select-dropdown"
:dropdown-match-select-width="false"
:dropdown-style="{ minWidth: '28rem' }"
:items="pricesList"
valueName="name"
keyName="id"
v-model:value="form.reading_method"
/>
</div>
<div class="md:flex justify-between items-center">
<div class="ml-4">تعدیل نسبت به قیمت مرجع (٪) :</div>
@@ -74,10 +85,21 @@
<template #icon><Icon icon="vuesax-linear:minus" size="20" /></template>
</AButton>
</div>
<div v-if="methodOption === '2'" class="flex flex-col gap-2">
<div class="md:flex justify-between items-center">
<div class="ml-4">قیمت :</div>
<BasicSelect class="w-11rem method_style" allowClear :items="pricesList" valueName="name" keyName="id" v-model:value="form.reading_method" />
<div v-if="methodOption === '2'" class="flex flex-col gap-2 md:min-w-[28rem]">
<div class="flex flex-col gap-1">
<div>قیمت :</div>
<BasicSelect
class="price-reference-select"
allowClear
:hasTooltip="false"
popup-class-name="price-reference-select-dropdown"
:dropdown-match-select-width="false"
:dropdown-style="{ minWidth: '28rem' }"
:items="pricesList"
valueName="name"
keyName="id"
v-model:value="form.reading_method"
/>
</div>
<div class="md:flex justify-between items-center">
<div class="ml-4">تعدیل نسبت به قیمت مرجع (٪) :</div>
@@ -95,6 +117,30 @@
</div>
</div>
<div class="mt-2 rounded-lg border border-solid border-blue-200 bg-blue-50 p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-600 text-gray-700">قیمت لحظه‌ای</div>
<AButton size="small" :loading="pricesLoading || appPriceLoading" @click="$emit('refreshPrices')">
بروزرسانی
</AButton>
</div>
<div v-if="livePricePreview.status === 'ready'" class="mt-2 text-lg font-700 text-blue-700">
{{ usePrice(livePricePreview.amount) }}
</div>
<div v-else-if="livePricePreview.status === 'loading'" class="mt-2 text-sm text-gray-500">
{{ livePricePreview.message }}
</div>
<div v-else class="mt-2 text-sm text-gray-500">
{{ livePricePreview.message }}
</div>
<div v-if="livePricePreview.status === 'ready'" class="mt-1 text-xs text-gray-500">
مبنا: {{ livePricePreview.source }}
<span v-if="Number(form.price_offset_percent) !== 0">
— تعدیل {{ form.price_offset_percent }}٪
</span>
</div>
</div>
<div
v-if="!isMeltedCategory"
class="md:flex justify-between p-2 border-1 border-solid rounded-lg border-gray-200 mt-2"
@@ -183,7 +229,11 @@ import Icon from '@core/components/BaseIcon/src/Icon.vue'
import BaseCollapse from '@/components/BaseCollapse.vue'
import IndexImageUploader from '@/components/ProductUploader/IndexImageUploader.vue'
import { getConfiguration } from '@/api/configuration'
import type { last } from '@/model/price'
import type { MethodePrices } from '@/model/product'
import { usePrice } from '@core/utils/usePrice'
import { isMeltedTradeGroup } from '@/utils/productCategories'
import { computeProductEditorPricePreview } from '@/utils/productEditorPrice'
import { resolveTradeWeightUnit, weightFieldLabelForUnit } from '@/utils/weightUnit'
import dayjs from 'dayjs'
import { computed, onMounted, ref, watch } from 'vue'
@@ -196,6 +246,10 @@ const props = defineProps<{
meltedPriceOption: string
activeKey: string[]
pricesList: Array<{ id: string; name: string }>
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<string, any>): void
(e: 'deleteIndexFile'): void
(e: 'refreshPrices'): void
}>()
</script>
<style scoped>
.price-reference-select {
width: 100%;
min-width: 22rem;
}
.price-reference-select :deep(.ant-select-selector) {
height: auto !important;
min-height: 32px;
}
.price-reference-select :deep(.ant-select-selection-item) {
white-space: normal;
line-height: 1.4;
overflow: visible;
text-overflow: unset;
}
</style>
<style>
.price-reference-select-dropdown .ant-select-item-option-content {
white-space: normal;
overflow: visible;
text-overflow: unset;
line-height: 1.4;
}
</style>
+62 -10
View File
@@ -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"
/>
</BasicActionSheet>
</div>
@@ -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<Item> = [
]
const pricesData = ref<MethodePrices[]>([])
const pricesLoading = ref(false)
const appLastPrice = ref<(last & { price?: number }) | null>(null)
const appPriceLoading = ref(false)
let pricePollTimer: ReturnType<typeof setInterval> | 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<string, any>) {
@@ -372,8 +429,3 @@ const minusPrice = () => {
}
</script>
<style scoped>
.method_style .ant-select-selector {
width: 15rem !important;
}
</style>
+141
View File
@@ -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<last> & { 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<last> & { 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
}
}