Allow multi-company carts to submit as one checkout creating one order per company.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-15 08:30:57 +02:00
co-authored by Cursor
parent 800b950a46
commit 1b4a149e76
8 changed files with 255 additions and 83 deletions
+13 -3
View File
@@ -15,6 +15,7 @@ export function useCartPage() {
const items = computed(() => cartStore.activeCart?.items ?? [])
const isEmpty = computed(() => items.value.length === 0)
const companyName = computed(() => cartStore.companyNamesLabel || cartStore.activeCart?.companyName || '')
const companySummaries = computed(() => cartStore.companySummaries)
async function removeItem(itemId: string) {
const item = items.value.find((entry) => entry.id === itemId)
@@ -40,17 +41,25 @@ export function useCartPage() {
submitting.value = true
submitError.value = ''
try {
// همگام‌سازی سبد با سرور قبل از ثبت تا productId/companyId کهنه نباشد
await cartStore.hydrate()
if (!cartStore.activeCart?.items.length) {
submitError.value = 'سبد خرید خالی است یا با سرور همگام نشد.'
return
}
const order = await orderStore.submitOrder(cartStore.activeCart)
const created = await orderStore.submitOrder(cartStore.activeCart)
await cartStore.hydrate()
router.replace(`/orders/${order.id}/success`)
if (created.length === 1) {
router.replace(`/orders/${created[0]!.id}/success`)
return
}
// چند شرکت → چند سفارش؛ به لیست سفارش‌ها برو
router.replace({
path: '/orders',
query: { submitted: String(created.length) },
})
} catch (e) {
submitError.value = e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.'
await cartStore.hydrate()
} finally {
submitting.value = false
}
@@ -90,6 +99,7 @@ export function useCartPage() {
items,
isEmpty,
companyName,
companySummaries,
showFullDialog,
submitting,
submitError,
+23 -1
View File
@@ -1,5 +1,5 @@
import { computed, ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import { useAuthStore } from '@/stores/auth'
import { useCatalogStore } from '@/stores/catalog'
@@ -12,22 +12,43 @@ import {
matchesMobileOrderTab,
type DesktopOrderFilterId,
} from '@/utils/orderStatusGroups'
import { toPersianDigits } from '@/utils/format'
const DESKTOP_PAGE_SIZE = 5
const MOBILE_PAGE_SIZE = 5
export function useOrderList() {
const router = useRouter()
const route = useRoute()
const orderStore = useOrderStore()
const authStore = useAuthStore()
const catalogStore = useCatalogStore()
const currentPage = ref(1)
const mobilePage = ref(1)
const submitSuccessMessage = ref('')
onMounted(async () => {
await Promise.all([orderStore.hydrate(), catalogStore.loadCompanies()])
})
watch(
() => route.query.submitted,
(submitted) => {
const count = Number(submitted)
if (!Number.isFinite(count) || count < 1) {
submitSuccessMessage.value = ''
return
}
submitSuccessMessage.value =
count === 1
? 'پیش‌سفارش با موفقیت ثبت شد.'
: `${toPersianDigits(String(count))} پیش‌سفارش (برای شرکت‌های مختلف) ثبت شد.`
const { submitted: _submitted, ...rest } = route.query
void router.replace({ query: rest })
},
{ immediate: true },
)
const tab = computed({
get: () => (router.currentRoute.value.query.tab as string) ?? 'all',
set: (v: string) => router.replace({ query: { ...router.currentRoute.value.query, tab: v } }),
@@ -202,6 +223,7 @@ export function useOrderList() {
mobilePage,
companySlug,
iconVariant,
submitSuccessMessage,
goToOrder,
setPage,
setMobilePage,
+53 -12
View File
@@ -45,30 +45,70 @@ export const useCartStore = defineStore('cart', () => {
const itemCount = computed(() => activeCart.value?.items.length ?? 0)
const catalogStore = useCatalogStore()
const companyNamesLabel = computed(() => {
if (!activeCart.value?.items.length) return ''
return uniqueCompanyNames(activeCart.value.items)
})
const catalogStore = useCatalogStore()
const companySummaries = computed(() => {
const cart = activeCart.value
if (!cart?.items.length) return [] as {
companyId: string
companyName: string
meterage: number
maxMeterage: number
remaining: number
}[]
const byCompany = new Map<string, { companyName: string; meterage: number }>()
for (const item of cart.items) {
const current = byCompany.get(item.companyId)
if (current) {
current.meterage += item.meterage
} else {
byCompany.set(item.companyId, {
companyName: item.companyName || 'شرکت',
meterage: item.meterage,
})
}
}
return [...byCompany.entries()].map(([companyId, info]) => {
const max =
catalogStore.getCompany(companyId)?.maxMeteragePerInvoice ?? DEFAULT_MAX_METERAGE
return {
companyId,
companyName: info.companyName,
meterage: info.meterage,
maxMeterage: max,
remaining: Math.max(0, max - info.meterage),
}
})
})
/** سقف نمایشی: مجموع سقف شرکت‌های داخل سبد (هر شرکت جداگانه محدود می‌شود). */
const maxMeterage = computed(() => {
if (!activeCart.value?.items.length) {
if (!companySummaries.value.length) {
const selected = selectedCompanyId.value
? catalogStore.getCompany(selectedCompanyId.value)
: null
return selected?.maxMeteragePerInvoice ?? DEFAULT_MAX_METERAGE
}
const companyIds = [...new Set(activeCart.value.items.map((item) => item.companyId))]
return companyIds.reduce((sum, id) => {
const company = catalogStore.getCompany(id)
return sum + (company?.maxMeteragePerInvoice ?? DEFAULT_MAX_METERAGE)
}, 0)
return companySummaries.value.reduce((sum, row) => sum + row.maxMeterage, 0)
})
const remainingCapacity = computed(() => Math.max(0, maxMeterage.value - totalMeterage.value))
const isFull = computed(() => totalMeterage.value >= maxMeterage.value)
const remainingCapacity = computed(() =>
companySummaries.value.length
? companySummaries.value.reduce((sum, row) => sum + row.remaining, 0)
: Math.max(0, maxMeterage.value - totalMeterage.value),
)
/** وقتی حداقل یک شرکت به سقف خودش رسیده باشد. */
const isFull = computed(() =>
companySummaries.value.some((row) => row.meterage >= row.maxMeterage),
)
function applyServerCart(serverCart: ApiCart) {
const cart: Cart = {
@@ -178,8 +218,8 @@ export const useCartStore = defineStore('cart', () => {
replaceActiveCart({
id: cart.id || 'local',
companyId: company.id,
companyName: company.name,
companyId: items[0]?.companyId || company.id,
companyName: uniqueCompanyNames(items) || company.name,
items,
notes: cart.notes,
})
@@ -349,6 +389,7 @@ export const useCartStore = defineStore('cart', () => {
totalMeterage,
itemCount,
companyNamesLabel,
companySummaries,
maxMeterage,
remainingCapacity,
isFull,
+35 -27
View File
@@ -117,8 +117,9 @@ export const useOrderStore = defineStore('order', () => {
/**
* سفارش بدون روش تسویه ثبت می‌شود؛ مشتری بعد از قیمت‌گذاری مدیریت
* روش تسویه را انتخاب می‌کند.
* سبد چندشرکتی: برای هر شرکت یک سفارش جدا ساخته می‌شود.
*/
async function submitOrder(cart: Cart): Promise<Order> {
async function submitOrder(cart: Cart): Promise<Order[]> {
if (!cart.items.length) {
throw new Error('سبد خرید خالی است.')
}
@@ -126,43 +127,50 @@ export const useOrderStore = defineStore('order', () => {
throw new Error('سبد خرید با سرور همگام نیست. لطفاً دوباره محصولات را به سبد اضافه کنید.')
}
// شرکت را از خود آیتم‌ها بگیر؛ هدر سبد گاهی با شرکت واقعی آیتم‌ها ناسازگار است.
const companyIds = [
...new Set(cart.items.map((item) => item.companyId).filter(Boolean)),
]
if (!companyIds.length) {
throw new Error('شرکت سبد مشخص نیست. سبد را خالی کنید و دوباره اضافه کنید.')
}
if (companyIds.length > 1) {
throw new Error(
'سبد شامل چند شرکت است. فعلاً فقط اقلام یک شرکت را نگه دارید و ثبت کنید.',
)
}
const companyId = companyIds[0]!
const itemsForOrder = cart.items.filter((item) => item.companyId === companyId)
const body = {
companyId,
notes: cart.notes,
items: itemsForOrder.map((item) => ({
productId: item.productId,
meterage: Number(item.meterage),
notes: item.notes,
})),
}
const createdOrders: Order[] = []
try {
const order = await apiRequest<Order>('/orders', {
method: 'POST',
body,
})
orders.value.unshift(order)
clearCheckDraft()
return order
for (const companyId of companyIds) {
const itemsForOrder = cart.items.filter((item) => item.companyId === companyId)
if (!itemsForOrder.length) continue
const order = await apiRequest<Order>('/orders', {
method: 'POST',
body: {
companyId,
notes: cart.notes,
items: itemsForOrder.map((item) => ({
productId: item.productId,
meterage: Number(item.meterage),
notes: item.notes,
})),
},
})
orders.value.unshift(order)
createdOrders.push(order)
}
} catch (e) {
if (createdOrders.length) {
// بخشی ثبت شده؛ سفارش‌های موفق را نگه می‌داریم و خطا را با توضیح برمی‌گردانیم
clearCheckDraft()
const names = createdOrders.map((o) => o.orderNumber).join('، ')
throw new Error(
e instanceof ApiError
? `${e.message} (سفارش‌های ثبت‌شده: ${names})`
: `ثبت بخشی از سفارش‌ها ناموفق بود. ثبت‌شده: ${names}`,
)
}
throw new Error(e instanceof ApiError ? e.message : 'ثبت سفارش ناموفق بود.')
}
clearCheckDraft()
return createdOrders
}
/** انتخاب روش تسویه پس از صدور فاکتور. */
+62 -34
View File
@@ -25,6 +25,7 @@ const {
mobilePage,
companySlug,
iconVariant,
submitSuccessMessage,
goToOrder,
setPage,
setMobilePage,
@@ -49,39 +50,66 @@ const desktopFilterTabs: { id: DesktopOrderFilterId; label: string }[] = [
</script>
<template>
<MobileOrdersPage
class="mobile-only"
:tab="tab"
:tabs="mobileTabs"
:orders="paginatedOrdersMobile"
:total-count="filteredOrdersMobile.length"
:current-page="mobilePage"
:total-pages="mobileTotalPages"
:pagination-range="mobilePaginationRange"
:visible-pages="mobileVisiblePages"
:company-slug="companySlug"
:icon-variant="iconVariant"
@update:tab="tab = $event"
@select="goToOrder"
@back="router.push('/')"
@set-page="setMobilePage"
/>
<div class="orders-view">
<p v-if="submitSuccessMessage" class="orders-view__success" role="status">
{{ submitSuccessMessage }}
</p>
<DesktopOrdersPage
class="desktop-only"
:stats="stats"
:desktop-filter="desktopFilter"
:desktop-filter-tabs="desktopFilterTabs"
:filtered-orders="filteredOrdersDesktop"
:paginated-orders="paginatedOrders"
:pagination-range="paginationRange"
:visible-pages="visiblePages"
:current-page="currentPage"
:total-pages="totalPages"
@view-stat-filter="viewStatFilter"
@set-desktop-filter="setDesktopFilter"
@select="goToOrder"
@set-page="setPage"
@new-order="router.push('/')"
/>
<MobileOrdersPage
class="mobile-only"
:tab="tab"
:tabs="mobileTabs"
:orders="paginatedOrdersMobile"
:total-count="filteredOrdersMobile.length"
:current-page="mobilePage"
:total-pages="mobileTotalPages"
:pagination-range="mobilePaginationRange"
:visible-pages="mobileVisiblePages"
:company-slug="companySlug"
:icon-variant="iconVariant"
@update:tab="tab = $event"
@select="goToOrder"
@back="router.push('/')"
@set-page="setMobilePage"
/>
<DesktopOrdersPage
class="desktop-only"
:stats="stats"
:desktop-filter="desktopFilter"
:desktop-filter-tabs="desktopFilterTabs"
:filtered-orders="filteredOrdersDesktop"
:paginated-orders="paginatedOrders"
:pagination-range="paginationRange"
:visible-pages="visiblePages"
:current-page="currentPage"
:total-pages="totalPages"
@view-stat-filter="viewStatFilter"
@set-desktop-filter="setDesktopFilter"
@select="goToOrder"
@set-page="setPage"
@new-order="router.push('/')"
/>
</div>
</template>
<style scoped>
.orders-view {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.orders-view__success {
margin: 0 var(--page-padding) 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--color-success-soft);
border: 1px solid color-mix(in srgb, var(--color-success) 35%, transparent);
color: var(--color-success);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.5;
}
</style>
+4 -1
View File
@@ -13,6 +13,7 @@ const {
items,
isEmpty,
companyName,
companySummaries,
showFullDialog,
submitting,
submitError,
@@ -40,6 +41,7 @@ function goToOrdering() {
class="mobile-only"
:items="items"
:company-name="companyName"
:company-summaries="companySummaries"
:total-meterage="cartStore.totalMeterage"
:max-meterage="cartStore.maxMeterage"
:remaining-capacity="cartStore.remainingCapacity"
@@ -55,6 +57,7 @@ function goToOrdering() {
class="desktop-only"
:items="items"
:company-name="companyName"
:company-summaries="companySummaries"
:total-meterage="cartStore.totalMeterage"
:max-meterage="cartStore.maxMeterage"
:remaining-capacity="cartStore.remainingCapacity"
@@ -70,7 +73,7 @@ function goToOrdering() {
<div class="dialog card">
<h3>سقف متراژ تکمیل شد</h3>
<p class="text-sm">
سقف متراژ این فاکتور تکمیل شده است. آیا مایلید ادامه محصولات را در فاکتور جدید ثبت کنید؟
سقف متراژ حداقل یک شرکت در سبد پر شده است. می‌توانید همین پیش‌سفارش‌ها را ثبت کنید یا سبد را خالی کرده و فاکتور جدید بسازید.
</p>
<div class="dialog-actions">
<BaseButton variant="outline" :loading="submitting" @click="proceedAnyway">
+30 -1
View File
@@ -16,6 +16,13 @@ import type { CartItem } from '@/types'
defineProps<{
items: CartItem[]
companyName: string
companySummaries?: {
companyId: string
companyName: string
meterage: number
maxMeterage: number
remaining: number
}[]
totalMeterage: number
maxMeterage: number
remainingCapacity: number
@@ -156,6 +163,17 @@ const quickLinks = [
</span>
<strong>{{ formatMeterage(totalMeterage) }}</strong>
</div>
<div
v-for="row in companySummaries ?? []"
:key="row.companyId"
class="cart-summary-card__row cart-summary-card__row--sub"
>
<span class="cart-summary-card__row-label">{{ row.companyName }}</span>
<strong>
{{ formatMeterage(row.meterage) }}
<span class="cart-summary-card__cap">/ {{ formatMeterage(row.maxMeterage) }}</span>
</strong>
</div>
<div class="cart-summary-card__row cart-summary-card__row--hint">
<span class="cart-summary-card__row-label">
<IconOrders :size="16" />
@@ -176,7 +194,7 @@ const quickLinks = [
{{ submitting ? 'در حال ثبت…' : 'ثبت پیش‌سفارش' }}
</button>
<p class="cart-summary-card__note">
قیمت‌گذاری توسط مدیریت انجام می‌شود؛ پس از صدور فاکتور روش تسویه را انتخاب می‌کنید.
اقلام همه شرکت‌ها در یک سبد هستند؛ با ثبت، برای هر شرکت یک پیش‌سفارش جدا ساخته می‌شود.
</p>
</div>
@@ -483,6 +501,17 @@ const quickLinks = [
border-bottom: 1px solid var(--color-border);
}
.cart-summary-card__row--sub {
padding-block: 6px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.cart-summary-card__cap {
font-weight: 500;
color: var(--color-text-muted);
}
.cart-summary-card__row:last-child {
border-bottom: none;
}
+35 -4
View File
@@ -9,6 +9,13 @@ import type { CartItem } from '@/types'
defineProps<{
items: CartItem[]
companyName: string
companySummaries?: {
companyId: string
companyName: string
meterage: number
maxMeterage: number
remaining: number
}[]
totalMeterage: number
maxMeterage: number
remainingCapacity: number
@@ -67,10 +74,23 @@ const emit = defineEmits<{
<div class="cart-mobile-page__footer">
<div class="cart-mobile-summary">
<div class="cart-mobile-summary__row">
<span>جمع متراژ</span>
<span>جمع متراژ کل</span>
<strong>{{ formatMeterage(totalMeterage) }}</strong>
</div>
<div class="cart-mobile-summary__row">
<template v-if="companySummaries?.length">
<div
v-for="row in companySummaries"
:key="row.companyId"
class="cart-mobile-summary__row cart-mobile-summary__row--sub"
>
<span>{{ row.companyName }}</span>
<strong>
{{ formatMeterage(row.meterage) }}
<span class="cart-mobile-summary__cap">/ {{ formatMeterage(row.maxMeterage) }}</span>
</strong>
</div>
</template>
<div v-else class="cart-mobile-summary__row">
<span>حداکثر مجاز</span>
<strong>{{ formatMeterage(maxMeterage) }}</strong>
</div>
@@ -80,8 +100,7 @@ const emit = defineEmits<{
</div>
</div>
<p class="cart-mobile-page__hint">
پس از ثبت، مدیریت سفارش را قیمت‌گذاری می‌کند و فاکتور برای انتخاب روش تسویه به شما اعلام
می‌شود.
پس از ثبت، برای هر شرکت یک پیش‌سفارش جداگانه ساخته می‌شود و مدیریت قیمت‌گذاری می‌کند.
</p>
<BaseButton block :loading="submitting" @click="emit('checkout')">ثبت پیش‌سفارش</BaseButton>
</div>
@@ -250,10 +269,22 @@ const emit = defineEmits<{
.cart-mobile-summary__row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 8px 0;
font-size: 0.875rem;
}
.cart-mobile-summary__row--sub {
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.cart-mobile-summary__cap {
font-weight: 500;
color: var(--color-text-muted);
}
.cart-mobile-summary__row:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}