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:
co-authored by
Claude Sonnet 5.5
parent
959a272227
commit
6514b05d18
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user