feat(pricing): the server prices app orders with legacy's making-charge formula (MON-08)
Legacy: final % = variant % + customer % − the largest active promotion on the product, its category or its brand; rial charge 0. The apps sent the variant's own % only, so the new app undercharged by the customer's percent (usually 3) and ignored promotions, and the server trusted whatever the app sent. - pricing/wage-formula.ts: one formula, legacy by default, with coefficients, promotion pick, constant, floor/cap and rounding so the admin screen can change it; validated - an app order is priced by it; the app's wage, discount and rial charge are ignored; an operator's manual numbers are kept - POST customer/me/wage-quote; both apps show that figure - admin/pricing/wage-formula (config.wage): read, preview, approve with the sample it was checked on, last 20 versions kept; the setting is kept off the generic const routes Owner approval: 2026-10-08. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4b7b2473fc
commit
699657e483
@@ -303,6 +303,15 @@ export interface AccountingBalance {
|
||||
provider: string | null
|
||||
}
|
||||
|
||||
/** POST /customer/me/wage-quote — one variant's making charge for the signed-in customer. */
|
||||
export interface WageQuote {
|
||||
variantId: string
|
||||
variantPercent: string
|
||||
customerPercent: string
|
||||
promotionPercent: string
|
||||
finalPercent: string
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||
|
||||
@@ -336,6 +345,9 @@ export const api = {
|
||||
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
|
||||
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
|
||||
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
|
||||
/** The making charge the server will charge for these variants (MON-08). */
|
||||
getWageQuotes: (variantIds: string[]) =>
|
||||
request<WageQuote[]>('/customer/me/wage-quote', { method: 'POST', body: JSON.stringify({ variantIds }) }),
|
||||
/** Favourite product ids, newest first — legacy get-favorites (FAV-01). */
|
||||
getFavorites: () => request<{ ids: string[] }>('/customer/me/favorites'),
|
||||
/** Legacy add-to-fav: adds or removes; answers the new state. */
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
// data exists in the new API, so `hasDiscountWage` is always null here —
|
||||
// the discount badge and "before/after" price never render, which is
|
||||
// correct given there is genuinely nothing to show, not a bug.
|
||||
// - `wage`: legacy added the logged-in customer's own `percent` (a
|
||||
// commission/markup field on their account) on top of the variant's
|
||||
// making-charge %. No login exists in this port yet, so wage is just the
|
||||
// selected variant's defaultWagePercent on its own.
|
||||
// - `wage` now comes from the server (wageQuoteStore), which adds the
|
||||
// customer's own percent and the promotion exactly as legacy did.
|
||||
// - `is_exist` (stock) and `has_warranty`: no such fields exist on our
|
||||
// ProductVariant model yet — always true / false respectively.
|
||||
import { computed, ref, unref, type Ref } from 'vue'
|
||||
import type { ProductSummary, ProductVariantSummary } from '../api/client'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { useWageQuoteStore } from '../stores/wageQuoteStore'
|
||||
|
||||
/** A variant can be ordered when it has stock, or when it is flagged pre-order. */
|
||||
export function isVariantOrderable(v: ProductVariantSummary): boolean {
|
||||
@@ -52,7 +52,20 @@ export function useProduct(productItem: ProductSummary, initialVariant?: Product
|
||||
() => selectedVariant.value?.image?.url ?? product.value.images?.[0]?.url,
|
||||
)
|
||||
const min_gram = computed(() => selectedVariant.value ? parseFloat(selectedVariant.value.rashWeightGrams) : 0)
|
||||
const wage = computed(() => (selectedVariant.value ? parseFloat(selectedVariant.value.defaultWagePercent) : null))
|
||||
// The making charge the server will charge (MON-08): variant + the
|
||||
// customer's own percent − the best active promotion, by the formula the
|
||||
// admin «اجرت» screen controls. Signed in: the server's figure, hidden for
|
||||
// the moment it loads rather than flashing a different number. Signed out
|
||||
// (no customer, so no customer percent): the variant's own figure.
|
||||
const session = useSessionStore()
|
||||
const wageQuotes = useWageQuoteStore()
|
||||
const wage = computed(() => {
|
||||
const v = selectedVariant.value
|
||||
if (!v) return null
|
||||
if (!session.isLoggedIn) return parseFloat(v.defaultWagePercent)
|
||||
wageQuotes.request(v.id)
|
||||
return wageQuotes.finalPercent(v.id)
|
||||
})
|
||||
const hasDiscountWage = ref<number | null>(null) // no promotions backend yet — see file header
|
||||
// REAL stock: ProductVariant.qty / isPreOrder exist and are returned by
|
||||
// GET /catalog/products. A variant is orderable when it has stock, or when
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// The making charge («اجرت») the server will charge this customer for each
|
||||
// variant — fetched from /customer/me/wage-quote, which runs the same formula
|
||||
// that prices the order (MON-08, owner ruling 2026-10-08). The app shows this
|
||||
// figure, never one it computes itself, so what is shown is what is charged.
|
||||
//
|
||||
// Requests are batched: many cards asking at once become one call.
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { api, type WageQuote } from '../api/client'
|
||||
import { useSessionStore } from './session'
|
||||
|
||||
export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
||||
const session = useSessionStore()
|
||||
const quotes = ref<Record<string, WageQuote>>({})
|
||||
const pending = new Set<string>()
|
||||
const inFlight = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// A different customer has a different percent: never reuse another's quotes.
|
||||
watch(
|
||||
() => session.token,
|
||||
() => {
|
||||
quotes.value = {}
|
||||
pending.clear()
|
||||
inFlight.clear()
|
||||
},
|
||||
)
|
||||
|
||||
async function flush() {
|
||||
timer = null
|
||||
const ids = [...pending].slice(0, 200)
|
||||
ids.forEach((id) => {
|
||||
pending.delete(id)
|
||||
inFlight.add(id)
|
||||
})
|
||||
if (pending.size) timer = setTimeout(flush, 0)
|
||||
if (!ids.length) return
|
||||
try {
|
||||
const rows = await api.getWageQuotes(ids)
|
||||
const next = { ...quotes.value }
|
||||
for (const r of rows) next[r.variantId] = r
|
||||
quotes.value = next
|
||||
} catch (e) {
|
||||
console.warn('wage quote failed', e)
|
||||
} finally {
|
||||
ids.forEach((id) => inFlight.delete(id))
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks for a variant's quote (idempotent; safe to call from a computed). */
|
||||
function request(variantId: string) {
|
||||
if (!session.token || quotes.value[variantId] || pending.has(variantId) || inFlight.has(variantId)) return
|
||||
pending.add(variantId)
|
||||
if (!timer) timer = setTimeout(flush, 30)
|
||||
}
|
||||
|
||||
/** The charged percent, or null while it is still loading. */
|
||||
function finalPercent(variantId: string): number | null {
|
||||
const q = quotes.value[variantId]
|
||||
return q ? parseFloat(q.finalPercent) : null
|
||||
}
|
||||
|
||||
return { quotes, request, finalPercent }
|
||||
})
|
||||
@@ -213,6 +213,8 @@ const submitOrder = async () => {
|
||||
return {
|
||||
productVariantId: item.vid,
|
||||
quantity: item.qty,
|
||||
// Sent for older API builds only: the server prices app orders itself
|
||||
// (MON-08) and ignores this figure.
|
||||
wagePercent: variant ? parseFloat(variant.defaultWagePercent) : 0,
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user