feat(treasury): number documents and manage categories on their tab
Save a system tracking number on each income and expense, reuse the full contact dialogs for a new party, and create, rename, or delete categories only from the categories tab. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -43,6 +43,20 @@ export class TreasuryController {
|
||||
return this.treasury.createCategory(user, body);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
updateCategory(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; isActive?: boolean },
|
||||
) {
|
||||
return this.treasury.updateCategory(user, id, body);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.treasury.deleteCategory(user, id);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
accounts(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { CompaniesService } from '../contacts/companies.service';
|
||||
import { PeopleService } from '../contacts/people.service';
|
||||
import { dateToJalali } from '../common/jalali';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
@@ -119,19 +120,68 @@ export class TreasuryService {
|
||||
): Promise<FinancialCategoryDto> {
|
||||
const name = input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام دستهبندی کوتاه است.');
|
||||
const row = await this.prisma.financialCategory.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
kind: input.kind,
|
||||
},
|
||||
try {
|
||||
const row = await this.prisma.financialCategory.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
kind: input.kind,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCategory(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { name?: string; isActive?: boolean },
|
||||
): Promise<FinancialCategoryDto> {
|
||||
const current = await this.prisma.financialCategory.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
if (!current) throw new NotFoundException('دستهبندی پیدا نشد.');
|
||||
const name = input.name == null ? current.name : input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام دستهبندی کوتاه است.');
|
||||
try {
|
||||
const row = await this.prisma.financialCategory.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
name,
|
||||
...(input.isActive == null ? {} : { isActive: input.isActive }),
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCategory(actor: Actor, id: string): Promise<{ ok: true }> {
|
||||
const current = await this.prisma.financialCategory.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!current) throw new NotFoundException('دستهبندی پیدا نشد.');
|
||||
await this.prisma.financialCategory.delete({ where: { id: current.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listAccounts(
|
||||
@@ -511,6 +561,13 @@ export class TreasuryService {
|
||||
}
|
||||
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
|
||||
const doc = await this.prisma.$transaction(async (tx) => {
|
||||
const reference = await this.nextDocumentReference(
|
||||
tx,
|
||||
actor.organizationId,
|
||||
input.kind,
|
||||
input.bookedAt,
|
||||
bookedAt,
|
||||
);
|
||||
const created = await tx.financialDocument.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
@@ -528,7 +585,7 @@ export class TreasuryService {
|
||||
accountId: input.accountId || null,
|
||||
method: input.method || null,
|
||||
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
|
||||
reference: input.reference?.trim() || null,
|
||||
reference,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
@@ -544,7 +601,7 @@ export class TreasuryService {
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
reference: input.reference?.trim() || null,
|
||||
reference,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
@@ -557,6 +614,32 @@ export class TreasuryService {
|
||||
return this.toDocumentDto(doc);
|
||||
}
|
||||
|
||||
/** شماره پیگیری روزانه: DR-14050813-0001 برای درآمد و PD-… برای هزینه. */
|
||||
private async nextDocumentReference(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
kind: 'income' | 'expense',
|
||||
bookedAtInput: string | undefined,
|
||||
bookedAt: Date,
|
||||
) {
|
||||
const match = bookedAtInput?.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
const when = match
|
||||
? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||||
: bookedAt;
|
||||
const { jy, jm, jd } = dateToJalali(when);
|
||||
const ymd = `${jy}${String(jm).padStart(2, '0')}${String(jd).padStart(2, '0')}`;
|
||||
const prefix = `${kind === 'income' ? 'DR' : 'PD'}-${ymd}-`;
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`finref:${organizationId}:${prefix}`})::bigint)`;
|
||||
const latest = await tx.financialDocument.findFirst({
|
||||
where: { organizationId, reference: { startsWith: prefix } },
|
||||
orderBy: { reference: 'desc' },
|
||||
select: { reference: true },
|
||||
});
|
||||
const current = Number(latest?.reference?.slice(prefix.length));
|
||||
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
|
||||
return `${prefix}${String(next).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async searchParties(actor: Actor, q?: string): Promise<TreasuryPartySearchDto> {
|
||||
const term = q?.trim() ?? '';
|
||||
const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined;
|
||||
|
||||
Reference in New Issue
Block a user