feat(convert): read quantity from the asset so the leftover can be split across 1.5 and 2.5 wire.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-31 17:38:11 +03:30
co-authored by Cursor
parent adaf0a087b
commit 0de5b0dcb7
3 changed files with 296 additions and 52 deletions
@@ -50,3 +50,4 @@
- 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` | زبانه مقدار تا موجودی؛ مانده با زبانه ۱.۵ (تا کل مانده) و ۲.۵ (تا باقی‌مانده بعد از ۱.۵)
@@ -1,7 +1,7 @@
<template>
<div class="copper-convert" dir="rtl">
<p class="copper-convert__lead">
وزن دارایی را وارد کنید؛ معادل مفتول و تعداد حلقه سیم ۱.۵ و ۲.۵ با قیمت روز محاسبه می‌شود.
مقدار کاتد، مفتول یا شمش از دارایی شما خوانده می‌شود. با زبانه از صفر تا همان موجودی انتخاب کنید. معادل مفتول و حلقه سیم ۱.۵ و ۲.۵ با قیمت روز حساب می‌شود.
</p>
<a-spin :spinning="loading">
@@ -59,18 +59,33 @@
</div>
<div class="copper-convert__weight">
<label class="copper-convert__field-label" for="copper-convert-kg">مقدار</label>
<label class="copper-convert__field-label">مقدار</label>
<div class="copper-convert__weight-wrap">
<input
id="copper-convert-kg"
v-model="inputKg"
class="copper-convert__weight-input"
type="text"
inputmode="decimal"
placeholder="مثلاً ۱۰"
autocomplete="off"
<span class="copper-convert__weight-value" dir="ltr">{{ formatAssetAmount(sliderAmount) }}</span>
<span class="copper-convert__weight-unit">{{ assetUnit }}</span>
</div>
<p class="copper-convert__asset-hint">
<template v-if="assetAmount > 0">از دارایی شما؛ انتهای زبانه {{ formatAssetAmount(assetAmount) }} {{ assetUnit }} است</template>
<template v-else>موجودی این دارایی صفر است.</template>
</p>
<div
class="app-touch-slider copper-convert__slider"
@touchstart.stop
@touchmove.stop
@pointerdown.stop
>
<a-slider
v-model:value="sliderAmount"
:min="0"
:max="assetAmount > 0 ? assetAmount : 1"
:step="sliderStep"
:disabled="assetAmount <= 0"
:tip-formatter="sliderTip"
/>
<span class="copper-convert__weight-unit">کیلوگرم</span>
</div>
<div class="copper-convert__slider-scale">
<span>۰</span>
<span dir="ltr">{{ formatAssetAmount(assetAmount) }} {{ assetUnit }}</span>
</div>
</div>
</section>
@@ -86,25 +101,11 @@
<span class="copper-convert__ring-badge">۱.۵ mm²</span>
<p class="copper-convert__ring-count" dir="ltr">{{ formatRings(result.rings15ByValue) }}</p>
<p class="copper-convert__ring-caption">حلقه سیم</p>
<p
v-if="result.rings15ByWeight != null"
class="copper-convert__ring-sub"
dir="ltr"
>
≈ {{ formatRings(result.rings15ByWeight) }} بر اساس وزن حلقه
</p>
</article>
<article class="copper-convert__ring-card">
<span class="copper-convert__ring-badge">۲.۵ mm²</span>
<p class="copper-convert__ring-count" dir="ltr">{{ formatRings(result.rings25ByValue) }}</p>
<p class="copper-convert__ring-caption">حلقه سیم</p>
<p
v-if="result.rings25ByWeight != null"
class="copper-convert__ring-sub"
dir="ltr"
>
≈ {{ formatRings(result.rings25ByWeight) }} بر اساس وزن حلقه
</p>
</article>
</div>
@@ -120,7 +121,72 @@
</div>
</section>
<p v-else-if="inputKg && !loading" class="copper-convert__warn">
<section v-if="result && remainderAmount > 0" class="copper-convert__split">
<div class="copper-convert__result-head">
<span class="copper-convert__result-kicker">مانده دارایی</span>
<span class="copper-convert__result-title">تقسیم بین دو سیم</span>
</div>
<p class="copper-convert__asset-hint">
مانده {{ 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>
</div>
<p class="copper-convert__asset-hint">معادل {{ formatRings(wire15Rings) }} حلقه</p>
<div
class="app-touch-slider copper-convert__slider"
@touchstart.stop
@touchmove.stop
@pointerdown.stop
>
<a-slider
v-model:value="wire15Amount"
:min="0"
:max="remainderAmount"
:step="sliderStep"
:tip-formatter="sliderTip"
/>
</div>
<div class="copper-convert__slider-scale">
<span>۰</span>
<span dir="ltr">{{ formatAssetAmount(remainderAmount) }} {{ assetUnit }}</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>
</div>
<p class="copper-convert__asset-hint">معادل {{ formatRings(wire25Rings) }} حلقه</p>
<div
class="app-touch-slider copper-convert__slider"
@touchstart.stop
@touchmove.stop
@pointerdown.stop
>
<a-slider
v-model:value="wire25Amount"
:min="0"
:max="wire25Max > 0 ? wire25Max : 1"
:step="sliderStep"
:disabled="wire25Max <= 0"
:tip-formatter="sliderTip"
/>
</div>
<div class="copper-convert__slider-scale">
<span>۰</span>
<span dir="ltr">{{ formatAssetAmount(wire25Max) }} {{ assetUnit }}</span>
</div>
</div>
</section>
<p v-if="!result && amountAttempted && !loading" class="copper-convert__warn">
مقدار معتبر وارد کنید و از فعال بودن قیمت کاتد/مفتول در سامانه مطمئن شوید.
</p>
</a-spin>
@@ -132,7 +198,24 @@ import { computed } from 'vue'
import { useCopperConvertPageContext } from '../composables/useCopperConvertPage'
import type { CopperConvertSource } from '@/utils/copperWireConvert'
const { loading, source, inputKg, result, priceRows } = useCopperConvertPageContext()
const {
loading,
source,
assetAmount,
assetUnit,
sliderAmount,
sliderStep,
remainderAmount,
wire15Amount,
wire25Amount,
wire25Max,
wire15Rings,
wire25Rings,
result,
priceRows,
} = useCopperConvertPageContext()
const amountAttempted = computed(() => sliderAmount.value > 0)
const sourceOptions = computed(() => [
{ label: 'کاتد', value: 'katod' as CopperConvertSource },
@@ -143,6 +226,17 @@ const sourceOptions = computed(() => [
const metalPriceRows = computed(() => priceRows.value.slice(0, 3))
const wirePriceRows = computed(() => priceRows.value.slice(3))
function formatAssetAmount(value: number) {
if (!Number.isFinite(value)) return '0'
const rounded = Math.round(value * 1000) / 1000
if (Number.isInteger(rounded)) return rounded.toLocaleString('en-US')
return rounded.toLocaleString('en-US', { maximumFractionDigits: 3 })
}
function sliderTip(value?: number) {
return `${formatAssetAmount(Number(value ?? 0))} ${assetUnit.value}`
}
function formatRial(value: number) {
return Math.round(value).toLocaleString('en-US')
}
@@ -178,7 +272,8 @@ function formatRings(value: number) {
.copper-convert__panel,
.copper-convert__form,
.copper-convert__result {
.copper-convert__result,
.copper-convert__split {
padding: 0.875rem;
border-radius: 0.875rem;
background: #fff;
@@ -273,7 +368,8 @@ function formatRings(value: number) {
text-overflow: ellipsis;
}
.copper-convert__form {
.copper-convert__form,
.copper-convert__split {
display: flex;
flex-direction: column;
gap: 0.65rem;
@@ -326,23 +422,36 @@ function formatRings(value: number) {
box-shadow: inset 0 1px 2px rgba(15, 39, 68, 0.04);
}
.copper-convert__weight-input {
.copper-convert__weight-value {
flex: 1 1 auto;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 1.125rem;
font-weight: 800;
color: #0f172a;
text-align: right;
direction: ltr;
text-align: right;
}
.copper-convert__weight-input::placeholder {
color: #94a3b8;
font-weight: 500;
font-size: 0.875rem;
.copper-convert__asset-hint {
margin: 0.35rem 0 0;
font-size: 0.6875rem;
line-height: 1.5;
color: #64748b;
}
.copper-convert__slider {
margin-top: 0.15rem;
}
.copper-convert__slider-scale {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: -0.15rem;
font-size: 0.6875rem;
font-weight: 700;
color: #64748b;
}
.copper-convert__weight-unit {
@@ -5,12 +5,15 @@ import {
onUnmounted,
provide,
ref,
watch,
type ComputedRef,
type InjectionKey,
type Ref,
} from 'vue'
import { getProducts } from '@/api/products'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { Product } from '@/models/products'
import type { WalletInventoryCard } from '@/models/walletAssets'
import {
COPPER_CONVERT_WIRE_FALLBACK,
calcCopperWireConvert,
@@ -19,9 +22,10 @@ import {
type CopperConvertSource,
} from '@/utils/copperWireConvert'
import { LEGACY_GROUP_KATOD, LEGACY_GROUP_MAFTOL, normalizeProductGroupType } from '@/utils/productCategories'
import { getProductWeightUnit } from '@/utils/productDealMethod'
import { getProductBuyPrice } from '@/utils/productPrice'
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
import { persianToEnglishDigits } from '@/utils/persianDigits'
import { weightToGrams } from '@/utils/weightUnit'
export const COPPER_CONVERT_PAGE_KEY: InjectionKey<CopperConvertPageContext> =
Symbol('copperConvertPageContext')
@@ -29,18 +33,45 @@ export const COPPER_CONVERT_PAGE_KEY: InjectionKey<CopperConvertPageContext> =
export interface CopperConvertPageContext {
loading: Ref<boolean>
source: Ref<CopperConvertSource>
inputKg: Ref<string>
assetAmount: ComputedRef<number>
assetUnit: ComputedRef<string>
sliderAmount: Ref<number>
sliderStep: ComputedRef<number>
remainderAmount: ComputedRef<number>
wire15Amount: Ref<number>
wire25Amount: Ref<number>
wire25Max: ComputedRef<number>
wire15Rings: ComputedRef<number>
wire25Rings: ComputedRef<number>
prices: ComputedRef<CopperConvertPrices>
result: ComputedRef<CopperConvertResult | null>
priceRows: ComputedRef<{ label: string; value: string; missing?: boolean }[]>
}
function parseKg(raw: string): number {
const s = persianToEnglishDigits(String(raw ?? ''))
.replace(/,/g, '')
.replace(/[^\d.]/g, '')
const n = Number.parseFloat(s)
return Number.isFinite(n) ? n : 0
function roundAmount(value: number): number {
return Math.round(value * 1000) / 1000
}
function cardMatchesSource(card: WalletInventoryCard, src: CopperConvertSource) {
const group = normalizeProductGroupType(card.group_type)
const title = card.title ?? ''
if (src === 'katod') return group === LEGACY_GROUP_KATOD || title.includes('کاتد')
if (src === 'maftoul') return group === LEGACY_GROUP_MAFTOL || title.includes('مفتول')
return group.includes('شمش') || title.includes('شمش')
}
function cardQuantity(card: WalletInventoryCard): number {
const qty = Number(card.quantity ?? 0)
return Number.isFinite(qty) && qty > 0 ? qty : 0
}
function quantityToKg(quantity: number, unit: string, product?: Product): number {
if (quantity <= 0) return 0
if (unit.includes('کیلو')) return quantity
if (unit.includes('گرم')) return quantity / 1000
const perPiece = Number(product?.amount ?? 0)
if (perPiece > 0) return weightToGrams(quantity * perPiece, getProductWeightUnit(product)) / 1000
return quantity
}
function titleIncludes(product: Product, part: string) {
@@ -90,8 +121,71 @@ function resolveWireRingPrice(product: Product | undefined, fallback: number): n
export function useCopperConvertPage() {
const loading = ref(true)
const source = ref<CopperConvertSource>('katod')
const inputKg = ref('')
const sliderAmount = ref(0)
const wire15Amount = ref(0)
const wire25Amount = ref(0)
const catalog = ref<Product[]>([])
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const assetCards = computed(() => {
const summary = assetsSummary.value
if (!summary) return [] as WalletInventoryCard[]
const src = source.value
const fromInventory = (summary.inventory_cards ?? []).filter((card) =>
cardMatchesSource(card, src),
)
return fromInventory.length
? fromInventory
: (summary.count_products ?? []).filter((card) => cardMatchesSource(card, src))
})
const assetUnit = computed(() => {
const units = [...new Set(assetCards.value.map((card) => (card.unit ?? '').trim()).filter(Boolean))]
if (units.length === 1) return units[0]
return 'کیلوگرم'
})
const assetAmount = computed(() => {
const cards = assetCards.value
if (!cards.length) return 0
const units = [...new Set(cards.map((card) => (card.unit ?? '').trim()).filter(Boolean))]
if (units.length > 1) {
return roundAmount(
cards.reduce((sum, card) => sum + quantityToKg(cardQuantity(card), card.unit ?? '', undefined), 0),
)
}
return roundAmount(cards.reduce((sum, card) => sum + cardQuantity(card), 0))
})
const sliderStep = computed(() => (assetUnit.value.includes('عدد') ? 1 : 0.001))
const remainderAmount = computed(() =>
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
})
watch(
assetAmount,
(amount, prev) => {
const previous = prev ?? 0
if (sliderAmount.value === previous || sliderAmount.value > amount) sliderAmount.value = amount
},
{ immediate: true },
)
const katodProduct = computed(() => pickProduct(catalog.value, isKatodProduct))
const maftolProduct = computed(() => pickProduct(catalog.value, isMaftolProduct))
@@ -113,10 +207,38 @@ export function useCopperConvertPage() {
wireWeightGrams25: COPPER_CONVERT_WIRE_FALLBACK.wireWeightGrams25,
}))
const result = computed(() =>
calcCopperWireConvert(parseKg(inputKg.value), source.value, prices.value),
const sourceProduct = computed(() => {
if (source.value === 'katod') return katodProduct.value
if (source.value === 'maftoul') return maftolProduct.value
return shamshProduct.value
})
const selectedKg = computed(() =>
quantityToKg(sliderAmount.value, assetUnit.value, sourceProduct.value),
)
const result = computed(() =>
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 wire25Rings = computed(() => {
const converted = calcCopperWireConvert(
quantityToKg(wire25Amount.value, assetUnit.value, sourceProduct.value),
source.value,
prices.value,
)
return converted?.rings25ByValue ?? 0
})
const priceRows = computed(() => {
const p = prices.value
const row = (label: string, amount: number) => ({
@@ -136,8 +258,11 @@ export function useCopperConvertPage() {
async function loadCatalog() {
loading.value = true
try {
await getLastPrices()
const list = await getProducts()
const [, list] = await Promise.all([
getLastPrices(),
getProducts(),
loadWalletAssets(),
])
catalog.value = Array.isArray(list) ? list : []
} catch {
catalog.value = []
@@ -158,7 +283,16 @@ export function useCopperConvertPage() {
const ctx: CopperConvertPageContext = {
loading,
source,
inputKg,
assetAmount,
assetUnit,
sliderAmount,
sliderStep,
remainderAmount,
wire15Amount,
wire25Amount,
wire25Max,
wire15Rings,
wire25Rings,
prices,
result,
priceRows,