price handling

This commit is contained in:
2026-06-22 21:09:26 +03:30
parent cc5e3ce5f2
commit e3f603b37b
8 changed files with 268 additions and 46 deletions
+1
View File
@@ -10,6 +10,7 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
_index: typeof import('./src/core/components/_sample/_index.vue')['default']
AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
+12 -2
View File
@@ -31,10 +31,13 @@
<AButton
v-if="showOk"
type="primary"
html-type="button"
:loading="loading"
:disabled="loading"
:class="[{ 'col-span-8': showCancel }]"
size="large"
block
@click="$emit('ok')"
@click="handleOk"
>
{{ okText }}
</AButton>
@@ -79,10 +82,13 @@
<AButton
v-if="showOk"
type="primary"
html-type="button"
:loading="loading"
:disabled="loading"
:class="[{ 'col-span-8': showCancel }]"
size="large"
block
@click="$emit('ok')"
@click="handleOk"
>
{{ okText }}
</AButton>
@@ -151,6 +157,10 @@ function handleCancel() {
emit('close')
visibleProxy.value = false
}
function handleOk() {
emit('ok')
}
</script>
<style lang="less">
+19 -1
View File
@@ -26,6 +26,19 @@ const headers = {
Accept: 'application/json'
}
function formatApiErrorMessage(raw: string): string {
const unknownColumn = raw.match(/Unknown column '([^']+)'/)
if (unknownColumn) {
return `ستون «${unknownColumn[1]}» در دیتابیس وجود ندارد — سرور را به‌روز کنید یا با پشتیبانی تماس بگیرید`
}
if (raw.length > 220) {
return `${raw.slice(0, 220)}…`
}
return raw
}
const _axios = axios.create({
baseURL,
headers
@@ -82,7 +95,12 @@ _axios.interceptors.response.use(
}
} else if (error.response) {
if (!error.config?.meta?.silent) {
message.error('خطا در بارگذاری اطلاعات')
const apiMessage = error.response.data?.message
message.error(
typeof apiMessage === 'string' && apiMessage.trim()
? formatApiErrorMessage(apiMessage)
: 'خطا در بارگذاری اطلاعات',
)
}
}
// Any status codes that falls outside the range of 2xx cause this function to trigger
@@ -2,6 +2,13 @@
<Collapse class="grid grid-cols-1 gap-2" :activeKey="activeKey" @update:activeKey="$emit('update:activeKey', $event)">
<BaseCollapse title="اطلاعات محصول" active-key="0" :actives="activeKey">
<template #content>
<a-form
:model="form"
ref="innerFormRef"
autocomplete="off"
scroll-to-first-error
class="product-editor-form"
>
<div
v-if="isMeltedCategory"
class="md:flex flex-col gap-3 border-1 border-solid rounded-lg border-gray-200 p-3"
@@ -46,10 +53,10 @@
<div class="text-xs text-gray-500">
مثبت = گران‌تر، منفی = ارزان‌تر نسبت به قیمت مرجع.
</div>
<a-form-item label="قیمت صفر از provider" name="use_last_price_if_zero" class="!mb-0">
<a-form-item label="قیمت صفر از مرجع" name="use_last_price_if_zero" class="!mb-0">
<a-switch v-model:checked="form.use_last_price_if_zero" />
<span class="mr-2 text-xs text-gray-500">
اگر provider قیمت نداد (صفر)، با آخرین قیمت ذخیره‌شده معامله شود
اگر مرجع قیمت نداد (صفر)، با آخرین قیمت ذخیره‌شده معامله شود
</span>
</a-form-item>
</div>
@@ -66,10 +73,10 @@
<div class="text-xs text-gray-500">
قیمت خرید/فروش از قیمت صفحه اصلی اپ محاسبه می‌شود. مثبت = گران‌تر، منفی = ارزان‌تر.
</div>
<a-form-item label="قیمت صفر از provider" name="use_last_price_if_zero" class="!mb-0">
<a-form-item label="قیمت صفر از مرجع" name="use_last_price_if_zero" class="!mb-0">
<a-switch v-model:checked="form.use_last_price_if_zero" />
<span class="mr-2 text-xs text-gray-500">
اگر provider قیمت نداد (صفر)، با آخرین قیمت ذخیره‌شده معامله شود
اگر مرجع قیمت نداد (صفر)، با آخرین قیمت ذخیره‌شده معامله شود
</span>
</a-form-item>
</div>
@@ -125,10 +132,10 @@
<div class="text-xs text-gray-500">
مثبت = گران‌تر، منفی = ارزان‌تر نسبت به قیمت اتوماتیک. مثلاً ۵ یعنی ۵٪ بالاتر از مرجع.
</div>
<a-form-item label="قیمت صفر از provider" name="use_last_price_if_zero" class="!mb-0">
<a-form-item label="قیمت صفر از مرجع" name="use_last_price_if_zero" class="!mb-0">
<a-switch v-model:checked="form.use_last_price_if_zero" />
<span class="mr-2 text-xs text-gray-500">
اگر provider قیمت نداد (صفر)، با آخرین قیمت ذخیره‌شده معامله شود
اگر مرجع قیمت نداد (صفر)، با آخرین قیمت ذخیره‌شده معامله شود
</span>
</a-form-item>
</div>
@@ -172,15 +179,20 @@
<a-radio value="2">ندارد</a-radio>
</a-radio-group>
</div>
<div v-if="weightOption === '1'" class="flex items-center">
<span class="ml-4 ">{{ weightFieldLabel }} :</span>
<BasicInput v-model:value="form.amount" type="number" />
<div class="mt-1 mr-4" v-if="form.amount && form.amount < 1 && 0 < form.amount && tradeWeightUnit === 'گرم'">معادل {{ form.amount * 1000 }} سوت</div>
<div v-if="weightOption === '1'" class="flex items-center md:col-span-2">
<a-form-item
class="!mb-0 w-full"
:label="weightFieldLabel"
name="amount"
:rules="inputRulesNumber('وزن')"
>
<BasicInput v-model:value="form.amount" type="number" />
</a-form-item>
<div class="mt-1 mr-4 shrink-0" v-if="form.amount && form.amount < 1 && 0 < form.amount && tradeWeightUnit === 'گرم'">معادل {{ form.amount * 1000 }} سوت</div>
</div>
</div>
<a-form :model="form" ref="formRef" autocomplete="off">
<div class="grid md:grid-cols-2 gap-4">
<div class="grid md:grid-cols-2 gap-4 mt-2">
<a-form-item label="عنوان" name="title" :rules="inputRules()"><BasicInput v-model:value="form.title" /></a-form-item>
<a-form-item label="دسته بندی" name="group_type" :rules="inputRules()">
<BasicSelect v-model:value="form.group_type" :items="categoryItems" valueName="value" keyName="key" />
@@ -207,10 +219,10 @@
>
<BasicInput v-model:value="form.inventory" type="number" />
</a-form-item>
<a-form-item label="هزینه نقدی موقع خرید" name="cash_fee_buy" :rules="inputRules()">
<a-form-item label="هزینه نقدی موقع خرید" name="cash_fee_buy" :rules="requiredPriceRules('هزینه نقدی موقع خرید')">
<BasicInput v-model:value="form.cash_fee_buy" addon-before="ریال" type="price" />
</a-form-item>
<a-form-item label="هزینه نقدی موقع فروش" name="cash_fee_sell" :rules="inputRules()">
<a-form-item label="هزینه نقدی موقع فروش" name="cash_fee_sell" :rules="requiredPriceRules('هزینه نقدی موقع فروش')">
<BasicInput v-model:value="form.cash_fee_sell" addon-before="ریال" type="price" />
</a-form-item>
<a-form-item label="زمان شروع فعال سازی" name="auto_open_at">
@@ -277,8 +289,9 @@
</template>
<script setup lang="ts">
import type { FormInstance } from 'ant-design-vue'
import { Collapse, TimePicker } from 'ant-design-vue'
import { inputRules } from '@/utils/inputRules'
import { inputRules, inputRulesNumber, requiredPriceRules } from '@/utils/inputRules'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import BaseCollapse from '@/components/BaseCollapse.vue'
import IndexImageUploader from '@/components/ProductUploader/IndexImageUploader.vue'
@@ -293,9 +306,10 @@ import { resolveTradeWeightUnit, weightFieldLabelForUnit } from '@/utils/weightU
import dayjs from 'dayjs'
import { computed, onMounted, ref, watch } from 'vue'
const innerFormRef = ref<FormInstance>()
const props = defineProps<{
form: any
formRef: any
methodOption: string
weightOption: string
meltedPriceOption: string
@@ -392,6 +406,19 @@ defineEmits<{
(e: 'deleteIndexFile'): void
(e: 'refreshPrices'): void
}>()
defineExpose({
async validateFields() {
if (!innerFormRef.value) {
throw {
errorFields: [{ errors: ['فرم آماده نیست — لطفاً دوباره تلاش کنید'] }],
}
}
return innerFormRef.value.validateFields()
},
resetFields: () => innerFormRef.value?.resetFields(),
})
</script>
<style scoped>
+71 -24
View File
@@ -11,14 +11,23 @@
:showOk="true"
okText="ثبت"
cancelText="انصراف"
@ok="ok"
@ok="submitProduct"
@cancel="closeProductModal"
@close="closeProductModal"
>
<div v-if="isMobile" class="mb-3 text-base font-700">{{ `${editId ? 'ویرایش' : 'افزودن'} محصول` }}</div>
<a-alert
v-if="submitError"
type="error"
:message="submitError"
show-icon
closable
class="mb-3"
@close="submitError = ''"
/>
<ProductEditorFormBody
ref="editorFormRef"
:form="form"
:formRef="formRef"
:methodOption="methodOption"
:weightOption="weightOption"
:activeKey="activeKey_"
@@ -51,7 +60,6 @@
<script setup lang="ts">
import { message } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import locale from 'ant-design-vue/es/date-picker/locale/fa_IR'
import { cloneDeep } from 'lodash'
import dayjs from 'dayjs'
@@ -66,9 +74,11 @@ import BasicActionSheet from '@/components/BasicActionSheet.vue'
import type { Item } from '@/model/Item'
import type { MethodePrices, Product } from '@/model/product'
import ProductEditorFormBody from '@/pages/menuPages/products/base/components/ProductEditorFormBody.vue'
import { validateProductSubmitInput } from '@/pages/menuPages/products/base/productFormValidation'
import tbl from './components/table.vue'
import { isMobile } from '@core/utils/is'
import { usePrice } from '@core/utils/usePrice'
import { referenceProviderTotal } from '@/utils/productEditorPrice'
import {
applyFileToUploadList,
normalizeFileResource,
@@ -90,6 +100,7 @@ import { resolveTradeWeightUnit } from '@/utils/weightUnit'
dayjs.extend(customParseFormat)
const loading = ref(false)
const submitError = ref('')
const table = ref<null | { fetch: () => void }>(null)
const refreshTable = () => table.value?.fetch()
@@ -107,7 +118,7 @@ const form = ref<Partial<Product>>({
auto_open_at: dayjs('00:00:00', 'HH:mm:ss'),
metal_id: 1
})
const formRef = ref<FormInstance>()
const editorFormRef = ref<InstanceType<typeof ProductEditorFormBody>>()
const customLocale = ref({
...locale,
@@ -143,8 +154,8 @@ let pricePollTimer: ReturnType<typeof setInterval> | undefined
const pricesList = computed(() =>
pricesData.value.map((x) => ({
id: `${x.key ?? x.id ?? x.name}`,
name: `${x.name} (${usePrice(x.price)})`
}))
name: `${x.name} (${usePrice(referenceProviderTotal(x))})`,
})),
)
async function fetchPrices() {
@@ -329,17 +340,19 @@ async function edit(record: Product) {
function closeProductModal() {
addUserVisible.value = false
editId = undefined
submitError.value = ''
}
async function add() {
editId = undefined
submitError.value = ''
addUserVisible.value = true
resetData()
await loadCategorySelectItems()
}
function resetData() {
formRef.value?.resetFields()
editorFormRef.value?.resetFields()
ProductIndexImage.value = []
methodOption.value = '1'
weightOption.value = '1'
@@ -349,9 +362,9 @@ function resetData() {
group_type: '',
amount: undefined,
percent_fee_buy: 0,
cash_fee_buy: 0,
cash_fee_buy: undefined,
percent_fee_sell: 0,
cash_fee_sell: 0,
cash_fee_sell: undefined,
price_offset_percent: 0,
metal_id: 1,
direct_price: 0,
@@ -369,29 +382,59 @@ function resetData() {
}
}
async function ok() {
if (!isMeltedTradeGroup(form.value.group_type ?? '')) {
if (weightOption.value === '1') {
const amount = Number(form.value.amount)
if (!Number.isFinite(amount) || amount <= 0) {
message.error('لطفاً وزن محصول را وارد کنید')
return
}
}
function showSubmitError(text: string) {
submitError.value = text
message.error(text, 4)
}
async function submitProduct() {
try {
await saveProduct()
} catch (err: unknown) {
console.error(err)
const text = err instanceof Error ? err.message : 'خطای غیرمنتظره در ثبت محصول'
showSubmitError(text)
}
}
async function saveProduct() {
if (!activeKey_.value.includes('0')) {
activeKey_.value = ['0', ...activeKey_.value]
await nextTick()
}
try {
await formRef.value?.validateFields()
} catch {
const businessError = validateProductSubmitInput({
form: form.value as Record<string, unknown>,
methodOption: methodOption.value,
weightOption: weightOption.value,
meltedPriceOption: meltedPriceOption.value,
isEdit: Boolean(editId),
})
if (businessError) {
showSubmitError(businessError)
return
}
if (!editorFormRef.value) {
showSubmitError('فرم آماده نیست — لطفاً مودال را ببندید و دوباره باز کنید')
return
}
try {
await editorFormRef.value.validateFields()
} catch (err: unknown) {
const first = (err as { errorFields?: Array<{ errors?: string[] }> })?.errorFields?.[0]?.errors?.[0]
showSubmitError(first || 'لطفاً فیلدهای الزامی را تکمیل کنید')
return
}
submitError.value = ''
loading.value = true
try {
const payload = cloneDeep(form.value)
const limitError = validateProductOrderLimitForm(payload)
if (limitError) {
message.error(limitError)
showSubmitError(limitError)
loading.value = false
return
}
@@ -433,6 +476,8 @@ async function ok() {
if (isStoreApp() && !isMeltedTradeGroup(payload.group_type)) {
payload.inventory = Math.max(0, Number(payload.inventory ?? 0))
} else {
delete payload.inventory
}
payload.auto_open_at = payload.auto_open_at
@@ -463,12 +508,14 @@ async function ok() {
addUserVisible.value = false
editId = undefined
submitError.value = ''
refreshTable()
resetData()
message.success('عملیات با موفقیت انجام شد')
} catch (e) {
} catch (e: unknown) {
console.error(e)
message.error('خطا در ثبت اطلاعات')
const apiMessage = (e as { response?: { data?: { message?: string } } })?.response?.data?.message
showSubmitError(apiMessage || 'خطا در ثبت اطلاعات')
} finally {
loading.value = false
}
@@ -0,0 +1,78 @@
import { isMeltedTradeGroup } from '@/utils/productCategories'
function isBlank(value: unknown) {
return value === undefined || value === null || value === ''
}
function isMissingNumber(value: unknown) {
if (isBlank(value)) {
return true
}
const num = Number(value)
return !Number.isFinite(num)
}
export function validateProductSubmitInput(input: {
form: Record<string, unknown>
methodOption: string
weightOption: string
meltedPriceOption: string
isEdit: boolean
}): string | null {
const { form, methodOption, weightOption, meltedPriceOption, isEdit } = input
const groupType = String(form.group_type ?? '')
const melted = isMeltedTradeGroup(groupType)
if (!String(form.title ?? '').trim()) {
return 'عنوان محصول الزامی است'
}
if (!groupType) {
return 'دسته‌بندی الزامی است'
}
if (isBlank(form.metal_id)) {
return 'جنس فلز الزامی است'
}
if (melted || weightOption === '1') {
if (!String(form.unit ?? '').trim()) {
return 'واحد الزامی است'
}
}
if (!melted && weightOption === '1') {
const amount = Number(form.amount)
if (!Number.isFinite(amount) || amount <= 0) {
return 'وزن محصول را وارد کنید'
}
}
if (melted) {
if (meltedPriceOption === 'fixed' && isMissingNumber(form.price)) {
return 'قیمت ثابت را وارد کنید'
}
if (meltedPriceOption === 'reference' && !String(form.reading_method ?? '').trim()) {
return 'مرجع قیمت را انتخاب کنید'
}
} else if (methodOption === '1') {
if (isMissingNumber(form.price)) {
return 'قیمت دستی را وارد کنید'
}
} else if (methodOption === '2') {
if (!String(form.reading_method ?? '').trim()) {
return 'مرجع قیمت را انتخاب کنید'
}
}
if (isBlank(form.cash_fee_buy)) {
return 'هزینه نقدی موقع خرید را وارد کنید'
}
if (isBlank(form.cash_fee_sell)) {
return 'هزینه نقدی موقع فروش را وارد کنید'
}
return null
}
+23
View File
@@ -6,6 +6,29 @@ export function inputRules(title?: string) {
}
}
/** فیلد قیمت/عدد — خالی رد می‌شود، صفر مجاز است */
export function requiredPriceRules(title?: string) {
const label = title || 'مورد بالا'
return [
{
validator(_rule: unknown, value: unknown) {
if (value === undefined || value === null || String(value).trim() === '') {
return Promise.reject(`پر کردن ${label} الزامی است`)
}
const num = Number(String(value).replaceAll(',', ''))
if (!Number.isFinite(num)) {
return Promise.reject('لطفاً عدد معتبر وارد کنید')
}
return Promise.resolve()
},
trigger: ['change', 'blur'],
},
]
}
export function inputRulesNumber(title?: string) {
if (title) {
return [
+21 -3
View File
@@ -23,6 +23,10 @@ export function normalizeReferenceBase(entry: MethodePrices): { base: number; pr
let base = Number(entry.price) || 0
const profit = Number(entry.profit) || 0
if (base <= 0) {
return { base: 0, profit: 0 }
}
if (entry.name === GOLD_DOT_REFERENCE_NAME) {
base = base / 4.3318
}
@@ -39,6 +43,20 @@ export function adjustedProductPrice(
return Math.round(reference * (1 + (Number(offsetPercent) || 0) / 100))
}
/** جمع مرجع — وقتی price صفر است profit نادیده گرفته می‌شود (مقدار قدیمی) */
export function referenceProviderTotal(entry?: Pick<MethodePrices, 'price' | 'profit'> | null): number {
if (!entry) {
return 0
}
const price = Number(entry.price ?? 0)
if (price <= 0) {
return 0
}
return price + Number(entry.profit ?? 0)
}
export function resolveAppBasePrice(lastPrice?: Partial<last> & { price?: number } | null): number {
if (!lastPrice) return 0
@@ -118,7 +136,7 @@ export function computeProductEditorPricePreview(input: {
: adjustedProductPrice(fallback, 0, offsetPercent)
return { status: 'ready', amount, source: 'آخرین قیمت ذخیره‌شده' }
}
return { status: 'empty', message: 'قیمت provider صفر است' }
return { status: 'empty', message: 'قیمت مرجع صفر است' }
}
if (!input.form.reading_method) {
@@ -140,7 +158,7 @@ export function computeProductEditorPricePreview(input: {
)
return fallback > 0
? { status: 'ready', amount: fallback, source: 'آخرین قیمت ذخیره‌شده' }
: { status: 'empty', message: 'قیمت provider صفر است' }
: { status: 'empty', message: 'قیمت مرجع صفر است' }
}
if (input.methodOption === '1') {
@@ -169,5 +187,5 @@ export function computeProductEditorPricePreview(input: {
)
return fallback > 0
? { status: 'ready', amount: fallback, source: 'آخرین قیمت ذخیره‌شده' }
: { status: 'empty', message: 'قیمت provider صفر است' }
: { status: 'empty', message: 'قیمت مرجع صفر است' }
}