Let cheques be received or paid without an invoice, and remind staff three days before due date.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.12",
|
"@nestjs/platform-express": "^11.0.12",
|
||||||
|
"@nestjs/schedule": "^6.0.1",
|
||||||
"@prisma/client": "^6.5.0",
|
"@prisma/client": "^6.5.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
@@ -213,6 +213,7 @@ model User {
|
|||||||
salesGoals SalesGoal[] @relation("SalesGoalUser")
|
salesGoals SalesGoal[] @relation("SalesGoalUser")
|
||||||
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
|
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
|
||||||
commissionEntries CommissionEntry[]
|
commissionEntries CommissionEntry[]
|
||||||
|
receivedCheques Cheque[] @relation("ChequeOwner")
|
||||||
assignedTickets Ticket[] @relation("TicketAssignee")
|
assignedTickets Ticket[] @relation("TicketAssignee")
|
||||||
reportedTickets Ticket[] @relation("TicketReporter")
|
reportedTickets Ticket[] @relation("TicketReporter")
|
||||||
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
||||||
@@ -291,6 +292,7 @@ model Person {
|
|||||||
accountingLinks PersonAccountingLink[]
|
accountingLinks PersonAccountingLink[]
|
||||||
accountingDocuments AccountingDocumentLog[]
|
accountingDocuments AccountingDocumentLog[]
|
||||||
openingBalances PartyOpeningBalance[]
|
openingBalances PartyOpeningBalance[]
|
||||||
|
cheques Cheque[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1284,26 +1286,38 @@ model CommissionEntry {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// چک دریافتی وصل به پرداخت فاکتور
|
/// چک دریافتی؛ مستقل یا وصل به پرداخت فاکتور
|
||||||
model Cheque {
|
model Cheque {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
paymentId String @unique
|
paymentId String? @unique
|
||||||
payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade)
|
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: Cascade)
|
||||||
invoiceId String
|
invoiceId String?
|
||||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
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
|
number String
|
||||||
bank String
|
bank String
|
||||||
dueDate DateTime
|
dueDate DateTime
|
||||||
/// pending | cleared | bounced
|
/// pending | cleared | bounced
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
|
/// in = دریافتی، out = پرداختی
|
||||||
|
direction String @default("in")
|
||||||
|
reminderNotifiedAt DateTime?
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([organizationId, status, dueDate])
|
@@index([organizationId, status, dueDate])
|
||||||
|
@@index([organizationId, direction, dueDate])
|
||||||
@@index([invoiceId])
|
@@index([invoiceId])
|
||||||
|
@@index([personId])
|
||||||
|
@@index([ownerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// مانده اول دوره طرفحساب (مثبت = بدهکار ما)
|
/// مانده اول دوره طرفحساب (مثبت = بدهکار ما)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
@@ -36,6 +37,7 @@ import { FinanceModule } from './finance/finance.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
envFilePath: ['.env', '../../.env'],
|
envFilePath: ['.env', '../../.env'],
|
||||||
}),
|
}),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
AiModule,
|
AiModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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)} (۳ روز دیگر).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
await this.notifyDueSoon();
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyDueSoon(now: Date = new Date()): Promise<number> {
|
||||||
|
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<string>();
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,14 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { ChequeDto, UpdateChequeRequest } from '@mento/shared';
|
import type {
|
||||||
|
ChequeDto,
|
||||||
|
CreateChequeRequest,
|
||||||
|
UpdateChequeRequest,
|
||||||
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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;
|
const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const;
|
||||||
|
|
||||||
@@ -17,13 +21,16 @@ export class ChequesService {
|
|||||||
async list(
|
async list(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
actor: Pick<Actor, 'userId' | 'role'>,
|
actor: Pick<Actor, 'userId' | 'role'>,
|
||||||
opts?: { status?: string; q?: string },
|
opts?: { status?: string; q?: string; direction?: string },
|
||||||
): Promise<ChequeDto[]> {
|
): Promise<ChequeDto[]> {
|
||||||
const rows = await this.prisma.cheque.findMany({
|
const rows = await this.prisma.cheque.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
organizationId,
|
||||||
invoice: ownerWhere(actor),
|
...this.accessWhere(actor),
|
||||||
...(opts?.status ? { status: opts.status } : {}),
|
...(opts?.status ? { status: opts.status } : {}),
|
||||||
|
...(opts?.direction === 'in' || opts?.direction === 'out'
|
||||||
|
? { direction: opts.direction }
|
||||||
|
: {}),
|
||||||
...(opts?.q?.trim()
|
...(opts?.q?.trim()
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -34,6 +41,24 @@ export class ChequesService {
|
|||||||
number: { contains: opts.q.trim(), mode: 'insensitive' },
|
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));
|
return rows.map((row) => this.toDto(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
organizationId: string,
|
||||||
|
actor: Pick<Actor, 'userId' | 'role'>,
|
||||||
|
dto: CreateChequeRequest,
|
||||||
|
): Promise<ChequeDto> {
|
||||||
|
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(
|
async update(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -52,7 +116,7 @@ export class ChequesService {
|
|||||||
dto: UpdateChequeRequest,
|
dto: UpdateChequeRequest,
|
||||||
): Promise<ChequeDto> {
|
): Promise<ChequeDto> {
|
||||||
const existing = await this.prisma.cheque.findFirst({
|
const existing = await this.prisma.cheque.findFirst({
|
||||||
where: { id, organizationId, invoice: ownerWhere(actor) },
|
where: { id, organizationId, ...this.accessWhere(actor) },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!existing) throw new NotFoundException('چک پیدا نشد');
|
if (!existing) throw new NotFoundException('چک پیدا نشد');
|
||||||
@@ -79,8 +143,18 @@ export class ChequesService {
|
|||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private accessWhere(
|
||||||
|
actor: Pick<Actor, 'userId' | 'role'>,
|
||||||
|
): Prisma.ChequeWhereInput {
|
||||||
|
if (elevated(actor.role)) return {};
|
||||||
|
return {
|
||||||
|
OR: [{ ownerId: actor.userId }, { invoice: { ownerId: actor.userId } }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private include = {
|
private include = {
|
||||||
payment: { select: { id: true, amount: true, currency: true, paidAt: true } },
|
payment: { select: { id: true, amount: true, currency: true, paidAt: true } },
|
||||||
|
person: { select: { firstName: true, lastName: true } },
|
||||||
invoice: {
|
invoice: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -95,24 +169,28 @@ export class ChequesService {
|
|||||||
private toDto(
|
private toDto(
|
||||||
row: Prisma.ChequeGetPayload<{ include: ChequesService['include'] }>,
|
row: Prisma.ChequeGetPayload<{ include: ChequesService['include'] }>,
|
||||||
): ChequeDto {
|
): ChequeDto {
|
||||||
const personName = row.invoice.person
|
const personName = row.person
|
||||||
? `${row.invoice.person.firstName} ${row.invoice.person.lastName}`.trim()
|
? `${row.person.firstName} ${row.person.lastName}`.trim()
|
||||||
: row.invoice.company?.name ?? null;
|
: row.invoice?.person
|
||||||
|
? `${row.invoice.person.firstName} ${row.invoice.person.lastName}`.trim()
|
||||||
|
: row.invoice?.company?.name ?? null;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
paymentId: row.paymentId,
|
paymentId: row.paymentId,
|
||||||
invoiceId: row.invoiceId,
|
invoiceId: row.invoiceId,
|
||||||
invoiceNumber: row.invoice.number,
|
invoiceNumber: row.invoice?.number ?? null,
|
||||||
invoiceTitle: row.invoice.title,
|
invoiceTitle: row.invoice?.title ?? null,
|
||||||
|
personId: row.personId,
|
||||||
personName,
|
personName,
|
||||||
amount: Number(row.payment.amount),
|
amount: Number(row.amount ?? row.payment?.amount ?? 0),
|
||||||
currency: row.payment.currency,
|
currency: row.currency || row.payment?.currency || 'IRR',
|
||||||
number: row.number,
|
number: row.number,
|
||||||
bank: row.bank,
|
bank: row.bank,
|
||||||
dueDate: row.dueDate.toISOString(),
|
dueDate: row.dueDate.toISOString(),
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
direction: row.direction === 'out' ? 'out' : 'in',
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
paidAt: row.payment.paidAt.toISOString(),
|
paidAt: (row.payment?.paidAt ?? row.createdAt).toISOString(),
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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_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 {
|
export class UpdateChequeDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { UpdateChequeDto } from './dto';
|
import { CreateChequeDto, UpdateChequeDto } from './dto';
|
||||||
import { ChequesService } from './cheques.service';
|
import { ChequesService } from './cheques.service';
|
||||||
import { CommissionsService } from './commissions.service';
|
import { CommissionsService } from './commissions.service';
|
||||||
|
|
||||||
@@ -20,8 +20,17 @@ export class FinanceController {
|
|||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Query('q') q?: 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')
|
@Patch('cheques/:id')
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { SmsService } from '../common/sms.service';
|
||||||
import { ChequesService } from './cheques.service';
|
import { ChequesService } from './cheques.service';
|
||||||
|
import { ChequeRemindersService } from './cheque-reminders.service';
|
||||||
import { CommissionsService } from './commissions.service';
|
import { CommissionsService } from './commissions.service';
|
||||||
import { FinanceController } from './finance.controller';
|
import { FinanceController } from './finance.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, NotificationsModule],
|
||||||
controllers: [FinanceController],
|
controllers: [FinanceController],
|
||||||
providers: [ChequesService, CommissionsService],
|
providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService],
|
||||||
exports: [ChequesService, CommissionsService],
|
exports: [ChequesService, CommissionsService],
|
||||||
})
|
})
|
||||||
export class FinanceModule {}
|
export class FinanceModule {}
|
||||||
|
|||||||
@@ -446,7 +446,14 @@ export class InvoicesService {
|
|||||||
): Promise<InvoiceDto> {
|
): Promise<InvoiceDto> {
|
||||||
const invoice = await this.prisma.invoice.findFirst({
|
const invoice = await this.prisma.invoice.findFirst({
|
||||||
where: { id: invoiceId, organizationId, ...ownerWhere(actor) },
|
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) throw new NotFoundException('فاکتور پیدا نشد');
|
||||||
if (invoice.status === 'canceled') {
|
if (invoice.status === 'canceled') {
|
||||||
@@ -493,9 +500,14 @@ export class InvoicesService {
|
|||||||
organizationId: invoice.organizationId,
|
organizationId: invoice.organizationId,
|
||||||
paymentId: payment.id,
|
paymentId: payment.id,
|
||||||
invoiceId,
|
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(),
|
number: dto.cheque.number.trim(),
|
||||||
bank: dto.cheque.bank.trim(),
|
bank: dto.cheque.bank.trim(),
|
||||||
dueDate: new Date(dto.cheque.dueDate),
|
dueDate: new Date(dto.cheque.dueDate),
|
||||||
|
direction: 'in',
|
||||||
status: dto.cheque.status === 'cleared' || dto.cheque.status === 'bounced'
|
status: dto.cheque.status === 'cleared' || dto.cheque.status === 'bounced'
|
||||||
? dto.cheque.status
|
? dto.cheque.status
|
||||||
: 'pending',
|
: 'pending',
|
||||||
@@ -765,15 +777,17 @@ export class InvoicesService {
|
|||||||
invoiceId: row.id,
|
invoiceId: row.id,
|
||||||
invoiceNumber: row.number,
|
invoiceNumber: row.number,
|
||||||
invoiceTitle: row.title,
|
invoiceTitle: row.title,
|
||||||
|
personId: row.person?.id ?? p.cheque.personId ?? null,
|
||||||
personName: row.person
|
personName: row.person
|
||||||
? `${row.person.firstName} ${row.person.lastName}`.trim()
|
? `${row.person.firstName} ${row.person.lastName}`.trim()
|
||||||
: row.company?.name ?? null,
|
: row.company?.name ?? null,
|
||||||
amount: Number(p.amount),
|
amount: Number(p.cheque.amount ?? p.amount),
|
||||||
currency: p.currency,
|
currency: p.cheque.currency || p.currency,
|
||||||
number: p.cheque.number,
|
number: p.cheque.number,
|
||||||
bank: p.cheque.bank,
|
bank: p.cheque.bank,
|
||||||
dueDate: p.cheque.dueDate.toISOString(),
|
dueDate: p.cheque.dueDate.toISOString(),
|
||||||
status: p.cheque.status,
|
status: p.cheque.status,
|
||||||
|
direction: p.cheque.direction === 'out' ? 'out' : 'in',
|
||||||
notes: p.cheque.notes,
|
notes: p.cheque.notes,
|
||||||
paidAt: p.paidAt.toISOString(),
|
paidAt: p.paidAt.toISOString(),
|
||||||
createdAt: p.cheque.createdAt.toISOString(),
|
createdAt: p.cheque.createdAt.toISOString(),
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { formatMoney } from '@/lib/money';
|
import { CURRENCY_OPTIONS, formatMoney, parseMoneyNumber } from '@/lib/money';
|
||||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
import { formatJalaliNumeric, formatLocalDateInput } from '@/lib/jalali';
|
||||||
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||||
|
import MoneyInput from '@/components/MoneyInput.vue';
|
||||||
import UiButton from '@/components/ui/UiButton.vue';
|
import UiButton from '@/components/ui/UiButton.vue';
|
||||||
import type { ChequeDto, ChequeStatus } from '@mento/shared';
|
import UiInput from '@/components/ui/UiInput.vue';
|
||||||
|
import UiModal from '@/components/ui/UiModal.vue';
|
||||||
|
import type {
|
||||||
|
ChequeDirection,
|
||||||
|
ChequeDto,
|
||||||
|
ChequeStatus,
|
||||||
|
CreateChequeRequest,
|
||||||
|
OrganizationDto,
|
||||||
|
PaginatedResponse,
|
||||||
|
PersonDto,
|
||||||
|
} from '@mento/shared';
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
@@ -14,27 +26,69 @@ const items = ref<ChequeDto[]>([]);
|
|||||||
const status = ref('');
|
const status = ref('');
|
||||||
const q = ref('');
|
const q = ref('');
|
||||||
const savingId = ref('');
|
const savingId = ref('');
|
||||||
|
const tab = ref<ChequeDirection>('in');
|
||||||
|
|
||||||
const statusLabel: Record<ChequeStatus, string> = {
|
const formOpen = ref(false);
|
||||||
pending: 'در جریان',
|
const formDirection = ref<ChequeDirection>('in');
|
||||||
cleared: 'وصول شد',
|
const saving = ref(false);
|
||||||
bounced: 'برگشت',
|
const formError = ref('');
|
||||||
};
|
const people = ref<PersonDto[]>([]);
|
||||||
|
const enabledCurrencies = ref<string[]>(['IRR']);
|
||||||
|
const personId = ref('');
|
||||||
|
const amount = ref('');
|
||||||
|
const currency = ref('IRR');
|
||||||
|
const chequeNumber = ref('');
|
||||||
|
const chequeBank = ref('');
|
||||||
|
const dueDate = ref(formatLocalDateInput(new Date()));
|
||||||
|
const formStatus = ref<ChequeStatus>('pending');
|
||||||
|
const notes = ref('');
|
||||||
|
|
||||||
const filtered = computed(() => items.value);
|
function statusLabelFor(direction: string): Record<ChequeStatus, string> {
|
||||||
|
return {
|
||||||
|
pending: 'در جریان',
|
||||||
|
cleared: direction === 'out' ? 'پرداخت شد' : 'وصول شد',
|
||||||
|
bounced: 'برگشت',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formStatusLabel = computed(() => statusLabelFor(formDirection.value));
|
||||||
|
const isIncomingForm = computed(() => formDirection.value !== 'out');
|
||||||
|
|
||||||
|
const currencyOptions = computed(() => {
|
||||||
|
const codes = enabledCurrencies.value.length ? [...enabledCurrencies.value] : [currency.value];
|
||||||
|
if (currency.value && !codes.includes(currency.value)) codes.push(currency.value);
|
||||||
|
return codes.map((code) => {
|
||||||
|
const opt = CURRENCY_OPTIONS.find((c) => c.code === code);
|
||||||
|
return { code, label: opt ? `${opt.suffix} - ${opt.code}` : code };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function axiosMessage(e: unknown, fallback: string) {
|
||||||
|
if (axios.isAxiosError(e)) {
|
||||||
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||||
|
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function personLabel(p: PersonDto) {
|
||||||
|
return p.company?.name ? `${p.fullName} — ${p.company.name}` : p.fullName;
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<ChequeDto[]>('/cheques', {
|
const { data } = await api.get<ChequeDto[]>('/cheques', {
|
||||||
params: { status: status.value || undefined, q: q.value || undefined },
|
params: {
|
||||||
|
status: status.value || undefined,
|
||||||
|
q: q.value || undefined,
|
||||||
|
direction: tab.value,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
items.value = data;
|
items.value = data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = axios.isAxiosError(e)
|
error.value = axiosMessage(e, 'بارگذاری چکها ناموفق بود.');
|
||||||
? 'بارگذاری چکها ناموفق بود.'
|
|
||||||
: 'بارگذاری چکها ناموفق بود.';
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -55,6 +109,81 @@ async function setStatus(row: ChequeDto, next: ChequeStatus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFormOptions() {
|
||||||
|
const [peopleRes, orgRes] = await Promise.all([
|
||||||
|
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
|
||||||
|
api.get<OrganizationDto>('/organizations/current'),
|
||||||
|
]);
|
||||||
|
people.value = peopleRes.data.items;
|
||||||
|
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
|
||||||
|
? orgRes.data.enabledCurrencies
|
||||||
|
: [orgRes.data.baseCurrency || 'IRR'];
|
||||||
|
if (!currency.value || !enabledCurrencies.value.includes(currency.value)) {
|
||||||
|
currency.value = orgRes.data.baseCurrency || 'IRR';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openForm(direction: ChequeDirection) {
|
||||||
|
formDirection.value = direction;
|
||||||
|
formError.value = '';
|
||||||
|
personId.value = '';
|
||||||
|
amount.value = '';
|
||||||
|
chequeNumber.value = '';
|
||||||
|
chequeBank.value = '';
|
||||||
|
dueDate.value = formatLocalDateInput(new Date());
|
||||||
|
formStatus.value = 'pending';
|
||||||
|
notes.value = '';
|
||||||
|
formOpen.value = true;
|
||||||
|
try {
|
||||||
|
await loadFormOptions();
|
||||||
|
} catch {
|
||||||
|
formError.value = 'بارگذاری اشخاص ناموفق بود.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
const parsedAmount = parseMoneyNumber(String(amount.value ?? ''), 0);
|
||||||
|
if (!personId.value) {
|
||||||
|
formError.value = 'شخص را انتخاب کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsedAmount == null || parsedAmount <= 0) {
|
||||||
|
formError.value = 'مبلغ چک را وارد کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!chequeNumber.value.trim() || !chequeBank.value.trim() || !dueDate.value) {
|
||||||
|
formError.value = 'شماره، بانک و سررسید چک لازم است.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
formError.value = '';
|
||||||
|
try {
|
||||||
|
const body: CreateChequeRequest = {
|
||||||
|
personId: personId.value,
|
||||||
|
amount: parsedAmount,
|
||||||
|
currency: currency.value,
|
||||||
|
number: chequeNumber.value.trim(),
|
||||||
|
bank: chequeBank.value.trim(),
|
||||||
|
dueDate: new Date(dueDate.value).toISOString(),
|
||||||
|
status: formStatus.value,
|
||||||
|
direction: formDirection.value,
|
||||||
|
notes: notes.value.trim() || undefined,
|
||||||
|
};
|
||||||
|
await api.post<ChequeDto>('/cheques', body);
|
||||||
|
formOpen.value = false;
|
||||||
|
tab.value = formDirection.value;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
formError.value = axiosMessage(e, 'ثبت چک ناموفق بود.');
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(tab, () => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -63,19 +192,33 @@ onMounted(load);
|
|||||||
<header class="head">
|
<header class="head">
|
||||||
<div>
|
<div>
|
||||||
<h1>چکها</h1>
|
<h1>چکها</h1>
|
||||||
<p class="sub">دفتر چکهای دریافتی وصل به فاکتور؛ وضعیت فقط برای پیگیری است</p>
|
<p class="sub">دفتر چکهای دریافتی و پرداختی. وضعیت فقط برای پیگیری است</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<UiButton type="button" @click="openForm('in')">دریافت چک</UiButton>
|
||||||
|
<UiButton variant="ghost" type="button" @click="openForm('out')">پرداخت چک</UiButton>
|
||||||
|
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
|
||||||
|
بروزرسانی
|
||||||
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
|
|
||||||
بروزرسانی
|
|
||||||
</UiButton>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button type="button" :class="{ on: tab === 'in' }" @click="tab = 'in'">دریافتی</button>
|
||||||
|
<button type="button" :class="{ on: tab === 'out' }" @click="tab = 'out'">پرداختی</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input v-model="q" type="search" placeholder="شماره چک، بانک یا فاکتور" @keyup.enter="load" />
|
<input
|
||||||
|
v-model="q"
|
||||||
|
type="search"
|
||||||
|
placeholder="شماره چک، بانک، فاکتور یا شخص"
|
||||||
|
@keyup.enter="load"
|
||||||
|
/>
|
||||||
<select v-model="status" @change="load">
|
<select v-model="status" @change="load">
|
||||||
<option value="">همه وضعیتها</option>
|
<option value="">همه وضعیتها</option>
|
||||||
<option value="pending">در جریان</option>
|
<option value="pending">در جریان</option>
|
||||||
<option value="cleared">وصول شد</option>
|
<option value="cleared">{{ tab === 'out' ? 'پرداخت شد' : 'وصول شد' }}</option>
|
||||||
<option value="bounced">برگشت</option>
|
<option value="bounced">برگشت</option>
|
||||||
</select>
|
</select>
|
||||||
<UiButton type="button" @click="load">اعمال</UiButton>
|
<UiButton type="button" @click="load">اعمال</UiButton>
|
||||||
@@ -83,7 +226,13 @@ onMounted(load);
|
|||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
|
<p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
|
||||||
<p v-else-if="!filtered.length" class="muted">چکی ثبت نشده.</p>
|
<p v-else-if="!items.length" class="muted">
|
||||||
|
{{
|
||||||
|
tab === 'out'
|
||||||
|
? 'چک پرداختی ثبت نشده. از «پرداخت چک» میتوانید به شخص دیگری چک بدهید.'
|
||||||
|
: 'چک دریافتی ثبت نشده. از «دریافت چک» میتوانید بدون فاکتور ثبت کنید.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<div v-else class="table-wrap">
|
<div v-else class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -92,47 +241,145 @@ onMounted(load);
|
|||||||
<th>بانک</th>
|
<th>بانک</th>
|
||||||
<th>سررسید</th>
|
<th>سررسید</th>
|
||||||
<th>مبلغ</th>
|
<th>مبلغ</th>
|
||||||
<th>فاکتور</th>
|
<th v-if="tab === 'in'">فاکتور</th>
|
||||||
<th>طرفحساب</th>
|
<th>طرفحساب</th>
|
||||||
<th>وضعیت</th>
|
<th>وضعیت</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in filtered" :key="row.id">
|
<tr v-for="row in items" :key="row.id">
|
||||||
<td>{{ row.number }}</td>
|
<td>{{ row.number }}</td>
|
||||||
<td>{{ row.bank }}</td>
|
<td>{{ row.bank }}</td>
|
||||||
<td>{{ formatJalaliNumeric(new Date(row.dueDate)) }}</td>
|
<td>{{ formatJalaliNumeric(new Date(row.dueDate)) }}</td>
|
||||||
<td>{{ formatMoney(row.amount, row.currency) }}</td>
|
<td>{{ formatMoney(row.amount, row.currency) }}</td>
|
||||||
<td>
|
<td v-if="tab === 'in'">
|
||||||
<RouterLink :to="{ name: 'invoice-detail', params: { id: row.invoiceId } }">
|
<RouterLink
|
||||||
|
v-if="row.invoiceId"
|
||||||
|
:to="{ name: 'invoice-detail', params: { id: row.invoiceId } }"
|
||||||
|
>
|
||||||
{{ row.invoiceNumber }}
|
{{ row.invoiceNumber }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<span v-else class="muted">بدون فاکتور</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<RouterLink
|
||||||
|
v-if="row.personId"
|
||||||
|
:to="{ name: 'person-detail', params: { id: row.personId } }"
|
||||||
|
>
|
||||||
|
{{ row.personName || '—' }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else>{{ row.personName || '—' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ row.personName || '—' }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
:value="row.status"
|
:value="row.status"
|
||||||
:disabled="savingId === row.id"
|
:disabled="savingId === row.id"
|
||||||
@change="setStatus(row, ($event.target as HTMLSelectElement).value as ChequeStatus)"
|
@change="setStatus(row, ($event.target as HTMLSelectElement).value as ChequeStatus)"
|
||||||
>
|
>
|
||||||
<option value="pending">{{ statusLabel.pending }}</option>
|
<option value="pending">{{ statusLabelFor(row.direction).pending }}</option>
|
||||||
<option value="cleared">{{ statusLabel.cleared }}</option>
|
<option value="cleared">{{ statusLabelFor(row.direction).cleared }}</option>
|
||||||
<option value="bounced">{{ statusLabel.bounced }}</option>
|
<option value="bounced">{{ statusLabelFor(row.direction).bounced }}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UiModal
|
||||||
|
:open="formOpen"
|
||||||
|
:title="isIncomingForm ? 'دریافت چک' : 'پرداخت چک'"
|
||||||
|
@close="formOpen = false"
|
||||||
|
>
|
||||||
|
<form class="receive-form" @submit.prevent="submitForm">
|
||||||
|
<p class="hint">
|
||||||
|
{{
|
||||||
|
isIncomingForm
|
||||||
|
? 'چک را از یک شخص بگیرید؛ لازم نیست به فاکتور وصل باشد.'
|
||||||
|
: 'چکی که به شخص دیگری میپردازید را ثبت کنید.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p v-if="formError" class="error">{{ formError }}</p>
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ isIncomingForm ? 'شخص' : 'گیرنده' }} <em>*</em></span>
|
||||||
|
<select v-model="personId" required>
|
||||||
|
<option value="">انتخاب شخص</option>
|
||||||
|
<option v-for="p in people" :key="p.id" :value="p.id">
|
||||||
|
{{ personLabel(p) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="row-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>مبلغ <em>*</em></span>
|
||||||
|
<MoneyInput v-model="amount" :decimals="0" placeholder="مبلغ چک" />
|
||||||
|
</label>
|
||||||
|
<label v-if="currencyOptions.length > 1" class="field">
|
||||||
|
<span>ارز</span>
|
||||||
|
<select v-model="currency">
|
||||||
|
<option v-for="opt in currencyOptions" :key="opt.code" :value="opt.code">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>شماره چک <em>*</em></span>
|
||||||
|
<UiInput v-model="chequeNumber" placeholder="شماره چک" maxlength="80" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>بانک <em>*</em></span>
|
||||||
|
<UiInput v-model="chequeBank" placeholder="نام بانک" maxlength="120" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>سررسید <em>*</em></span>
|
||||||
|
<JalaliDateInput v-model="dueDate" required placeholder="سررسید چک" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>وضعیت</span>
|
||||||
|
<select v-model="formStatus">
|
||||||
|
<option value="pending">{{ formStatusLabel.pending }}</option>
|
||||||
|
<option value="cleared">{{ formStatusLabel.cleared }}</option>
|
||||||
|
<option value="bounced">{{ formStatusLabel.bounced }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>یادداشت</span>
|
||||||
|
<textarea v-model="notes" rows="3" maxlength="2000" placeholder="اختیاری" />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
<template #footer>
|
||||||
|
<UiButton type="button" :disabled="saving" @click="submitForm">
|
||||||
|
{{ saving ? 'در حال ثبت…' : isIncomingForm ? 'ثبت دریافت' : 'ثبت پرداخت' }}
|
||||||
|
</UiButton>
|
||||||
|
<UiButton variant="ghost" type="button" :disabled="saving" @click="formOpen = false">
|
||||||
|
انصراف
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</UiModal>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page { display: grid; gap: 1rem; }
|
.page { display: grid; gap: 1rem; }
|
||||||
.head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
|
.head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; }
|
||||||
h1 { margin: 0; font-size: 1.25rem; }
|
h1 { margin: 0; font-size: 1.25rem; }
|
||||||
.sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; }
|
.sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; }
|
||||||
.error { color: var(--danger, #dc2626); font-weight: 600; }
|
.error { color: var(--danger, #dc2626); font-weight: 600; }
|
||||||
|
.hint { margin: 0 0 0.75rem; color: var(--muted); font-size: 0.88rem; }
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 0.35rem; overflow-x: auto; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.tabs button {
|
||||||
|
border: 0; background: transparent; padding: 0.65rem 0.85rem; cursor: pointer;
|
||||||
|
color: #64748b; font-weight: 700; border-bottom: 2px solid transparent; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tabs button.on { color: var(--teal); border-bottom-color: var(--teal); }
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
|
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
|
||||||
}
|
}
|
||||||
@@ -149,4 +396,25 @@ select {
|
|||||||
padding: 0 0.55rem; background: #fff;
|
padding: 0 0.55rem; background: #fff;
|
||||||
}
|
}
|
||||||
a { color: #0f766e; font-weight: 700; }
|
a { color: #0f766e; font-weight: 700; }
|
||||||
|
.receive-form { display: grid; gap: 0.85rem; }
|
||||||
|
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||||
|
.field { display: grid; gap: 0.35rem; }
|
||||||
|
.field > span { color: var(--muted); font-size: 0.86rem; font-weight: 600; }
|
||||||
|
.field em { color: #ef4444; font-style: normal; }
|
||||||
|
.field select,
|
||||||
|
.field textarea,
|
||||||
|
.field :deep(.money-input) {
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--control-h);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.field textarea { min-height: 5rem; resize: vertical; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.row-2 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# چک خروجی و یادآوری سررسید — برنامه پیادهسازی
|
||||||
|
|
||||||
|
> اجرا در همین نشست؛ کامیت فقط اگر کاربر بخواهد.
|
||||||
|
|
||||||
|
**Goal:** دفتر چک دریافتی/پرداختی + یادآوری یکبار سه روز قبل از سررسید.
|
||||||
|
|
||||||
|
**Architecture:** فیلد `direction` و `reminderNotifiedAt` روی `Cheque`. زمانبند `@nestjs/schedule` ساعت ۸ تهران. متن و تشخیص روز در `cheque-reminder.util.ts`.
|
||||||
|
|
||||||
|
**Tech Stack:** Prisma, NestJS, Vue 3, `@nestjs/schedule`, SMS موجود، اعلان موجود.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- npm workspaces؛ Prisma فقط از `apps/api`
|
||||||
|
- متن پیامک با `یادآوری منتکت:` و حداکثر ۱۶۰ نویسه
|
||||||
|
- پرداختی به فاکتور وصل نمیشود؛ چک فاکتور همیشه `in`
|
||||||
|
- بدون سند حسابداری
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/api/prisma/schema.prisma` + migration `20260911120000_cheque_direction_reminder`
|
||||||
|
- `packages/shared/src/index.ts`
|
||||||
|
- `apps/api/src/finance/cheque-reminder.util.ts`
|
||||||
|
- `apps/api/src/finance/__tests__/cheque-reminder.check.ts`
|
||||||
|
- `apps/api/src/finance/cheque-reminders.service.ts`
|
||||||
|
- `apps/api/src/finance/{cheques.service,dto,finance.controller,finance.module}.ts`
|
||||||
|
- `apps/api/src/invoices/invoices.service.ts`
|
||||||
|
- `apps/api/src/app.module.ts`
|
||||||
|
- `apps/api/package.json` (`@nestjs/schedule`)
|
||||||
|
- `apps/web/src/views/ChequesView.vue`
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. util + unit check (روز تهران، متن پیامک)
|
||||||
|
2. schema/migration + shared types
|
||||||
|
3. API create/list direction؛ فاکتور `direction: in`
|
||||||
|
4. ChequeRemindersService + cron
|
||||||
|
5. UI تب و مودال پرداخت
|
||||||
|
6. `npm run build -w @mento/shared`، migrate، generate، unit check
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# چک خروجی و یادآوری سررسید
|
||||||
|
|
||||||
|
تاریخ: ۱۴۰۵/۶/۲۰
|
||||||
|
وضعیت: تأییدشده برای پیادهسازی
|
||||||
|
|
||||||
|
## هدف
|
||||||
|
|
||||||
|
دفتر چک هم دریافتی و هم پرداختی باشد. سه روز قبل از سررسید، یکبار به افراد توافقشده اطلاع داده شود.
|
||||||
|
|
||||||
|
## صفحه
|
||||||
|
|
||||||
|
مسیر همان `/cheques` است؛ لینک سایدبار عوض نمیشود.
|
||||||
|
|
||||||
|
دو تب: **دریافتی** و **پرداختی**. دو دکمه: **دریافت چک** و **پرداخت چک**. هر کدام مودال جدا با فیلدهای شخص، مبلغ، ارز (اگر بیش از یکی فعال باشد)، شماره، بانک، سررسید، وضعیت، یادداشت. در پرداختی شخص یعنی گیرنده.
|
||||||
|
|
||||||
|
ستونهای جدول مثل الان. وضعیت:
|
||||||
|
|
||||||
|
- دریافتی: در جریان / وصول شد / برگشت
|
||||||
|
- پرداختی: در جریان / پرداخت شد / برگشت
|
||||||
|
|
||||||
|
چک وصل به فاکتور فقط در تب دریافتی دیده میشود. پرداختی به فاکتور وصل نمیشود.
|
||||||
|
|
||||||
|
## داده
|
||||||
|
|
||||||
|
همان مدل `Cheque` با:
|
||||||
|
|
||||||
|
- `direction`: `in` | `out` (چک فاکتور همیشه `in`)
|
||||||
|
- `reminderNotifiedAt`: تا هر چک فقط یکبار یادآوری شود
|
||||||
|
|
||||||
|
`POST /cheques` جهت را میگیرد. `GET /cheques?direction=in|out` فیلتر میکند. `PATCH` وضعیت مثل قبل.
|
||||||
|
|
||||||
|
## یادآوری
|
||||||
|
|
||||||
|
زمانبند روزانه حدود ساعت ۸ به وقت تهران. فقط چکهای `pending` که روز سررسیدشان دقیقاً ۳ روز بعد است (تقویم تهران).
|
||||||
|
|
||||||
|
گیرندگان داخلی: همهٔ کاربران `owner`/`admin` سازمان + ثبتکنندهٔ چک (`ownerId`). اگر یک نفر دو نقش دارد، یکبار.
|
||||||
|
|
||||||
|
کانال:
|
||||||
|
|
||||||
|
- همهٔ گیرندگان داخلی: اعلان داخل برنامه (عنوان «سررسید چک»، لینک `/cheques`) + پیامک اگر موبایل داشته باشند
|
||||||
|
- دریافتی: پیامک به موبایل شخص طرفحساب
|
||||||
|
- پرداختی: به شخص پیامک نمیرود
|
||||||
|
|
||||||
|
بعد از تلاش برای آن چک، `reminderNotifiedAt` ست میشود. خطای یک چک بقیه را متوقف نمیکند. اگر پیامک پیکربندی نباشد یا موبایل خالی باشد، همان کانال رد میشود؛ اعلان داخل برنامه برای کاربران CRM میماند.
|
||||||
|
|
||||||
|
متنها با پیشوند `یادآوری منتکت:` و حداکثر حدود ۱۶۰ نویسه:
|
||||||
|
|
||||||
|
- شخص (فقط دریافتی): `یادآوری منتکت: چک شماره {شماره} به مبلغ {مبلغ} سه روز دیگر ({سررسید شمسی}) سررسید میشود.`
|
||||||
|
- داخلی دریافتی: `یادآوری منتکت: چک دریافتی {شماره} از {نام شخص}، مبلغ {مبلغ}، سررسید {تاریخ} (۳ روز دیگر).`
|
||||||
|
- داخلی پرداختی: `یادآوری منتکت: چک پرداختی {شماره} به {نام شخص}، مبلغ {مبلغ}، سررسید {تاریخ} (۳ روز دیگر).`
|
||||||
|
|
||||||
|
## خارج از این نسخه
|
||||||
|
|
||||||
|
سند حسابداری برای چک خروجی، وصل پرداختی به فاکتور، یادآوری روز سررسید یا برگشت، تنظیم فاصلهٔ روز از داخل تنظیمات.
|
||||||
Generated
+46
@@ -29,6 +29,7 @@
|
|||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.12",
|
"@nestjs/platform-express": "^11.0.12",
|
||||||
|
"@nestjs/schedule": "^6.0.1",
|
||||||
"@prisma/client": "^6.5.0",
|
"@prisma/client": "^6.5.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -1393,6 +1394,19 @@
|
|||||||
"@nestjs/core": "^11.0.0"
|
"@nestjs/core": "^11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/schedule": {
|
||||||
|
"version": "6.1.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@nestjs/schedule/-/schedule-6.1.3.tgz",
|
||||||
|
"integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron": "4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||||
@@ -2043,6 +2057,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/luxon": {
|
||||||
|
"version": "3.7.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/luxon/-/luxon-3.7.5.tgz",
|
||||||
|
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ms": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz",
|
||||||
@@ -3595,6 +3615,23 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cron": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/cron/-/cron-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/luxon": "~3.7.0",
|
||||||
|
"luxon": "~3.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.x"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "ko-fi",
|
||||||
|
"url": "https://ko-fi.com/intcreator"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -5310,6 +5347,15 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.17",
|
||||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz",
|
||||||
|
|||||||
@@ -873,6 +873,7 @@ export type NotificationType =
|
|||||||
| "activity_reminder"
|
| "activity_reminder"
|
||||||
| "deal_moved"
|
| "deal_moved"
|
||||||
| "note_reminder"
|
| "note_reminder"
|
||||||
|
| "cheque_due"
|
||||||
| "ticket_assigned"
|
| "ticket_assigned"
|
||||||
| "automation"
|
| "automation"
|
||||||
| "system";
|
| "system";
|
||||||
@@ -1137,13 +1138,15 @@ export interface InvoiceLineItemDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ChequeStatus = "pending" | "cleared" | "bounced";
|
export type ChequeStatus = "pending" | "cleared" | "bounced";
|
||||||
|
export type ChequeDirection = "in" | "out";
|
||||||
|
|
||||||
export interface ChequeDto {
|
export interface ChequeDto {
|
||||||
id: string;
|
id: string;
|
||||||
paymentId: string;
|
paymentId: string | null;
|
||||||
invoiceId: string;
|
invoiceId: string | null;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string | null;
|
||||||
invoiceTitle: string;
|
invoiceTitle: string | null;
|
||||||
|
personId: string | null;
|
||||||
personName: string | null;
|
personName: string | null;
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -1151,6 +1154,7 @@ export interface ChequeDto {
|
|||||||
bank: string;
|
bank: string;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
status: ChequeStatus | string;
|
status: ChequeStatus | string;
|
||||||
|
direction: ChequeDirection | string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
paidAt: string;
|
paidAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -1165,6 +1169,18 @@ export interface ChequeInput {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateChequeRequest {
|
||||||
|
personId: string;
|
||||||
|
amount: number;
|
||||||
|
currency?: string;
|
||||||
|
number: string;
|
||||||
|
bank: string;
|
||||||
|
dueDate: string;
|
||||||
|
status?: ChequeStatus | string;
|
||||||
|
direction?: ChequeDirection | string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateChequeRequest {
|
export interface UpdateChequeRequest {
|
||||||
number?: string;
|
number?: string;
|
||||||
bank?: string;
|
bank?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user