feat(tahesab): sales report snapshot with org-module gate
Sync linked asnad into CRM, filter by group/kind/metal, and require the tahesab superadmin module plus reports role access.
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AccountingAsnadLine" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"connectionId" TEXT NOT NULL,
|
||||||
|
"personId" TEXT,
|
||||||
|
"linkId" TEXT,
|
||||||
|
"moshtariCode" INTEGER NOT NULL,
|
||||||
|
"lineKey" TEXT NOT NULL,
|
||||||
|
"tahesabId" INTEGER,
|
||||||
|
"factorCode" TEXT,
|
||||||
|
"shFactor" INTEGER,
|
||||||
|
"radif" INTEGER,
|
||||||
|
"tarikh" TEXT NOT NULL,
|
||||||
|
"noRaw" TEXT NOT NULL DEFAULT '',
|
||||||
|
"kind" TEXT NOT NULL DEFAULT 'other',
|
||||||
|
"jensFelez" INTEGER,
|
||||||
|
"noKar" TEXT,
|
||||||
|
"vazn" DOUBLE PRECISION,
|
||||||
|
"tabdilVazn" DOUBLE PRECISION,
|
||||||
|
"mali" DOUBLE PRECISION,
|
||||||
|
"count" DOUBLE PRECISION,
|
||||||
|
"ayar" DOUBLE PRECISION,
|
||||||
|
"mazaneh" DOUBLE PRECISION,
|
||||||
|
"fi" DOUBLE PRECISION,
|
||||||
|
"sharh1" TEXT,
|
||||||
|
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AccountingAsnadLine_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AccountingAsnadLine_connectionId_lineKey_key" ON "AccountingAsnadLine"("connectionId", "lineKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountingAsnadLine_organizationId_connectionId_tarikh_idx" ON "AccountingAsnadLine"("organizationId", "connectionId", "tarikh");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountingAsnadLine_organizationId_kind_idx" ON "AccountingAsnadLine"("organizationId", "kind");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountingAsnadLine_personId_idx" ON "AccountingAsnadLine"("personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountingAsnadLine_linkId_idx" ON "AccountingAsnadLine"("linkId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountingAsnadLine_moshtariCode_idx" ON "AccountingAsnadLine"("moshtariCode");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "PersonAccountingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -196,6 +196,7 @@ model Organization {
|
|||||||
noteAiSuggestions NoteAiSuggestion[]
|
noteAiSuggestions NoteAiSuggestion[]
|
||||||
accountingConnections AccountingConnection[]
|
accountingConnections AccountingConnection[]
|
||||||
accountingSyncJobs AccountingSyncJob[]
|
accountingSyncJobs AccountingSyncJob[]
|
||||||
|
accountingAsnadLines AccountingAsnadLine[]
|
||||||
warehouses Warehouse[]
|
warehouses Warehouse[]
|
||||||
stockMovements StockMovement[]
|
stockMovements StockMovement[]
|
||||||
partyOpenings PartyOpeningBalance[]
|
partyOpenings PartyOpeningBalance[]
|
||||||
@@ -614,6 +615,7 @@ model Person {
|
|||||||
memoryItems MemoryItem[]
|
memoryItems MemoryItem[]
|
||||||
accountingLinks PersonAccountingLink[]
|
accountingLinks PersonAccountingLink[]
|
||||||
accountingDocuments AccountingDocumentLog[]
|
accountingDocuments AccountingDocumentLog[]
|
||||||
|
asnadLines AccountingAsnadLine[]
|
||||||
openingBalances PartyOpeningBalance[]
|
openingBalances PartyOpeningBalance[]
|
||||||
cheques Cheque[]
|
cheques Cheque[]
|
||||||
trackingLinks TrackingLink[]
|
trackingLinks TrackingLink[]
|
||||||
@@ -1747,6 +1749,7 @@ model AccountingConnection {
|
|||||||
productLinks ProductAccountingLink[]
|
productLinks ProductAccountingLink[]
|
||||||
documentLogs AccountingDocumentLog[]
|
documentLogs AccountingDocumentLog[]
|
||||||
balanceCaches AccountingBalanceCache[]
|
balanceCaches AccountingBalanceCache[]
|
||||||
|
asnadLines AccountingAsnadLine[]
|
||||||
|
|
||||||
@@unique([organizationId, baseUrl, dbName])
|
@@unique([organizationId, baseUrl, dbName])
|
||||||
@@index([organizationId, enabled])
|
@@index([organizationId, enabled])
|
||||||
@@ -1766,6 +1769,7 @@ model PersonAccountingLink {
|
|||||||
linkedAt DateTime @default(now())
|
linkedAt DateTime @default(now())
|
||||||
lastBalanceSyncedAt DateTime?
|
lastBalanceSyncedAt DateTime?
|
||||||
balanceCache AccountingBalanceCache?
|
balanceCache AccountingBalanceCache?
|
||||||
|
asnadLines AccountingAsnadLine[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1844,6 +1848,51 @@ model AccountingBalanceCache {
|
|||||||
@@index([connectionId])
|
@@index([connectionId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// اسنپشات ردیفهای DoListAsnad برای گزارش فروش گروهی
|
||||||
|
model AccountingAsnadLine {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
connectionId String
|
||||||
|
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||||
|
personId String?
|
||||||
|
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||||
|
linkId String?
|
||||||
|
link PersonAccountingLink? @relation(fields: [linkId], references: [id], onDelete: SetNull)
|
||||||
|
moshtariCode Int
|
||||||
|
/// کلید پایدار برای upsert: ID تهحساب یا f:factor|r:radif|t:tarikh|n:no
|
||||||
|
lineKey String
|
||||||
|
tahesabId Int?
|
||||||
|
factorCode String?
|
||||||
|
shFactor Int?
|
||||||
|
radif Int?
|
||||||
|
/// شمسی YYYY-MM-DD
|
||||||
|
tarikh String
|
||||||
|
noRaw String @default("")
|
||||||
|
/// sale | return | purchase | other
|
||||||
|
kind String @default("other")
|
||||||
|
jensFelez Int?
|
||||||
|
noKar String?
|
||||||
|
vazn Float?
|
||||||
|
tabdilVazn Float?
|
||||||
|
mali Float?
|
||||||
|
count Float?
|
||||||
|
ayar Float?
|
||||||
|
mazaneh Float?
|
||||||
|
fi Float?
|
||||||
|
sharh1 String?
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([connectionId, lineKey])
|
||||||
|
@@index([organizationId, connectionId, tarikh])
|
||||||
|
@@index([organizationId, kind])
|
||||||
|
@@index([personId])
|
||||||
|
@@index([linkId])
|
||||||
|
@@index([moshtariCode])
|
||||||
|
}
|
||||||
|
|
||||||
model AccountingSyncJob {
|
model AccountingSyncJob {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
CustomerGroupSummaryDto,
|
||||||
|
TahesabAsnadKind,
|
||||||
|
TahesabAsnadLineDto,
|
||||||
|
TahesabAsnadReportPageDto,
|
||||||
|
TahesabAsnadSummaryDto,
|
||||||
|
TahesabAsnadSyncJobDto,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import {
|
||||||
|
TAHESAB_ASNAD_KIND_LABELS,
|
||||||
|
TAHESAB_JENS_FELEZ_LABELS,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { TahesabConnectionsService } from './connections.service';
|
||||||
|
|
||||||
|
const ASNAD_COUNT_LAST = 200;
|
||||||
|
const SYNC_CONCURRENCY = 3;
|
||||||
|
const ASNAD_KINDS = new Set<string>(['sale', 'return', 'purchase', 'other']);
|
||||||
|
|
||||||
|
function normalizeShamsiYmd(input: string): string {
|
||||||
|
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(input.trim());
|
||||||
|
if (!m) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'تاریخ از/تا باید شمسی با قالب YYYY-MM-DD باشد.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${m[1]}-${m[2]!.padStart(2, '0')}-${m[3]!.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asAsnadList(raw: unknown): Array<Record<string, unknown>> {
|
||||||
|
if (!raw) return [];
|
||||||
|
if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
|
||||||
|
if (typeof raw === 'object') {
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
for (const key of ['DoListAsnad', 'Asnad', 'asnad', 'Items', 'items', 'Data']) {
|
||||||
|
const nested = obj[key];
|
||||||
|
if (Array.isArray(nested)) {
|
||||||
|
return nested.filter((v) => v && typeof v === 'object') as Array<
|
||||||
|
Record<string, unknown>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.values(obj).filter(
|
||||||
|
(v) => v && typeof v === 'object' && !Array.isArray(v),
|
||||||
|
) as Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** طبقهبندی تقریبی — قابل اصلاح بعد از فیدبک کارفرما */
|
||||||
|
export function classifyAsnadNo(noRaw: unknown): TahesabAsnadKind {
|
||||||
|
const s = String(noRaw ?? '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (/مرجوع|برگشت/.test(s)) return 'return';
|
||||||
|
if (/فروش/.test(s)) return 'sale';
|
||||||
|
if (/خرید/.test(s)) return 'purchase';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNum(v: unknown): number | null {
|
||||||
|
if (v == null || v === '' || v === 'null') return null;
|
||||||
|
const n = typeof v === 'number' ? v : Number(String(v).replace(/,/g, ''));
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickInt(v: unknown): number | null {
|
||||||
|
const n = pickNum(v);
|
||||||
|
return n == null ? null : Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickStr(v: unknown): string | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const s = String(v).trim();
|
||||||
|
if (!s || s === 'null') return null;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تاریخ تهحساب اغلب 1405/05/04 است → YYYY-MM-DD */
|
||||||
|
function normalizeTarikh(raw: unknown, fallbackFrom: string, fallbackTo: string): string {
|
||||||
|
const s = pickStr(raw);
|
||||||
|
if (!s) return fallbackFrom;
|
||||||
|
const slash = /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})/.exec(s);
|
||||||
|
if (slash) {
|
||||||
|
return `${slash[1]}-${slash[2]!.padStart(2, '0')}-${slash[3]!.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return fallbackFrom <= fallbackTo ? fallbackFrom : fallbackTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineKey(row: Record<string, unknown>): string {
|
||||||
|
const id = pickInt(row.ID ?? row.Id ?? row.id);
|
||||||
|
if (id != null) return `id:${id}`;
|
||||||
|
const factor = pickStr(row.Factor_Code) ?? '';
|
||||||
|
const radif = pickInt(row.Radif) ?? 0;
|
||||||
|
const tarikh = pickStr(row.Tarikh) ?? '';
|
||||||
|
const no = pickStr(row.NO) ?? '';
|
||||||
|
const sh = pickInt(row.Sh_Factor ?? row.Sh_factor) ?? 0;
|
||||||
|
return `f:${factor}|r:${radif}|t:${tarikh}|n:${no}|s:${sh}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function personDisplayName(p: {
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
} | null): string | null {
|
||||||
|
if (!p) return null;
|
||||||
|
const name = [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
return name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncProgress = {
|
||||||
|
connectionId: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
linesUpserted: number;
|
||||||
|
truncatedPersonIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TahesabAsnadReportService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly connections: TahesabConnectionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private mapJob(
|
||||||
|
job: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
connectionId: string | null;
|
||||||
|
request: unknown;
|
||||||
|
result: unknown;
|
||||||
|
errorMessage: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
},
|
||||||
|
): TahesabAsnadSyncJobDto {
|
||||||
|
const req = (job.request ?? {}) as Record<string, unknown>;
|
||||||
|
const res = (job.result ?? {}) as Partial<SyncProgress>;
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
status: job.status,
|
||||||
|
connectionId: job.connectionId,
|
||||||
|
from: String(req.from ?? res.from ?? ''),
|
||||||
|
to: String(req.to ?? res.to ?? ''),
|
||||||
|
processed: Number(res.processed ?? 0),
|
||||||
|
total: Number(res.total ?? 0),
|
||||||
|
linesUpserted: Number(res.linesUpserted ?? 0),
|
||||||
|
truncatedPersonIds: Array.isArray(res.truncatedPersonIds)
|
||||||
|
? res.truncatedPersonIds.map(String)
|
||||||
|
: [],
|
||||||
|
errorMessage: job.errorMessage,
|
||||||
|
createdAt: job.createdAt.toISOString(),
|
||||||
|
updatedAt: job.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSync(
|
||||||
|
organizationId: string,
|
||||||
|
connectionId: string | undefined,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): Promise<TahesabAsnadSyncJobDto> {
|
||||||
|
const fromNorm = normalizeShamsiYmd(from);
|
||||||
|
const toNorm = normalizeShamsiYmd(to);
|
||||||
|
if (fromNorm > toNorm) {
|
||||||
|
throw new BadRequestException('تاریخ «از» نباید بعد از «تا» باشد.');
|
||||||
|
}
|
||||||
|
const resolvedId = await this.connections.resolveConnectionId(
|
||||||
|
organizationId,
|
||||||
|
connectionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const job = await this.prisma.accountingSyncJob.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
connectionId: resolvedId,
|
||||||
|
kind: 'asnad-sync',
|
||||||
|
status: 'pending',
|
||||||
|
request: { from: fromNorm, to: toNorm, connectionId: resolvedId },
|
||||||
|
result: {
|
||||||
|
connectionId: resolvedId,
|
||||||
|
from: fromNorm,
|
||||||
|
to: toNorm,
|
||||||
|
processed: 0,
|
||||||
|
total: 0,
|
||||||
|
linesUpserted: 0,
|
||||||
|
truncatedPersonIds: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
void this.runSync(job.id, organizationId, resolvedId, fromNorm, toNorm).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.mapJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSyncJob(
|
||||||
|
organizationId: string,
|
||||||
|
jobId: string,
|
||||||
|
): Promise<TahesabAsnadSyncJobDto> {
|
||||||
|
const job = await this.prisma.accountingSyncJob.findFirst({
|
||||||
|
where: { id: jobId, organizationId, kind: 'asnad-sync' },
|
||||||
|
});
|
||||||
|
if (!job) throw new NotFoundException('کار همگامسازی یافت نشد.');
|
||||||
|
return this.mapJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runSync(
|
||||||
|
jobId: string,
|
||||||
|
organizationId: string,
|
||||||
|
connectionId: string,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
) {
|
||||||
|
const progress: SyncProgress = {
|
||||||
|
connectionId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
processed: 0,
|
||||||
|
total: 0,
|
||||||
|
linesUpserted: 0,
|
||||||
|
truncatedPersonIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.prisma.accountingSyncJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: 'running', result: progress },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { api } = await this.connections.getApi(organizationId, connectionId);
|
||||||
|
const links = await this.prisma.personAccountingLink.findMany({
|
||||||
|
where: { connectionId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
personId: true,
|
||||||
|
moshtariCode: true,
|
||||||
|
},
|
||||||
|
orderBy: { moshtariCode: 'asc' },
|
||||||
|
});
|
||||||
|
progress.total = links.length;
|
||||||
|
await this.prisma.accountingSyncJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { result: { ...progress } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.accountingAsnadLine.deleteMany({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
connectionId,
|
||||||
|
tarikh: { gte: from, lte: to },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(SYNC_CONCURRENCY, Math.max(links.length, 1)) },
|
||||||
|
async () => {
|
||||||
|
while (true) {
|
||||||
|
const i = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
if (i >= links.length) return;
|
||||||
|
const link = links[i]!;
|
||||||
|
try {
|
||||||
|
const raw = await api.doListAsnad(
|
||||||
|
ASNAD_COUNT_LAST,
|
||||||
|
link.moshtariCode,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
);
|
||||||
|
const items = asAsnadList(raw);
|
||||||
|
if (items.length >= ASNAD_COUNT_LAST) {
|
||||||
|
progress.truncatedPersonIds.push(link.personId);
|
||||||
|
}
|
||||||
|
const rows: Prisma.AccountingAsnadLineCreateManyInput[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const tarikh = normalizeTarikh(item.Tarikh, from, to);
|
||||||
|
if (tarikh < from || tarikh > to) continue;
|
||||||
|
rows.push({
|
||||||
|
organizationId,
|
||||||
|
connectionId,
|
||||||
|
personId: link.personId,
|
||||||
|
linkId: link.id,
|
||||||
|
moshtariCode: link.moshtariCode,
|
||||||
|
lineKey: buildLineKey(item),
|
||||||
|
tahesabId: pickInt(item.ID ?? item.Id ?? item.id),
|
||||||
|
factorCode: pickStr(item.Factor_Code),
|
||||||
|
shFactor: pickInt(item.Sh_Factor ?? item.Sh_factor),
|
||||||
|
radif: pickInt(item.Radif),
|
||||||
|
tarikh,
|
||||||
|
noRaw: pickStr(item.NO) ?? '',
|
||||||
|
kind: classifyAsnadNo(item.NO),
|
||||||
|
jensFelez: pickInt(item.Jens_Felez),
|
||||||
|
noKar: pickStr(item.No_Kar),
|
||||||
|
vazn: pickNum(item.Vazn),
|
||||||
|
tabdilVazn: pickNum(item.TabdilVazn),
|
||||||
|
mali: pickNum(item.Mali),
|
||||||
|
count: pickNum(item.Count),
|
||||||
|
ayar: pickNum(item.Ayar),
|
||||||
|
mazaneh: pickNum(item.Mazaneh),
|
||||||
|
fi: pickNum(item.Fi),
|
||||||
|
sharh1: pickStr(item.Sharh1),
|
||||||
|
syncedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rows.length) {
|
||||||
|
// createMany skipDuplicates در صورت برخورد نادر lineKey
|
||||||
|
const result = await this.prisma.accountingAsnadLine.createMany({
|
||||||
|
data: rows,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
progress.linesUpserted += result.count;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// یک مشتری خراب کل sync را متوقف نکند
|
||||||
|
}
|
||||||
|
progress.processed += 1;
|
||||||
|
if (progress.processed % 5 === 0 || progress.processed === progress.total) {
|
||||||
|
await this.prisma.accountingSyncJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { result: { ...progress } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
await this.prisma.accountingSyncJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status: 'done', result: { ...progress } },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'همگامسازی اسناد ناموفق بود.';
|
||||||
|
await this.prisma.accountingSyncJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: message,
|
||||||
|
result: { ...progress },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildWhere(params: {
|
||||||
|
organizationId: string;
|
||||||
|
connectionId?: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
groupIds?: string[];
|
||||||
|
personId?: string;
|
||||||
|
kinds?: string[];
|
||||||
|
jensFelez?: number;
|
||||||
|
}): Promise<Prisma.AccountingAsnadLineWhereInput> {
|
||||||
|
const from = normalizeShamsiYmd(params.from);
|
||||||
|
const to = normalizeShamsiYmd(params.to);
|
||||||
|
const connectionId = await this.connections.resolveConnectionId(
|
||||||
|
params.organizationId,
|
||||||
|
params.connectionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const kinds = (params.kinds ?? []).filter((k) => ASNAD_KINDS.has(k));
|
||||||
|
const where: Prisma.AccountingAsnadLineWhereInput = {
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
connectionId,
|
||||||
|
tarikh: { gte: from, lte: to },
|
||||||
|
};
|
||||||
|
if (params.personId) where.personId = params.personId;
|
||||||
|
if (kinds.length) where.kind = { in: kinds };
|
||||||
|
if (params.jensFelez != null && Number.isFinite(params.jensFelez)) {
|
||||||
|
where.jensFelez = params.jensFelez;
|
||||||
|
}
|
||||||
|
if (params.groupIds?.length) {
|
||||||
|
where.personId = {
|
||||||
|
in: (
|
||||||
|
await this.prisma.personGroupLink.findMany({
|
||||||
|
where: {
|
||||||
|
groupId: { in: params.groupIds },
|
||||||
|
person: { organizationId: params.organizationId },
|
||||||
|
},
|
||||||
|
select: { personId: true },
|
||||||
|
distinct: ['personId'],
|
||||||
|
})
|
||||||
|
).map((r) => r.personId),
|
||||||
|
};
|
||||||
|
if (params.personId) {
|
||||||
|
// تقاطع با شخص مشخص
|
||||||
|
const allowed = where.personId as { in: string[] };
|
||||||
|
if (!allowed.in.includes(params.personId)) {
|
||||||
|
where.personId = '__none__';
|
||||||
|
} else {
|
||||||
|
where.personId = params.personId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(
|
||||||
|
organizationId: string,
|
||||||
|
opts: {
|
||||||
|
connectionId?: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
groupIds?: string[];
|
||||||
|
personId?: string;
|
||||||
|
kinds?: string[];
|
||||||
|
jensFelez?: number;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
},
|
||||||
|
): Promise<TahesabAsnadReportPageDto> {
|
||||||
|
const page = Math.max(1, opts.page ?? 1);
|
||||||
|
const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50));
|
||||||
|
const where = await this.buildWhere({ organizationId, ...opts });
|
||||||
|
|
||||||
|
const [total, rows] = await Promise.all([
|
||||||
|
this.prisma.accountingAsnadLine.count({ where }),
|
||||||
|
this.prisma.accountingAsnadLine.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ tarikh: 'desc' }, { shFactor: 'desc' }, { radif: 'asc' }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
person: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
groupLinks: {
|
||||||
|
include: {
|
||||||
|
group: { select: { id: true, name: true, color: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const items: TahesabAsnadLineDto[] = rows.map((r) => {
|
||||||
|
const groups: CustomerGroupSummaryDto[] = (r.person?.groupLinks ?? []).map(
|
||||||
|
(gl) => ({
|
||||||
|
id: gl.group.id,
|
||||||
|
name: gl.group.name,
|
||||||
|
color: gl.group.color,
|
||||||
|
source: gl.source === 'tahesab' ? 'tahesab' : 'manual',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
connectionId: r.connectionId,
|
||||||
|
personId: r.personId,
|
||||||
|
personName: personDisplayName(r.person),
|
||||||
|
moshtariCode: r.moshtariCode,
|
||||||
|
tarikh: r.tarikh,
|
||||||
|
noRaw: r.noRaw,
|
||||||
|
kind: r.kind,
|
||||||
|
jensFelez: r.jensFelez,
|
||||||
|
noKar: r.noKar,
|
||||||
|
vazn: r.vazn,
|
||||||
|
tabdilVazn: r.tabdilVazn,
|
||||||
|
mali: r.mali,
|
||||||
|
count: r.count,
|
||||||
|
shFactor: r.shFactor,
|
||||||
|
factorCode: r.factorCode,
|
||||||
|
groups,
|
||||||
|
syncedAt: r.syncedAt.toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async summary(
|
||||||
|
organizationId: string,
|
||||||
|
opts: {
|
||||||
|
connectionId?: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
groupIds?: string[];
|
||||||
|
personId?: string;
|
||||||
|
kinds?: string[];
|
||||||
|
jensFelez?: number;
|
||||||
|
},
|
||||||
|
): Promise<TahesabAsnadSummaryDto> {
|
||||||
|
const where = await this.buildWhere({ organizationId, ...opts });
|
||||||
|
|
||||||
|
const rows = await this.prisma.accountingAsnadLine.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
kind: true,
|
||||||
|
jensFelez: true,
|
||||||
|
mali: true,
|
||||||
|
vazn: true,
|
||||||
|
noRaw: true,
|
||||||
|
syncedAt: true,
|
||||||
|
personId: true,
|
||||||
|
person: {
|
||||||
|
select: {
|
||||||
|
groupLinks: {
|
||||||
|
include: {
|
||||||
|
group: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let maliSum = 0;
|
||||||
|
let vaznSum = 0;
|
||||||
|
let lastSyncedAt: Date | null = null;
|
||||||
|
const byKind = new Map<string, { lineCount: number; maliSum: number; vaznSum: number }>();
|
||||||
|
const byMetal = new Map<string, { lineCount: number; maliSum: number; vaznSum: number; label: string }>();
|
||||||
|
const byGroup = new Map<string, { lineCount: number; maliSum: number; vaznSum: number; label: string }>();
|
||||||
|
const nos = new Set<string>();
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const mali = r.mali ?? 0;
|
||||||
|
const vazn = r.vazn ?? 0;
|
||||||
|
maliSum += mali;
|
||||||
|
vaznSum += vazn;
|
||||||
|
if (!lastSyncedAt || r.syncedAt > lastSyncedAt) lastSyncedAt = r.syncedAt;
|
||||||
|
if (r.noRaw) nos.add(r.noRaw);
|
||||||
|
|
||||||
|
const k = r.kind || 'other';
|
||||||
|
const kb = byKind.get(k) ?? { lineCount: 0, maliSum: 0, vaznSum: 0 };
|
||||||
|
kb.lineCount += 1;
|
||||||
|
kb.maliSum += mali;
|
||||||
|
kb.vaznSum += vazn;
|
||||||
|
byKind.set(k, kb);
|
||||||
|
|
||||||
|
const metalKey = r.jensFelez == null ? 'none' : String(r.jensFelez);
|
||||||
|
const metalLabel =
|
||||||
|
r.jensFelez == null
|
||||||
|
? 'نامشخص'
|
||||||
|
: TAHESAB_JENS_FELEZ_LABELS[r.jensFelez] ?? `فلز ${r.jensFelez}`;
|
||||||
|
const mb = byMetal.get(metalKey) ?? {
|
||||||
|
lineCount: 0,
|
||||||
|
maliSum: 0,
|
||||||
|
vaznSum: 0,
|
||||||
|
label: metalLabel,
|
||||||
|
};
|
||||||
|
mb.lineCount += 1;
|
||||||
|
mb.maliSum += mali;
|
||||||
|
mb.vaznSum += vazn;
|
||||||
|
byMetal.set(metalKey, mb);
|
||||||
|
|
||||||
|
const gls = r.person?.groupLinks ?? [];
|
||||||
|
if (!gls.length) {
|
||||||
|
const gb = byGroup.get('__none__') ?? {
|
||||||
|
lineCount: 0,
|
||||||
|
maliSum: 0,
|
||||||
|
vaznSum: 0,
|
||||||
|
label: 'بدون گروه',
|
||||||
|
};
|
||||||
|
gb.lineCount += 1;
|
||||||
|
gb.maliSum += mali;
|
||||||
|
gb.vaznSum += vazn;
|
||||||
|
byGroup.set('__none__', gb);
|
||||||
|
} else {
|
||||||
|
for (const gl of gls) {
|
||||||
|
const gb = byGroup.get(gl.group.id) ?? {
|
||||||
|
lineCount: 0,
|
||||||
|
maliSum: 0,
|
||||||
|
vaznSum: 0,
|
||||||
|
label: gl.group.name,
|
||||||
|
};
|
||||||
|
gb.lineCount += 1;
|
||||||
|
gb.maliSum += mali;
|
||||||
|
gb.vaznSum += vazn;
|
||||||
|
byGroup.set(gl.group.id, gb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalLines: rows.length,
|
||||||
|
maliSum,
|
||||||
|
vaznSum,
|
||||||
|
byKind: [...byKind.entries()].map(([key, v]) => ({
|
||||||
|
key,
|
||||||
|
label:
|
||||||
|
TAHESAB_ASNAD_KIND_LABELS[key as TahesabAsnadKind] ?? key,
|
||||||
|
...v,
|
||||||
|
})),
|
||||||
|
byMetal: [...byMetal.entries()].map(([key, v]) => ({
|
||||||
|
key,
|
||||||
|
label: v.label,
|
||||||
|
lineCount: v.lineCount,
|
||||||
|
maliSum: v.maliSum,
|
||||||
|
vaznSum: v.vaznSum,
|
||||||
|
})),
|
||||||
|
byGroup: [...byGroup.entries()].map(([key, v]) => ({
|
||||||
|
key,
|
||||||
|
label: v.label,
|
||||||
|
lineCount: v.lineCount,
|
||||||
|
maliSum: v.maliSum,
|
||||||
|
vaznSum: v.vaznSum,
|
||||||
|
})),
|
||||||
|
distinctNos: [...nos].sort((a, b) => a.localeCompare(b, 'fa')),
|
||||||
|
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -433,6 +433,22 @@ export class PushSanadDto {
|
|||||||
factorCode?: string;
|
factorCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SyncAsnadReportDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
connectionId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(12)
|
||||||
|
from!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(12)
|
||||||
|
to!: string;
|
||||||
|
}
|
||||||
|
|
||||||
// silence unused import warnings for ValidateNested/Type if unused
|
// silence unused import warnings for ValidateNested/Type if unused
|
||||||
void ValidateNested;
|
void ValidateNested;
|
||||||
void Type;
|
void Type;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -13,11 +14,14 @@ import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
|||||||
import { CurrentUser } from '../../auth/current-user.decorator';
|
import { CurrentUser } from '../../auth/current-user.decorator';
|
||||||
import { Roles } from '../../auth/roles.decorator';
|
import { Roles } from '../../auth/roles.decorator';
|
||||||
import { RolesGuard } from '../../auth/roles.guard';
|
import { RolesGuard } from '../../auth/roles.guard';
|
||||||
|
import { OrgModuleGuard } from '../../billing/org-module.guard';
|
||||||
|
import { RequireOrgModule } from '../../billing/org-module.decorator';
|
||||||
import { TahesabConnectionsService } from './connections.service';
|
import { TahesabConnectionsService } from './connections.service';
|
||||||
import { TahesabPersonLinkService } from './person-link.service';
|
import { TahesabPersonLinkService } from './person-link.service';
|
||||||
import { TahesabProductLinkService } from './product-link.service';
|
import { TahesabProductLinkService } from './product-link.service';
|
||||||
import { TahesabBalanceService } from './balance.service';
|
import { TahesabBalanceService } from './balance.service';
|
||||||
import { TahesabDocumentsService } from './documents.service';
|
import { TahesabDocumentsService } from './documents.service';
|
||||||
|
import { TahesabAsnadReportService } from './asnad-report.service';
|
||||||
import {
|
import {
|
||||||
CreateInTahesabDto,
|
CreateInTahesabDto,
|
||||||
CreateTahesabConnectionDto,
|
CreateTahesabConnectionDto,
|
||||||
@@ -31,12 +35,14 @@ import {
|
|||||||
PushSanadDto,
|
PushSanadDto,
|
||||||
SearchTahesabDto,
|
SearchTahesabDto,
|
||||||
SearchTahesabProductsDto,
|
SearchTahesabProductsDto,
|
||||||
|
SyncAsnadReportDto,
|
||||||
SyncGroupsPageDto,
|
SyncGroupsPageDto,
|
||||||
UpdateTahesabConnectionDto,
|
UpdateTahesabConnectionDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
|
||||||
@Controller('integrations/tahesab')
|
@Controller('integrations/tahesab')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||||
|
@RequireOrgModule('tahesab')
|
||||||
export class TahesabController {
|
export class TahesabController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly connections: TahesabConnectionsService,
|
private readonly connections: TahesabConnectionsService,
|
||||||
@@ -44,6 +50,7 @@ export class TahesabController {
|
|||||||
private readonly products: TahesabProductLinkService,
|
private readonly products: TahesabProductLinkService,
|
||||||
private readonly balance: TahesabBalanceService,
|
private readonly balance: TahesabBalanceService,
|
||||||
private readonly documents: TahesabDocumentsService,
|
private readonly documents: TahesabDocumentsService,
|
||||||
|
private readonly asnadReport: TahesabAsnadReportService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// —— Connections ——
|
// —— Connections ——
|
||||||
@@ -271,4 +278,107 @@ export class TahesabController {
|
|||||||
) {
|
) {
|
||||||
return this.products.importBulk(user.organizationId, dto);
|
return this.products.importBulk(user.organizationId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— گزارش فروش اسناد (اسنپشات DoListAsnad) ——
|
||||||
|
@Post('reports/asnad/sync')
|
||||||
|
@Roles('owner', 'admin', 'sales')
|
||||||
|
syncAsnadReport(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Body() dto: SyncAsnadReportDto,
|
||||||
|
) {
|
||||||
|
return this.asnadReport.startSync(
|
||||||
|
user.organizationId,
|
||||||
|
dto.connectionId,
|
||||||
|
dto.from,
|
||||||
|
dto.to,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('reports/asnad/sync/:jobId')
|
||||||
|
syncAsnadReportStatus(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Param('jobId') jobId: string,
|
||||||
|
) {
|
||||||
|
return this.asnadReport.getSyncJob(user.organizationId, jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('reports/asnad/summary')
|
||||||
|
asnadReportSummary(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Query('connectionId') connectionId?: string,
|
||||||
|
@Query('from') from?: string,
|
||||||
|
@Query('to') to?: string,
|
||||||
|
@Query('groupIds') groupIds?: string | string[],
|
||||||
|
@Query('personId') personId?: string,
|
||||||
|
@Query('kinds') kinds?: string | string[],
|
||||||
|
@Query('jensFelez') jensFelez?: string,
|
||||||
|
) {
|
||||||
|
if (!from || !to) {
|
||||||
|
throw new BadRequestException('پارامتر from و to الزامی است.');
|
||||||
|
}
|
||||||
|
return this.asnadReport.summary(user.organizationId, {
|
||||||
|
connectionId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
...this.parseAsnadFilters(groupIds, personId, kinds, jensFelez),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('reports/asnad')
|
||||||
|
listAsnadReport(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Query('connectionId') connectionId?: string,
|
||||||
|
@Query('from') from?: string,
|
||||||
|
@Query('to') to?: string,
|
||||||
|
@Query('groupIds') groupIds?: string | string[],
|
||||||
|
@Query('personId') personId?: string,
|
||||||
|
@Query('kinds') kinds?: string | string[],
|
||||||
|
@Query('jensFelez') jensFelez?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
if (!from || !to) {
|
||||||
|
throw new BadRequestException('پارامتر from و to الزامی است.');
|
||||||
|
}
|
||||||
|
return this.asnadReport.query(user.organizationId, {
|
||||||
|
connectionId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
...this.parseAsnadFilters(groupIds, personId, kinds, jensFelez),
|
||||||
|
page: page ? Number(page) : undefined,
|
||||||
|
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAsnadFilters(
|
||||||
|
groupIds?: string | string[],
|
||||||
|
personId?: string,
|
||||||
|
kinds?: string | string[],
|
||||||
|
jensFelez?: string,
|
||||||
|
) {
|
||||||
|
const groups = Array.isArray(groupIds)
|
||||||
|
? groupIds
|
||||||
|
: groupIds
|
||||||
|
? groupIds
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
const kindList = Array.isArray(kinds)
|
||||||
|
? kinds
|
||||||
|
: kinds
|
||||||
|
? kinds
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
const jf =
|
||||||
|
jensFelez != null && jensFelez !== '' ? Number(jensFelez) : undefined;
|
||||||
|
return {
|
||||||
|
groupIds: groups,
|
||||||
|
personId,
|
||||||
|
kinds: kindList,
|
||||||
|
jensFelez: jf != null && Number.isFinite(jf) ? jf : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { TahesabProductLinkService } from './product-link.service';
|
import { TahesabProductLinkService } from './product-link.service';
|
||||||
import { TahesabBalanceService } from './balance.service';
|
import { TahesabBalanceService } from './balance.service';
|
||||||
import { TahesabDocumentsService } from './documents.service';
|
import { TahesabDocumentsService } from './documents.service';
|
||||||
|
import { TahesabAsnadReportService } from './asnad-report.service';
|
||||||
import { TahesabController } from './tahesab.controller';
|
import { TahesabController } from './tahesab.controller';
|
||||||
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
|
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
|
|||||||
TahesabProductLinkService,
|
TahesabProductLinkService,
|
||||||
TahesabBalanceService,
|
TahesabBalanceService,
|
||||||
TahesabDocumentsService,
|
TahesabDocumentsService,
|
||||||
|
TahesabAsnadReportService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TahesabCryptoService,
|
TahesabCryptoService,
|
||||||
@@ -33,6 +35,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
|
|||||||
TahesabProductLinkService,
|
TahesabProductLinkService,
|
||||||
TahesabBalanceService,
|
TahesabBalanceService,
|
||||||
TahesabDocumentsService,
|
TahesabDocumentsService,
|
||||||
|
TahesabAsnadReportService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TahesabModule {}
|
export class TahesabModule {}
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ function pathIn(prefixes: string[]) {
|
|||||||
|
|
||||||
const dashboardActive = computed(() => route.path === '/');
|
const dashboardActive = computed(() => route.path === '/');
|
||||||
const loyaltyActive = computed(() => pathIn(['/loyalty']));
|
const loyaltyActive = computed(() => pathIn(['/loyalty']));
|
||||||
const reportsActive = computed(() => pathIn(['/reports']));
|
const reportsActive = computed(() =>
|
||||||
|
pathIn(['/reports', '/reports/tahesab-sales']),
|
||||||
|
);
|
||||||
const clubActive = computed(() => pathIn(['/club']));
|
const clubActive = computed(() => pathIn(['/club']));
|
||||||
const leadsActive = computed(() => pathIn(['/leads', '/settings/lead-stages']));
|
const leadsActive = computed(() => pathIn(['/leads', '/settings/lead-stages']));
|
||||||
const customersActive = computed(
|
const customersActive = computed(
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ export const router = createRouter({
|
|||||||
name: 'reports',
|
name: 'reports',
|
||||||
component: () => import('@/views/ReportsView.vue'),
|
component: () => import('@/views/ReportsView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'reports/tahesab-sales',
|
||||||
|
name: 'tahesab-sales-report',
|
||||||
|
component: () => import('@/views/TahesabSalesReportView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'behavior/disc',
|
path: 'behavior/disc',
|
||||||
name: 'disc-report',
|
name: 'disc-report',
|
||||||
@@ -637,6 +642,15 @@ router.beforeEach((to) => {
|
|||||||
const modules = auth.user?.enabledModules;
|
const modules = auth.user?.enabledModules;
|
||||||
if (modules && !modules.includes('treasury')) return { name: 'home' };
|
if (modules && !modules.includes('treasury')) return { name: 'home' };
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
to.name === 'tahesab-sales-report' ||
|
||||||
|
to.name === 'tahesab-settings' ||
|
||||||
|
String(to.path).startsWith('/settings/tahesab') ||
|
||||||
|
String(to.path).startsWith('/reports/tahesab')
|
||||||
|
) {
|
||||||
|
const modules = auth.user?.enabledModules;
|
||||||
|
if (modules && !modules.includes('tahesab')) return { name: 'home' };
|
||||||
|
}
|
||||||
if (!canOpenPath(auth.user?.role, auth.user?.permissions, String(to.path))) {
|
if (!canOpenPath(auth.user?.role, auth.user?.permissions, String(to.path))) {
|
||||||
return { name: 'home' };
|
return { name: 'home' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1139,6 +1139,14 @@ const activityMax = computed(() =>
|
|||||||
lead="نمای جامع عملکرد فروش، مشتریان، فعالیتها و تیم"
|
lead="نمای جامع عملکرد فروش، مشتریان، فعالیتها و تیم"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
<UiButton
|
||||||
|
v-if="auth.user?.enabledModules?.includes('tahesab')"
|
||||||
|
variant="secondary"
|
||||||
|
@click="router.push({ name: 'tahesab-sales-report' })"
|
||||||
|
>
|
||||||
|
<UiIcon name="chart" :size="16" />
|
||||||
|
فروش تهحساب
|
||||||
|
</UiButton>
|
||||||
<UiButton variant="secondary" @click="openSaveModal">
|
<UiButton variant="secondary" @click="openSaveModal">
|
||||||
<UiIcon name="layers" :size="16" />
|
<UiIcon name="layers" :size="16" />
|
||||||
ذخیره نما
|
ذخیره نما
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue';
|
||||||
|
import { ReloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
||||||
|
import {
|
||||||
|
TAHESAB_ASNAD_KIND_LABELS,
|
||||||
|
TAHESAB_ASNAD_KINDS,
|
||||||
|
TAHESAB_JENS_FELEZ_LABELS,
|
||||||
|
type CustomerGroupDto,
|
||||||
|
type PersonDto,
|
||||||
|
type TahesabAsnadKind,
|
||||||
|
type TahesabAsnadLineDto,
|
||||||
|
type TahesabAsnadReportPageDto,
|
||||||
|
type TahesabAsnadSummaryDto,
|
||||||
|
type TahesabAsnadSyncJobDto,
|
||||||
|
type TahesabConnectionDto,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
dateToJalali,
|
||||||
|
formatLocalDateInput,
|
||||||
|
parseLocalInput,
|
||||||
|
toFaDigits,
|
||||||
|
} from '@/lib/jalali';
|
||||||
|
import { formatMoney } from '@/lib/money';
|
||||||
|
import { useCustomerGroups } from '@/lib/customer-groups';
|
||||||
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
|
||||||
|
|
||||||
|
const connections = ref<TahesabConnectionDto[]>([]);
|
||||||
|
const connectionId = ref<string | undefined>();
|
||||||
|
const fromDate = ref('');
|
||||||
|
const toDate = ref('');
|
||||||
|
const groupIds = ref<string[]>([]);
|
||||||
|
const personId = ref<string | undefined>();
|
||||||
|
const personOptions = ref<Array<{ value: string; label: string }>>([]);
|
||||||
|
const kinds = ref<TahesabAsnadKind[]>(['sale', 'return']);
|
||||||
|
const jensFelez = ref<number | undefined>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const syncHint = ref('');
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(50);
|
||||||
|
const report = ref<TahesabAsnadReportPageDto | null>(null);
|
||||||
|
const summary = ref<TahesabAsnadSummaryDto | null>(null);
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const kindOptions = TAHESAB_ASNAD_KINDS.map((k) => ({
|
||||||
|
value: k,
|
||||||
|
label: TAHESAB_ASNAD_KIND_LABELS[k],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const metalOptions = [
|
||||||
|
{ value: 0, label: TAHESAB_JENS_FELEZ_LABELS[0] },
|
||||||
|
{ value: 1, label: TAHESAB_JENS_FELEZ_LABELS[1] },
|
||||||
|
{ value: 2, label: TAHESAB_JENS_FELEZ_LABELS[2] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function toShamsiYmd(localIso: string): string | null {
|
||||||
|
const d = parseLocalInput(localIso);
|
||||||
|
if (!d) return null;
|
||||||
|
const { jy, jm, jd } = dateToJalali(d);
|
||||||
|
return `${jy}-${String(jm).padStart(2, '0')}-${String(jd).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRange() {
|
||||||
|
const now = new Date();
|
||||||
|
const from = new Date(now);
|
||||||
|
from.setDate(from.getDate() - 30);
|
||||||
|
fromDate.value = formatLocalDateInput(from);
|
||||||
|
toDate.value = formatLocalDateInput(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindLabel(k: string) {
|
||||||
|
return TAHESAB_ASNAD_KIND_LABELS[k as TahesabAsnadKind] ?? k;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metalLabel(v: number | null) {
|
||||||
|
if (v == null) return '—';
|
||||||
|
return TAHESAB_JENS_FELEZ_LABELS[v] ?? String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNum(n: number | null | undefined) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return toFaDigits(new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMali(n: number | null | undefined) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return formatMoney(n, 'IRR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<TahesabAsnadLineDto> = [
|
||||||
|
{ title: 'تاریخ', dataIndex: 'tarikh', key: 'tarikh', width: 110 },
|
||||||
|
{
|
||||||
|
title: 'شخص',
|
||||||
|
key: 'person',
|
||||||
|
ellipsis: true,
|
||||||
|
customRender: ({ record }) =>
|
||||||
|
record.personName
|
||||||
|
? `${record.personName} (${toFaDigits(String(record.moshtariCode))})`
|
||||||
|
: toFaDigits(String(record.moshtariCode)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'گروه',
|
||||||
|
key: 'groups',
|
||||||
|
ellipsis: true,
|
||||||
|
customRender: ({ record }) =>
|
||||||
|
record.groups?.map((g) => g.name).join('، ') || '—',
|
||||||
|
},
|
||||||
|
{ title: 'نوع سند (NO)', dataIndex: 'noRaw', key: 'noRaw', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: 'طبقهبندی',
|
||||||
|
key: 'kind',
|
||||||
|
width: 100,
|
||||||
|
customRender: ({ record }) => kindLabel(record.kind),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'فلز',
|
||||||
|
key: 'jensFelez',
|
||||||
|
width: 90,
|
||||||
|
customRender: ({ record }) => metalLabel(record.jensFelez),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'وزن',
|
||||||
|
key: 'vazn',
|
||||||
|
width: 90,
|
||||||
|
customRender: ({ record }) => formatNum(record.vazn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'مبلغ',
|
||||||
|
key: 'mali',
|
||||||
|
width: 140,
|
||||||
|
customRender: ({ record }) => formatMali(record.mali),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ش. فاکتور',
|
||||||
|
key: 'shFactor',
|
||||||
|
width: 100,
|
||||||
|
customRender: ({ record }) =>
|
||||||
|
record.shFactor != null ? toFaDigits(String(record.shFactor)) : '—',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterParams = computed(() => {
|
||||||
|
const from = toShamsiYmd(fromDate.value);
|
||||||
|
const to = toShamsiYmd(toDate.value);
|
||||||
|
if (!from || !to) return null;
|
||||||
|
return {
|
||||||
|
connectionId: connectionId.value,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
groupIds: groupIds.value.length ? groupIds.value.join(',') : undefined,
|
||||||
|
personId: personId.value,
|
||||||
|
kinds: kinds.value.length ? kinds.value.join(',') : undefined,
|
||||||
|
jensFelez: jensFelez.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadConnections() {
|
||||||
|
const { data } = await api.get<TahesabConnectionDto[]>(
|
||||||
|
'/integrations/tahesab/connections/public',
|
||||||
|
);
|
||||||
|
connections.value = data.filter((c) => c.enabled);
|
||||||
|
const def =
|
||||||
|
connections.value.find((c) => c.isDefault) ?? connections.value[0];
|
||||||
|
if (!connectionId.value && def) connectionId.value = def.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchPeople(q: string) {
|
||||||
|
if (!q || q.trim().length < 2) {
|
||||||
|
personOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ items: PersonDto[] }>('/people', {
|
||||||
|
params: { q: q.trim(), limit: 20 },
|
||||||
|
});
|
||||||
|
const items = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
personOptions.value = items.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: [p.firstName, p.lastName].filter(Boolean).join(' ') || p.id,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
personOptions.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReport() {
|
||||||
|
const params = filterParams.value;
|
||||||
|
if (!params) {
|
||||||
|
error.value = 'تاریخ از و تا را انتخاب کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const [pageRes, sumRes] = await Promise.all([
|
||||||
|
api.get<TahesabAsnadReportPageDto>('/integrations/tahesab/reports/asnad', {
|
||||||
|
params: { ...params, page: page.value, pageSize: pageSize.value },
|
||||||
|
}),
|
||||||
|
api.get<TahesabAsnadSummaryDto>(
|
||||||
|
'/integrations/tahesab/reports/asnad/summary',
|
||||||
|
{ params },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
report.value = pageRes.data;
|
||||||
|
summary.value = sumRes.data;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? 'بارگذاری گزارش ناموفق بود.';
|
||||||
|
error.value = String(msg);
|
||||||
|
report.value = null;
|
||||||
|
summary.value = null;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPoll() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollJob(jobId: string) {
|
||||||
|
stopPoll();
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<TahesabAsnadSyncJobDto>(
|
||||||
|
`/integrations/tahesab/reports/asnad/sync/${jobId}`,
|
||||||
|
);
|
||||||
|
syncHint.value = `همگامسازی: ${toFaDigits(String(data.processed))} از ${toFaDigits(String(data.total))} · ${toFaDigits(String(data.linesUpserted))} ردیف`;
|
||||||
|
if (data.status === 'done' || data.status === 'failed') {
|
||||||
|
stopPoll();
|
||||||
|
syncing.value = false;
|
||||||
|
if (data.status === 'failed') {
|
||||||
|
error.value = data.errorMessage || 'همگامسازی ناموفق بود.';
|
||||||
|
} else {
|
||||||
|
if (data.truncatedPersonIds?.length) {
|
||||||
|
syncHint.value += ` · هشدار: برای ${toFaDigits(String(data.truncatedPersonIds.length))} مشتری ممکن است اسناد ناقص باشد (سقف ۲۰۰).`;
|
||||||
|
} else {
|
||||||
|
syncHint.value = `همگامسازی تمام شد · ${toFaDigits(String(data.linesUpserted))} ردیف`;
|
||||||
|
}
|
||||||
|
await loadReport();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
stopPoll();
|
||||||
|
syncing.value = false;
|
||||||
|
error.value = 'وضعیت همگامسازی دریافت نشد.';
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSync() {
|
||||||
|
const params = filterParams.value;
|
||||||
|
if (!params) {
|
||||||
|
error.value = 'تاریخ از و تا را انتخاب کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncing.value = true;
|
||||||
|
error.value = '';
|
||||||
|
syncHint.value = 'شروع همگامسازی…';
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<TahesabAsnadSyncJobDto>(
|
||||||
|
'/integrations/tahesab/reports/asnad/sync',
|
||||||
|
{
|
||||||
|
connectionId: params.connectionId,
|
||||||
|
from: params.from,
|
||||||
|
to: params.to,
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
await pollJob(data.id);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
syncing.value = false;
|
||||||
|
const msg =
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? 'شروع همگامسازی ناموفق بود.';
|
||||||
|
error.value = String(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTableChange(pag: TablePaginationConfig) {
|
||||||
|
page.value = pag.current ?? 1;
|
||||||
|
pageSize.value = pag.pageSize ?? 50;
|
||||||
|
void loadReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([connectionId, groupIds, kinds, jensFelez, personId], () => {
|
||||||
|
page.value = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
defaultRange();
|
||||||
|
try {
|
||||||
|
await Promise.all([loadConnections(), loadCustomerGroups()]);
|
||||||
|
await loadReport();
|
||||||
|
} catch {
|
||||||
|
error.value = 'بارگذاری اولیه ناموفق بود.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => stopPoll());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-flex vertical :gap="16">
|
||||||
|
<a-row :gutter="[16, 16]" align="middle" justify="space-between">
|
||||||
|
<a-col :xs="24" :md="14">
|
||||||
|
<a-typography-title :level="3" style="margin: 0">
|
||||||
|
گزارش فروش تهحساب
|
||||||
|
</a-typography-title>
|
||||||
|
<a-typography-text type="secondary">
|
||||||
|
اسنپشات اسناد مشتریان لینکشده — طبقهبندی فروش/مرجوعی تقریبی است و بعد از بازخورد کارفرما اصلاح میشود.
|
||||||
|
</a-typography-text>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :md="10">
|
||||||
|
<a-flex justify="flex-end" wrap="wrap" :gap="8">
|
||||||
|
<a-button @click="router.push({ name: 'reports' })">بازگشت به گزارشها</a-button>
|
||||||
|
<a-button :loading="loading" @click="loadReport">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
نمایش
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" :loading="syncing" @click="startSync">
|
||||||
|
<template #icon><SyncOutlined /></template>
|
||||||
|
بهروزرسانی از تهحساب
|
||||||
|
</a-button>
|
||||||
|
</a-flex>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||||
|
<a-alert v-if="syncHint" type="info" show-icon :message="syncHint" />
|
||||||
|
|
||||||
|
<a-card :bordered="false" title="فیلترها">
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-row :gutter="[16, 8]">
|
||||||
|
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||||
|
<a-form-item label="اتصال تهحساب">
|
||||||
|
<a-select
|
||||||
|
v-model:value="connectionId"
|
||||||
|
:options="connections.map((c) => ({ value: c.id, label: c.label }))"
|
||||||
|
placeholder="اتصال"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||||
|
<a-form-item label="از تاریخ">
|
||||||
|
<JalaliDateInput v-model="fromDate" placeholder="از" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||||
|
<a-form-item label="تا تاریخ">
|
||||||
|
<JalaliDateInput v-model="toDate" placeholder="تا" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||||
|
<a-form-item label="گروه مشتری">
|
||||||
|
<a-select
|
||||||
|
v-model:value="groupIds"
|
||||||
|
mode="multiple"
|
||||||
|
allow-clear
|
||||||
|
placeholder="همه گروهها"
|
||||||
|
:options="(customerGroups as CustomerGroupDto[]).map((g) => ({ value: g.id, label: g.name }))"
|
||||||
|
:max-tag-count="2"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||||
|
<a-form-item label="نوع سند">
|
||||||
|
<a-select
|
||||||
|
v-model:value="kinds"
|
||||||
|
mode="multiple"
|
||||||
|
:options="kindOptions"
|
||||||
|
placeholder="نوع"
|
||||||
|
:max-tag-count="2"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||||
|
<a-form-item label="فلز">
|
||||||
|
<a-select
|
||||||
|
v-model:value="jensFelez"
|
||||||
|
allow-clear
|
||||||
|
placeholder="همه"
|
||||||
|
:options="metalOptions"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||||
|
<a-form-item label="شخص">
|
||||||
|
<a-select
|
||||||
|
v-model:value="personId"
|
||||||
|
show-search
|
||||||
|
allow-clear
|
||||||
|
placeholder="جستجوی نام…"
|
||||||
|
:filter-option="false"
|
||||||
|
:options="personOptions"
|
||||||
|
@search="searchPeople"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-row v-if="summary" :gutter="[16, 16]">
|
||||||
|
<a-col :xs="24" :sm="8">
|
||||||
|
<a-card :bordered="false">
|
||||||
|
<a-statistic title="تعداد ردیف" :value="summary.totalLines" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="8">
|
||||||
|
<a-card :bordered="false">
|
||||||
|
<a-statistic title="جمع مبلغ" :value="formatMali(summary.maliSum)" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="8">
|
||||||
|
<a-card :bordered="false">
|
||||||
|
<a-statistic title="جمع وزن" :value="formatNum(summary.vaznSum)" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row v-if="summary?.byKind?.length" :gutter="[16, 16]">
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :bordered="false" title="به تفکیک نوع">
|
||||||
|
<a-list
|
||||||
|
:data-source="summary.byKind"
|
||||||
|
item-layout="horizontal"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #renderItem="{ item }">
|
||||||
|
<a-list-item>
|
||||||
|
<a-list-item-meta :title="item.label">
|
||||||
|
<template #description>
|
||||||
|
{{ toFaDigits(String(item.lineCount)) }} ردیف ·
|
||||||
|
{{ formatMali(item.maliSum) }} · وزن {{ formatNum(item.vaznSum) }}
|
||||||
|
</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
</a-list-item>
|
||||||
|
</template>
|
||||||
|
</a-list>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :lg="12">
|
||||||
|
<a-card :bordered="false" title="به تفکیک گروه">
|
||||||
|
<a-list
|
||||||
|
:data-source="summary.byGroup.slice(0, 8)"
|
||||||
|
item-layout="horizontal"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #renderItem="{ item }">
|
||||||
|
<a-list-item>
|
||||||
|
<a-list-item-meta :title="item.label">
|
||||||
|
<template #description>
|
||||||
|
{{ toFaDigits(String(item.lineCount)) }} ردیف ·
|
||||||
|
{{ formatMali(item.maliSum) }}
|
||||||
|
</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
</a-list-item>
|
||||||
|
</template>
|
||||||
|
</a-list>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-card v-if="summary?.distinctNos?.length" :bordered="false" title="مقادیر خام NO در این بازه">
|
||||||
|
<a-space wrap>
|
||||||
|
<a-tag v-for="no in summary.distinctNos" :key="no">{{ no }}</a-tag>
|
||||||
|
</a-space>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card :bordered="false" title="ردیفها">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="report?.items ?? []"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: report?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['20', '50', '100'],
|
||||||
|
}"
|
||||||
|
row-key="id"
|
||||||
|
:scroll="{ x: 1100 }"
|
||||||
|
@change="onTableChange"
|
||||||
|
/>
|
||||||
|
</a-card>
|
||||||
|
</a-flex>
|
||||||
|
</template>
|
||||||
@@ -226,7 +226,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
|||||||
loyalty: "باشگاه مشتریان",
|
loyalty: "باشگاه مشتریان",
|
||||||
reports: "گزارشها",
|
reports: "گزارشها",
|
||||||
notes: "یادداشتها",
|
notes: "یادداشتها",
|
||||||
tahesab: "تهحساب",
|
tahesab: "تهحساب (اتصال و گزارش فروش)",
|
||||||
custom_fields: "فیلدهای سفارشی",
|
custom_fields: "فیلدهای سفارشی",
|
||||||
currencies: "ارزها",
|
currencies: "ارزها",
|
||||||
team_settings: "تیم و تنظیمات سازمان",
|
team_settings: "تیم و تنظیمات سازمان",
|
||||||
@@ -4061,6 +4061,110 @@ export interface PersonAccountingLinkDto {
|
|||||||
isDefaultConnection?: boolean;
|
isDefaultConnection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** طبقهبندی تقریبی نوع سند از روی متن NO تهحساب */
|
||||||
|
export const TAHESAB_ASNAD_KINDS = [
|
||||||
|
"sale",
|
||||||
|
"return",
|
||||||
|
"purchase",
|
||||||
|
"other",
|
||||||
|
] as const;
|
||||||
|
export type TahesabAsnadKind = (typeof TAHESAB_ASNAD_KINDS)[number];
|
||||||
|
|
||||||
|
export const TAHESAB_ASNAD_KIND_LABELS: Record<TahesabAsnadKind, string> = {
|
||||||
|
sale: "فروش",
|
||||||
|
return: "مرجوعی",
|
||||||
|
purchase: "خرید",
|
||||||
|
other: "سایر",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TAHESAB_JENS_FELEZ_LABELS: Record<number, string> = {
|
||||||
|
0: "طلا",
|
||||||
|
1: "پلاتین",
|
||||||
|
2: "نقره",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TahesabAsnadSyncRequest {
|
||||||
|
connectionId?: string;
|
||||||
|
/** شمسی YYYY-MM-DD */
|
||||||
|
from: string;
|
||||||
|
/** شمسی YYYY-MM-DD */
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TahesabAsnadSyncJobDto {
|
||||||
|
id: string;
|
||||||
|
status: "pending" | "running" | "done" | "failed" | string;
|
||||||
|
connectionId: string | null;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
linesUpserted: number;
|
||||||
|
truncatedPersonIds: string[];
|
||||||
|
errorMessage: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TahesabAsnadLineDto {
|
||||||
|
id: string;
|
||||||
|
connectionId: string;
|
||||||
|
personId: string | null;
|
||||||
|
personName: string | null;
|
||||||
|
moshtariCode: number;
|
||||||
|
tarikh: string;
|
||||||
|
noRaw: string;
|
||||||
|
kind: TahesabAsnadKind | string;
|
||||||
|
jensFelez: number | null;
|
||||||
|
noKar: string | null;
|
||||||
|
vazn: number | null;
|
||||||
|
tabdilVazn: number | null;
|
||||||
|
mali: number | null;
|
||||||
|
count: number | null;
|
||||||
|
shFactor: number | null;
|
||||||
|
factorCode: string | null;
|
||||||
|
groups: CustomerGroupSummaryDto[];
|
||||||
|
syncedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TahesabAsnadReportQuery {
|
||||||
|
connectionId?: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
groupIds?: string[];
|
||||||
|
personId?: string;
|
||||||
|
kinds?: TahesabAsnadKind[];
|
||||||
|
jensFelez?: number;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TahesabAsnadReportPageDto {
|
||||||
|
items: TahesabAsnadLineDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TahesabAsnadSummaryBucketDto {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
lineCount: number;
|
||||||
|
maliSum: number;
|
||||||
|
vaznSum: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TahesabAsnadSummaryDto {
|
||||||
|
totalLines: number;
|
||||||
|
maliSum: number;
|
||||||
|
vaznSum: number;
|
||||||
|
byKind: TahesabAsnadSummaryBucketDto[];
|
||||||
|
byMetal: TahesabAsnadSummaryBucketDto[];
|
||||||
|
byGroup: TahesabAsnadSummaryBucketDto[];
|
||||||
|
distinctNos: string[];
|
||||||
|
lastSyncedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MandehValueDto {
|
export interface MandehValueDto {
|
||||||
amount: string | number;
|
amount: string | number;
|
||||||
unit: string;
|
unit: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user