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 | نمودار خانه = کارت لندینگ | `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 | چیدمان کارت نمودار خانه | همان دو فایل vue/less | هدر: نام+وضعیت راست، تب محصول چپ؛ قیمت راست و تغییر چپ؛ تب بازه کنار عنوان نمودار
- 2026-10-21 | تب نمودار = محصول فعال | `useLandingLiveChart.ts` + `LandingLiveChartMain.vue` | تب از کاتالوگ `active && show`؛ داده با `price_offset_percent` همان محصول - 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> <template>
<div class="copper-convert" dir="rtl"> <div class="copper-convert" dir="rtl">
<p class="copper-convert__lead"> <p class="copper-convert__lead">
وزن دارایی را وارد کنید؛ معادل مفتول و تعداد حلقه سیم ۱.۵ و ۲.۵ با قیمت روز محاسبه می‌شود. مقدار کاتد، مفتول یا شمش از دارایی شما خوانده می‌شود. با زبانه از صفر تا همان موجودی انتخاب کنید. معادل مفتول و حلقه سیم ۱.۵ و ۲.۵ با قیمت روز حساب می‌شود.
</p> </p>
<a-spin :spinning="loading"> <a-spin :spinning="loading">
@@ -59,18 +59,33 @@
</div> </div>
<div class="copper-convert__weight"> <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"> <div class="copper-convert__weight-wrap">
<input <span class="copper-convert__weight-value" dir="ltr">{{ formatAssetAmount(sliderAmount) }}</span>
id="copper-convert-kg" <span class="copper-convert__weight-unit">{{ assetUnit }}</span>
v-model="inputKg" </div>
class="copper-convert__weight-input" <p class="copper-convert__asset-hint">
type="text" <template v-if="assetAmount > 0">از دارایی شما؛ انتهای زبانه {{ formatAssetAmount(assetAmount) }} {{ assetUnit }} است</template>
inputmode="decimal" <template v-else>موجودی این دارایی صفر است.</template>
placeholder="مثلاً ۱۰" </p>
autocomplete="off" <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>
</div> </div>
</section> </section>
@@ -86,25 +101,11 @@
<span class="copper-convert__ring-badge">۱.۵ mm²</span> <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-count" dir="ltr">{{ formatRings(result.rings15ByValue) }}</p>
<p class="copper-convert__ring-caption">حلقه سیم</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>
<article class="copper-convert__ring-card"> <article class="copper-convert__ring-card">
<span class="copper-convert__ring-badge">۲.۵ mm²</span> <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-count" dir="ltr">{{ formatRings(result.rings25ByValue) }}</p>
<p class="copper-convert__ring-caption">حلقه سیم</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> </article>
</div> </div>
@@ -120,7 +121,72 @@
</div> </div>
</section> </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> </p>
</a-spin> </a-spin>
@@ -132,7 +198,24 @@ import { computed } from 'vue'
import { useCopperConvertPageContext } from '../composables/useCopperConvertPage' import { useCopperConvertPageContext } from '../composables/useCopperConvertPage'
import type { CopperConvertSource } from '@/utils/copperWireConvert' 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(() => [ const sourceOptions = computed(() => [
{ label: 'کاتد', value: 'katod' as CopperConvertSource }, { label: 'کاتد', value: 'katod' as CopperConvertSource },
@@ -143,6 +226,17 @@ const sourceOptions = computed(() => [
const metalPriceRows = computed(() => priceRows.value.slice(0, 3)) const metalPriceRows = computed(() => priceRows.value.slice(0, 3))
const wirePriceRows = computed(() => priceRows.value.slice(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) { function formatRial(value: number) {
return Math.round(value).toLocaleString('en-US') return Math.round(value).toLocaleString('en-US')
} }
@@ -178,7 +272,8 @@ function formatRings(value: number) {
.copper-convert__panel, .copper-convert__panel,
.copper-convert__form, .copper-convert__form,
.copper-convert__result { .copper-convert__result,
.copper-convert__split {
padding: 0.875rem; padding: 0.875rem;
border-radius: 0.875rem; border-radius: 0.875rem;
background: #fff; background: #fff;
@@ -273,7 +368,8 @@ function formatRings(value: number) {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.copper-convert__form { .copper-convert__form,
.copper-convert__split {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.65rem; gap: 0.65rem;
@@ -326,23 +422,36 @@ function formatRings(value: number) {
box-shadow: inset 0 1px 2px rgba(15, 39, 68, 0.04); box-shadow: inset 0 1px 2px rgba(15, 39, 68, 0.04);
} }
.copper-convert__weight-input { .copper-convert__weight-value {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 800; font-weight: 800;
color: #0f172a; color: #0f172a;
text-align: right;
direction: ltr; direction: ltr;
text-align: right;
} }
.copper-convert__weight-input::placeholder { .copper-convert__asset-hint {
color: #94a3b8; margin: 0.35rem 0 0;
font-weight: 500; font-size: 0.6875rem;
font-size: 0.875rem; 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 { .copper-convert__weight-unit {
@@ -5,12 +5,15 @@ import {
onUnmounted, onUnmounted,
provide, provide,
ref, ref,
watch,
type ComputedRef, type ComputedRef,
type InjectionKey, type InjectionKey,
type Ref, type Ref,
} from 'vue' } from 'vue'
import { getProducts } from '@/api/products' import { getProducts } from '@/api/products'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { Product } from '@/models/products' import type { Product } from '@/models/products'
import type { WalletInventoryCard } from '@/models/walletAssets'
import { import {
COPPER_CONVERT_WIRE_FALLBACK, COPPER_CONVERT_WIRE_FALLBACK,
calcCopperWireConvert, calcCopperWireConvert,
@@ -19,9 +22,10 @@ import {
type CopperConvertSource, type CopperConvertSource,
} from '@/utils/copperWireConvert' } from '@/utils/copperWireConvert'
import { LEGACY_GROUP_KATOD, LEGACY_GROUP_MAFTOL, normalizeProductGroupType } from '@/utils/productCategories' import { LEGACY_GROUP_KATOD, LEGACY_GROUP_MAFTOL, normalizeProductGroupType } from '@/utils/productCategories'
import { getProductWeightUnit } from '@/utils/productDealMethod'
import { getProductBuyPrice } from '@/utils/productPrice' import { getProductBuyPrice } from '@/utils/productPrice'
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price' 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> = export const COPPER_CONVERT_PAGE_KEY: InjectionKey<CopperConvertPageContext> =
Symbol('copperConvertPageContext') Symbol('copperConvertPageContext')
@@ -29,18 +33,45 @@ export const COPPER_CONVERT_PAGE_KEY: InjectionKey<CopperConvertPageContext> =
export interface CopperConvertPageContext { export interface CopperConvertPageContext {
loading: Ref<boolean> loading: Ref<boolean>
source: Ref<CopperConvertSource> 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> prices: ComputedRef<CopperConvertPrices>
result: ComputedRef<CopperConvertResult | null> result: ComputedRef<CopperConvertResult | null>
priceRows: ComputedRef<{ label: string; value: string; missing?: boolean }[]> priceRows: ComputedRef<{ label: string; value: string; missing?: boolean }[]>
} }
function parseKg(raw: string): number { function roundAmount(value: number): number {
const s = persianToEnglishDigits(String(raw ?? '')) return Math.round(value * 1000) / 1000
.replace(/,/g, '') }
.replace(/[^\d.]/g, '')
const n = Number.parseFloat(s) function cardMatchesSource(card: WalletInventoryCard, src: CopperConvertSource) {
return Number.isFinite(n) ? n : 0 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) { function titleIncludes(product: Product, part: string) {
@@ -90,8 +121,71 @@ function resolveWireRingPrice(product: Product | undefined, fallback: number): n
export function useCopperConvertPage() { export function useCopperConvertPage() {
const loading = ref(true) const loading = ref(true)
const source = ref<CopperConvertSource>('katod') 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 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 katodProduct = computed(() => pickProduct(catalog.value, isKatodProduct))
const maftolProduct = computed(() => pickProduct(catalog.value, isMaftolProduct)) const maftolProduct = computed(() => pickProduct(catalog.value, isMaftolProduct))
@@ -113,10 +207,38 @@ export function useCopperConvertPage() {
wireWeightGrams25: COPPER_CONVERT_WIRE_FALLBACK.wireWeightGrams25, wireWeightGrams25: COPPER_CONVERT_WIRE_FALLBACK.wireWeightGrams25,
})) }))
const result = computed(() => const sourceProduct = computed(() => {
calcCopperWireConvert(parseKg(inputKg.value), source.value, prices.value), 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 priceRows = computed(() => {
const p = prices.value const p = prices.value
const row = (label: string, amount: number) => ({ const row = (label: string, amount: number) => ({
@@ -136,8 +258,11 @@ export function useCopperConvertPage() {
async function loadCatalog() { async function loadCatalog() {
loading.value = true loading.value = true
try { try {
await getLastPrices() const [, list] = await Promise.all([
const list = await getProducts() getLastPrices(),
getProducts(),
loadWalletAssets(),
])
catalog.value = Array.isArray(list) ? list : [] catalog.value = Array.isArray(list) ? list : []
} catch { } catch {
catalog.value = [] catalog.value = []
@@ -158,7 +283,16 @@ export function useCopperConvertPage() {
const ctx: CopperConvertPageContext = { const ctx: CopperConvertPageContext = {
loading, loading,
source, source,
inputKg, assetAmount,
assetUnit,
sliderAmount,
sliderStep,
remainderAmount,
wire15Amount,
wire25Amount,
wire25Max,
wire15Rings,
wire25Rings,
prices, prices,
result, result,
priceRows, priceRows,