feat: add internal finance ledger for accounts, claims, and payroll
Keep booked income and expense separate from cash balances, with installments, recurring items, and reports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "FinancialAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"bankName" TEXT,
|
||||
"accountRef" TEXT,
|
||||
"responsibleName" TEXT,
|
||||
"openingBalance" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"openingDate" TIMESTAMP(3) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinancialAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinancialCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"bookedAt" TIMESTAMP(3) NOT NULL,
|
||||
"dueAt" TIMESTAMP(3),
|
||||
"categoryId" TEXT,
|
||||
"personId" TEXT,
|
||||
"partyName" TEXT,
|
||||
"accountId" TEXT,
|
||||
"method" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"reference" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"voidReason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinancialDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialMovement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"documentId" TEXT,
|
||||
"direction" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"transferGroupId" TEXT,
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL,
|
||||
"memo" TEXT,
|
||||
"reference" TEXT,
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FinancialMovement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "FinancialAccount_organizationId_isActive_idx" ON "FinancialAccount"("organizationId", "isActive");
|
||||
CREATE UNIQUE INDEX "FinancialCategory_organizationId_kind_name_key" ON "FinancialCategory"("organizationId", "kind", "name");
|
||||
CREATE INDEX "FinancialCategory_organizationId_kind_idx" ON "FinancialCategory"("organizationId", "kind");
|
||||
CREATE INDEX "FinancialDocument_organizationId_kind_bookedAt_idx" ON "FinancialDocument"("organizationId", "kind", "bookedAt");
|
||||
CREATE INDEX "FinancialDocument_organizationId_status_idx" ON "FinancialDocument"("organizationId", "status");
|
||||
CREATE INDEX "FinancialDocument_personId_idx" ON "FinancialDocument"("personId");
|
||||
CREATE INDEX "FinancialMovement_organizationId_accountId_occurredAt_idx" ON "FinancialMovement"("organizationId", "accountId", "occurredAt");
|
||||
CREATE INDEX "FinancialMovement_transferGroupId_idx" ON "FinancialMovement"("transferGroupId");
|
||||
CREATE INDEX "FinancialMovement_documentId_idx" ON "FinancialMovement"("documentId");
|
||||
|
||||
ALTER TABLE "FinancialAccount" ADD CONSTRAINT "FinancialAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialCategory" ADD CONSTRAINT "FinancialCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FinancialCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,42 @@
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "receivableId" TEXT;
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "installmentId" TEXT;
|
||||
|
||||
CREATE TABLE "FinancialReceivable" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"personId" TEXT,
|
||||
"partyName" TEXT,
|
||||
"totalAmount" DECIMAL(18,2) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "FinancialReceivable_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialInstallment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"receivableId" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"reminderNotifiedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialInstallment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "FinancialMovement_receivableId_idx" ON "FinancialMovement"("receivableId");
|
||||
CREATE INDEX "FinancialMovement_installmentId_idx" ON "FinancialMovement"("installmentId");
|
||||
CREATE INDEX "FinancialReceivable_organizationId_status_idx" ON "FinancialReceivable"("organizationId", "status");
|
||||
CREATE INDEX "FinancialReceivable_personId_idx" ON "FinancialReceivable"("personId");
|
||||
CREATE UNIQUE INDEX "FinancialInstallment_receivableId_position_key" ON "FinancialInstallment"("receivableId", "position");
|
||||
CREATE INDEX "FinancialInstallment_dueDate_idx" ON "FinancialInstallment"("dueDate");
|
||||
|
||||
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialInstallment" ADD CONSTRAINT "FinancialInstallment_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_installmentId_fkey" FOREIGN KEY ("installmentId") REFERENCES "FinancialInstallment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,79 @@
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "payrollId" TEXT;
|
||||
CREATE INDEX "FinancialMovement_payrollId_idx" ON "FinancialMovement"("payrollId");
|
||||
|
||||
CREATE TABLE "FinancialPayroll" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"period" TEXT NOT NULL,
|
||||
"baseAmount" DECIMAL(18,2) NOT NULL,
|
||||
"benefits" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"bonus" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"deductions" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"payable" DECIMAL(18,2) NOT NULL,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "FinancialPayroll_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialRecurringItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"interval" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"endDate" TIMESTAMP(3),
|
||||
"nextDue" TIMESTAMP(3) NOT NULL,
|
||||
"accountId" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "FinancialRecurringItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialRecurringOccurrence" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"periodKey" TEXT NOT NULL,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"documentId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialRecurringOccurrence_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialReconciliation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"reconciledAt" TIMESTAMP(3) NOT NULL,
|
||||
"statedBalance" DECIMAL(18,2) NOT NULL,
|
||||
"systemBalance" DECIMAL(18,2) NOT NULL,
|
||||
"difference" DECIMAL(18,2) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialReconciliation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "FinancialPayroll_organizationId_userId_period_key" ON "FinancialPayroll"("organizationId", "userId", "period");
|
||||
CREATE INDEX "FinancialPayroll_organizationId_status_idx" ON "FinancialPayroll"("organizationId", "status");
|
||||
CREATE INDEX "FinancialRecurringItem_organizationId_isActive_nextDue_idx" ON "FinancialRecurringItem"("organizationId", "isActive", "nextDue");
|
||||
CREATE UNIQUE INDEX "FinancialRecurringOccurrence_itemId_periodKey_key" ON "FinancialRecurringOccurrence"("itemId", "periodKey");
|
||||
CREATE INDEX "FinancialReconciliation_organizationId_accountId_reconciledAt_idx" ON "FinancialReconciliation"("organizationId", "accountId", "reconciledAt");
|
||||
|
||||
ALTER TABLE "FinancialPayroll" ADD CONSTRAINT "FinancialPayroll_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_payrollId_fkey" FOREIGN KEY ("payrollId") REFERENCES "FinancialPayroll"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialRecurringItem" ADD CONSTRAINT "FinancialRecurringItem_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialRecurringOccurrence" ADD CONSTRAINT "FinancialRecurringOccurrence_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "FinancialRecurringItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -203,6 +203,14 @@ model Organization {
|
||||
webhookEndpoints WebhookEndpoint[]
|
||||
smsCampaigns SmsCampaign[]
|
||||
outboundSmsLogs OutboundSmsLog[]
|
||||
financialAccounts FinancialAccount[]
|
||||
financialCategories FinancialCategory[]
|
||||
financialDocuments FinancialDocument[]
|
||||
financialMovements FinancialMovement[]
|
||||
financialReceivables FinancialReceivable[]
|
||||
financialPayrolls FinancialPayroll[]
|
||||
financialRecurringItems FinancialRecurringItem[]
|
||||
financialReconciliations FinancialReconciliation[]
|
||||
wooCommerceConnections WooCommerceConnection[]
|
||||
montifyConnections MontifyConnection[]
|
||||
voipConnections VoipConnection[]
|
||||
@@ -543,6 +551,8 @@ model Person {
|
||||
trackingEvents TrackingEvent[]
|
||||
routineTasks RoutineTask[]
|
||||
outboundSmsLogs OutboundSmsLog[]
|
||||
financialDocuments FinancialDocument[]
|
||||
financialReceivables FinancialReceivable[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1991,6 +2001,239 @@ model WebhookDelivery {
|
||||
@@index([endpointId, createdAt])
|
||||
}
|
||||
|
||||
/// دفتر مالی داخلی: حساب صندوق/بانک/تنخواه
|
||||
model FinancialAccount {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
/// bank | cash | petty
|
||||
type String
|
||||
bankName String?
|
||||
accountRef String?
|
||||
responsibleName String?
|
||||
openingBalance Decimal @default(0) @db.Decimal(18, 2)
|
||||
openingDate DateTime
|
||||
notes String?
|
||||
isActive Boolean @default(true)
|
||||
currency String @default("IRR")
|
||||
createdById String?
|
||||
movements FinancialMovement[]
|
||||
documents FinancialDocument[] @relation("DocumentAccount")
|
||||
reconciliations FinancialReconciliation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, isActive])
|
||||
}
|
||||
|
||||
model FinancialCategory {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
/// income | expense
|
||||
kind String
|
||||
isActive Boolean @default(true)
|
||||
documents FinancialDocument[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, kind, name])
|
||||
@@index([organizationId, kind])
|
||||
}
|
||||
|
||||
/// درآمد یا هزینهٔ ثبتشده (تعهدی). موجودی حساب را خودش عوض نمیکند.
|
||||
model FinancialDocument {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
/// income | expense
|
||||
kind String
|
||||
title String
|
||||
amount Decimal @db.Decimal(18, 2)
|
||||
bookedAt DateTime
|
||||
dueAt DateTime?
|
||||
categoryId String?
|
||||
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
partyName String?
|
||||
accountId String?
|
||||
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
|
||||
/// cash | card_to_card | pos | transfer | other
|
||||
method String?
|
||||
/// open | partial | settled | void
|
||||
status String @default("open")
|
||||
reference String?
|
||||
notes String?
|
||||
createdById String?
|
||||
voidedAt DateTime?
|
||||
voidReason String?
|
||||
movements FinancialMovement[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, kind, bookedAt])
|
||||
@@index([organizationId, status])
|
||||
@@index([personId])
|
||||
}
|
||||
|
||||
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
|
||||
model FinancialMovement {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
accountId String
|
||||
account FinancialAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
documentId String?
|
||||
document FinancialDocument? @relation(fields: [documentId], references: [id], onDelete: SetNull)
|
||||
receivableId String?
|
||||
receivable FinancialReceivable? @relation(fields: [receivableId], references: [id], onDelete: SetNull)
|
||||
installmentId String?
|
||||
installment FinancialInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
|
||||
payrollId String?
|
||||
payroll FinancialPayroll? @relation(fields: [payrollId], references: [id], onDelete: SetNull)
|
||||
/// in | out
|
||||
direction String
|
||||
amount Decimal @db.Decimal(18, 2)
|
||||
/// opening | receipt | payment | transfer_in | transfer_out
|
||||
kind String
|
||||
transferGroupId String?
|
||||
occurredAt DateTime
|
||||
memo String?
|
||||
reference String?
|
||||
createdById String?
|
||||
voidedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([organizationId, accountId, occurredAt])
|
||||
@@index([transferGroupId])
|
||||
@@index([documentId])
|
||||
@@index([receivableId])
|
||||
@@index([installmentId])
|
||||
@@index([payrollId])
|
||||
}
|
||||
|
||||
model FinancialPayroll {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
period String
|
||||
baseAmount Decimal @db.Decimal(18, 2)
|
||||
benefits Decimal @default(0) @db.Decimal(18, 2)
|
||||
bonus Decimal @default(0) @db.Decimal(18, 2)
|
||||
deductions Decimal @default(0) @db.Decimal(18, 2)
|
||||
payable Decimal @db.Decimal(18, 2)
|
||||
dueDate DateTime
|
||||
notes String?
|
||||
/// open | partial | settled | void
|
||||
status String @default("open")
|
||||
createdById String?
|
||||
voidedAt DateTime?
|
||||
movements FinancialMovement[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, userId, period])
|
||||
@@index([organizationId, status])
|
||||
}
|
||||
|
||||
model FinancialRecurringItem {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
/// income | expense
|
||||
kind String
|
||||
amount Decimal @db.Decimal(18, 2)
|
||||
/// weekly | monthly | yearly
|
||||
interval String
|
||||
startDate DateTime
|
||||
endDate DateTime?
|
||||
nextDue DateTime
|
||||
accountId String?
|
||||
categoryId String?
|
||||
isActive Boolean @default(true)
|
||||
createdById String?
|
||||
occurrences FinancialRecurringOccurrence[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, isActive, nextDue])
|
||||
}
|
||||
|
||||
model FinancialRecurringOccurrence {
|
||||
id String @id @default(cuid())
|
||||
itemId String
|
||||
item FinancialRecurringItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
periodKey String
|
||||
dueDate DateTime
|
||||
documentId String?
|
||||
/// pending | confirmed
|
||||
status String @default("pending")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([itemId, periodKey])
|
||||
}
|
||||
|
||||
model FinancialReconciliation {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
accountId String
|
||||
account FinancialAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
reconciledAt DateTime
|
||||
statedBalance Decimal @db.Decimal(18, 2)
|
||||
systemBalance Decimal @db.Decimal(18, 2)
|
||||
difference Decimal @db.Decimal(18, 2)
|
||||
notes String?
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([organizationId, accountId, reconciledAt])
|
||||
}
|
||||
|
||||
/// قرارداد یا طلب اقساطی دفتر داخلی
|
||||
model FinancialReceivable {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
partyName String?
|
||||
totalAmount Decimal @db.Decimal(18, 2)
|
||||
notes String?
|
||||
/// open | settled | void
|
||||
status String @default("open")
|
||||
createdById String?
|
||||
voidedAt DateTime?
|
||||
installments FinancialInstallment[]
|
||||
movements FinancialMovement[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, status])
|
||||
@@index([personId])
|
||||
}
|
||||
|
||||
model FinancialInstallment {
|
||||
id String @id @default(cuid())
|
||||
receivableId String
|
||||
receivable FinancialReceivable @relation(fields: [receivableId], references: [id], onDelete: Cascade)
|
||||
position Int
|
||||
dueDate DateTime
|
||||
amount Decimal @db.Decimal(18, 2)
|
||||
reminderNotifiedAt DateTime?
|
||||
movements FinancialMovement[]
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([receivableId, position])
|
||||
@@index([dueDate])
|
||||
}
|
||||
|
||||
/// لاگ یکپارچهٔ پیامک خروجی (دستی / کمپین / اتوماسیون)
|
||||
model OutboundSmsLog {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -33,6 +33,7 @@ import { MemoryModule } from './memory/memory.module';
|
||||
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||
import { InventoryModule } from './inventory/inventory.module';
|
||||
import { FinanceModule } from './finance/finance.module';
|
||||
import { TreasuryModule } from './treasury/treasury.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { AuditLogsModule } from './audit-logs/audit-logs.module';
|
||||
@@ -83,6 +84,7 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
||||
InvoicesModule,
|
||||
InventoryModule,
|
||||
FinanceModule,
|
||||
TreasuryModule,
|
||||
SalesModule,
|
||||
TicketsModule,
|
||||
FaqModule,
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||
return new Prisma.Decimal(value ?? 0);
|
||||
}
|
||||
function money(value: Prisma.Decimal | number | string) {
|
||||
return new Prisma.Decimal(value).toFixed(2);
|
||||
}
|
||||
function parseDate(iso?: string | null) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
function periodKey(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
function addInterval(date: Date, interval: string) {
|
||||
const next = new Date(date);
|
||||
if (interval === 'weekly') next.setDate(next.getDate() + 7);
|
||||
else if (interval === 'yearly') next.setFullYear(next.getFullYear() + 1);
|
||||
else next.setMonth(next.getMonth() + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TreasuryOpsService {
|
||||
private readonly logger = new Logger(TreasuryOpsService.name);
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listPayroll(actor: Actor) {
|
||||
const rows = await this.prisma.financialPayroll.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
include: { movements: true },
|
||||
orderBy: { dueDate: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { id: { in: rows.map((row) => row.userId) } },
|
||||
select: { id: true, fullName: true },
|
||||
});
|
||||
const names = new Map(users.map((user) => [user.id, user.fullName]));
|
||||
return rows.map((row) => this.payrollDto(row, names.get(row.userId) ?? ''));
|
||||
}
|
||||
|
||||
async createPayroll(
|
||||
actor: Actor,
|
||||
input: {
|
||||
userId: string;
|
||||
period: string;
|
||||
baseAmount: string | number;
|
||||
benefits?: string | number;
|
||||
bonus?: string | number;
|
||||
deductions?: string | number;
|
||||
dueDate?: string;
|
||||
notes?: string | null;
|
||||
},
|
||||
) {
|
||||
const base = dec(input.baseAmount);
|
||||
const benefits = dec(input.benefits ?? 0);
|
||||
const bonus = dec(input.bonus ?? 0);
|
||||
const deductions = dec(input.deductions ?? 0);
|
||||
const payable = base.plus(benefits).plus(bonus).minus(deductions);
|
||||
if (!input.userId || !input.period?.trim()) {
|
||||
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
|
||||
}
|
||||
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگتر از صفر باشد.');
|
||||
const member = await this.prisma.membership.findFirst({
|
||||
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
|
||||
});
|
||||
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
|
||||
const row = await this.prisma.financialPayroll.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
userId: input.userId,
|
||||
period: input.period.trim(),
|
||||
baseAmount: base,
|
||||
benefits,
|
||||
bonus,
|
||||
deductions,
|
||||
payable,
|
||||
dueDate: parseDate(input.dueDate) ?? new Date(),
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
include: { movements: true },
|
||||
});
|
||||
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
|
||||
return this.payrollDto(row, user?.fullName ?? '');
|
||||
}
|
||||
|
||||
async payPayroll(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { amount: string | number; accountId: string; occurredAt?: string; clientKey?: string | null },
|
||||
) {
|
||||
const row = await this.prisma.financialPayroll.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
include: { movements: true },
|
||||
});
|
||||
if (!row || row.voidedAt || row.status === 'void' || row.status === 'settled') {
|
||||
throw new NotFoundException('رکورد حقوق قابل پرداخت نیست.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
const paid = this.paidOf(row.movements);
|
||||
const remaining = row.payable.minus(paid);
|
||||
if (!amount.greaterThan(0) || amount.greaterThan(remaining)) {
|
||||
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
|
||||
}
|
||||
if (input.clientKey) {
|
||||
const dup = await this.prisma.financialMovement.findFirst({
|
||||
where: { payrollId: id, reference: input.clientKey, voidedAt: null },
|
||||
});
|
||||
if (dup) throw new BadRequestException('این پرداخت قبلاً ثبت شده است.');
|
||||
}
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
|
||||
const balance = await this.balance(account.id);
|
||||
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
|
||||
const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial';
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
payrollId: row.id,
|
||||
direction: 'out',
|
||||
amount,
|
||||
kind: 'payment',
|
||||
occurredAt: parseDate(input.occurredAt) ?? new Date(),
|
||||
memo: `حقوق ${row.period}`,
|
||||
reference: input.clientKey || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
}),
|
||||
this.prisma.financialPayroll.update({ where: { id: row.id }, data: { status } }),
|
||||
]);
|
||||
return this.listPayroll(actor);
|
||||
}
|
||||
|
||||
async listRecurring(actor: Actor) {
|
||||
const rows = await this.prisma.financialRecurringItem.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
orderBy: { nextDue: 'asc' },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
kind: row.kind,
|
||||
amount: money(row.amount),
|
||||
interval: row.interval,
|
||||
nextDue: row.nextDue.toISOString(),
|
||||
isActive: row.isActive,
|
||||
}));
|
||||
}
|
||||
|
||||
async createRecurring(
|
||||
actor: Actor,
|
||||
input: {
|
||||
title: string;
|
||||
kind: 'income' | 'expense';
|
||||
amount: string | number;
|
||||
interval: 'weekly' | 'monthly' | 'yearly';
|
||||
startDate?: string;
|
||||
endDate?: string | null;
|
||||
accountId?: string | null;
|
||||
categoryId?: string | null;
|
||||
},
|
||||
) {
|
||||
const amount = dec(input.amount);
|
||||
if (!input.title?.trim() || !amount.greaterThan(0)) {
|
||||
throw new BadRequestException('عنوان و مبلغ تکرارشونده لازم است.');
|
||||
}
|
||||
const start = parseDate(input.startDate) ?? new Date();
|
||||
const row = await this.prisma.financialRecurringItem.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
title: input.title.trim(),
|
||||
kind: input.kind,
|
||||
amount,
|
||||
interval: input.interval,
|
||||
startDate: start,
|
||||
endDate: parseDate(input.endDate),
|
||||
nextDue: start,
|
||||
accountId: input.accountId || null,
|
||||
categoryId: input.categoryId || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
return { id: row.id };
|
||||
}
|
||||
|
||||
@Cron('30 8 * * *')
|
||||
async spawnRecurring() {
|
||||
const now = new Date();
|
||||
const items = await this.prisma.financialRecurringItem.findMany({
|
||||
where: { isActive: true, nextDue: { lte: now } },
|
||||
take: 100,
|
||||
});
|
||||
for (const item of items) {
|
||||
if (item.endDate && item.nextDue > item.endDate) continue;
|
||||
const key = periodKey(item.nextDue);
|
||||
const exists = await this.prisma.financialRecurringOccurrence.findUnique({
|
||||
where: { itemId_periodKey: { itemId: item.id, periodKey: key } },
|
||||
});
|
||||
if (!exists) {
|
||||
const doc = await this.prisma.financialDocument.create({
|
||||
data: {
|
||||
organizationId: item.organizationId,
|
||||
kind: item.kind,
|
||||
title: item.title,
|
||||
amount: item.amount,
|
||||
bookedAt: item.nextDue,
|
||||
dueAt: item.nextDue,
|
||||
categoryId: item.categoryId,
|
||||
accountId: item.accountId,
|
||||
status: 'open',
|
||||
notes: 'ساختهشده از الگوی تکرارشونده؛ تا تأیید، موجودی حساب تغییر نمیکند.',
|
||||
},
|
||||
});
|
||||
await this.prisma.financialRecurringOccurrence.create({
|
||||
data: {
|
||||
itemId: item.id,
|
||||
periodKey: key,
|
||||
dueDate: item.nextDue,
|
||||
documentId: doc.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.prisma.financialRecurringItem.update({
|
||||
where: { id: item.id },
|
||||
data: { nextDue: addInterval(item.nextDue, item.interval) },
|
||||
});
|
||||
}
|
||||
if (items.length) this.logger.log(`recurring finance items: ${items.length}`);
|
||||
}
|
||||
|
||||
async reconcile(
|
||||
actor: Actor,
|
||||
input: { accountId: string; statedBalance: string | number; reconciledAt?: string; notes?: string | null },
|
||||
) {
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!account) throw new NotFoundException('حساب پیدا نشد.');
|
||||
const systemBalance = await this.balance(account.id);
|
||||
const stated = dec(input.statedBalance);
|
||||
const row = await this.prisma.financialReconciliation.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
reconciledAt: parseDate(input.reconciledAt) ?? new Date(),
|
||||
statedBalance: stated,
|
||||
systemBalance,
|
||||
difference: stated.minus(systemBalance),
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
statedBalance: money(row.statedBalance),
|
||||
systemBalance: money(row.systemBalance),
|
||||
difference: money(row.difference),
|
||||
};
|
||||
}
|
||||
|
||||
async adjust(
|
||||
actor: Actor,
|
||||
input: { accountId: string; amount: string | number; direction: 'in' | 'out'; reason: string },
|
||||
) {
|
||||
const reason = input.reason?.trim();
|
||||
if (!reason || reason.length < 3) throw new BadRequestException('علت اصلاح موجودی لازم است.');
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ اصلاح باید بزرگتر از صفر باشد.');
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
|
||||
});
|
||||
if (!account) throw new NotFoundException('حساب پیدا نشد.');
|
||||
if (input.direction === 'out') {
|
||||
const balance = await this.balance(account.id);
|
||||
if (balance.lessThan(amount)) throw new BadRequestException('موجودی برای این اصلاح کافی نیست.');
|
||||
}
|
||||
await this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
direction: input.direction,
|
||||
amount,
|
||||
kind: 'adjustment',
|
||||
occurredAt: new Date(),
|
||||
memo: `اصلاحیه: ${reason}`,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async report(actor: Actor, fromIso?: string, toIso?: string) {
|
||||
const to = parseDate(toIso) ?? new Date();
|
||||
const from = parseDate(fromIso) ?? new Date(to.getFullYear(), to.getMonth(), 1);
|
||||
const span = to.getTime() - from.getTime();
|
||||
const prevTo = new Date(from.getTime() - 1);
|
||||
const prevFrom = new Date(prevTo.getTime() - span);
|
||||
const current = await this.period(actor.organizationId, from, to);
|
||||
const previous = await this.period(actor.organizationId, prevFrom, prevTo);
|
||||
const breakdown = await this.breakdown(actor.organizationId);
|
||||
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
|
||||
}
|
||||
|
||||
async reportExcel(actor: Actor, from?: string, to?: string) {
|
||||
const data = await this.report(actor, from, to);
|
||||
const book = new ExcelJS.Workbook();
|
||||
const sheet = book.addWorksheet('گزارش مالی');
|
||||
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
|
||||
const rows: Array<[string, string, string]> = [
|
||||
['درآمد ثبتشده', data.current.bookedIncome, data.previous.bookedIncome],
|
||||
['هزینه ثبتشده', data.current.bookedExpense, data.previous.bookedExpense],
|
||||
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
|
||||
['ورود وجه', data.current.cashIn, data.previous.cashIn],
|
||||
['خروج وجه', data.current.cashOut, data.previous.cashOut],
|
||||
['خالص جریان نقدی', data.current.cashNet, data.previous.cashNet],
|
||||
];
|
||||
rows.forEach((row) => sheet.addRow(row));
|
||||
sheet.addRow([]);
|
||||
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
|
||||
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
|
||||
const buffer = await book.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
private async period(organizationId: string, from: Date, to: Date) {
|
||||
const docs = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
status: { not: 'void' },
|
||||
bookedAt: { gte: from, lte: to },
|
||||
},
|
||||
include: { category: true },
|
||||
});
|
||||
const cash = await this.prisma.financialMovement.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
voidedAt: null,
|
||||
occurredAt: { gte: from, lte: to },
|
||||
kind: { in: ['receipt', 'payment'] },
|
||||
},
|
||||
});
|
||||
let bookedIncome = new Prisma.Decimal(0);
|
||||
let bookedExpense = new Prisma.Decimal(0);
|
||||
const byCat = new Map<string, Prisma.Decimal>();
|
||||
for (const doc of docs) {
|
||||
if (doc.kind === 'income') bookedIncome = bookedIncome.plus(doc.amount);
|
||||
else {
|
||||
bookedExpense = bookedExpense.plus(doc.amount);
|
||||
const name = doc.category?.name ?? 'بدون دسته';
|
||||
byCat.set(name, (byCat.get(name) ?? new Prisma.Decimal(0)).plus(doc.amount));
|
||||
}
|
||||
}
|
||||
let cashIn = new Prisma.Decimal(0);
|
||||
let cashOut = new Prisma.Decimal(0);
|
||||
for (const row of cash) {
|
||||
if (row.direction === 'in') cashIn = cashIn.plus(row.amount);
|
||||
else cashOut = cashOut.plus(row.amount);
|
||||
}
|
||||
const expenseCategories = [...byCat.entries()].map(([name, amount]) => ({
|
||||
name,
|
||||
amount: money(amount),
|
||||
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
|
||||
}));
|
||||
return {
|
||||
bookedIncome: money(bookedIncome),
|
||||
bookedExpense: money(bookedExpense),
|
||||
operatingProfit: money(bookedIncome.minus(bookedExpense)),
|
||||
cashIn: money(cashIn),
|
||||
cashOut: money(cashOut),
|
||||
cashNet: money(cashIn.minus(cashOut)),
|
||||
expenseCategories,
|
||||
};
|
||||
}
|
||||
|
||||
private async breakdown(organizationId: string) {
|
||||
const [docs, payroll, contracts] = await Promise.all([
|
||||
this.prisma.financialDocument.findMany({
|
||||
where: { organizationId, kind: 'expense', status: { in: ['open', 'partial'] } },
|
||||
include: { movements: true },
|
||||
}),
|
||||
this.prisma.financialPayroll.findMany({
|
||||
where: { organizationId, status: { in: ['open', 'partial'] } },
|
||||
include: { movements: true },
|
||||
}),
|
||||
this.prisma.financialReceivable.findMany({
|
||||
where: { organizationId, status: 'open' },
|
||||
include: { movements: true },
|
||||
}),
|
||||
]);
|
||||
const payablesByMonth = new Map<string, Prisma.Decimal>();
|
||||
const addMonth = (date: Date, amount: Prisma.Decimal) => {
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
payablesByMonth.set(key, (payablesByMonth.get(key) ?? new Prisma.Decimal(0)).plus(amount));
|
||||
};
|
||||
for (const doc of docs) {
|
||||
const left = doc.amount.minus(this.paidOf(doc.movements));
|
||||
if (left.greaterThan(0)) addMonth(doc.dueAt ?? doc.bookedAt, left);
|
||||
}
|
||||
for (const row of payroll) {
|
||||
const left = row.payable.minus(this.paidOf(row.movements));
|
||||
if (left.greaterThan(0)) addMonth(row.dueDate, left);
|
||||
}
|
||||
const receivablesByParty = new Map<string, Prisma.Decimal>();
|
||||
for (const row of contracts) {
|
||||
const paid = this.paidOf(row.movements);
|
||||
const left = row.totalAmount.minus(paid);
|
||||
if (!left.greaterThan(0)) continue;
|
||||
const name = row.partyName?.trim() || 'بدون نام';
|
||||
receivablesByParty.set(name, (receivablesByParty.get(name) ?? new Prisma.Decimal(0)).plus(left));
|
||||
}
|
||||
return {
|
||||
payablesByMonth: [...payablesByMonth.entries()].map(([month, amount]) => ({ month, amount: money(amount) })),
|
||||
receivablesByParty: [...receivablesByParty.entries()].map(([name, amount]) => ({ name, amount: money(amount) })),
|
||||
};
|
||||
}
|
||||
|
||||
private paidOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
|
||||
return movements.filter((row) => !row.voidedAt).reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0));
|
||||
}
|
||||
|
||||
private payrollDto(
|
||||
row: {
|
||||
id: string;
|
||||
userId: string;
|
||||
period: string;
|
||||
baseAmount: Prisma.Decimal;
|
||||
benefits: Prisma.Decimal;
|
||||
bonus: Prisma.Decimal;
|
||||
deductions: Prisma.Decimal;
|
||||
payable: Prisma.Decimal;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
|
||||
},
|
||||
fullName: string,
|
||||
) {
|
||||
const paid = this.paidOf(row.movements);
|
||||
const left = row.payable.minus(paid);
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
fullName,
|
||||
period: row.period,
|
||||
baseAmount: money(row.baseAmount),
|
||||
benefits: money(row.benefits),
|
||||
bonus: money(row.bonus),
|
||||
deductions: money(row.deductions),
|
||||
payable: money(row.payable),
|
||||
paidAmount: money(paid),
|
||||
remaining: money(left.isNegative() ? 0 : left),
|
||||
dueDate: row.dueDate.toISOString(),
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
|
||||
private async balance(accountId: string) {
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId, voidedAt: null },
|
||||
select: { direction: true, amount: true },
|
||||
});
|
||||
return rows.reduce(
|
||||
(sum, row) => (row.direction === 'in' ? sum.plus(row.amount) : sum.minus(row.amount)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
FinancialInstallmentDto,
|
||||
FinancialInstallmentStatus,
|
||||
FinancialReceivableDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||
return new Prisma.Decimal(value ?? 0);
|
||||
}
|
||||
|
||||
function money(value: Prisma.Decimal | number | string) {
|
||||
return new Prisma.Decimal(value).toFixed(2);
|
||||
}
|
||||
|
||||
function parseDate(iso?: string | null) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function addPeriod(base: Date, interval: 'weekly' | 'monthly', step: number) {
|
||||
const next = new Date(base);
|
||||
if (interval === 'weekly') next.setDate(next.getDate() + step * 7);
|
||||
else next.setMonth(next.getMonth() + step);
|
||||
return next;
|
||||
}
|
||||
|
||||
type MovementSlice = { amount: Prisma.Decimal; voidedAt: Date | null; installmentId?: string | null };
|
||||
|
||||
@Injectable()
|
||||
export class TreasuryReceivablesService {
|
||||
private readonly logger = new Logger(TreasuryReceivablesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async list(actor: Actor): Promise<FinancialReceivableDto[]> {
|
||||
const rows = await this.prisma.financialReceivable.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
include: {
|
||||
installments: { include: { movements: true }, orderBy: { position: 'asc' } },
|
||||
movements: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async create(
|
||||
actor: Actor,
|
||||
input: {
|
||||
title: string;
|
||||
personId?: string | null;
|
||||
partyName?: string | null;
|
||||
totalAmount: string | number;
|
||||
prepaidAmount?: string | number | null;
|
||||
accountId?: string | null;
|
||||
startDate?: string;
|
||||
count?: number;
|
||||
interval?: 'weekly' | 'monthly';
|
||||
notes?: string | null;
|
||||
installments?: Array<{ amount: string | number; dueDate: string }>;
|
||||
},
|
||||
): Promise<FinancialReceivableDto> {
|
||||
const title = input.title.trim();
|
||||
const total = dec(input.totalAmount);
|
||||
const prepaid = dec(input.prepaidAmount ?? 0);
|
||||
if (title.length < 2) throw new BadRequestException('عنوان قرارداد را وارد کنید.');
|
||||
if (!total.greaterThan(0)) throw new BadRequestException('مبلغ قرارداد باید بزرگتر از صفر باشد.');
|
||||
if (prepaid.isNegative() || prepaid.greaterThan(total)) {
|
||||
throw new BadRequestException('پیشپرداخت نامعتبر است.');
|
||||
}
|
||||
const financed = total.minus(prepaid);
|
||||
const lines = this.buildSchedule(financed, input);
|
||||
if (prepaid.greaterThan(0) && !input.accountId) {
|
||||
throw new BadRequestException('برای پیشپرداخت، حساب مقصد را انتخاب کنید.');
|
||||
}
|
||||
if (input.accountId) {
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('حساب مقصد پیدا نشد.');
|
||||
}
|
||||
const start = parseDate(input.startDate) ?? new Date();
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const receivable = await tx.financialReceivable.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
personId: input.personId || null,
|
||||
partyName: input.partyName?.trim() || null,
|
||||
totalAmount: total,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
status: financed.equals(0) ? 'settled' : 'open',
|
||||
installments: {
|
||||
create: lines.map((line, index) => ({
|
||||
position: index + 1,
|
||||
dueDate: line.dueDate,
|
||||
amount: line.amount,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { installments: true },
|
||||
});
|
||||
if (prepaid.greaterThan(0) && input.accountId) {
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: input.accountId,
|
||||
receivableId: receivable.id,
|
||||
direction: 'in',
|
||||
amount: prepaid,
|
||||
kind: 'receipt',
|
||||
occurredAt: start,
|
||||
memo: `پیشپرداخت ${title}`,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return receivable.id;
|
||||
});
|
||||
return this.get(actor, created);
|
||||
}
|
||||
|
||||
async collect(
|
||||
actor: Actor,
|
||||
installmentId: string,
|
||||
input: {
|
||||
amount: string | number;
|
||||
accountId: string;
|
||||
occurredAt?: string;
|
||||
notes?: string | null;
|
||||
clientKey?: string | null;
|
||||
},
|
||||
): Promise<FinancialReceivableDto> {
|
||||
const installment = await this.prisma.financialInstallment.findFirst({
|
||||
where: { id: installmentId, receivable: { organizationId: actor.organizationId } },
|
||||
include: { movements: true, receivable: true },
|
||||
});
|
||||
if (!installment || installment.receivable.voidedAt) {
|
||||
throw new NotFoundException('قسط پیدا نشد.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ دریافت باید بزرگتر از صفر باشد.');
|
||||
const paid = this.paidOf(installment.movements);
|
||||
const remaining = installment.amount.minus(paid);
|
||||
if (!remaining.greaterThan(0)) {
|
||||
throw new BadRequestException('این قسط قبلاً تسویه شده است.');
|
||||
}
|
||||
if (amount.greaterThan(remaining)) {
|
||||
throw new BadRequestException('مبلغ از مانده قسط بیشتر است.');
|
||||
}
|
||||
const clientKey = input.clientKey?.trim();
|
||||
if (clientKey) {
|
||||
const dup = await this.prisma.financialMovement.findFirst({
|
||||
where: { installmentId, reference: clientKey, voidedAt: null },
|
||||
});
|
||||
if (dup) throw new BadRequestException('این دریافت قبلاً ثبت شده است.');
|
||||
}
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('حساب مقصد پیدا نشد.');
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
receivableId: installment.receivableId,
|
||||
installmentId: installment.id,
|
||||
direction: 'in',
|
||||
amount,
|
||||
kind: 'receipt',
|
||||
occurredAt: parseDate(input.occurredAt) ?? new Date(),
|
||||
memo: input.notes?.trim() || `قسط ${installment.position} ${installment.receivable.title}`,
|
||||
reference: clientKey || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
const nextPaid = paid.plus(amount);
|
||||
const all = await tx.financialInstallment.findMany({
|
||||
where: { receivableId: installment.receivableId },
|
||||
include: { movements: true },
|
||||
});
|
||||
const settled = all.every((row) => {
|
||||
const rowPaid = row.id === installment.id ? nextPaid : this.paidOf(row.movements);
|
||||
return rowPaid.greaterThanOrEqualTo(row.amount);
|
||||
});
|
||||
if (settled) {
|
||||
await tx.financialReceivable.update({
|
||||
where: { id: installment.receivableId },
|
||||
data: { status: 'settled' },
|
||||
});
|
||||
}
|
||||
});
|
||||
return this.get(actor, installment.receivableId);
|
||||
}
|
||||
|
||||
async upcoming(actor: Actor) {
|
||||
const rows = await this.prisma.financialInstallment.findMany({
|
||||
where: {
|
||||
receivable: { organizationId: actor.organizationId, status: { not: 'void' }, voidedAt: null },
|
||||
},
|
||||
include: { movements: true, receivable: true },
|
||||
orderBy: { dueDate: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
return rows
|
||||
.map((row) => this.toInstallment(row))
|
||||
.filter((row) => row.status === 'open' || row.status === 'partial' || row.status === 'overdue')
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
async payables(actor: Actor) {
|
||||
return this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind: 'expense',
|
||||
status: { in: ['open', 'partial'] },
|
||||
},
|
||||
include: { category: true, account: true, movements: true },
|
||||
orderBy: [{ dueAt: 'asc' }, { bookedAt: 'asc' }],
|
||||
take: 12,
|
||||
});
|
||||
}
|
||||
|
||||
@Cron('15 8 * * *')
|
||||
async remindDue() {
|
||||
const now = new Date();
|
||||
const horizon = new Date(now);
|
||||
horizon.setDate(horizon.getDate() + 3);
|
||||
const rows = await this.prisma.financialInstallment.findMany({
|
||||
where: {
|
||||
dueDate: { lte: horizon },
|
||||
reminderNotifiedAt: null,
|
||||
receivable: { status: 'open', voidedAt: null },
|
||||
},
|
||||
include: { movements: true, receivable: true },
|
||||
take: 80,
|
||||
});
|
||||
for (const row of rows) {
|
||||
const dto = this.toInstallment(row);
|
||||
if (dto.status === 'settled') continue;
|
||||
const admins = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
organizationId: row.receivable.organizationId,
|
||||
isActive: true,
|
||||
role: { in: ['owner', 'admin'] },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
for (const admin of admins) {
|
||||
await this.notifications.create({
|
||||
organizationId: row.receivable.organizationId,
|
||||
userId: admin.userId,
|
||||
type: 'treasury_installment',
|
||||
title: dto.status === 'overdue' ? 'قسط معوق' : 'قسط نزدیک سررسید',
|
||||
body: `${row.receivable.title} · قسط ${row.position}`,
|
||||
link: '/finance/receivables',
|
||||
entityType: 'financial_installment',
|
||||
entityId: row.id,
|
||||
dedupe: true,
|
||||
});
|
||||
}
|
||||
await this.prisma.financialInstallment.update({
|
||||
where: { id: row.id },
|
||||
data: { reminderNotifiedAt: now },
|
||||
});
|
||||
}
|
||||
if (rows.length) this.logger.log(`treasury installment reminders: ${rows.length}`);
|
||||
}
|
||||
|
||||
private async get(actor: Actor, id: string): Promise<FinancialReceivableDto> {
|
||||
const row = await this.prisma.financialReceivable.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
include: {
|
||||
installments: { include: { movements: true }, orderBy: { position: 'asc' } },
|
||||
movements: true,
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('قرارداد پیدا نشد.');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
private buildSchedule(
|
||||
financed: Prisma.Decimal,
|
||||
input: {
|
||||
startDate?: string;
|
||||
count?: number;
|
||||
interval?: 'weekly' | 'monthly';
|
||||
installments?: Array<{ amount: string | number; dueDate: string }>;
|
||||
},
|
||||
) {
|
||||
if (financed.equals(0)) return [];
|
||||
if (input.installments?.length) {
|
||||
const lines = input.installments.map((line) => {
|
||||
const dueDate = parseDate(line.dueDate);
|
||||
const amount = dec(line.amount);
|
||||
if (!dueDate || !amount.greaterThan(0)) {
|
||||
throw new BadRequestException('مبلغ و سررسید هر قسط لازم است.');
|
||||
}
|
||||
return { amount, dueDate };
|
||||
});
|
||||
const sum = lines.reduce((total, line) => total.plus(line.amount), new Prisma.Decimal(0));
|
||||
if (!sum.equals(financed)) {
|
||||
throw new BadRequestException('جمع اقساط باید برابر مانده پس از پیشپرداخت باشد.');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
const count = Math.floor(input.count ?? 0);
|
||||
if (count < 1 || count > 60) {
|
||||
throw new BadRequestException('تعداد اقساط باید بین ۱ و ۶۰ باشد.');
|
||||
}
|
||||
const interval = input.interval === 'weekly' ? 'weekly' : 'monthly';
|
||||
const start = parseDate(input.startDate) ?? new Date();
|
||||
const base = financed.dividedBy(count).toDecimalPlaces(2, Prisma.Decimal.ROUND_DOWN);
|
||||
const lines = Array.from({ length: count }, (_, index) => ({
|
||||
amount: base,
|
||||
dueDate: addPeriod(start, interval, index),
|
||||
}));
|
||||
const assigned = base.mul(count);
|
||||
lines[lines.length - 1]!.amount = lines[lines.length - 1]!.amount.plus(financed.minus(assigned));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private paidOf(movements: MovementSlice[]) {
|
||||
return movements
|
||||
.filter((row) => !row.voidedAt)
|
||||
.reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0));
|
||||
}
|
||||
|
||||
private toInstallment(row: {
|
||||
id: string;
|
||||
position: number;
|
||||
dueDate: Date;
|
||||
amount: Prisma.Decimal;
|
||||
movements: MovementSlice[];
|
||||
}): FinancialInstallmentDto {
|
||||
const paid = this.paidOf(row.movements);
|
||||
const remainingRaw = row.amount.minus(paid);
|
||||
const remaining = remainingRaw.isNegative() ? new Prisma.Decimal(0) : remainingRaw;
|
||||
let status: FinancialInstallmentStatus = 'open';
|
||||
if (remaining.equals(0)) status = 'settled';
|
||||
else if (row.dueDate.getTime() < Date.now()) status = 'overdue';
|
||||
else if (paid.greaterThan(0)) status = 'partial';
|
||||
return {
|
||||
id: row.id,
|
||||
position: row.position,
|
||||
dueDate: row.dueDate.toISOString(),
|
||||
amount: money(row.amount),
|
||||
paidAmount: money(paid),
|
||||
remaining: money(remaining),
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
title: string;
|
||||
personId: string | null;
|
||||
partyName: string | null;
|
||||
totalAmount: Prisma.Decimal;
|
||||
notes: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
movements: MovementSlice[];
|
||||
installments: Array<{
|
||||
id: string;
|
||||
position: number;
|
||||
dueDate: Date;
|
||||
amount: Prisma.Decimal;
|
||||
movements: MovementSlice[];
|
||||
}>;
|
||||
}): FinancialReceivableDto {
|
||||
const paid = this.paidOf(row.movements);
|
||||
const remainingRaw = row.totalAmount.minus(paid);
|
||||
const remaining = remainingRaw.isNegative() ? new Prisma.Decimal(0) : remainingRaw;
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
personId: row.personId,
|
||||
partyName: row.partyName,
|
||||
totalAmount: money(row.totalAmount),
|
||||
paidAmount: money(paid),
|
||||
remaining: money(remaining),
|
||||
status: row.status as FinancialReceivableDto['status'],
|
||||
notes: row.notes,
|
||||
installments: row.installments.map((item) => this.toInstallment(item)),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { TreasuryOpsService } from './treasury-ops.service';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
|
||||
@Controller('treasury')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
export class TreasuryController {
|
||||
constructor(
|
||||
private readonly treasury: TreasuryService,
|
||||
private readonly receivables: TreasuryReceivablesService,
|
||||
private readonly ops: TreasuryOpsService,
|
||||
) {}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
return this.treasury.dashboard(user, { from, to });
|
||||
}
|
||||
|
||||
@Get('categories')
|
||||
categories(@CurrentUser() user: Actor, @Query('kind') kind?: string) {
|
||||
return this.treasury.listCategories(user, kind);
|
||||
}
|
||||
|
||||
@Post('categories')
|
||||
createCategory(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: { name: string; kind: 'income' | 'expense' },
|
||||
) {
|
||||
return this.treasury.createCategory(user, body);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
accounts(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
return this.treasury.listAccounts(user, { from, to });
|
||||
}
|
||||
|
||||
@Post('accounts')
|
||||
createAccount(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryService['createAccount']>[1]) {
|
||||
return this.treasury.createAccount(user, body);
|
||||
}
|
||||
|
||||
@Patch('accounts/:id')
|
||||
updateAccount(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
|
||||
) {
|
||||
return this.treasury.updateAccount(user, id, body);
|
||||
}
|
||||
|
||||
@Get('accounts/:id/ledger')
|
||||
ledger(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
return this.treasury.ledger(user, id, { from, to });
|
||||
}
|
||||
|
||||
@Post('transfers')
|
||||
transfer(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryService['transfer']>[1]) {
|
||||
return this.treasury.transfer(user, body);
|
||||
}
|
||||
|
||||
@Get('documents')
|
||||
documents(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('kind') kind: 'income' | 'expense',
|
||||
@Query('q') q?: string,
|
||||
) {
|
||||
return this.treasury.listDocuments(user, kind === 'expense' ? 'expense' : 'income', q);
|
||||
}
|
||||
|
||||
@Post('documents')
|
||||
createDocument(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryService['createDocument']>[1]) {
|
||||
return this.treasury.createDocument(user, body);
|
||||
}
|
||||
|
||||
@Post('documents/:id/settle')
|
||||
settle(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { amount: string | number; accountId: string; occurredAt?: string; reference?: string | null },
|
||||
) {
|
||||
return this.treasury.settleDocument(user, id, body);
|
||||
}
|
||||
|
||||
@Get('receivables')
|
||||
receivablesList(@CurrentUser() user: Actor) {
|
||||
return this.receivables.list(user);
|
||||
}
|
||||
|
||||
@Post('receivables')
|
||||
createReceivable(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: Parameters<TreasuryReceivablesService['create']>[1],
|
||||
) {
|
||||
return this.receivables.create(user, body);
|
||||
}
|
||||
|
||||
@Post('installments/:id/collect')
|
||||
collectInstallment(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Parameters<TreasuryReceivablesService['collect']>[2],
|
||||
) {
|
||||
return this.receivables.collect(user, id, body);
|
||||
}
|
||||
|
||||
@Get('payroll')
|
||||
payroll(@CurrentUser() user: Actor) {
|
||||
return this.ops.listPayroll(user);
|
||||
}
|
||||
|
||||
@Post('payroll')
|
||||
createPayroll(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryOpsService['createPayroll']>[1]) {
|
||||
return this.ops.createPayroll(user, body);
|
||||
}
|
||||
|
||||
@Post('payroll/:id/pay')
|
||||
payPayroll(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Parameters<TreasuryOpsService['payPayroll']>[2],
|
||||
) {
|
||||
return this.ops.payPayroll(user, id, body);
|
||||
}
|
||||
|
||||
@Get('recurring')
|
||||
recurring(@CurrentUser() user: Actor) {
|
||||
return this.ops.listRecurring(user);
|
||||
}
|
||||
|
||||
@Post('recurring')
|
||||
createRecurring(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: Parameters<TreasuryOpsService['createRecurring']>[1],
|
||||
) {
|
||||
return this.ops.createRecurring(user, body);
|
||||
}
|
||||
|
||||
@Get('reports')
|
||||
report(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
return this.ops.report(user, from, to);
|
||||
}
|
||||
|
||||
@Get('reports/export')
|
||||
async exportReport(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
const buffer = await this.ops.reportExcel(user, from, to);
|
||||
return new StreamableFile(buffer, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment; filename="treasury-report.xlsx"',
|
||||
});
|
||||
}
|
||||
|
||||
@Post('reconciliations')
|
||||
reconcile(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryOpsService['reconcile']>[1]) {
|
||||
return this.ops.reconcile(user, body);
|
||||
}
|
||||
|
||||
@Post('adjustments')
|
||||
adjust(@CurrentUser() user: Actor, @Body() body: Parameters<TreasuryOpsService['adjust']>[1]) {
|
||||
return this.ops.adjust(user, body);
|
||||
}
|
||||
|
||||
@Post('documents/:id/void')
|
||||
voidDoc(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { reason?: string },
|
||||
) {
|
||||
return this.treasury.voidDocument(user, id, body.reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TreasuryController } from './treasury.controller';
|
||||
import { TreasuryOpsService } from './treasury-ops.service';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, NotificationsModule],
|
||||
controllers: [TreasuryController],
|
||||
providers: [TreasuryService, TreasuryReceivablesService, TreasuryOpsService],
|
||||
exports: [TreasuryService],
|
||||
})
|
||||
export class TreasuryModule {}
|
||||
@@ -0,0 +1,769 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
FinancialAccountDto,
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
FinancialMovementDto,
|
||||
TreasuryDashboardDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
|
||||
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
|
||||
const EXPENSE_CATEGORIES = [
|
||||
'اجاره',
|
||||
'آب، برق، گاز و ارتباطات',
|
||||
'حقوق و مزایا',
|
||||
'اشتراک نرمافزار',
|
||||
'تجهیزات و ملزومات',
|
||||
'تبلیغات',
|
||||
'ایاب و ذهاب',
|
||||
'پشتیبانی و خدمات',
|
||||
'مالیات و عوارض',
|
||||
'سایر هزینهها',
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
opening: 'مانده افتتاحیه',
|
||||
receipt: 'دریافت',
|
||||
payment: 'پرداخت',
|
||||
transfer_in: 'انتقال ورودی',
|
||||
transfer_out: 'انتقال خروجی',
|
||||
};
|
||||
|
||||
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||
return new Prisma.Decimal(value ?? 0);
|
||||
}
|
||||
|
||||
function money(value: Prisma.Decimal | number | string) {
|
||||
return new Prisma.Decimal(value).toFixed(2);
|
||||
}
|
||||
|
||||
function startOfDay(iso?: string) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TreasuryService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly receivables: TreasuryReceivablesService,
|
||||
) {}
|
||||
|
||||
async ensureCategories(organizationId: string) {
|
||||
const count = await this.prisma.financialCategory.count({
|
||||
where: { organizationId },
|
||||
});
|
||||
if (count > 0) return;
|
||||
await this.prisma.financialCategory.createMany({
|
||||
data: [
|
||||
...INCOME_CATEGORIES.map((name) => ({
|
||||
organizationId,
|
||||
name,
|
||||
kind: 'income',
|
||||
})),
|
||||
...EXPENSE_CATEGORIES.map((name) => ({
|
||||
organizationId,
|
||||
name,
|
||||
kind: 'expense',
|
||||
})),
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async listCategories(actor: Actor, kind?: string): Promise<FinancialCategoryDto[]> {
|
||||
await this.ensureCategories(actor.organizationId);
|
||||
const rows = await this.prisma.financialCategory.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(kind === 'income' || kind === 'expense' ? { kind } : {}),
|
||||
},
|
||||
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
}));
|
||||
}
|
||||
|
||||
async createCategory(
|
||||
actor: Actor,
|
||||
input: { name: string; kind: 'income' | 'expense' },
|
||||
): 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,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
async listAccounts(
|
||||
actor: Actor,
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<FinancialAccountDto[]> {
|
||||
const accounts = await this.prisma.financialAccount.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
return Promise.all(accounts.map((account) => this.toAccountDto(account, range)));
|
||||
}
|
||||
|
||||
async createAccount(
|
||||
actor: Actor,
|
||||
input: {
|
||||
name: string;
|
||||
type: 'bank' | 'cash' | 'petty';
|
||||
bankName?: string | null;
|
||||
accountRef?: string | null;
|
||||
responsibleName?: string | null;
|
||||
openingBalance?: string | number;
|
||||
openingDate?: string;
|
||||
notes?: string | null;
|
||||
},
|
||||
): Promise<FinancialAccountDto> {
|
||||
const name = input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام حساب را وارد کنید.');
|
||||
if (!['bank', 'cash', 'petty'].includes(input.type)) {
|
||||
throw new BadRequestException('نوع حساب نامعتبر است.');
|
||||
}
|
||||
const opening = dec(input.openingBalance ?? 0);
|
||||
if (opening.isNegative()) {
|
||||
throw new BadRequestException('موجودی اولیه نمیتواند منفی باشد.');
|
||||
}
|
||||
const openingDate = startOfDay(input.openingDate) ?? new Date();
|
||||
const account = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.financialAccount.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
type: input.type,
|
||||
bankName: input.bankName?.trim() || null,
|
||||
accountRef: input.accountRef?.trim() || null,
|
||||
responsibleName: input.responsibleName?.trim() || null,
|
||||
openingBalance: opening,
|
||||
openingDate,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
if (opening.greaterThan(0)) {
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: created.id,
|
||||
direction: 'in',
|
||||
amount: opening,
|
||||
kind: 'opening',
|
||||
occurredAt: openingDate,
|
||||
memo: 'مانده افتتاحیه',
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
return this.toAccountDto(account);
|
||||
}
|
||||
|
||||
async updateAccount(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
|
||||
): Promise<FinancialAccountDto> {
|
||||
const existing = await this.requireAccount(actor.organizationId, id);
|
||||
const row = await this.prisma.financialAccount.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(input.name?.trim() ? { name: input.name.trim() } : {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
|
||||
...(input.responsibleName !== undefined
|
||||
? { responsibleName: input.responsibleName?.trim() || null }
|
||||
: {}),
|
||||
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
|
||||
},
|
||||
});
|
||||
return this.toAccountDto(row);
|
||||
}
|
||||
|
||||
async ledger(
|
||||
actor: Actor,
|
||||
accountId: string,
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<{ account: FinancialAccountDto; items: FinancialMovementDto[] }> {
|
||||
const account = await this.requireAccount(actor.organizationId, accountId);
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId,
|
||||
voidedAt: null,
|
||||
...(from || to
|
||||
? {
|
||||
occurredAt: {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lte: to } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ occurredAt: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
let running = new Prisma.Decimal(0);
|
||||
if (from) {
|
||||
const prior = await this.prisma.financialMovement.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId,
|
||||
voidedAt: null,
|
||||
occurredAt: { lt: from },
|
||||
},
|
||||
select: { direction: true, amount: true },
|
||||
});
|
||||
for (const row of prior) {
|
||||
running = row.direction === 'in' ? running.plus(row.amount) : running.minus(row.amount);
|
||||
}
|
||||
}
|
||||
const items: FinancialMovementDto[] = rows.map((row) => {
|
||||
running = row.direction === 'in' ? running.plus(row.amount) : running.minus(row.amount);
|
||||
return {
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
direction: row.direction as 'in' | 'out',
|
||||
amount: money(row.amount),
|
||||
kind: row.kind,
|
||||
kindLabel: KIND_LABEL[row.kind] ?? row.kind,
|
||||
transferGroupId: row.transferGroupId,
|
||||
documentId: row.documentId,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
memo: row.memo,
|
||||
reference: row.reference,
|
||||
balanceAfter: money(running),
|
||||
};
|
||||
});
|
||||
return { account: await this.toAccountDto(account, range), items };
|
||||
}
|
||||
|
||||
async transfer(
|
||||
actor: Actor,
|
||||
input: {
|
||||
fromAccountId: string;
|
||||
toAccountId: string;
|
||||
amount: string | number;
|
||||
occurredAt?: string;
|
||||
reference?: string | null;
|
||||
memo?: string | null;
|
||||
clientKey?: string | null;
|
||||
},
|
||||
) {
|
||||
if (input.fromAccountId === input.toAccountId) {
|
||||
throw new BadRequestException('حساب مبدأ و مقصد نباید یکسان باشند.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) {
|
||||
throw new BadRequestException('مبلغ انتقال باید بزرگتر از صفر باشد.');
|
||||
}
|
||||
const clientKey = input.clientKey?.trim();
|
||||
if (clientKey) {
|
||||
const dup = await this.prisma.financialMovement.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
transferGroupId: clientKey,
|
||||
voidedAt: null,
|
||||
},
|
||||
});
|
||||
if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.');
|
||||
}
|
||||
const [source, target] = await Promise.all([
|
||||
this.requireAccount(actor.organizationId, input.fromAccountId),
|
||||
this.requireAccount(actor.organizationId, input.toAccountId),
|
||||
]);
|
||||
if (!source.isActive || !target.isActive) {
|
||||
throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.');
|
||||
}
|
||||
const balance = await this.accountBalance(source.id);
|
||||
if (balance.lessThan(amount)) {
|
||||
throw new BadRequestException('موجودی حساب مبدأ برای این انتقال کافی نیست.');
|
||||
}
|
||||
const groupId = clientKey || randomUUID();
|
||||
const occurredAt = startOfDay(input.occurredAt) ?? new Date();
|
||||
const memo = input.memo?.trim() || `انتقال از ${source.name} به ${target.name}`;
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: source.id,
|
||||
direction: 'out',
|
||||
amount,
|
||||
kind: 'transfer_out',
|
||||
transferGroupId: groupId,
|
||||
occurredAt,
|
||||
memo,
|
||||
reference: input.reference?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
}),
|
||||
this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: target.id,
|
||||
direction: 'in',
|
||||
amount,
|
||||
kind: 'transfer_in',
|
||||
transferGroupId: groupId,
|
||||
occurredAt,
|
||||
memo,
|
||||
reference: input.reference?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { ok: true, transferGroupId: groupId };
|
||||
}
|
||||
|
||||
async listDocuments(
|
||||
actor: Actor,
|
||||
kind: 'income' | 'expense',
|
||||
q?: string,
|
||||
): Promise<FinancialDocumentDto[]> {
|
||||
const rows = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind,
|
||||
...(q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ partyName: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ reference: { contains: q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: { category: true, account: true, movements: true },
|
||||
orderBy: { bookedAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((row) => this.toDocumentDto(row));
|
||||
}
|
||||
|
||||
async createDocument(
|
||||
actor: Actor,
|
||||
input: {
|
||||
kind: 'income' | 'expense';
|
||||
title: string;
|
||||
amount: string | number;
|
||||
bookedAt?: string;
|
||||
dueAt?: string | null;
|
||||
categoryId?: string | null;
|
||||
personId?: string | null;
|
||||
partyName?: string | null;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
notes?: string | null;
|
||||
settleAmount?: string | number | null;
|
||||
accountId?: string | null;
|
||||
settledAt?: string | null;
|
||||
},
|
||||
): Promise<FinancialDocumentDto> {
|
||||
const title = input.title.trim();
|
||||
const amount = dec(input.amount);
|
||||
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
||||
const settle = input.settleAmount == null || input.settleAmount === ''
|
||||
? new Prisma.Decimal(0)
|
||||
: dec(input.settleAmount);
|
||||
if (settle.isNegative() || settle.greaterThan(amount)) {
|
||||
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
|
||||
}
|
||||
if (settle.greaterThan(0) && !input.accountId) {
|
||||
throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.');
|
||||
}
|
||||
if (input.accountId) {
|
||||
const account = await this.requireAccount(actor.organizationId, input.accountId);
|
||||
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
||||
if (input.kind === 'expense' && settle.greaterThan(0)) {
|
||||
const balance = await this.accountBalance(account.id);
|
||||
if (balance.lessThan(settle)) {
|
||||
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
|
||||
}
|
||||
}
|
||||
}
|
||||
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
|
||||
const doc = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.financialDocument.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
kind: input.kind,
|
||||
title,
|
||||
amount,
|
||||
bookedAt,
|
||||
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
|
||||
categoryId: input.categoryId || null,
|
||||
personId: input.personId || null,
|
||||
partyName: input.partyName?.trim() || null,
|
||||
accountId: input.accountId || null,
|
||||
method: input.method || null,
|
||||
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
|
||||
reference: input.reference?.trim() || null,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
if (settle.greaterThan(0) && input.accountId) {
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: input.accountId,
|
||||
documentId: created.id,
|
||||
direction: input.kind === 'income' ? 'in' : 'out',
|
||||
amount: settle,
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
reference: input.reference?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return tx.financialDocument.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: { category: true, account: true, movements: true },
|
||||
});
|
||||
});
|
||||
return this.toDocumentDto(doc);
|
||||
}
|
||||
|
||||
async settleDocument(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { amount: string | number; accountId: string; occurredAt?: string; reference?: string | null },
|
||||
): Promise<FinancialDocumentDto> {
|
||||
const doc = await this.prisma.financialDocument.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
include: { movements: true, category: true, account: true },
|
||||
});
|
||||
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
|
||||
if (doc.status === 'settled' || doc.status === 'void') {
|
||||
throw new BadRequestException('این سند دیگر قابل تسویه نیست.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
||||
const settled = this.settledOf(doc.movements);
|
||||
const remaining = doc.amount.minus(settled);
|
||||
if (amount.greaterThan(remaining)) {
|
||||
throw new BadRequestException('مبلغ از مانده سند بیشتر است.');
|
||||
}
|
||||
const account = await this.requireAccount(actor.organizationId, input.accountId);
|
||||
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
||||
if (doc.kind === 'expense') {
|
||||
const balance = await this.accountBalance(account.id);
|
||||
if (balance.lessThan(amount)) {
|
||||
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
|
||||
}
|
||||
}
|
||||
const nextSettled = settled.plus(amount);
|
||||
const status = nextSettled.equals(doc.amount) ? 'settled' : 'partial';
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
documentId: doc.id,
|
||||
direction: doc.kind === 'income' ? 'in' : 'out',
|
||||
amount,
|
||||
kind: doc.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.occurredAt) ?? new Date(),
|
||||
memo: doc.title,
|
||||
reference: input.reference?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
}),
|
||||
this.prisma.financialDocument.update({
|
||||
where: { id: doc.id },
|
||||
data: { status, accountId: account.id },
|
||||
}),
|
||||
]);
|
||||
if (status === 'settled') {
|
||||
await this.prisma.financialRecurringOccurrence.updateMany({
|
||||
where: { documentId: doc.id, status: 'pending' },
|
||||
data: { status: 'confirmed' },
|
||||
});
|
||||
}
|
||||
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
|
||||
where: { id: doc.id },
|
||||
include: { category: true, account: true, movements: true },
|
||||
});
|
||||
return this.toDocumentDto(fresh);
|
||||
}
|
||||
|
||||
async voidDocument(actor: Actor, id: string, reason?: string): Promise<FinancialDocumentDto> {
|
||||
const doc = await this.prisma.financialDocument.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.updateMany({
|
||||
where: { documentId: doc.id, voidedAt: null },
|
||||
data: { voidedAt: now },
|
||||
}),
|
||||
this.prisma.financialDocument.update({
|
||||
where: { id: doc.id },
|
||||
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
|
||||
}),
|
||||
]);
|
||||
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
|
||||
where: { id: doc.id },
|
||||
include: { category: true, account: true, movements: true },
|
||||
});
|
||||
return this.toDocumentDto(fresh);
|
||||
}
|
||||
|
||||
async dashboard(
|
||||
actor: Actor,
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<TreasuryDashboardDto> {
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const bookedWhere = {
|
||||
organizationId: actor.organizationId,
|
||||
status: { not: 'void' },
|
||||
...(from || to
|
||||
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
||||
: {}),
|
||||
};
|
||||
const cashWhere = {
|
||||
organizationId: actor.organizationId,
|
||||
voidedAt: null,
|
||||
...(from || to
|
||||
? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
||||
: {}),
|
||||
};
|
||||
const [income, expense, receipts, payments, openDocs, accounts, recent, upcomingInstallments, payableRows] =
|
||||
await Promise.all([
|
||||
this.prisma.financialDocument.aggregate({
|
||||
where: { ...bookedWhere, kind: 'income' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.financialDocument.aggregate({
|
||||
where: { ...bookedWhere, kind: 'expense' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.financialMovement.aggregate({
|
||||
where: { ...cashWhere, kind: 'receipt' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.financialMovement.aggregate({
|
||||
where: { ...cashWhere, kind: 'payment' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
status: { in: ['open', 'partial'] },
|
||||
},
|
||||
include: { movements: true },
|
||||
}),
|
||||
this.listAccounts(actor),
|
||||
this.prisma.financialDocument.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
include: { category: true, account: true, movements: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
}),
|
||||
this.receivables.upcoming(actor),
|
||||
this.receivables.payables(actor),
|
||||
]);
|
||||
let receivables = new Prisma.Decimal(0);
|
||||
let payables = new Prisma.Decimal(0);
|
||||
for (const doc of openDocs) {
|
||||
const left = doc.amount.minus(this.settledOf(doc.movements));
|
||||
if (left.lessThanOrEqualTo(0)) continue;
|
||||
if (doc.kind === 'income') receivables = receivables.plus(left);
|
||||
else payables = payables.plus(left);
|
||||
}
|
||||
const contracts = await this.receivables.list(actor);
|
||||
for (const contract of contracts) {
|
||||
if (contract.status === 'open') receivables = receivables.plus(contract.remaining);
|
||||
}
|
||||
const openPayroll = await this.prisma.financialPayroll.findMany({
|
||||
where: { organizationId: actor.organizationId, status: { in: ['open', 'partial'] } },
|
||||
include: { movements: true },
|
||||
});
|
||||
for (const row of openPayroll) {
|
||||
const paid = row.movements
|
||||
.filter((item) => !item.voidedAt)
|
||||
.reduce((sum, item) => sum.plus(item.amount), new Prisma.Decimal(0));
|
||||
const left = row.payable.minus(paid);
|
||||
if (left.greaterThan(0)) payables = payables.plus(left);
|
||||
}
|
||||
const bookedIncome = dec(income._sum.amount);
|
||||
const bookedExpense = dec(expense._sum.amount);
|
||||
const totalBalance = accounts.reduce(
|
||||
(sum, account) => sum.plus(account.balance),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
return {
|
||||
bookedIncome: money(bookedIncome),
|
||||
actualReceipts: money(dec(receipts._sum.amount)),
|
||||
bookedExpense: money(bookedExpense),
|
||||
actualPayments: money(dec(payments._sum.amount)),
|
||||
operatingProfit: money(bookedIncome.minus(bookedExpense)),
|
||||
receivables: money(receivables),
|
||||
payables: money(payables),
|
||||
totalBalance: money(totalBalance),
|
||||
recent: recent.map((row) => this.toDocumentDto(row)),
|
||||
upcomingInstallments,
|
||||
upcomingPayables: payableRows.map((row) => this.toDocumentDto(row)),
|
||||
};
|
||||
}
|
||||
|
||||
private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
|
||||
return movements
|
||||
.filter((row) => !row.voidedAt)
|
||||
.reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0));
|
||||
}
|
||||
|
||||
private async accountBalance(accountId: string) {
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId, voidedAt: null },
|
||||
select: { direction: true, amount: true },
|
||||
});
|
||||
return rows.reduce(
|
||||
(sum, row) => (row.direction === 'in' ? sum.plus(row.amount) : sum.minus(row.amount)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
}
|
||||
|
||||
private async requireAccount(organizationId: string, id: string) {
|
||||
const row = await this.prisma.financialAccount.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('حساب پیدا نشد.');
|
||||
return row;
|
||||
}
|
||||
|
||||
private async toAccountDto(
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
bankName: string | null;
|
||||
accountRef: string | null;
|
||||
responsibleName: string | null;
|
||||
openingBalance: Prisma.Decimal;
|
||||
openingDate: Date;
|
||||
notes: string | null;
|
||||
isActive: boolean;
|
||||
currency: string;
|
||||
},
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<FinancialAccountDto> {
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const movements = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId: account.id, voidedAt: null },
|
||||
select: { direction: true, amount: true, kind: true, occurredAt: true },
|
||||
});
|
||||
let balance = new Prisma.Decimal(0);
|
||||
let periodIn = new Prisma.Decimal(0);
|
||||
let periodOut = new Prisma.Decimal(0);
|
||||
let last: Date | null = null;
|
||||
for (const row of movements) {
|
||||
balance = row.direction === 'in' ? balance.plus(row.amount) : balance.minus(row.amount);
|
||||
if (!last || row.occurredAt > last) last = row.occurredAt;
|
||||
const inRange =
|
||||
(!from || row.occurredAt >= from) && (!to || row.occurredAt <= to);
|
||||
if (!inRange || row.kind === 'opening') continue;
|
||||
if (row.direction === 'in') periodIn = periodIn.plus(row.amount);
|
||||
else periodOut = periodOut.plus(row.amount);
|
||||
}
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
type: account.type as FinancialAccountDto['type'],
|
||||
bankName: account.bankName,
|
||||
accountRef: account.accountRef,
|
||||
responsibleName: account.responsibleName,
|
||||
openingBalance: money(account.openingBalance),
|
||||
openingDate: account.openingDate.toISOString(),
|
||||
notes: account.notes,
|
||||
isActive: account.isActive,
|
||||
currency: account.currency,
|
||||
balance: money(balance),
|
||||
periodIn: money(periodIn),
|
||||
periodOut: money(periodOut),
|
||||
lastMovementAt: last ? last.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
private toDocumentDto(row: {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
amount: Prisma.Decimal;
|
||||
bookedAt: Date;
|
||||
dueAt: Date | null;
|
||||
categoryId: string | null;
|
||||
personId: string | null;
|
||||
partyName: string | null;
|
||||
accountId: string | null;
|
||||
method: string | null;
|
||||
status: string;
|
||||
reference: string | null;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
category?: { name: string } | null;
|
||||
account?: { name: string } | null;
|
||||
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
|
||||
}): FinancialDocumentDto {
|
||||
const settled = this.settledOf(row.movements);
|
||||
const rawRemaining = row.amount.minus(settled);
|
||||
const remaining = rawRemaining.isNegative() ? new Prisma.Decimal(0) : rawRemaining;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as FinancialDocumentDto['kind'],
|
||||
title: row.title,
|
||||
amount: money(row.amount),
|
||||
settledAmount: money(settled),
|
||||
remaining: money(remaining),
|
||||
bookedAt: row.bookedAt.toISOString(),
|
||||
dueAt: row.dueAt ? row.dueAt.toISOString() : null,
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.category?.name ?? null,
|
||||
personId: row.personId,
|
||||
partyName: row.partyName,
|
||||
accountId: row.accountId,
|
||||
accountName: row.account?.name ?? null,
|
||||
method: row.method,
|
||||
status: row.status as FinancialDocumentDto['status'],
|
||||
reference: row.reference,
|
||||
notes: row.notes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const links = [
|
||||
{ to: '/finance', label: 'داشبورد', exact: true },
|
||||
{ to: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
||||
{ to: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
||||
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
|
||||
{ to: '/finance/accounts', label: 'صندوق و حسابها' },
|
||||
{ to: '/finance/payroll', label: 'حقوق' },
|
||||
{ to: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ to: '/finance/reports', label: 'گزارش' },
|
||||
{ to: '/finance/categories', label: 'دستهبندیها' },
|
||||
];
|
||||
|
||||
function on(path: string, exact?: boolean) {
|
||||
if (exact) return route.path === path;
|
||||
return route.path === path || route.path.startsWith(`${path}/`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="treasury-nav" aria-label="مدیریت مالی">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
:class="{ on: on(link.to, link.exact) }"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.treasury-nav {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
overflow: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.treasury-nav a {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
color: #475569;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 0.86rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.treasury-nav a.on {
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -354,6 +354,14 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
tint: 'sky',
|
||||
module: 'cheques',
|
||||
},
|
||||
{
|
||||
to: '/finance',
|
||||
title: 'مدیریت مالی',
|
||||
desc: 'صندوق، درآمد و هزینه داخلی',
|
||||
icon: 'dollar',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/reservations',
|
||||
title: 'رزرو و نوبتدهی',
|
||||
|
||||
@@ -228,6 +228,66 @@ export const router = createRouter({
|
||||
name: 'cheques',
|
||||
component: () => import('@/views/ChequesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
name: 'finance',
|
||||
component: () => import('@/views/treasury/TreasuryHomeView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/accounts',
|
||||
name: 'finance-accounts',
|
||||
component: () => import('@/views/treasury/TreasuryAccountsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/accounts/:id',
|
||||
name: 'finance-account',
|
||||
component: () => import('@/views/treasury/TreasuryAccountView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/incomes',
|
||||
name: 'finance-incomes',
|
||||
component: () => import('@/views/treasury/TreasuryIncomesView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/expenses',
|
||||
name: 'finance-expenses',
|
||||
component: () => import('@/views/treasury/TreasuryExpensesView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/receivables',
|
||||
name: 'finance-receivables',
|
||||
component: () => import('@/views/treasury/TreasuryReceivablesView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/payroll',
|
||||
name: 'finance-payroll',
|
||||
component: () => import('@/views/treasury/TreasuryPayrollView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/recurring',
|
||||
name: 'finance-recurring',
|
||||
component: () => import('@/views/treasury/TreasuryRecurringView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/reports',
|
||||
name: 'finance-reports',
|
||||
component: () => import('@/views/treasury/TreasuryReportsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/categories',
|
||||
name: 'finance-categories',
|
||||
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'reservations',
|
||||
name: 'reservations',
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
|
||||
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
const route = useRoute();
|
||||
const account = ref<FinancialAccountDto | null>(null);
|
||||
const items = ref<FinancialMovementDto[]>([]);
|
||||
const error = ref('');
|
||||
const stated = ref('');
|
||||
const reason = ref('');
|
||||
const note = ref('');
|
||||
|
||||
async function reconcile() {
|
||||
error.value = '';
|
||||
note.value = '';
|
||||
try {
|
||||
const { data } = await api.post<{ difference: string; systemBalance: string }>('/treasury/reconciliations', {
|
||||
accountId: route.params.id,
|
||||
statedBalance: stated.value,
|
||||
});
|
||||
note.value = `اختلاف ${toman(data.difference)} نسبت به موجودی سیستم ${toman(data.systemBalance)}. موجودی خودکار عوض نشد.`;
|
||||
} catch {
|
||||
error.value = 'ثبت مغایرت ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function adjust(direction: 'in' | 'out') {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/treasury/adjustments', {
|
||||
accountId: route.params.id,
|
||||
amount: stated.value,
|
||||
direction,
|
||||
reason: reason.value,
|
||||
});
|
||||
window.location.reload();
|
||||
} catch {
|
||||
error.value = 'اصلاح موجودی ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get<{ account: FinancialAccountDto; items: FinancialMovementDto[] }>(
|
||||
`/treasury/accounts/${route.params.id}/ledger`,
|
||||
);
|
||||
account.value = data.account;
|
||||
items.value = data.items;
|
||||
} catch {
|
||||
error.value = 'گردش حساب بارگذاری نشد.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader :title="account?.name || 'حساب'" description="گردش قطعی این حساب" />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div v-if="account" class="stats">
|
||||
<article><span>موجودی فعلی</span><strong>{{ toman(account.balance) }}</strong></article>
|
||||
<article><span>مانده افتتاحیه</span><strong>{{ toman(account.openingBalance) }}</strong></article>
|
||||
<article><span>ورودی</span><strong>{{ toman(account.periodIn) }}</strong></article>
|
||||
<article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article>
|
||||
</div>
|
||||
<div v-if="account" class="reconcile">
|
||||
<UiField label="موجودی واقعی"><UiInput v-model="stated" /></UiField>
|
||||
<UiField label="علت اصلاح"><UiInput v-model="reason" /></UiField>
|
||||
<UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton>
|
||||
<UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton>
|
||||
<UiButton variant="secondary" @click="adjust('out')">اصلاح خروج</UiButton>
|
||||
<p v-if="note" class="note">{{ note }}</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>تاریخ</th>
|
||||
<th>شرح</th>
|
||||
<th>نوع</th>
|
||||
<th>ورودی</th>
|
||||
<th>خروجی</th>
|
||||
<th>مانده</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in items" :key="row.id">
|
||||
<td>{{ formatJalaliNumeric(new Date(row.occurredAt)) }}</td>
|
||||
<td>{{ row.memo || '—' }}</td>
|
||||
<td>{{ row.kindLabel }}</td>
|
||||
<td>{{ row.direction === 'in' ? toman(row.amount) : '—' }}</td>
|
||||
<td>{{ row.direction === 'out' ? toman(row.amount) : '—' }}</td>
|
||||
<td>{{ row.balanceAfter ? toman(row.balanceAfter) : '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!items.length"><td colspan="6">گردشی ثبت نشده است.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; }
|
||||
.stats article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem; }
|
||||
.stats span { color: #64748b; display: block; }
|
||||
.table-wrap { overflow: auto; background: #fff; border: 1px solid #e7eef2; border-radius: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 0.7rem; text-align: right; border-bottom: 1px solid #f1f5f9; white-space: nowrap; }
|
||||
.reconcile { display: grid; grid-template-columns: 1fr 1fr auto auto auto; gap: 0.6rem; align-items: end; }
|
||||
.note { grid-column: 1 / -1; color: #0f766e; font-weight: 700; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
@media (max-width: 800px) { .reconcile { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const openForm = ref(false);
|
||||
const saving = ref(false);
|
||||
const transferOpen = ref(false);
|
||||
const form = ref({
|
||||
name: '',
|
||||
type: 'cash' as FinancialAccountType,
|
||||
bankName: '',
|
||||
accountRef: '',
|
||||
responsibleName: '',
|
||||
openingBalance: '',
|
||||
openingDate: '',
|
||||
notes: '',
|
||||
});
|
||||
const transfer = ref({
|
||||
fromAccountId: '',
|
||||
toAccountId: '',
|
||||
amount: '',
|
||||
memo: '',
|
||||
});
|
||||
|
||||
const active = computed(() => accounts.value.filter((row) => row.isActive));
|
||||
const total = computed(() =>
|
||||
active.value.reduce((sum, row) => sum + Number(row.balance), 0),
|
||||
);
|
||||
const periodIn = computed(() =>
|
||||
active.value.reduce((sum, row) => sum + Number(row.periodIn), 0),
|
||||
);
|
||||
const periodOut = computed(() =>
|
||||
active.value.reduce((sum, row) => sum + Number(row.periodOut), 0),
|
||||
);
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
bank: 'حساب بانکی',
|
||||
cash: 'صندوق نقدی',
|
||||
petty: 'تنخواه',
|
||||
};
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
|
||||
accounts.value = data;
|
||||
}
|
||||
|
||||
async function createAccount() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post('/treasury/accounts', {
|
||||
...form.value,
|
||||
openingBalance: form.value.openingBalance || 0,
|
||||
openingDate: form.value.openingDate || undefined,
|
||||
});
|
||||
openForm.value = false;
|
||||
form.value = {
|
||||
name: '',
|
||||
type: 'cash',
|
||||
bankName: '',
|
||||
accountRef: '',
|
||||
responsibleName: '',
|
||||
openingBalance: '',
|
||||
openingDate: '',
|
||||
notes: '',
|
||||
};
|
||||
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTransfer() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post('/treasury/transfers', {
|
||||
...transfer.value,
|
||||
clientKey: crypto.randomUUID(),
|
||||
});
|
||||
transferOpen.value = false;
|
||||
ok.value = 'انتقال ثبت شد و در درآمد یا هزینه نیامده است.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری حسابها ناموفق بود.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="صندوق و حسابها" description="موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه میشود." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<div class="stats">
|
||||
<article>
|
||||
<span>مجموع موجودی حسابها</span>
|
||||
<strong>{{ toman(total) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>ورودی این فهرست</span>
|
||||
<strong class="in">{{ toman(periodIn) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>خروجی این فهرست</span>
|
||||
<strong class="out">{{ toman(periodOut) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>حسابهای فعال</span>
|
||||
<strong>{{ active.length }} حساب</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
<article v-for="account in accounts" :key="account.id" class="account">
|
||||
<div>
|
||||
<strong>{{ account.name }}</strong>
|
||||
<small>{{ typeLabel[account.type] }}{{ account.isActive ? '' : ' · غیرفعال' }}</small>
|
||||
<p>مشاهده گردش حساب و جزئیات</p>
|
||||
</div>
|
||||
<div class="side">
|
||||
<b>{{ toman(account.balance) }}</b>
|
||||
<RouterLink class="link" :to="`/finance/accounts/${account.id}`">جزئیات حساب</RouterLink>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<UiButton @click="openForm = !openForm">افزودن حساب جدید</UiButton>
|
||||
<UiButton variant="secondary" @click="transferOpen = !transferOpen">انتقال وجه</UiButton>
|
||||
</div>
|
||||
|
||||
<UiCard v-if="openForm" title="حساب جدید">
|
||||
<div class="grid">
|
||||
<UiField label="نام حساب"><UiInput v-model="form.name" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="form.type" class="select">
|
||||
<option value="cash">صندوق نقدی</option>
|
||||
<option value="bank">حساب بانکی</option>
|
||||
<option value="petty">تنخواه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField>
|
||||
<UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField>
|
||||
<UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField>
|
||||
<UiField label="موجودی اولیه"><UiInput v-model="form.openingBalance" /></UiField>
|
||||
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
|
||||
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createAccount">ثبت حساب</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="transferOpen" title="انتقال بین حسابها">
|
||||
<div class="grid">
|
||||
<UiField label="از حساب">
|
||||
<select v-model="transfer.fromAccountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="account in active" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="به حساب">
|
||||
<select v-model="transfer.toAccountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مبلغ"><UiInput v-model="transfer.amount" /></UiField>
|
||||
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
|
||||
</div>
|
||||
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمیشود.</p>
|
||||
<UiButton :loading="saving" @click="sendTransfer">تأیید انتقال</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.stats, .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; }
|
||||
.stats article, .account {
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
.stats span, .account small, .account p, .muted { color: #64748b; margin: 0.15rem 0 0; }
|
||||
.in { color: #15803d; }
|
||||
.out { color: #b91c1c; }
|
||||
.list { display: grid; gap: 0.65rem; }
|
||||
.account { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
|
||||
.side { display: grid; justify-items: end; gap: 0.4rem; }
|
||||
.link { color: #0f766e; font-weight: 700; text-decoration: none; }
|
||||
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialCategoryDto } from '@mento/shared';
|
||||
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
|
||||
const rows = ref<FinancialCategoryDto[]>([]);
|
||||
const name = ref('');
|
||||
const kind = ref<'income' | 'expense'>('expense');
|
||||
const error = ref('');
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories');
|
||||
rows.value = data;
|
||||
}
|
||||
|
||||
async function add() {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/treasury/categories', { name: name.value, kind: kind.value });
|
||||
name.value = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
|
||||
: 'ثبت ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="دستهبندیهای مالی" description="دستههای پیشفرض با اولین بازدید ساخته میشوند." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div class="add">
|
||||
<UiField label="نام"><UiInput v-model="name" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="kind" class="select">
|
||||
<option value="income">درآمد</option>
|
||||
<option value="expense">هزینه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiButton @click="add">افزودن</UiButton>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="row in rows" :key="row.id">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.add { display: grid; grid-template-columns: 1fr 160px auto; gap: 0.7rem; align-items: end; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
||||
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
|
||||
li span { color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
@media (max-width: 700px) { .add { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const props = defineProps<{ kind: 'income' | 'expense' }>();
|
||||
const rows = ref<FinancialDocumentDto[]>([]);
|
||||
const categories = ref<FinancialCategoryDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const q = ref('');
|
||||
const form = ref({
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: '',
|
||||
partyName: '',
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const title = computed(() => (props.kind === 'income' ? 'دریافتها و درآمدها' : 'پرداختها و هزینهها'));
|
||||
const settleLabel = computed(() => (props.kind === 'income' ? 'مبلغ دریافتشده' : 'مبلغ پرداختشده'));
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: props.kind === 'income' ? 'دریافتنشده' : 'پرداختنشده',
|
||||
partial: 'جزئی',
|
||||
settled: props.kind === 'income' ? 'دریافتشده' : 'پرداختشده',
|
||||
void: 'باطلشده',
|
||||
};
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [docs, cats, acc] = await Promise.all([
|
||||
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind: props.kind, q: q.value || undefined } }),
|
||||
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind: props.kind } }),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
]);
|
||||
rows.value = docs.data;
|
||||
categories.value = cats.data.filter((row) => row.isActive);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
|
||||
async function createDoc() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post('/treasury/documents', {
|
||||
kind: props.kind,
|
||||
title: form.value.title,
|
||||
amount: form.value.amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount: form.value.settleAmount || 0,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
});
|
||||
form.value = {
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: '',
|
||||
partyName: '',
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
};
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function voidDoc(id: string) {
|
||||
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
|
||||
try {
|
||||
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.kind, () => {
|
||||
void load();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader :title="title" description="ثبت سند، موجودی را عوض نمیکند مگر مبلغ دریافت یا پرداخت واقعی وارد شود." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<UiCard title="سند جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="مبلغ کل"><UiInput v-model="form.amount" /></UiField>
|
||||
<UiField label="دستهبندی">
|
||||
<select v-model="form.categoryId" class="select">
|
||||
<option value="">بدون دسته</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
|
||||
<UiField :label="settleLabel"><UiInput v-model="form.settleAmount" /></UiField>
|
||||
<UiField label="حساب">
|
||||
<select v-model="form.accountId" class="select">
|
||||
<option value="">انتخاب حساب</option>
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="روش">
|
||||
<select v-model="form.method" class="select">
|
||||
<option value="cash">نقدی</option>
|
||||
<option value="card_to_card">کارتبهکارت</option>
|
||||
<option value="pos">کارتخوان</option>
|
||||
<option value="transfer">حواله</option>
|
||||
<option value="other">سایر</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="پیگیری"><UiInput v-model="form.reference" /></UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createDoc">ثبت</UiButton>
|
||||
</UiCard>
|
||||
<UiField label="جستجو"><UiInput v-model="q" @change="load" /></UiField>
|
||||
<div class="list">
|
||||
<article v-for="row in rows" :key="row.id">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>
|
||||
{{ statusLabel[row.status] }}
|
||||
· مانده {{ toman(row.remaining) }}
|
||||
<template v-if="row.categoryName"> · {{ row.categoryName }}</template>
|
||||
</small>
|
||||
</div>
|
||||
<div class="side">
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
<button v-if="row.status !== 'void'" type="button" @click="voidDoc(row.id)">ابطال</button>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!rows.length" class="muted">سندی نیست.</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.list { display: grid; gap: 0.55rem; }
|
||||
.list article { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.list small, .muted { color: #64748b; }
|
||||
.side { display: grid; justify-items: end; gap: 0.3rem; }
|
||||
.side button { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import TreasuryDocsView from './TreasuryDocsView.vue';
|
||||
</script>
|
||||
<template>
|
||||
<TreasuryDocsView kind="expense" />
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TreasuryDashboardDto } from '@mento/shared';
|
||||
import { UiCard, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const data = ref<TreasuryDashboardDto | null>(null);
|
||||
const error = ref('');
|
||||
|
||||
const cards = [
|
||||
{ key: 'bookedIncome', label: 'درآمد ثبتشده', hint: 'تعهدی' },
|
||||
{ key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' },
|
||||
{ key: 'bookedExpense', label: 'هزینه ثبتشده', hint: 'تعهدی' },
|
||||
{ key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' },
|
||||
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبتشده − هزینه ثبتشده' },
|
||||
{ key: 'receivables', label: 'مطالبات وصولنشده', hint: 'درآمد بدون دریافت کامل' },
|
||||
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' },
|
||||
{ key: 'totalBalance', label: 'موجودی حسابها', hint: 'صندوق و بانک' },
|
||||
] as const;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get<TreasuryDashboardDto>('/treasury/dashboard');
|
||||
data.value = res.data;
|
||||
} catch {
|
||||
error.value = 'بارگذاری داشبورد مالی ناموفق بود.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
title="مدیریت مالی"
|
||||
description="دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست."
|
||||
/>
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div v-if="data" class="stats">
|
||||
<article v-for="card in cards" :key="card.key">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ toman(data[card.key]) }}</strong>
|
||||
<small>{{ card.hint }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="data" class="split">
|
||||
<UiCard title="اقساط نزدیک یا معوق">
|
||||
<p v-if="!data.upcomingInstallments.length" class="muted">قسط بازی نیست.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.upcomingInstallments" :key="row.id">
|
||||
<div>
|
||||
<strong>قسط {{ row.position }}</strong>
|
||||
<small>{{ row.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
<UiCard title="پرداختهای آینده">
|
||||
<p v-if="!data.upcomingPayables.length" class="muted">تعهد پرداخت بازی نیست.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.upcomingPayables" :key="row.id">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</div>
|
||||
<UiCard v-if="data" title="آخرین اسناد">
|
||||
<p v-if="!data.recent.length" class="muted">هنوز سندی ثبت نشده است.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.recent" :key="row.id">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.status }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.stats article {
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem 1rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.stats span, .stats small, .muted { color: #64748b; }
|
||||
.stats strong { font-size: 1.05rem; }
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
|
||||
@media (max-width: 800px) { .split { grid-template-columns: 1fr; } }
|
||||
.list li { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||
.list small { display: block; color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import TreasuryDocsView from './TreasuryDocsView.vue';
|
||||
</script>
|
||||
<template>
|
||||
<TreasuryDocsView kind="income" />
|
||||
</template>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
type PayrollRow = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
period: string;
|
||||
payable: string;
|
||||
remaining: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const rows = ref<PayrollRow[]>([]);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const form = ref({ userId: '', period: '', baseAmount: '', benefits: '', bonus: '', deductions: '' });
|
||||
const pay = ref({ id: '', amount: '', accountId: '' });
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [payroll, people, acc] = await Promise.all([
|
||||
api.get<PayrollRow[]>('/treasury/payroll'),
|
||||
api.get<OrgUserDto[]>('/users'),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
]);
|
||||
rows.value = payroll.data;
|
||||
users.value = people.data.filter((row) => row.isActive !== false);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
|
||||
async function createRow() {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/treasury/payroll', form.value);
|
||||
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمیکند.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function payRow() {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post(`/treasury/payroll/${pay.value.id}/pay`, {
|
||||
amount: pay.value.amount,
|
||||
accountId: pay.value.accountId,
|
||||
clientKey: crypto.randomUUID(),
|
||||
});
|
||||
ok.value = 'پرداخت حقوق از حساب ثبت شد.';
|
||||
pay.value = { id: '', amount: '', accountId: '' };
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری حقوق ناموفق بود.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="حقوق و دستمزد" description="فقط مالک و مدیر این صفحه را میبینند." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<UiCard title="ثبت دوره">
|
||||
<div class="grid">
|
||||
<UiField label="پرسنل">
|
||||
<select v-model="form.userId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.fullName }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
|
||||
<UiField label="پایه"><UiInput v-model="form.baseAmount" /></UiField>
|
||||
<UiField label="مزایا"><UiInput v-model="form.benefits" /></UiField>
|
||||
<UiField label="پاداش"><UiInput v-model="form.bonus" /></UiField>
|
||||
<UiField label="کسورات"><UiInput v-model="form.deductions" /></UiField>
|
||||
</div>
|
||||
<UiButton @click="createRow">ثبت حقوق</UiButton>
|
||||
</UiCard>
|
||||
<article v-for="row in rows" :key="row.id" class="row">
|
||||
<div>
|
||||
<strong>{{ row.fullName }}</strong>
|
||||
<small>{{ row.period }} · مانده {{ toman(row.remaining) }} · {{ row.status }}</small>
|
||||
</div>
|
||||
<div class="side">
|
||||
<b>{{ toman(row.payable) }}</b>
|
||||
<button v-if="row.status !== 'settled' && row.status !== 'void'" type="button" @click="pay = { id: row.id, amount: row.remaining, accountId: accounts[0]?.id || '' }">پرداخت</button>
|
||||
</div>
|
||||
</article>
|
||||
<UiCard v-if="pay.id" title="پرداخت حقوق">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><UiInput v-model="pay.amount" /></UiField>
|
||||
<UiField label="حساب">
|
||||
<select v-model="pay.accountId" class="select">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton @click="payRow">ثبت پرداخت</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.row { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.row small { display: block; color: #64748b; }
|
||||
.side { display: grid; justify-items: end; }
|
||||
.side button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
const rows = ref<FinancialReceivableDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const form = ref({
|
||||
title: '',
|
||||
partyName: '',
|
||||
totalAmount: '',
|
||||
prepaidAmount: '',
|
||||
accountId: '',
|
||||
count: '3',
|
||||
interval: 'monthly',
|
||||
startDate: '',
|
||||
});
|
||||
const collect = ref({ installmentId: '', receivableId: '', amount: '', accountId: '' });
|
||||
const custom = ref(false);
|
||||
const customLines = ref([{ amount: '', dueDate: '' }]);
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: 'باز',
|
||||
partial: 'جزئی',
|
||||
settled: 'تسویهشده',
|
||||
overdue: 'معوق',
|
||||
void: 'باطل',
|
||||
};
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [list, acc] = await Promise.all([
|
||||
api.get<FinancialReceivableDto[]>('/treasury/receivables'),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
]);
|
||||
rows.value = list.data;
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
|
||||
async function createContract() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post('/treasury/receivables', {
|
||||
title: form.value.title,
|
||||
partyName: form.value.partyName || null,
|
||||
totalAmount: form.value.totalAmount,
|
||||
prepaidAmount: form.value.prepaidAmount || 0,
|
||||
accountId: form.value.accountId || null,
|
||||
...(custom.value
|
||||
? { installments: customLines.value }
|
||||
: {
|
||||
count: Number(form.value.count),
|
||||
interval: form.value.interval,
|
||||
startDate: form.value.startDate || undefined,
|
||||
}),
|
||||
});
|
||||
ok.value = 'قرارداد و برنامه اقساط ثبت شد.';
|
||||
form.value.title = '';
|
||||
form.value.totalAmount = '';
|
||||
form.value.prepaidAmount = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectInstallment() {
|
||||
if (!collect.value.installmentId) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post(`/treasury/installments/${collect.value.installmentId}/collect`, {
|
||||
amount: collect.value.amount,
|
||||
accountId: collect.value.accountId,
|
||||
clientKey: crypto.randomUUID(),
|
||||
});
|
||||
ok.value = 'دریافت قسط در همان حساب ثبت شد.';
|
||||
collect.value = { installmentId: '', receivableId: '', amount: '', accountId: '' };
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری مطالبات ناموفق بود.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
title="اقساط و مطالبات"
|
||||
description="دریافت هر قسط فقط یک بار وارد حساب میشود و مانده همان قسط را کم میکند."
|
||||
/>
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<UiCard title="قرارداد جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
|
||||
<UiField label="مبلغ کل"><UiInput v-model="form.totalAmount" /></UiField>
|
||||
<UiField label="پیشپرداخت"><UiInput v-model="form.prepaidAmount" /></UiField>
|
||||
<UiField label="حساب پیشپرداخت">
|
||||
<select v-model="form.accountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="تعداد قسط"><UiInput v-model="form.count" :disabled="custom" /></UiField>
|
||||
<UiField label="دوره">
|
||||
<select v-model="form.interval" class="select">
|
||||
<option value="monthly">ماهانه</option>
|
||||
<option value="weekly">هفتگی</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
|
||||
</div>
|
||||
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
|
||||
<div v-if="custom" class="custom">
|
||||
<div v-for="(line, index) in customLines" :key="index" class="grid">
|
||||
<UiField label="مبلغ قسط"><UiInput v-model="line.amount" /></UiField>
|
||||
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<article v-for="row in rows" :key="row.id" class="contract">
|
||||
<header>
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.totalAmount) }}</b>
|
||||
</header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>قسط</th>
|
||||
<th>سررسید</th>
|
||||
<th>مبلغ</th>
|
||||
<th>مانده</th>
|
||||
<th>وضعیت</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in row.installments" :key="item.id">
|
||||
<td>{{ item.position }}</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(item.dueDate)) }}</td>
|
||||
<td>{{ toman(item.amount) }}</td>
|
||||
<td>{{ toman(item.remaining) }}</td>
|
||||
<td>{{ statusLabel[item.status] }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="item.status !== 'settled'"
|
||||
type="button"
|
||||
@click="collect = { installmentId: item.id, receivableId: row.id, amount: item.remaining, accountId: accounts[0]?.id || '' }"
|
||||
>
|
||||
دریافت
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<UiCard v-if="collect.installmentId" title="دریافت قسط">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><UiInput v-model="collect.amount" /></UiField>
|
||||
<UiField label="حساب مقصد">
|
||||
<select v-model="collect.accountId" class="select">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="collectInstallment">ثبت دریافت</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.contract { background: #fff; border: 1px solid #e7eef2; border-radius: 16px; padding: 0.8rem 1rem; overflow: auto; }
|
||||
.contract header, .contract header div { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||
.contract small { display: block; color: #64748b; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 0.6rem; }
|
||||
th, td { text-align: right; padding: 0.45rem; border-top: 1px solid #f1f5f9; white-space: nowrap; }
|
||||
button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.4rem 0 0.8rem; font-weight: 700; }
|
||||
.custom { display: grid; gap: 0.5rem; margin-bottom: 0.8rem; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
|
||||
const rows = ref<Row[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const form = ref({ title: '', kind: 'expense', amount: '', interval: 'monthly', startDate: '' });
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<Row[]>('/treasury/recurring');
|
||||
rows.value = data;
|
||||
}
|
||||
|
||||
async function createItem() {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/treasury/recurring', form.value);
|
||||
ok.value = 'الگو ثبت شد. در سررسید فقط سند باز ساخته میشود.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
|
||||
: 'ثبت ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="درآمد و هزینه تکرارشونده" description="تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمیشود." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<UiCard title="الگوی جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="form.kind" class="select">
|
||||
<option value="expense">هزینه</option>
|
||||
<option value="income">درآمد</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مبلغ"><UiInput v-model="form.amount" /></UiField>
|
||||
<UiField label="دوره">
|
||||
<select v-model="form.interval" class="select">
|
||||
<option value="monthly">ماهانه</option>
|
||||
<option value="weekly">هفتگی</option>
|
||||
<option value="yearly">سالانه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
|
||||
</div>
|
||||
<UiButton @click="createItem">ثبت الگو</UiButton>
|
||||
</UiCard>
|
||||
<article v-for="row in rows" :key="row.id" class="row">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.interval }} · بعدی {{ formatJalaliNumeric(new Date(row.nextDue)) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.row { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.row small { display: block; color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiButton, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
type Period = {
|
||||
bookedIncome: string;
|
||||
bookedExpense: string;
|
||||
operatingProfit: string;
|
||||
cashIn: string;
|
||||
cashOut: string;
|
||||
cashNet: string;
|
||||
expenseCategories: Array<{ name: string; amount: string; share: string }>;
|
||||
};
|
||||
const current = ref<Period | null>(null);
|
||||
const previous = ref<Period | null>(null);
|
||||
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
|
||||
const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]);
|
||||
const error = ref('');
|
||||
|
||||
const lines = [
|
||||
['درآمد ثبتشده', 'bookedIncome'],
|
||||
['هزینه ثبتشده', 'bookedExpense'],
|
||||
['سود عملیاتی', 'operatingProfit'],
|
||||
['ورود وجه', 'cashIn'],
|
||||
['خروج وجه', 'cashOut'],
|
||||
['خالص جریان نقدی', 'cashNet'],
|
||||
] as const;
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<{
|
||||
current: Period;
|
||||
previous: Period;
|
||||
payablesByMonth: Array<{ month: string; amount: string }>;
|
||||
receivablesByParty: Array<{ name: string; amount: string }>;
|
||||
}>('/treasury/reports');
|
||||
current.value = data.current;
|
||||
previous.value = data.previous;
|
||||
payablesByMonth.value = data.payablesByMonth;
|
||||
receivablesByParty.value = data.receivablesByParty;
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'treasury-report.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'گزارش بارگذاری نشد.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="گزارش مالی" description="سود عملیاتی از درآمد و هزینه ثبتشده است، نه از خالص وجه." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<UiButton variant="secondary" @click="download">خروجی اکسل</UiButton>
|
||||
<table v-if="current && previous">
|
||||
<thead>
|
||||
<tr><th>شاخص</th><th>این دوره</th><th>دوره قبل</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="line in lines" :key="line[1]">
|
||||
<td>{{ line[0] }}</td>
|
||||
<td>{{ toman(current[line[1]]) }}</td>
|
||||
<td>{{ toman(previous[line[1]]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>مطالبات به تفکیک طرف حساب</h2>
|
||||
<ul>
|
||||
<li v-for="row in receivablesByParty" :key="row.name"><span>{{ row.name }}</span><b>{{ toman(row.amount) }}</b></li>
|
||||
<li v-if="!receivablesByParty.length">مورد بازی نیست.</li>
|
||||
</ul>
|
||||
<h2>تعهدات به تفکیک ماه</h2>
|
||||
<ul>
|
||||
<li v-for="row in payablesByMonth" :key="row.month"><span>{{ row.month }}</span><b>{{ toman(row.amount) }}</b></li>
|
||||
<li v-if="!payablesByMonth.length">تعهد بازی نیست.</li>
|
||||
</ul>
|
||||
<h2 v-if="current">سهم هزینهها</h2>
|
||||
<ul v-if="current">
|
||||
<li v-for="row in current.expenseCategories" :key="row.name">
|
||||
<span>{{ row.name }}</span>
|
||||
<b>{{ toman(row.amount) }} · {{ row.share }}٪</b>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 16px; overflow: hidden; }
|
||||
th, td { text-align: right; padding: 0.7rem; border-bottom: 1px solid #f1f5f9; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
||||
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
|
||||
h2 { margin: 0; font-size: 1rem; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
export function toman(value: string | number | null | undefined) {
|
||||
const n = Number(value ?? 0);
|
||||
if (!Number.isFinite(n)) return '۰';
|
||||
return `${new Intl.NumberFormat('fa-IR').format(Math.round(n))} تومان`;
|
||||
}
|
||||
@@ -5598,6 +5598,115 @@ export interface RoutineUserSettingsDto {
|
||||
summaryHour: number;
|
||||
}
|
||||
|
||||
/* ───────── دفتر مالی داخلی ───────── */
|
||||
|
||||
export type FinancialAccountType = "bank" | "cash" | "petty";
|
||||
export type FinancialDocKind = "income" | "expense";
|
||||
export type FinancialDocStatus = "open" | "partial" | "settled" | "void";
|
||||
export type FinancialPayMethod = "cash" | "card_to_card" | "pos" | "transfer" | "other";
|
||||
|
||||
export interface FinancialAccountDto {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FinancialAccountType;
|
||||
bankName: string | null;
|
||||
accountRef: string | null;
|
||||
responsibleName: string | null;
|
||||
openingBalance: string;
|
||||
openingDate: string;
|
||||
notes: string | null;
|
||||
isActive: boolean;
|
||||
currency: string;
|
||||
balance: string;
|
||||
periodIn: string;
|
||||
periodOut: string;
|
||||
lastMovementAt: string | null;
|
||||
}
|
||||
|
||||
export interface FinancialCategoryDto {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: FinancialDocKind;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface FinancialDocumentDto {
|
||||
id: string;
|
||||
kind: FinancialDocKind;
|
||||
title: string;
|
||||
amount: string;
|
||||
settledAmount: string;
|
||||
remaining: string;
|
||||
bookedAt: string;
|
||||
dueAt: string | null;
|
||||
categoryId: string | null;
|
||||
categoryName: string | null;
|
||||
personId: string | null;
|
||||
partyName: string | null;
|
||||
accountId: string | null;
|
||||
accountName: string | null;
|
||||
method: string | null;
|
||||
status: FinancialDocStatus;
|
||||
reference: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FinancialMovementDto {
|
||||
id: string;
|
||||
accountId: string;
|
||||
direction: "in" | "out";
|
||||
amount: string;
|
||||
kind: string;
|
||||
kindLabel: string;
|
||||
transferGroupId: string | null;
|
||||
documentId: string | null;
|
||||
occurredAt: string;
|
||||
memo: string | null;
|
||||
reference: string | null;
|
||||
balanceAfter: string | null;
|
||||
}
|
||||
|
||||
export type FinancialInstallmentStatus = "open" | "partial" | "settled" | "overdue";
|
||||
|
||||
export interface FinancialInstallmentDto {
|
||||
id: string;
|
||||
position: number;
|
||||
dueDate: string;
|
||||
amount: string;
|
||||
paidAmount: string;
|
||||
remaining: string;
|
||||
status: FinancialInstallmentStatus;
|
||||
}
|
||||
|
||||
export interface FinancialReceivableDto {
|
||||
id: string;
|
||||
title: string;
|
||||
personId: string | null;
|
||||
partyName: string | null;
|
||||
totalAmount: string;
|
||||
paidAmount: string;
|
||||
remaining: string;
|
||||
status: "open" | "settled" | "void";
|
||||
notes: string | null;
|
||||
installments: FinancialInstallmentDto[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TreasuryDashboardDto {
|
||||
bookedIncome: string;
|
||||
actualReceipts: string;
|
||||
bookedExpense: string;
|
||||
actualPayments: string;
|
||||
operatingProfit: string;
|
||||
receivables: string;
|
||||
payables: string;
|
||||
totalBalance: string;
|
||||
recent: FinancialDocumentDto[];
|
||||
upcomingInstallments: FinancialInstallmentDto[];
|
||||
upcomingPayables: FinancialDocumentDto[];
|
||||
}
|
||||
|
||||
/* ───────── باشگاه امتیازات (گیمیفیکیشن کارکنان) ───────── */
|
||||
|
||||
export * from "./gamification";
|
||||
|
||||
Reference in New Issue
Block a user