fix(money,orders): wage/gram-debt parity, atomic document confirm, expert edit, made-to-order, QA fixes

- Rial wage and gram-debt arithmetic matched to the reference; order corrections keep debt in sync
- Document confirm/un-confirm is a compare-and-swap (no double-applied cache under concurrency)
- Settlement document and expert inputs validated (400 instead of 500)
- Expert: edit / activate-deactivate own customers, adjust raw weights only
- Made-to-order flow in the app, PreOrder.quantity migration
- Order grids: customer column, sort keys; panel select search; pre-order quantity save
- Rebuilt release artifacts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mahmood
2026-10-29 18:09:02 +03:30
co-authored by Claude Sonnet 5.5
parent 959a272227
commit 6514b05d18
321 changed files with 6984 additions and 2170 deletions
@@ -16,11 +16,15 @@
returns orders at that status (`where('sts', 5)`, ApiMobileController:766)
- the card is tappable, as the reference's was (:29)
Deliberately NOT ported: the reference's edit-order drawer behind that tap
(toEditOrder -> PUT /order/child/{id}) recalculated an order's weights and
prices from values the CLIENT sent. Bringing that across during a delivery
would put a money path in the app with no server-side recomputation; the tap
opens a read-only summary instead. Recorded in D21/DELIVERY.md.
The reference's edit-order drawer behind that tap (toEditOrder ->
PUT /order/child/{id}) is back, in a form that keeps the reason it was held
back: it used to recalculate an order's weights and prices from values the
CLIENT sent. Here the expert enters ONLY the real raw weight of each piece;
the server applies the making charge the order was placed at, recomputes the
totals and the customer's debt, and refuses anyone but the expert the order
belongs to (OrdersService.expertAdjust). The figures shown under each field
are a preview of that formula, not what is saved. «ثبت نهایی» hands the order
to the customer.
Scope is the server's: every row belongs to a customer this expert referred.
No id travels from the client.
@@ -133,6 +137,37 @@
<dd>تحویل مسئول فروش</dd>
</div>
</dl>
<!-- The real weight of each piece. Only the raw weight is sent; the making
charge, totals and debt are worked out by the server, and the figure
shown under each field is a preview of that. -->
<h3 v-if="drafts.length" class="cho__editTitle">وزن واقعی هر عدد (گرم)</h3>
<div v-for="d in drafts" :key="d.id" class="cho__line">
<div class="cho__lineHead">
<span class="cho__lineName">{{ d.title }}</span>
<span class="cho__lineQty" dir="rtl"><bdi>{{ d.qty }}</bdi> عدد · اجرت <bdi>{{ d.netPercent }}٪</bdi></span>
</div>
<InputNumber v-model:value="d.weight" class="cho__input" :min="0.001" :step="0.01" :precision="3" size="large" />
<div class="cho__lineSum" dir="ltr">
{{ grams(preview(d)) }} g
<span class="cho__muted">({{ d.qty }} × {{ grams(unitPreview(d)) }})</span>
</div>
</div>
<div v-if="drafts.length" class="cho__previewTotal">
<span>جمع وزن پس از ثبت</span>
<b dir="ltr">{{ grams(previewTotal) }} گرم</b>
</div>
<p v-if="saveError" class="cho__error">{{ saveError }}</p>
<div v-if="drafts.length" class="cho__actions">
<button type="button" class="cho__save" :disabled="saving" @click="save(false)">ثبت تغییرات</button>
<button type="button" class="cho__final" :disabled="saving" @click="confirmFinal = true">ثبت نهایی</button>
</div>
<p v-if="confirmFinal" class="cho__confirm">
آیا مطمئن به ثبت نهایی هستید؟ پس از آن سفارش تحویل مشتری می‌شود.
<button type="button" class="cho__final" :disabled="saving" @click="save(true)">بله، ثبت نهایی</button>
<button type="button" class="cho__link" @click="confirmFinal = false">بی‌خیال</button>
</p>
<button type="button" class="cho__close" @click="detailOpen = false">بستن</button>
</div>
</Drawer>
@@ -140,8 +175,8 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Drawer, InputNumber } from 'ant-design-vue'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
@@ -212,11 +247,67 @@ async function onRefresh() {
}
}
/** One line of the order as the expert edits it: the raw weight is the only input. */
interface Draft {
id: string
title: string
qty: number
/** The percent the order was placed at (charge − promotion), kept as it is. */
netPercent: number
weight: number | undefined
}
const drafts = ref<Draft[]>([])
const saving = ref(false)
const saveError = ref('')
const confirmFinal = ref(false)
function openDetail(item: ExpertChildOrder) {
active.value = item
drafts.value = (item.items ?? []).map((i) => ({
id: i.id,
title: [i.productVariant?.product?.title, i.productVariant?.title].filter(Boolean).join(' — ') || 'محصول',
qty: i.quantity,
netPercent: Number(i.wagePercentSnapshot) - Number(i.discountPercentSnapshot),
weight: Number(i.rashWeightGramsSnapshot),
}))
saveError.value = ''
confirmFinal.value = false
detailOpen.value = true
}
/** rash × (1 + net/100) — the server's own formula, shown so the effect is visible before saving. */
const unitPreview = (d: Draft) => (d.weight ?? 0) * (1 + d.netPercent / 100)
const preview = (d: Draft) => unitPreview(d) * d.qty
const previewTotal = computed(() => drafts.value.reduce((s, d) => s + preview(d), 0))
async function save(finalSubmit: boolean) {
if (!active.value || saving.value) return
if (drafts.value.some((d) => !d.weight || d.weight <= 0)) {
saveError.value = 'وزن هر عدد باید بیشتر از صفر باشد.'
return
}
saving.value = true
saveError.value = ''
try {
await api.adjustExpertChildOrder(
active.value.id,
drafts.value.map((d) => ({ id: d.id, rash_weight: d.weight as number })),
finalSubmit,
)
detailOpen.value = false
await reload()
} catch (e) {
// The server's Persian reason (not found, wrong status, accounting busy, …) is in the body.
const text = e instanceof Error ? e.message : ''
const m = text.match(/"message":"([^"]+)"/)
saveError.value = m ? m[1] : 'ثبت تغییرات انجام نشد؛ دوباره تلاش کنید.'
} finally {
saving.value = false
confirmFinal.value = false
}
}
// Infinite scroll, the reference's behaviour (useInfiniteScroll at :216/:330).
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
@@ -415,6 +506,109 @@ onBeforeUnmount(() => observer?.disconnect())
font-weight: 600;
}
.cho__editTitle {
margin: 18px 0 6px;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.cho__line {
padding: 12px 0;
border-bottom: 1px solid @hairline;
}
.cho__lineHead {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
font-size: 12.5px;
}
.cho__lineName {
font-weight: 700;
color: @ink;
}
.cho__lineQty {
color: @inkFaint;
white-space: nowrap;
}
.cho__input {
width: 100%;
}
.cho__lineSum {
margin-top: 6px;
font-size: 12px;
font-weight: 700;
color: @inkSoft;
}
.cho__previewTotal {
display: flex;
justify-content: space-between;
padding: 14px 0 4px;
font-size: 13px;
}
.cho__error {
margin: 10px 0 0;
font-size: 12.5px;
color: #c0392b;
}
.cho__actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
.cho__save,
.cho__final {
flex: 1;
padding: 12px;
border: 1px solid @wine;
border-radius: 14px;
background: transparent;
color: @wine;
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
}
.cho__final {
background: @wine;
color: #fff;
}
.cho__save:disabled,
.cho__final:disabled {
opacity: 0.5;
cursor: default;
}
.cho__confirm {
display: flex;
flex-direction: column;
gap: 8px;
margin: 12px 0 0;
font-size: 12.5px;
color: @inkSoft;
}
.cho__link {
border: none;
background: transparent;
color: @inkFaint;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.cho__close {
margin-top: 18px;
width: 100%;