feat(convert): send integer ring requests for admin approval

Leftover asset splits into whole 1.5 and 2.5 rings, and the customer submits that split as a request.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-01 13:13:09 +03:30
co-authored by Cursor
parent 0de5b0dcb7
commit f6fdce4c20
4 changed files with 139 additions and 57 deletions
@@ -50,4 +50,6 @@
- 2026-10-21 | نمودار خانه = کارت لندینگ | `home/components/LandingLiveChartMain.vue` + `useLandingLiveChart.ts` + `styles/landing-live-chart-main.less` | موبایل PriceChartSection و دسکتاپ DesktopChartSection/Panel همین کارت؛ استایل از main.less
- 2026-10-21 | چیدمان کارت نمودار خانه | همان دو فایل vue/less | هدر: نام+وضعیت راست، تب محصول چپ؛ قیمت راست و تغییر چپ؛ تب بازه کنار عنوان نمودار
- 2026-10-21 | تب نمودار = محصول فعال | `useLandingLiveChart.ts` + `LandingLiveChartMain.vue` | تب از کاتالوگ `active && show`؛ داده با `price_offset_percent` همان محصول
- 2026-10-31 | مقدار تبدیل کاتد/مفتول/شمش | `convert/components/CopperConvertCalculator.vue` + `useCopperConvertPage.ts` | زبانه مقدار تا موجودی؛ مانده با زبانه ۱.۵ (تا کل مانده) و ۲.۵ (تا باقی‌مانده بعد از ۱.۵)
- 2026-11-01 | درخواست تبدیل سیم | `convert/CopperConvertCalculator.vue` + `useCopperConvertPage.ts` + سرور `CopperConvertRequestService` + ادمین `hesabKetab.ts` فرزند `copperConvertMain` | حلقه صحیح؛ منوی ادمین زیر حساب کتاب نه منوی اصلی
- 2026-11-01 | ۵۰۰ لیست تبدیل | `2026_11_01_000001_create_copper_convert_requests_table.php` | git pull کافی نیست؛ بدون `php artisan migrate` جدول نیست و GET ادمین ۵۰۰ می‌دهد
- 2026-11-01 | جدول درخواست تبدیل | ادمین `copperConvert/components/table.vue` | دسکتاپ BaseTable؛ موبایل `CopperConvertMobileCardList` مثل بقیه صفحات
+10
View File
@@ -0,0 +1,10 @@
import useAxios from '@/utils/useAxios'
import type { CopperConvertSource } from '@/utils/copperWireConvert'
export function createCopperConvertRequest(payload: {
source: CopperConvertSource
rings15: number
rings25: number
}) {
return useAxios.post('/customer/copper-convert-request', payload)
}
@@ -127,16 +127,15 @@
<span class="copper-convert__result-title">تقسیم بین دو سیم</span>
</div>
<p class="copper-convert__asset-hint">
مانده {{ formatAssetAmount(remainderAmount) }} {{ assetUnit }} است. زبانهٔ ۱.۵ تا کل مانده می‌رود؛ زبانهٔ ۲.۵ فقط تا آنچه بعد از ۱.۵ می‌ماند.
مانده {{ formatAssetAmount(remainderAmount) }} {{ assetUnit }} است. هر زبانه فقط عدد صحیح حلقه است؛ اعشاری که به یک حلقه نرسد در دارایی می‌ماند.
</p>
<div class="copper-convert__weight">
<label class="copper-convert__field-label">سیم ۱.۵ mm²</label>
<div class="copper-convert__weight-wrap">
<span class="copper-convert__weight-value" dir="ltr">{{ formatAssetAmount(wire15Amount) }}</span>
<span class="copper-convert__weight-unit">{{ assetUnit }}</span>
<span class="copper-convert__weight-value" dir="ltr">{{ wire15Rings }}</span>
<span class="copper-convert__weight-unit">عدد حلقه</span>
</div>
<p class="copper-convert__asset-hint">معادل {{ formatRings(wire15Rings) }} حلقه</p>
<div
class="app-touch-slider copper-convert__slider"
@touchstart.stop
@@ -144,26 +143,26 @@
@pointerdown.stop
>
<a-slider
v-model:value="wire15Amount"
v-model:value="wire15Rings"
:min="0"
:max="remainderAmount"
:step="sliderStep"
:tip-formatter="sliderTip"
:max="maxRings15 > 0 ? maxRings15 : 1"
:step="1"
:disabled="maxRings15 <= 0"
:tip-formatter="ringTip"
/>
</div>
<div class="copper-convert__slider-scale">
<span>۰</span>
<span dir="ltr">{{ formatAssetAmount(remainderAmount) }} {{ assetUnit }}</span>
<span dir="ltr">{{ maxRings15 }} عدد حلقه</span>
</div>
</div>
<div class="copper-convert__weight">
<label class="copper-convert__field-label">سیم ۲.۵ mm²</label>
<div class="copper-convert__weight-wrap">
<span class="copper-convert__weight-value" dir="ltr">{{ formatAssetAmount(wire25Amount) }}</span>
<span class="copper-convert__weight-unit">{{ assetUnit }}</span>
<span class="copper-convert__weight-value" dir="ltr">{{ wire25Rings }}</span>
<span class="copper-convert__weight-unit">عدد حلقه</span>
</div>
<p class="copper-convert__asset-hint">معادل {{ formatRings(wire25Rings) }} حلقه</p>
<div
class="app-touch-slider copper-convert__slider"
@touchstart.stop
@@ -171,19 +170,35 @@
@pointerdown.stop
>
<a-slider
v-model:value="wire25Amount"
v-model:value="wire25Rings"
:min="0"
:max="wire25Max > 0 ? wire25Max : 1"
:step="sliderStep"
:disabled="wire25Max <= 0"
:tip-formatter="sliderTip"
:max="maxRings25 > 0 ? maxRings25 : 1"
:step="1"
:disabled="maxRings25 <= 0"
:tip-formatter="ringTip"
/>
</div>
<div class="copper-convert__slider-scale">
<span>۰</span>
<span dir="ltr">{{ formatAssetAmount(wire25Max) }} {{ assetUnit }}</span>
<span dir="ltr">{{ maxRings25 }} عدد حلقه</span>
</div>
</div>
<p class="copper-convert__asset-hint">
اعشار مانده: {{ formatAssetAmount(leftoverAmount) }} {{ assetUnit }} نگه داشته می‌شود.
</p>
<BasicButton
type="primary"
class="!w-full"
:loading="submitting"
:disabledB="wire15Rings + wire25Rings < 1"
@click="submitRequest"
>
ثبت درخواست تبدیل
</BasicButton>
<p class="copper-convert__asset-hint">
درخواست برای ادمین می‌رود. بعد از تأیید، همان مقدار دارایی فروخته و حلقه‌های انتخاب‌شده خریده می‌شود.
</p>
</section>
<p v-if="!result && amountAttempted && !loading" class="copper-convert__warn">
@@ -195,6 +210,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useCopperConvertPageContext } from '../composables/useCopperConvertPage'
import type { CopperConvertSource } from '@/utils/copperWireConvert'
@@ -206,11 +222,13 @@ const {
sliderAmount,
sliderStep,
remainderAmount,
wire15Amount,
wire25Amount,
wire25Max,
maxRings15,
maxRings25,
wire15Rings,
wire25Rings,
leftoverAmount,
submitting,
submitRequest,
result,
priceRows,
} = useCopperConvertPageContext()
@@ -237,6 +255,10 @@ function sliderTip(value?: number) {
return `${formatAssetAmount(Number(value ?? 0))} ${assetUnit.value}`
}
function ringTip(value?: number) {
return `${Math.floor(Number(value ?? 0))} عدد حلقه`
}
function formatRial(value: number) {
return Math.round(value).toLocaleString('en-US')
}
@@ -10,7 +10,9 @@ import {
type InjectionKey,
type Ref,
} from 'vue'
import { createCopperConvertRequest } from '@/api/copperConvert'
import { getProducts } from '@/api/products'
import { message } from 'ant-design-vue'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { Product } from '@/models/products'
import type { WalletInventoryCard } from '@/models/walletAssets'
@@ -38,11 +40,13 @@ export interface CopperConvertPageContext {
sliderAmount: Ref<number>
sliderStep: ComputedRef<number>
remainderAmount: ComputedRef<number>
wire15Amount: Ref<number>
wire25Amount: Ref<number>
wire25Max: ComputedRef<number>
wire15Rings: ComputedRef<number>
wire25Rings: ComputedRef<number>
maxRings15: ComputedRef<number>
maxRings25: ComputedRef<number>
wire15Rings: Ref<number>
wire25Rings: Ref<number>
leftoverAmount: ComputedRef<number>
submitting: Ref<boolean>
submitRequest: () => Promise<void>
prices: ComputedRef<CopperConvertPrices>
result: ComputedRef<CopperConvertResult | null>
priceRows: ComputedRef<{ label: string; value: string; missing?: boolean }[]>
@@ -74,6 +78,18 @@ function quantityToKg(quantity: number, unit: string, product?: Product): number
return quantity
}
function kgToNative(kg: number, unit: string, product?: Product): number {
if (kg <= 0) return 0
if (unit.includes('کیلو')) return kg
if (unit.includes('گرم')) return kg * 1000
const perPiece = Number(product?.amount ?? 0)
if (perPiece > 0) {
const gramsPerPiece = weightToGrams(perPiece, getProductWeightUnit(product))
if (gramsPerPiece > 0) return (kg * 1000) / gramsPerPiece
}
return kg
}
function titleIncludes(product: Product, part: string) {
return (product.title ?? '').includes(part)
}
@@ -122,8 +138,9 @@ export function useCopperConvertPage() {
const loading = ref(true)
const source = ref<CopperConvertSource>('katod')
const sliderAmount = ref(0)
const wire15Amount = ref(0)
const wire25Amount = ref(0)
const wire15Rings = ref(0)
const wire25Rings = ref(0)
const submitting = ref(false)
const catalog = ref<Product[]>([])
const { assetsSummary, loadWalletAssets } = useWalletAssets()
@@ -163,19 +180,10 @@ export function useCopperConvertPage() {
roundAmount(Math.max(0, assetAmount.value - sliderAmount.value)),
)
const wire25Max = computed(() =>
roundAmount(Math.max(0, remainderAmount.value - wire15Amount.value)),
)
watch(source, () => {
sliderAmount.value = assetAmount.value
wire15Amount.value = 0
wire25Amount.value = 0
})
watch([remainderAmount, wire15Amount], () => {
if (wire15Amount.value > remainderAmount.value) wire15Amount.value = remainderAmount.value
if (wire25Amount.value > wire25Max.value) wire25Amount.value = wire25Max.value
wire15Rings.value = 0
wire25Rings.value = 0
})
watch(
@@ -221,24 +229,62 @@ export function useCopperConvertPage() {
calcCopperWireConvert(selectedKg.value, source.value, prices.value),
)
const wire15Rings = computed(() => {
const converted = calcCopperWireConvert(
quantityToKg(wire15Amount.value, assetUnit.value, sourceProduct.value),
source.value,
prices.value,
)
return converted?.rings15ByValue ?? 0
const sourceUnitPrice = computed(() => {
if (source.value === 'katod') return prices.value.katodPerKg
if (source.value === 'maftoul') return prices.value.maftoulPerKg
return prices.value.shamshPerKg
})
const wire25Rings = computed(() => {
const converted = calcCopperWireConvert(
quantityToKg(wire25Amount.value, assetUnit.value, sourceProduct.value),
source.value,
prices.value,
)
return converted?.rings25ByValue ?? 0
const remainderValue = computed(() =>
quantityToKg(remainderAmount.value, assetUnit.value, sourceProduct.value) * sourceUnitPrice.value,
)
const maxRings15 = computed(() => {
const price = prices.value.wireRing15
if (price <= 0 || remainderValue.value <= 0) return 0
return Math.floor(remainderValue.value / price)
})
const maxRings25 = computed(() => {
const price = prices.value.wireRing25
const left = remainderValue.value - wire15Rings.value * prices.value.wireRing15
if (price <= 0 || left <= 0) return 0
return Math.floor(left / price)
})
const leftoverAmount = computed(() => {
const spent = wire15Rings.value * prices.value.wireRing15 + wire25Rings.value * prices.value.wireRing25
const leftValue = Math.max(0, remainderValue.value - spent)
const perUnit = sourceUnitPrice.value
if (perUnit <= 0) return remainderAmount.value
return roundAmount(kgToNative(leftValue / perUnit, assetUnit.value, sourceProduct.value))
})
watch([wire15Rings, maxRings15, maxRings25], () => {
const next15 = Math.max(0, Math.min(maxRings15.value, Math.floor(wire15Rings.value)))
if (next15 !== wire15Rings.value) wire15Rings.value = next15
const next25 = Math.max(0, Math.min(maxRings25.value, Math.floor(wire25Rings.value)))
if (next25 !== wire25Rings.value) wire25Rings.value = next25
})
async function submitRequest() {
if (submitting.value) return
if (wire15Rings.value + wire25Rings.value < 1) return
submitting.value = true
try {
await createCopperConvertRequest({
source: source.value,
rings15: wire15Rings.value,
rings25: wire25Rings.value,
})
wire15Rings.value = 0
wire25Rings.value = 0
message.success('درخواست تبدیل برای تأیید ادمین ثبت شد')
} finally {
submitting.value = false
}
}
const priceRows = computed(() => {
const p = prices.value
const row = (label: string, amount: number) => ({
@@ -288,11 +334,13 @@ export function useCopperConvertPage() {
sliderAmount,
sliderStep,
remainderAmount,
wire15Amount,
wire25Amount,
wire25Max,
maxRings15,
maxRings25,
wire15Rings,
wire25Rings,
leftoverAmount,
submitting,
submitRequest,
prices,
result,
priceRows,