diff --git a/apps/api/package.json b/apps/api/package.json index 094c9a9..6ef704b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,6 +20,7 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.12", + "@nestjs/schedule": "^6.0.1", "@prisma/client": "^6.5.0", "bcryptjs": "^3.0.2", "class-transformer": "^0.5.1", diff --git a/apps/api/prisma/migrations/20260911080000_standalone_cheques/migration.sql b/apps/api/prisma/migrations/20260911080000_standalone_cheques/migration.sql new file mode 100644 index 0000000..09d688e --- /dev/null +++ b/apps/api/prisma/migrations/20260911080000_standalone_cheques/migration.sql @@ -0,0 +1,31 @@ +-- AlterTable +ALTER TABLE "Cheque" ALTER COLUMN "paymentId" DROP NOT NULL; +ALTER TABLE "Cheque" ALTER COLUMN "invoiceId" DROP NOT NULL; +ALTER TABLE "Cheque" ADD COLUMN "amount" DECIMAL(18,2); +ALTER TABLE "Cheque" ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'IRR'; +ALTER TABLE "Cheque" ADD COLUMN "personId" TEXT; +ALTER TABLE "Cheque" ADD COLUMN "ownerId" TEXT; + +UPDATE "Cheque" AS c +SET + "amount" = p."amount", + "currency" = p."currency" +FROM "Payment" AS p +WHERE c."paymentId" = p."id"; + +UPDATE "Cheque" AS c +SET + "personId" = i."personId", + "ownerId" = i."ownerId" +FROM "Invoice" AS i +WHERE c."invoiceId" = i."id"; + +UPDATE "Cheque" SET "amount" = 0 WHERE "amount" IS NULL; + +ALTER TABLE "Cheque" ALTER COLUMN "amount" SET NOT NULL; + +CREATE INDEX "Cheque_personId_idx" ON "Cheque"("personId"); +CREATE INDEX "Cheque_ownerId_idx" ON "Cheque"("ownerId"); + +ALTER TABLE "Cheque" ADD CONSTRAINT "Cheque_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Cheque" ADD CONSTRAINT "Cheque_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260911120000_cheque_direction_reminder/migration.sql b/apps/api/prisma/migrations/20260911120000_cheque_direction_reminder/migration.sql new file mode 100644 index 0000000..9a2841f --- /dev/null +++ b/apps/api/prisma/migrations/20260911120000_cheque_direction_reminder/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cheque" ADD COLUMN "direction" TEXT NOT NULL DEFAULT 'in'; +ALTER TABLE "Cheque" ADD COLUMN "reminderNotifiedAt" TIMESTAMP(3); + +CREATE INDEX "Cheque_organizationId_direction_dueDate_idx" ON "Cheque"("organizationId", "direction", "dueDate"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f69c2de..574c274 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -213,6 +213,7 @@ model User { salesGoals SalesGoal[] @relation("SalesGoalUser") commissionPercent Decimal @default(0) @db.Decimal(8, 2) commissionEntries CommissionEntry[] + receivedCheques Cheque[] @relation("ChequeOwner") assignedTickets Ticket[] @relation("TicketAssignee") reportedTickets Ticket[] @relation("TicketReporter") ticketMessages TicketMessage[] @relation("TicketMessageAuthor") @@ -291,6 +292,7 @@ model Person { accountingLinks PersonAccountingLink[] accountingDocuments AccountingDocumentLog[] openingBalances PartyOpeningBalance[] + cheques Cheque[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1284,26 +1286,38 @@ model CommissionEntry { @@index([status]) } -/// چک دریافتی وصل به پرداخت فاکتور +/// چک دریافتی؛ مستقل یا وصل به پرداخت فاکتور model Cheque { id String @id @default(cuid()) organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - paymentId String @unique - payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade) - invoiceId String - invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + paymentId String? @unique + payment Payment? @relation(fields: [paymentId], references: [id], onDelete: Cascade) + invoiceId String? + invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + ownerId String? + owner User? @relation("ChequeOwner", fields: [ownerId], references: [id], onDelete: SetNull) + amount Decimal @db.Decimal(18, 2) + currency String @default("IRR") number String bank String dueDate DateTime /// pending | cleared | bounced status String @default("pending") + /// in = دریافتی، out = پرداختی + direction String @default("in") + reminderNotifiedAt DateTime? notes String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([organizationId, status, dueDate]) + @@index([organizationId, direction, dueDate]) @@index([invoiceId]) + @@index([personId]) + @@index([ownerId]) } /// مانده اول دوره طرف‌حساب (مثبت = بدهکار ما) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index fee1483..beb65f2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from './prisma/prisma.module'; import { HealthModule } from './health/health.module'; import { AuthModule } from './auth/auth.module'; @@ -36,6 +37,7 @@ import { FinanceModule } from './finance/finance.module'; isGlobal: true, envFilePath: ['.env', '../../.env'], }), + ScheduleModule.forRoot(), AiModule, PrismaModule, StorageModule, diff --git a/apps/api/src/finance/__tests__/cheque-reminder.check.ts b/apps/api/src/finance/__tests__/cheque-reminder.check.ts new file mode 100644 index 0000000..5351830 --- /dev/null +++ b/apps/api/src/finance/__tests__/cheque-reminder.check.ts @@ -0,0 +1,64 @@ +import { + addIsoDays, + clipSms, + isDueInDays, + personIncomingSms, + staffIncomingSms, + staffOutgoingSms, + tehranDayKey, +} from '../cheque-reminder.util'; + +function assert(cond: unknown, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} + +function testDayMath() { + assert(addIsoDays('2026-09-11', 3) === '2026-09-14', 'add 3 days'); + const now = new Date('2026-09-11T04:30:00.000Z'); // 08:00 Tehran + const due = new Date('2026-09-14T00:00:00.000Z'); + assert(isDueInDays(due, 3, now), 'due in exactly 3 Tehran days'); + assert(!isDueInDays(due, 2, now), 'not 2 days'); + assert(tehranDayKey(now) === '2026-09-11', 'tehran day key'); +} + +function testSms() { + const due = new Date('2026-09-14T00:00:00.000Z'); + const person = personIncomingSms({ + number: '۱۲۳۴', + amount: 1_000_000, + currency: 'IRR', + dueDate: due, + }); + assert(person.startsWith('یادآوری منتکت:'), 'person prefix'); + assert(person.includes('۱۲۳۴'), 'person number'); + assert(person.length <= 160, 'person length'); + + const staffIn = staffIncomingSms({ + number: '۱۲۳۴', + personName: 'علی رضایی', + amount: 1_000_000, + currency: 'IRR', + dueDate: due, + }); + assert(staffIn.includes('دریافتی'), 'staff in label'); + assert(staffIn.includes('علی رضایی'), 'staff in name'); + + const staffOut = staffOutgoingSms({ + number: '۱۲۳۴', + personName: 'علی رضایی', + amount: 1_000_000, + currency: 'IRR', + dueDate: due, + }); + assert(staffOut.includes('پرداختی'), 'staff out label'); + assert(!staffOut.includes('دریافتی'), 'staff out not incoming'); + assert(clipSms('x'.repeat(200)).length === 160, 'clip'); +} + +function main() { + testDayMath(); + testSms(); + console.log('cheque reminder checks: OK'); +} + +main(); diff --git a/apps/api/src/finance/cheque-reminder.util.ts b/apps/api/src/finance/cheque-reminder.util.ts new file mode 100644 index 0000000..ec74742 --- /dev/null +++ b/apps/api/src/finance/cheque-reminder.util.ts @@ -0,0 +1,87 @@ +import { brand, formatMoneyAmount } from '@mento/shared'; +import { toJalali } from '../common/jalali'; + +const TEHRAN_TZ = 'Asia/Tehran'; +const SMS_MAX = 160; +const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹'; + +export const CHEQUE_REMINDER_DAYS = 3; + +export function toFaDigits(value: string | number): string { + return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d); +} + +export function tehranDayKey(d: Date): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: TEHRAN_TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(d); +} + +export function addIsoDays(dayKey: string, days: number): string { + const [y, m, d] = dayKey.split('-').map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + dt.setUTCDate(dt.getUTCDate() + days); + return dt.toISOString().slice(0, 10); +} + +export function isDueInDays( + dueDate: Date, + days: number, + now: Date = new Date(), +): boolean { + return tehranDayKey(dueDate) === addIsoDays(tehranDayKey(now), days); +} + +export function formatTehranJalaliNumeric(d: Date): string { + const [gy, gm, gd] = tehranDayKey(d).split('-').map(Number); + const { jy, jm, jd } = toJalali(gy, gm, gd); + return `${toFaDigits(jy)}/${toFaDigits(String(jm).padStart(2, '0'))}/${toFaDigits(String(jd).padStart(2, '0'))}`; +} + +export function clipSms(text: string): string { + return text.slice(0, SMS_MAX); +} + +function money(amount: number, currency: string): string { + return formatMoneyAmount(amount, currency); +} + +export function personIncomingSms(input: { + number: string; + amount: number; + currency: string; + dueDate: Date; +}): string { + return clipSms( + `${brand.reminderPrefixFa} چک شماره ${input.number} به مبلغ ${money(input.amount, input.currency)} سه روز دیگر (${formatTehranJalaliNumeric(input.dueDate)}) سررسید می‌شود.`, + ); +} + +export function staffIncomingSms(input: { + number: string; + personName: string; + amount: number; + currency: string; + dueDate: Date; +}): string { + const who = input.personName.trim() || 'طرف‌حساب'; + return clipSms( + `${brand.reminderPrefixFa} چک دریافتی ${input.number} از ${who}، مبلغ ${money(input.amount, input.currency)}، سررسید ${formatTehranJalaliNumeric(input.dueDate)} (۳ روز دیگر).`, + ); +} + +export function staffOutgoingSms(input: { + number: string; + personName: string; + amount: number; + currency: string; + dueDate: Date; +}): string { + const who = input.personName.trim() || 'طرف‌حساب'; + return clipSms( + `${brand.reminderPrefixFa} چک پرداختی ${input.number} به ${who}، مبلغ ${money(input.amount, input.currency)}، سررسید ${formatTehranJalaliNumeric(input.dueDate)} (۳ روز دیگر).`, + ); +} diff --git a/apps/api/src/finance/cheque-reminders.service.ts b/apps/api/src/finance/cheque-reminders.service.ts new file mode 100644 index 0000000..1b1ea5c --- /dev/null +++ b/apps/api/src/finance/cheque-reminders.service.ts @@ -0,0 +1,139 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { NotificationsService } from '../notifications/notifications.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SmsService } from '../common/sms.service'; +import { + CHEQUE_REMINDER_DAYS, + isDueInDays, + personIncomingSms, + staffIncomingSms, + staffOutgoingSms, +} from './cheque-reminder.util'; + +@Injectable() +export class ChequeRemindersService { + private readonly logger = new Logger(ChequeRemindersService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + private readonly sms: SmsService, + ) {} + + @Cron('0 8 * * *', { timeZone: 'Asia/Tehran' }) + async handleDaily(): Promise { + await this.notifyDueSoon(); + } + + async notifyDueSoon(now: Date = new Date()): Promise { + const from = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const to = new Date(now.getTime() + 6 * 24 * 60 * 60 * 1000); + const rows = await this.prisma.cheque.findMany({ + where: { + status: 'pending', + reminderNotifiedAt: null, + dueDate: { gte: from, lte: to }, + }, + include: { + person: { select: { firstName: true, lastName: true, mobile: true } }, + }, + take: 500, + }); + + let sent = 0; + for (const row of rows) { + if (!isDueInDays(row.dueDate, CHEQUE_REMINDER_DAYS, now)) continue; + try { + await this.notifyOne(row); + sent += 1; + } catch (err) { + this.logger.warn( + `cheque reminder failed ${row.id}: ${err instanceof Error ? err.message : 'unknown'}`, + ); + } finally { + await this.prisma.cheque.update({ + where: { id: row.id }, + data: { reminderNotifiedAt: new Date() }, + }); + } + } + if (sent) this.logger.log(`cheque reminders sent: ${sent}`); + return sent; + } + + private async notifyOne(row: { + id: string; + organizationId: string; + ownerId: string | null; + direction: string; + number: string; + amount: { toString(): string } | number; + currency: string; + dueDate: Date; + person: { firstName: string; lastName: string; mobile: string | null } | null; + }) { + const personName = row.person + ? `${row.person.firstName} ${row.person.lastName}`.trim() + : ''; + const amount = Number(row.amount); + const incoming = row.direction !== 'out'; + const staffText = incoming + ? staffIncomingSms({ + number: row.number, + personName, + amount, + currency: row.currency, + dueDate: row.dueDate, + }) + : staffOutgoingSms({ + number: row.number, + personName, + amount, + currency: row.currency, + dueDate: row.dueDate, + }); + + const staff = await this.prisma.user.findMany({ + where: { + organizationId: row.organizationId, + OR: [ + { role: { in: ['owner', 'admin'] } }, + ...(row.ownerId ? [{ id: row.ownerId }] : []), + ], + }, + select: { id: true, mobile: true }, + }); + const seen = new Set(); + for (const user of staff) { + if (seen.has(user.id)) continue; + seen.add(user.id); + await this.notifications.create({ + organizationId: row.organizationId, + userId: user.id, + type: 'cheque_due', + title: 'سررسید چک', + body: staffText.replace(/^یادآوری منتکت:\s*/, ''), + link: '/cheques', + entityType: 'cheque', + entityId: row.id, + dedupe: true, + }); + if (user.mobile) { + await this.sms.sendSmsDetailed(user.mobile, staffText); + } + } + + if (incoming && row.person?.mobile) { + await this.sms.sendSmsDetailed( + row.person.mobile, + personIncomingSms({ + number: row.number, + amount, + currency: row.currency, + dueDate: row.dueDate, + }), + ); + } + } +} diff --git a/apps/api/src/finance/cheques.service.ts b/apps/api/src/finance/cheques.service.ts index cad6d4a..700d946 100644 --- a/apps/api/src/finance/cheques.service.ts +++ b/apps/api/src/finance/cheques.service.ts @@ -3,10 +3,14 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import type { ChequeDto, UpdateChequeRequest } from '@mento/shared'; +import type { + ChequeDto, + CreateChequeRequest, + UpdateChequeRequest, +} from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; -import { type Actor, ownerWhere } from '../common/record-scope'; +import { type Actor, elevated, ownerWhere } from '../common/record-scope'; const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const; @@ -17,13 +21,16 @@ export class ChequesService { async list( organizationId: string, actor: Pick, - opts?: { status?: string; q?: string }, + opts?: { status?: string; q?: string; direction?: string }, ): Promise { const rows = await this.prisma.cheque.findMany({ where: { organizationId, - invoice: ownerWhere(actor), + ...this.accessWhere(actor), ...(opts?.status ? { status: opts.status } : {}), + ...(opts?.direction === 'in' || opts?.direction === 'out' + ? { direction: opts.direction } + : {}), ...(opts?.q?.trim() ? { OR: [ @@ -34,6 +41,24 @@ export class ChequesService { number: { contains: opts.q.trim(), mode: 'insensitive' }, }, }, + { + person: { + OR: [ + { + firstName: { + contains: opts.q.trim(), + mode: 'insensitive', + }, + }, + { + lastName: { + contains: opts.q.trim(), + mode: 'insensitive', + }, + }, + ], + }, + }, ], } : {}), @@ -45,6 +70,45 @@ export class ChequesService { return rows.map((row) => this.toDto(row)); } + async create( + organizationId: string, + actor: Pick, + dto: CreateChequeRequest, + ): Promise { + const person = await this.prisma.person.findFirst({ + where: { id: dto.personId, organizationId, ...ownerWhere(actor) }, + select: { id: true }, + }); + if (!person) throw new BadRequestException('شخص معتبر نیست'); + if (!(dto.amount > 0)) { + throw new BadRequestException('مبلغ چک لازم است'); + } + if (!dto.number?.trim() || !dto.bank?.trim() || !dto.dueDate) { + throw new BadRequestException('شماره، بانک و سررسید چک لازم است'); + } + + const row = await this.prisma.cheque.create({ + data: { + organizationId, + personId: person.id, + ownerId: actor.userId, + amount: new Prisma.Decimal(dto.amount), + currency: (dto.currency ?? 'IRR').trim() || 'IRR', + number: dto.number.trim(), + bank: dto.bank.trim(), + dueDate: new Date(dto.dueDate), + direction: dto.direction === 'out' ? 'out' : 'in', + status: + dto.status === 'cleared' || dto.status === 'bounced' + ? dto.status + : 'pending', + notes: dto.notes?.trim() ? dto.notes.trim() : null, + }, + include: this.include, + }); + return this.toDto(row); + } + async update( organizationId: string, id: string, @@ -52,7 +116,7 @@ export class ChequesService { dto: UpdateChequeRequest, ): Promise { const existing = await this.prisma.cheque.findFirst({ - where: { id, organizationId, invoice: ownerWhere(actor) }, + where: { id, organizationId, ...this.accessWhere(actor) }, select: { id: true }, }); if (!existing) throw new NotFoundException('چک پیدا نشد'); @@ -79,8 +143,18 @@ export class ChequesService { return this.toDto(row); } + private accessWhere( + actor: Pick, + ): Prisma.ChequeWhereInput { + if (elevated(actor.role)) return {}; + return { + OR: [{ ownerId: actor.userId }, { invoice: { ownerId: actor.userId } }], + }; + } + private include = { payment: { select: { id: true, amount: true, currency: true, paidAt: true } }, + person: { select: { firstName: true, lastName: true } }, invoice: { select: { id: true, @@ -95,24 +169,28 @@ export class ChequesService { private toDto( row: Prisma.ChequeGetPayload<{ include: ChequesService['include'] }>, ): ChequeDto { - const personName = row.invoice.person - ? `${row.invoice.person.firstName} ${row.invoice.person.lastName}`.trim() - : row.invoice.company?.name ?? null; + const personName = row.person + ? `${row.person.firstName} ${row.person.lastName}`.trim() + : row.invoice?.person + ? `${row.invoice.person.firstName} ${row.invoice.person.lastName}`.trim() + : row.invoice?.company?.name ?? null; return { id: row.id, paymentId: row.paymentId, invoiceId: row.invoiceId, - invoiceNumber: row.invoice.number, - invoiceTitle: row.invoice.title, + invoiceNumber: row.invoice?.number ?? null, + invoiceTitle: row.invoice?.title ?? null, + personId: row.personId, personName, - amount: Number(row.payment.amount), - currency: row.payment.currency, + amount: Number(row.amount ?? row.payment?.amount ?? 0), + currency: row.currency || row.payment?.currency || 'IRR', number: row.number, bank: row.bank, dueDate: row.dueDate.toISOString(), status: row.status, + direction: row.direction === 'out' ? 'out' : 'in', notes: row.notes, - paidAt: row.payment.paidAt.toISOString(), + paidAt: (row.payment?.paidAt ?? row.createdAt).toISOString(), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; diff --git a/apps/api/src/finance/dto.ts b/apps/api/src/finance/dto.ts index f19fa39..867b670 100644 --- a/apps/api/src/finance/dto.ts +++ b/apps/api/src/finance/dto.ts @@ -1,6 +1,58 @@ -import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + IsIn, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const; +const CHEQUE_DIRECTIONS = ['in', 'out'] as const; + +export class CreateChequeDto { + @IsString() + @MinLength(1) + personId!: string; + + @Type(() => Number) + @IsNumber() + @Min(0.01) + amount!: number; + + @IsOptional() + @IsString() + @MaxLength(8) + currency?: string; + + @IsString() + @MinLength(1) + @MaxLength(80) + number!: string; + + @IsString() + @MinLength(1) + @MaxLength(120) + bank!: string; + + @IsString() + dueDate!: string; + + @IsOptional() + @IsIn(CHEQUE_STATUSES) + status?: (typeof CHEQUE_STATUSES)[number]; + + @IsOptional() + @IsIn(CHEQUE_DIRECTIONS) + direction?: (typeof CHEQUE_DIRECTIONS)[number]; + + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; +} export class UpdateChequeDto { @IsOptional() diff --git a/apps/api/src/finance/finance.controller.ts b/apps/api/src/finance/finance.controller.ts index 5a3f954..6ed1674 100644 --- a/apps/api/src/finance/finance.controller.ts +++ b/apps/api/src/finance/finance.controller.ts @@ -1,7 +1,7 @@ -import { Body, Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { UpdateChequeDto } from './dto'; +import { CreateChequeDto, UpdateChequeDto } from './dto'; import { ChequesService } from './cheques.service'; import { CommissionsService } from './commissions.service'; @@ -20,8 +20,17 @@ export class FinanceController { @CurrentUser() user: AuthUser, @Query('status') status?: string, @Query('q') q?: string, + @Query('direction') direction?: string, ) { - return this.cheques.list(user.organizationId, user, { status, q }); + return this.cheques.list(user.organizationId, user, { status, q, direction }); + } + + @Post('cheques') + createCheque( + @CurrentUser() user: AuthUser, + @Body() dto: CreateChequeDto, + ) { + return this.cheques.create(user.organizationId, user, dto); } @Patch('cheques/:id') diff --git a/apps/api/src/finance/finance.module.ts b/apps/api/src/finance/finance.module.ts index 4de26d8..c9c81f9 100644 --- a/apps/api/src/finance/finance.module.ts +++ b/apps/api/src/finance/finance.module.ts @@ -1,13 +1,16 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { SmsService } from '../common/sms.service'; import { ChequesService } from './cheques.service'; +import { ChequeRemindersService } from './cheque-reminders.service'; import { CommissionsService } from './commissions.service'; import { FinanceController } from './finance.controller'; @Module({ - imports: [AuthModule], + imports: [AuthModule, NotificationsModule], controllers: [FinanceController], - providers: [ChequesService, CommissionsService], + providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService], exports: [ChequesService, CommissionsService], }) export class FinanceModule {} diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts index 83c67af..402a192 100644 --- a/apps/api/src/invoices/invoices.service.ts +++ b/apps/api/src/invoices/invoices.service.ts @@ -446,7 +446,14 @@ export class InvoicesService { ): Promise { const invoice = await this.prisma.invoice.findFirst({ where: { id: invoiceId, organizationId, ...ownerWhere(actor) }, - select: { id: true, status: true, currency: true, organizationId: true }, + select: { + id: true, + status: true, + currency: true, + organizationId: true, + personId: true, + ownerId: true, + }, }); if (!invoice) throw new NotFoundException('فاکتور پیدا نشد'); if (invoice.status === 'canceled') { @@ -493,9 +500,14 @@ export class InvoicesService { organizationId: invoice.organizationId, paymentId: payment.id, invoiceId, + personId: invoice.personId, + ownerId: invoice.ownerId, + amount: new Prisma.Decimal(dto.amount), + currency: (dto.currency ?? invoice.currency).trim() || 'IRR', number: dto.cheque.number.trim(), bank: dto.cheque.bank.trim(), dueDate: new Date(dto.cheque.dueDate), + direction: 'in', status: dto.cheque.status === 'cleared' || dto.cheque.status === 'bounced' ? dto.cheque.status : 'pending', @@ -765,15 +777,17 @@ export class InvoicesService { invoiceId: row.id, invoiceNumber: row.number, invoiceTitle: row.title, + personId: row.person?.id ?? p.cheque.personId ?? null, personName: row.person ? `${row.person.firstName} ${row.person.lastName}`.trim() : row.company?.name ?? null, - amount: Number(p.amount), - currency: p.currency, + amount: Number(p.cheque.amount ?? p.amount), + currency: p.cheque.currency || p.currency, number: p.cheque.number, bank: p.cheque.bank, dueDate: p.cheque.dueDate.toISOString(), status: p.cheque.status, + direction: p.cheque.direction === 'out' ? 'out' : 'in', notes: p.cheque.notes, paidAt: p.paidAt.toISOString(), createdAt: p.cheque.createdAt.toISOString(), diff --git a/apps/web/src/views/ChequesView.vue b/apps/web/src/views/ChequesView.vue index 100046d..029fdfb 100644 --- a/apps/web/src/views/ChequesView.vue +++ b/apps/web/src/views/ChequesView.vue @@ -1,12 +1,24 @@ @@ -63,19 +192,33 @@ onMounted(load);

چک‌ها

-

دفتر چک‌های دریافتی وصل به فاکتور؛ وضعیت فقط برای پیگیری است

+

دفتر چک‌های دریافتی و پرداختی. وضعیت فقط برای پیگیری است

+
+
+ دریافت چک + پرداخت چک + + بروزرسانی +
- - بروزرسانی -
+
+ + +
+
- + اعمال @@ -83,7 +226,13 @@ onMounted(load);

{{ error }}

در حال بارگذاری…

-

چکی ثبت نشده.

+

+ {{ + tab === 'out' + ? 'چک پرداختی ثبت نشده. از «پرداخت چک» می‌توانید به شخص دیگری چک بدهید.' + : 'چک دریافتی ثبت نشده. از «دریافت چک» می‌توانید بدون فاکتور ثبت کنید.' + }} +

@@ -92,47 +241,145 @@ onMounted(load); - + - + - + -
بانک سررسید مبلغفاکتورفاکتور طرف‌حساب وضعیت
{{ row.number }} {{ row.bank }} {{ formatJalaliNumeric(new Date(row.dueDate)) }} {{ formatMoney(row.amount, row.currency) }} - + + {{ row.invoiceNumber }} + بدون فاکتور + + + {{ row.personName || '—' }} + + {{ row.personName || '—' }} {{ row.personName || '—' }}
+ + +
+

+ {{ + isIncomingForm + ? 'چک را از یک شخص بگیرید؛ لازم نیست به فاکتور وصل باشد.' + : 'چکی که به شخص دیگری می‌پردازید را ثبت کنید.' + }} +

+

{{ formError }}

+ +
+ + +
+
+ + +
+
+ + +
+