fix(treasury): show a party's net balance as debtor or creditor

Income and expense forms now use the same receivable-minus-payable figure beside the party.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-10 11:09:51 +03:30
co-authored by Cursor
parent ba7f942059
commit 49459707cb
3 changed files with 47 additions and 23 deletions
@@ -30,7 +30,7 @@ import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format';
import { partyNetPosition, toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography;
@@ -41,6 +41,7 @@ const ASelectOption = ASelect.Option;
const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const receivableBalances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
@@ -94,7 +95,7 @@ function docLine(item: FinancialDocumentDto) {
}
async function load() {
const [docs, cats, acc, bals] = await Promise.all([
const [docs, cats, acc, bals, receivables] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
@@ -112,9 +113,14 @@ async function load() {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'income' } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]);
rows.value = docs.data;
balances.value = bals.data;
receivableBalances.value = receivables.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
}
@@ -205,12 +211,14 @@ const partyRules = [
},
];
const partyRemainingLabel = computed(() => {
const kind = form.value.partyKind;
const partyPosition = computed(() => {
const partyKind = form.value.partyKind;
const id = form.value.partyId;
if (!kind || kind === 'other' || !id) return '';
const row = balances.value.find((item) => item.key === `${kind}:${id}`);
return `مانده ${toman(row?.remaining || 0)}`;
if (!partyKind || partyKind === 'other' || !id) return null;
const key = `${partyKind}:${id}`;
const receivable = Number(receivableBalances.value.find((item) => item.key === key)?.remaining || 0);
const payable = Number(balances.value.find((item) => item.key === key)?.remaining || 0);
return partyNetPosition(receivable, payable);
});
async function voidDoc(id: string) {
@@ -247,11 +255,11 @@ onMounted(() => {
<a-col :xs="24" :md="12">
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyRemainingLabel ? 16 : 24">
<a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-col>
<a-col v-if="partyRemainingLabel" :span="8">
<a-typography-text type="secondary">{{ partyRemainingLabel }}</a-typography-text>
<a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
</a-col>
</a-row>
</a-form-item>
@@ -50,7 +50,7 @@ import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format';
import { partyNetPosition, toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography;
@@ -64,6 +64,7 @@ const isMobile = computed(() => !screens.value.md);
const kind = 'income' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const payableBalances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null);
@@ -132,12 +133,14 @@ const partyRules = [
},
];
const partyRemainingLabel = computed(() => {
const kind = form.value.partyKind;
const partyPosition = computed(() => {
const partyKind = form.value.partyKind;
const id = form.value.partyId;
if (!kind || kind === 'other' || !id) return '';
const row = balances.value.find((item) => item.key === `${kind}:${id}`);
return `مانده ${toman(row?.remaining || 0)}`;
if (!partyKind || partyKind === 'other' || !id) return null;
const key = `${partyKind}:${id}`;
const receivable = Number(balances.value.find((item) => item.key === key)?.remaining || 0);
const payable = Number(payableBalances.value.find((item) => item.key === key)?.remaining || 0);
return partyNetPosition(receivable, payable);
});
const remainingAmount = computed(() => {
@@ -168,7 +171,7 @@ function docLine(item: FinancialDocumentDto) {
}
async function load() {
const [docs, cats, acc, dash, bals] = await Promise.all([
const [docs, cats, acc, dash, bals, payables] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
@@ -187,9 +190,14 @@ async function load() {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'expense' } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]);
rows.value = docs.data;
balances.value = bals.data;
payableBalances.value = payables.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data;
@@ -394,11 +402,11 @@ onMounted(() => {
<div v-show="formTab === 'main'">
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyRemainingLabel ? 16 : 24">
<a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-col>
<a-col v-if="partyRemainingLabel" :span="8">
<a-typography-text type="secondary">{{ partyRemainingLabel }}</a-typography-text>
<a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
</a-col>
</a-row>
</a-form-item>
@@ -641,11 +649,11 @@ onMounted(() => {
<a-col :xs="24" :md="12">
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyRemainingLabel ? 16 : 24">
<a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-col>
<a-col v-if="partyRemainingLabel" :span="8">
<a-typography-text type="secondary">{{ partyRemainingLabel }}</a-typography-text>
<a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
</a-col>
</a-row>
</a-form-item>
@@ -3,3 +3,11 @@ export function toman(value: string | number | null | undefined) {
if (!Number.isFinite(n)) return '۰';
return `${new Intl.NumberFormat('fa-IR').format(Math.round(n))} تومان`;
}
/** ماندهٔ دریافتنی منهای ماندهٔ پرداختنی: بد قرمز، بس سبز، صفر خاکستری. */
export function partyNetPosition(receivable: number, payable: number) {
const net = receivable - payable;
if (net > 0) return { text: `بد ${toman(net)}`, type: 'danger' as const };
if (net < 0) return { text: `بس ${toman(-net)}`, type: 'success' as const };
return { text: `مانده ${toman(0)}`, type: 'secondary' as const };
}