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
@@ -195,6 +195,8 @@ async function editW() {
color: tempRow.value?.color,
color_mina: tempRow.value?.color_mina,
color_stone: tempRow.value?.color_stone,
// The quantity field sits in the same edit mode; without it the edit was shown but never kept.
sum: tempRow.value?.sum,
});
editMode.value = false;
message.info('ویرایش موفق آمیز');
+91 -3
View File
@@ -109,21 +109,50 @@
</a-descriptions>
<a-card title="آیتم های سفارش">
<div v-for="(item, index) in tempRow.items" :key="index">
<template v-if="hasPermission('order.omde.view.edit')" #extra>
<a-button size="small" :type="editMode ? 'primary' : 'default'" @click="editMode = !editMode">
{{ editMode ? 'پایان ویرایش' : 'ویرایش ردیف‌ها' }}
</a-button>
</template>
<div v-for="(item, index) in tempRow.items" :key="item.id ?? index">
<!-- Correcting a line (reference order-item/edit): the raw weight, the
quantity and the percent actually charged. The order's totals and
the customer's debt are recomputed by the server on save. -->
<div v-if="editMode && hasPermission('order.omde.view.edit')" class="line-edit">
<a-tag color="blue">{{ item.title }} — {{ item.variant_title }}</a-tag>
<label>وزن خام (گرم)<a-input-number v-model:value="item.rash" :min="0.001" :step="0.01" /></label>
<label>تعداد<a-input-number v-model:value="item.qty" :min="1" :precision="0" /></label>
<label>درصد اجرت<a-input-number v-model:value="item.percent" :step="0.1" /></label>
<a-button type="primary" size="small" :loading="savingLine === item.id" @click="saveLine(item)">ذخیره</a-button>
<a-popconfirm
title="این ردیف از سفارش حذف شود؟"
ok-text="بله"
cancel-text="بی خیال"
@confirm="removeLine(item)"
>
<a-button danger size="small">حذف ردیف</a-button>
</a-popconfirm>
</div>
<a-descriptions>
<a-descriptions-item label="نام محصول">
<a-tag color="blue">{{ item.title }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="عنوان تنوع">{{ item.variant_title }}</a-descriptions-item>
<a-descriptions-item label="وزن نهایی">
<a-descriptions-item label="وزن نهایی (هر عدد)">
<a-tag color="red">{{ item.grams }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="تعداد">
<a-tag color="orange">{{ item.qty }}</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="Number(item.flatRialCharge) > 0" label="اجرت ریالی">
<a-descriptions-item label="جمع وزن ردیف">
<a-tag color="red">{{ formatGrams(item.total_grams) }} گرم</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="Number(item.flatRialCharge) > 0" label="اجرت ریالی (هر عدد)">
<a-tag>{{ Number(item.flatRialCharge).toLocaleString('fa-IR') }} ریال</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="Number(item.total_rial) > 0" label="جمع اجرت ریالی ردیف">
<a-tag>{{ Number(item.total_rial).toLocaleString('fa-IR') }} ریال</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-divider v-if="index < tempRow.items.length - 1" />
</div>
@@ -161,6 +190,9 @@ const visibleSts = ref(false);
const formatDate = (v: string | null) => (v ? jdate(v) : '');
/** Grams to four places with trailing zeros dropped (the column's own precision). */
const formatGrams = (v: unknown) => (v === null || v === undefined ? '-' : String(Number(Number(v).toFixed(4))));
/** legacy getCity(): resolve a city id through the users store lookup */
function getCity(id: string | number) {
return users.cityFilters[id] ?? '-';
@@ -177,9 +209,49 @@ async function loadDetail(id: string) {
async function viewOrder(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
editMode.value = false;
visibleList.value = true;
}
// ── correcting a line of the order (reference order-item/edit, order_item delete)
const editMode = ref(false);
const savingLine = ref<string | null>(null);
function errorText(e: any, fallback: string) {
return e?.response?.data?.message ?? fallback;
}
async function saveLine(item: Record<string, any>) {
try {
savingLine.value = item.id;
const { data } = await api.post('admin/orders/item/edit', {
id: item.id,
rash_weight: item.rash,
qty: item.qty,
percent: item.percent,
});
tempRow.value = data;
message.info('ردیف سفارش ویرایش شد؛ جمع و بدهی مشتری به‌روز شد');
tbl.value?.fetch();
} catch (e) {
message.error(errorText(e, 'خطا در ویرایش ردیف'));
if (tempRow.value) tempRow.value = await loadDetail(tempRow.value.id);
} finally {
savingLine.value = null;
}
}
async function removeLine(item: Record<string, any>) {
try {
const { data } = await api.delete(`admin/orders/item/${item.id}`);
tempRow.value = data;
message.info('ردیف از سفارش حذف شد؛ جمع و بدهی مشتری به‌روز شد');
tbl.value?.fetch();
} catch (e) {
message.error(errorText(e, 'خطا در حذف ردیف'));
}
}
async function showSts(row: Record<string, any>) {
tempRow.value = await loadDetail(row.id);
visibleSts.value = true;
@@ -237,4 +309,20 @@ function deleteOrder(record: Record<string, any>) {
.mr-3 {
margin-left: 0.75rem;
}
.line-edit {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding: 10px 12px;
margin-bottom: 8px;
border: 1px dashed var(--ant-color-border, #d9d9d9);
border-radius: 8px;
}
.line-edit label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
</style>
@@ -22,7 +22,9 @@ export function buildOmdeListModel(): XTbl {
new Xtc('customer.mobile', 'موبایل').filterText().hidden().noSort(),
);
tbl.add(new Xtc('input', 'جمع کل سفارش').filterKey('totalGoldWeightGrams').filterText());
tbl.add(new Xtc('discount', 'جمع کل تخفیف (درصد)').filterText());
tbl.add(new Xtc('wage', 'اجرت (گرم)').filterKey('wageWeightGrams').filterText().noSort());
tbl.add(new Xtc('rial_input', 'اجرت ریالی (ریال)').filterKey('flatRialCharge').filterText());
tbl.add(new Xtc('discount', 'جمع کل تخفیف (درصد)').noSort());
// legacy kept the .filterSelect(cityFilters) call commented out — preserved.
tbl.add(new Xtc('customer.city_id', 'شهر').hidden().renderSlot('city'));
tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort());
+11 -2
View File
@@ -102,15 +102,21 @@
<a-tag color="blue">{{ item.title }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="عنوان تنوع">{{ item.variant_title }}</a-descriptions-item>
<a-descriptions-item label="وزن نهایی">
<a-descriptions-item label="وزن نهایی (هر عدد)">
<a-tag color="red">{{ item.grams }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="تعداد">
<a-tag color="orange">{{ item.qty }}</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="Number(item.flatRialCharge) > 0" label="اجرت ریالی">
<a-descriptions-item label="جمع وزن ردیف">
<a-tag color="red">{{ formatGrams(item.total_grams) }} گرم</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="Number(item.flatRialCharge) > 0" label="اجرت ریالی (هر عدد)">
<a-tag>{{ Number(item.flatRialCharge).toLocaleString('fa-IR') }} ریال</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="Number(item.total_rial) > 0" label="جمع اجرت ریالی ردیف">
<a-tag>{{ Number(item.total_rial).toLocaleString('fa-IR') }} ریال</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-divider v-if="index < tempRow.items.length - 1" />
</div>
@@ -148,6 +154,9 @@ const visibleSts = ref(false);
const formatDate = (v: string | null) => (v ? jdate(v) : '');
/** Grams to four places with trailing zeros dropped (the column's own precision). */
const formatGrams = (v: unknown) => (v === null || v === undefined ? '-' : String(Number(Number(v).toFixed(4))));
function getColorSts(row: { proceed?: boolean }) {
return row.proceed ? 'green' : 'gray';
}
@@ -13,7 +13,8 @@ export function buildTaminListModel(): XTbl {
tbl.add(new Xtc('customer.name', 'مشتری').filterKey('customer.fullName').filterText());
tbl.add(new Xtc('customer.mobile', 'موبایل').filterText().hidden().noSort());
tbl.add(new Xtc('input', 'ورودی').filterKey('totalGoldWeightGrams').filterText());
tbl.add(new Xtc('wage', 'اجرت').filterKey('wageWeightGrams').filterText().noSort());
tbl.add(new Xtc('wage', 'اجرت (گرم)').filterKey('wageWeightGrams').filterText().noSort());
tbl.add(new Xtc('rial_input', 'اجرت ریالی (ریال)').filterKey('flatRialCharge').filterText());
tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort());
tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort());
+11 -11
View File
@@ -204,6 +204,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -382,7 +383,7 @@ async function editGram() {
}
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
return optionText(option)
.toLowerCase()
.includes(input.toLowerCase());
}
@@ -399,16 +400,15 @@ async function submitOrder() {
if (!validate()) return;
try {
loading.value = true;
const order_items = items.value.map((x) => {
const v = getVariant(x);
return {
vid: x.vid,
qty: x.qty,
percent: v.percentage_pay,
promotion_id: x.promotion_id,
rial_input: lineRial(x),
};
});
// No `percent`: the server prices each line at what the app would charge
// this customer (their percent + the variant's − the largest promotion),
// so the same order costs the same from here as from the app.
const order_items = items.value.map((x) => ({
vid: x.vid,
qty: x.qty,
promotion_id: x.promotion_id,
rial_input: lineRial(x),
}));
// Legacy also sent browser-computed total_price / total_weight /
// gold_price_original. The server recomputes those from the variant