feat(tahesab): net sales by factor, group, and commission
Report weight after returns for the selected period, with parent customer groups and separate silver and investment commission. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"lint": "echo \"api lint placeholder\"",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts"
|
||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mento/shared": "*",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE "CustomerGroup" ADD COLUMN IF NOT EXISTS "parentId" TEXT;
|
||||
CREATE INDEX IF NOT EXISTS "CustomerGroup_parentId_idx" ON "CustomerGroup"("parentId");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "CustomerGroup"
|
||||
ADD CONSTRAINT "CustomerGroup_parentId_fkey"
|
||||
FOREIGN KEY ("parentId") REFERENCES "CustomerGroup"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsad" DOUBLE PRECISION;
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsadTalayee" DOUBLE PRECISION;
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "maliatDarsad" DOUBLE PRECISION;
|
||||
@@ -527,6 +527,10 @@ model CustomerGroup {
|
||||
/// گروه متناظر در تهحساب (GID) روی همان اتصال
|
||||
tahesabConnectionId String?
|
||||
tahesabGid String?
|
||||
/// گروه اصلی؛ فقط یک سطح (اصلی / فرعی)
|
||||
parentId String?
|
||||
parent CustomerGroup? @relation("CustomerGroupTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children CustomerGroup[] @relation("CustomerGroupTree")
|
||||
people PersonGroupLink[]
|
||||
companies CompanyGroupLink[]
|
||||
createdAt DateTime @default(now())
|
||||
@@ -535,6 +539,7 @@ model CustomerGroup {
|
||||
@@unique([organizationId, name])
|
||||
@@index([organizationId, position])
|
||||
@@index([tahesabConnectionId, tahesabGid])
|
||||
@@index([parentId])
|
||||
}
|
||||
|
||||
model PersonGroupLink {
|
||||
@@ -1879,6 +1884,10 @@ model AccountingAsnadLine {
|
||||
mali Float?
|
||||
count Float?
|
||||
ayar Float?
|
||||
/// درصد اجرت سند تهحساب؛ بعد از همگامسازی پر میشود
|
||||
darsad Float?
|
||||
darsadTalayee Float?
|
||||
maliatDarsad Float?
|
||||
mazaneh Float?
|
||||
fi Float?
|
||||
sharh1 String?
|
||||
|
||||
@@ -38,6 +38,11 @@ class UpdateCustomerGroupDto {
|
||||
@IsOptional()
|
||||
@IsIn([...CUSTOMER_GROUP_COLORS])
|
||||
color?: CustomerGroupColor;
|
||||
|
||||
/** null گروه را اصلی میکند */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
@Controller('customer-groups')
|
||||
|
||||
@@ -48,7 +48,10 @@ export class CustomerGroupsService {
|
||||
const rows = await this.prisma.customerGroup.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||
include: { _count: { select: { people: true, companies: true } } },
|
||||
include: {
|
||||
_count: { select: { people: true, companies: true } },
|
||||
parent: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -58,6 +61,8 @@ export class CustomerGroupsService {
|
||||
fromTahesab: Boolean(r.tahesabGid),
|
||||
personCount: r._count.people,
|
||||
companyCount: r._count.companies,
|
||||
parentId: r.parentId,
|
||||
parentName: r.parent?.name ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -88,15 +93,21 @@ export class CustomerGroupsService {
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: { name?: string; color?: CustomerGroupColor },
|
||||
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
|
||||
): Promise<CustomerGroupDto> {
|
||||
await this.require(organizationId, id);
|
||||
const parentId =
|
||||
dto.parentId === undefined ? undefined : dto.parentId?.trim() || null;
|
||||
if (parentId !== undefined) {
|
||||
await this.assertParent(organizationId, id, parentId);
|
||||
}
|
||||
try {
|
||||
await this.prisma.customerGroup.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -105,6 +116,32 @@ export class CustomerGroupsService {
|
||||
return (await this.list(organizationId)).find((g) => g.id === id)!;
|
||||
}
|
||||
|
||||
/** فقط یک سطح: گروه اصلی خودش زیرگروه نیست و گروه فرعی زیرگروه ندارد. */
|
||||
private async assertParent(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
) {
|
||||
if (parentId == null) return;
|
||||
if (parentId === id) {
|
||||
throw new BadRequestException('گروه نمیتواند زیرمجموعهٔ خودش باشد.');
|
||||
}
|
||||
const parent = await this.prisma.customerGroup.findFirst({
|
||||
where: { id: parentId, organizationId },
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
if (!parent) throw new NotFoundException('گروه اصلی پیدا نشد.');
|
||||
if (parent.parentId) {
|
||||
throw new BadRequestException('فقط یک سطح گروه اصلی و فرعی ممکن است.');
|
||||
}
|
||||
const childCount = await this.prisma.customerGroup.count({
|
||||
where: { organizationId, parentId: id },
|
||||
});
|
||||
if (childCount > 0) {
|
||||
throw new BadRequestException('این گروه خودش زیرگروه دارد و نمیتواند فرعی شود.');
|
||||
}
|
||||
}
|
||||
|
||||
/** مشتریها حذف نمیشوند؛ فقط این گروه از رویشان برداشته میشود */
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
await this.require(organizationId, id);
|
||||
|
||||
@@ -6,20 +6,37 @@ import {
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
CustomerGroupSummaryDto,
|
||||
SalesExpertOverviewDto,
|
||||
SalesFactorOverviewDto,
|
||||
SalesGroupOverviewDto,
|
||||
SalesInternetOverviewDto,
|
||||
SalesNamedMovementDto,
|
||||
TahesabAsnadKind,
|
||||
TahesabAsnadLineDto,
|
||||
TahesabAsnadReportPageDto,
|
||||
TahesabAsnadSummaryDto,
|
||||
TahesabAsnadSyncJobDto,
|
||||
TahesabSalesOverviewDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
TAHESAB_ASNAD_KIND_LABELS,
|
||||
TAHESAB_JENS_FELEZ_LABELS,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { toGregorian } from '../../common/jalali';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import {
|
||||
SALES_FACTOR_KEYS,
|
||||
SALES_FACTOR_LABELS,
|
||||
classifyDirection,
|
||||
classifyFactor,
|
||||
combinedPercent,
|
||||
movementStats,
|
||||
positiveAmount,
|
||||
type SalesDoc,
|
||||
} from './sales-factors';
|
||||
|
||||
const ASNAD_COUNT_LAST = 200;
|
||||
const ASNAD_COUNT_LAST = 5000;
|
||||
const SYNC_CONCURRENCY = 3;
|
||||
const ASNAD_KINDS = new Set<string>(['sale', 'return', 'purchase', 'other']);
|
||||
|
||||
@@ -307,6 +324,11 @@ export class TahesabAsnadReportService {
|
||||
mali: pickNum(item.Mali),
|
||||
count: pickNum(item.Count),
|
||||
ayar: pickNum(item.Ayar),
|
||||
darsad: pickNum(item.Darsad ?? item.darsad),
|
||||
darsadTalayee: pickNum(
|
||||
item.Darsad_Talayee ?? item.DarsadTalayee ?? item.darsadTalayee,
|
||||
),
|
||||
maliatDarsad: pickNum(item.MaliatDarsad ?? item.Maliat_Darsad),
|
||||
mazaneh: pickNum(item.Mazaneh),
|
||||
fi: pickNum(item.Fi),
|
||||
sharh1: pickStr(item.Sharh1),
|
||||
@@ -383,11 +405,12 @@ export class TahesabAsnadReportService {
|
||||
where.jensFelez = params.jensFelez;
|
||||
}
|
||||
if (params.groupIds?.length) {
|
||||
const groupIds = await this.expandGroupIds(params.organizationId, params.groupIds);
|
||||
where.personId = {
|
||||
in: (
|
||||
await this.prisma.personGroupLink.findMany({
|
||||
where: {
|
||||
groupId: { in: params.groupIds },
|
||||
groupId: { in: groupIds },
|
||||
person: { organizationId: params.organizationId },
|
||||
},
|
||||
select: { personId: true },
|
||||
@@ -614,4 +637,477 @@ export class TahesabAsnadReportService {
|
||||
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** گروه اصلی، زیرگروهها را هم داخل فیلتر میآورد. */
|
||||
private async expandGroupIds(organizationId: string, ids: string[]): Promise<string[]> {
|
||||
const rows = await this.prisma.customerGroup.findMany({
|
||||
where: { organizationId },
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
const children = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
if (!row.parentId) continue;
|
||||
const list = children.get(row.parentId) ?? [];
|
||||
list.push(row.id);
|
||||
children.set(row.parentId, list);
|
||||
}
|
||||
const out = new Set(ids);
|
||||
const walk = (id: string) => {
|
||||
for (const child of children.get(id) ?? []) {
|
||||
if (out.has(child)) continue;
|
||||
out.add(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
for (const id of ids) walk(id);
|
||||
return [...out];
|
||||
}
|
||||
|
||||
async overview(
|
||||
organizationId: string,
|
||||
opts: {
|
||||
connectionId?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
groupIds?: string[];
|
||||
personId?: string;
|
||||
jensFelez?: number;
|
||||
},
|
||||
): Promise<TahesabSalesOverviewDto> {
|
||||
const where = await this.buildWhere({ organizationId, ...opts });
|
||||
const rows = await this.prisma.accountingAsnadLine.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
tarikh: true,
|
||||
noRaw: true,
|
||||
noKar: true,
|
||||
jensFelez: true,
|
||||
vazn: true,
|
||||
tabdilVazn: true,
|
||||
mali: true,
|
||||
count: true,
|
||||
darsad: true,
|
||||
darsadTalayee: true,
|
||||
maliatDarsad: true,
|
||||
factorCode: true,
|
||||
personId: true,
|
||||
person: {
|
||||
select: {
|
||||
ownerId: true,
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
groupLinks: {
|
||||
include: {
|
||||
group: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parentId: true,
|
||||
parent: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const docs: SalesDoc[] = rows.map((row) => ({
|
||||
bucketKey: (row.noKar ?? '').trim() || 'بدون نام',
|
||||
factor: classifyFactor(row.noRaw, row.noKar),
|
||||
direction: classifyDirection(row.noRaw),
|
||||
weight: positiveAmount(row.vazn, row.tabdilVazn),
|
||||
mali: Math.abs(row.mali ?? 0),
|
||||
count: Math.abs(row.count ?? 0),
|
||||
percent: combinedPercent(row.darsad, row.darsadTalayee, row.maliatDarsad),
|
||||
tarikh: row.tarikh,
|
||||
}));
|
||||
|
||||
const factors: SalesFactorOverviewDto[] = SALES_FACTOR_KEYS.map((key) => {
|
||||
const slice = docs.filter((d) => d.factor === key);
|
||||
return {
|
||||
key,
|
||||
label: SALES_FACTOR_LABELS[key],
|
||||
sale: movementStats(slice, 'out'),
|
||||
purchase: movementStats(slice, 'in'),
|
||||
};
|
||||
});
|
||||
|
||||
const names = [...new Set(docs.filter((d) => d.factor === 'karsakhte').map((d) => d.bucketKey))];
|
||||
names.sort((a, b) => a.localeCompare(b, 'fa'));
|
||||
const karsakhte: SalesNamedMovementDto[] = names.map((name) => {
|
||||
const slice = docs.filter((d) => d.factor === 'karsakhte' && d.bucketKey === name);
|
||||
return {
|
||||
name,
|
||||
sale: movementStats(slice, 'out'),
|
||||
purchase: movementStats(slice, 'in'),
|
||||
};
|
||||
});
|
||||
|
||||
const groups = this.groupOverview(rows, docs);
|
||||
const experts = await this.expertOverview(organizationId, rows, docs);
|
||||
const internetPack = await this.internetOverview(
|
||||
organizationId,
|
||||
opts.from,
|
||||
opts.to,
|
||||
rows,
|
||||
opts.personId,
|
||||
opts.groupIds,
|
||||
);
|
||||
await this.addInternetCommission(organizationId, experts, internetPack.owners);
|
||||
const internet = internetPack.public;
|
||||
|
||||
const nos = new Set<string>();
|
||||
const otherNos = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.noRaw) continue;
|
||||
nos.add(row.noRaw);
|
||||
if (classifyFactor(row.noRaw, row.noKar) === 'other') otherNos.add(row.noRaw);
|
||||
}
|
||||
|
||||
return {
|
||||
factors,
|
||||
karsakhte,
|
||||
groups,
|
||||
experts,
|
||||
internet,
|
||||
distinctNos: [...nos].sort((a, b) => a.localeCompare(b, 'fa')),
|
||||
otherNos: [...otherNos].sort((a, b) => a.localeCompare(b, 'fa')),
|
||||
percentReady: rows.some((r) => r.darsad != null || r.darsadTalayee != null || r.maliatDarsad != null),
|
||||
};
|
||||
}
|
||||
|
||||
private groupOverview(
|
||||
rows: Array<{
|
||||
id: string;
|
||||
person: {
|
||||
groupLinks: Array<{
|
||||
group: { id: string; name: string; parentId: string | null; parent: { id: string; name: string } | null };
|
||||
}>;
|
||||
} | null;
|
||||
}>,
|
||||
docs: SalesDoc[],
|
||||
): SalesGroupOverviewDto[] {
|
||||
const byId = new Map(docs.map((d, i) => [rows[i]!.id, d]));
|
||||
type Acc = { label: string; parentId: string | null; parentName: string | null; gross: number; ret: number; mali: number; retMali: number };
|
||||
const acc = new Map<string, Acc>();
|
||||
rows.forEach((row, index) => {
|
||||
const doc = byId.get(row.id) ?? docs[index];
|
||||
if (!doc || (doc.direction !== 'out' && doc.direction !== 'sale_return')) return;
|
||||
const links = row.person?.groupLinks ?? [];
|
||||
const targets = links.length
|
||||
? links
|
||||
: [{ group: { id: '__none__', name: 'بدون گروه', parentId: null, parent: null } }];
|
||||
for (const link of targets) {
|
||||
const g = link.group;
|
||||
const bag = acc.get(g.id) ?? {
|
||||
label: g.name,
|
||||
parentId: g.parentId,
|
||||
parentName: g.parent?.name ?? null,
|
||||
gross: 0,
|
||||
ret: 0,
|
||||
mali: 0,
|
||||
retMali: 0,
|
||||
};
|
||||
if (doc.direction === 'out') {
|
||||
bag.gross += doc.weight;
|
||||
bag.mali += doc.mali;
|
||||
} else {
|
||||
bag.ret += doc.weight;
|
||||
bag.retMali += doc.mali;
|
||||
}
|
||||
acc.set(g.id, bag);
|
||||
}
|
||||
});
|
||||
|
||||
const parents = new Map<string, SalesGroupOverviewDto>();
|
||||
const ensure = (id: string, label: string) => {
|
||||
const found = parents.get(id);
|
||||
if (found) return found;
|
||||
const created: SalesGroupOverviewDto = {
|
||||
id,
|
||||
label,
|
||||
netSaleWeight: 0,
|
||||
netSaleMali: 0,
|
||||
saleReturnWeight: 0,
|
||||
children: [],
|
||||
};
|
||||
parents.set(id, created);
|
||||
return created;
|
||||
};
|
||||
for (const [id, bag] of acc) {
|
||||
const netW = Math.max(0, bag.gross - bag.ret);
|
||||
const netM = Math.max(0, bag.mali - bag.retMali);
|
||||
if (bag.parentId) {
|
||||
const parent = ensure(bag.parentId, bag.parentName || 'گروه اصلی');
|
||||
parent.netSaleWeight += netW;
|
||||
parent.netSaleMali += netM;
|
||||
parent.saleReturnWeight += bag.ret;
|
||||
parent.children.push({
|
||||
id,
|
||||
label: bag.label,
|
||||
netSaleWeight: netW,
|
||||
netSaleMali: netM,
|
||||
saleReturnWeight: bag.ret,
|
||||
});
|
||||
} else {
|
||||
const parent = ensure(id, bag.label);
|
||||
parent.netSaleWeight += netW;
|
||||
parent.netSaleMali += netM;
|
||||
parent.saleReturnWeight += bag.ret;
|
||||
}
|
||||
}
|
||||
return [...parents.values()].sort((a, b) => b.netSaleWeight - a.netSaleWeight);
|
||||
}
|
||||
|
||||
private async expertOverview(
|
||||
organizationId: string,
|
||||
rows: Array<{
|
||||
id: string;
|
||||
jensFelez: number | null;
|
||||
person: { ownerId: string | null; owner: { id: string; fullName: string } | null } | null;
|
||||
}>,
|
||||
docs: SalesDoc[],
|
||||
): Promise<SalesExpertOverviewDto[]> {
|
||||
type Bag = { name: string; silverW: number; silverM: number; silverRetW: number; silverRetM: number; investW: number; investM: number; investRetW: number; investRetM: number };
|
||||
const bags = new Map<string, Bag>();
|
||||
const touch = (id: string, name: string) => {
|
||||
const bag = bags.get(id) ?? {
|
||||
name,
|
||||
silverW: 0,
|
||||
silverM: 0,
|
||||
silverRetW: 0,
|
||||
silverRetM: 0,
|
||||
investW: 0,
|
||||
investM: 0,
|
||||
investRetW: 0,
|
||||
investRetM: 0,
|
||||
};
|
||||
bags.set(id, bag);
|
||||
return bag;
|
||||
};
|
||||
rows.forEach((row, index) => {
|
||||
const doc = docs[index];
|
||||
if (!doc || (doc.direction !== 'out' && doc.direction !== 'sale_return')) return;
|
||||
const ownerId = row.person?.owner?.id ?? row.person?.ownerId ?? '__none__';
|
||||
const name = row.person?.owner?.fullName || 'بدون کارشناس';
|
||||
const bag = touch(ownerId, name);
|
||||
const isReturn = doc.direction === 'sale_return';
|
||||
const silver = doc.factor === 'karsakhte' && row.jensFelez === 2;
|
||||
const invest = doc.factor === 'etiket_jewel';
|
||||
if (silver) {
|
||||
if (isReturn) {
|
||||
bag.silverRetW += doc.weight;
|
||||
bag.silverRetM += doc.mali;
|
||||
} else {
|
||||
bag.silverW += doc.weight;
|
||||
bag.silverM += doc.mali;
|
||||
}
|
||||
}
|
||||
if (invest) {
|
||||
if (isReturn) {
|
||||
bag.investRetW += doc.weight;
|
||||
bag.investRetM += doc.mali;
|
||||
} else {
|
||||
bag.investW += doc.weight;
|
||||
bag.investM += doc.mali;
|
||||
}
|
||||
}
|
||||
});
|
||||
const ownerIds = [...bags.keys()].filter((id) => id !== '__none__');
|
||||
const memberships = ownerIds.length
|
||||
? await this.prisma.membership.findMany({
|
||||
where: { organizationId, userId: { in: ownerIds } },
|
||||
select: { userId: true, commissionPercent: true },
|
||||
})
|
||||
: [];
|
||||
const percentOf = new Map(memberships.map((m) => [m.userId, Number(m.commissionPercent ?? 0)]));
|
||||
return [...bags.entries()]
|
||||
.map(([userId, bag]) => {
|
||||
const percent = percentOf.get(userId) ?? 0;
|
||||
const silverMali = Math.max(0, bag.silverM - bag.silverRetM);
|
||||
const investMali = Math.max(0, bag.investM - bag.investRetM);
|
||||
return {
|
||||
userId,
|
||||
name: bag.name,
|
||||
percent,
|
||||
silverWeight: Math.max(0, bag.silverW - bag.silverRetW),
|
||||
silverMali,
|
||||
silverCommission: (silverMali * percent) / 100,
|
||||
investWeight: Math.max(0, bag.investW - bag.investRetW),
|
||||
investMali,
|
||||
investCommission: (investMali * percent) / 100,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.silverMali + b.investMali - (a.silverMali + a.investMali));
|
||||
}
|
||||
|
||||
private async addInternetCommission(
|
||||
organizationId: string,
|
||||
experts: SalesExpertOverviewDto[],
|
||||
owners: Map<string, { name: string; weight: number; mali: number }>,
|
||||
) {
|
||||
const missing = [...owners.keys()].filter((id) => id !== '__none__' && !experts.some((e) => e.userId === id));
|
||||
const memberships = missing.length
|
||||
? await this.prisma.membership.findMany({
|
||||
where: { organizationId, userId: { in: missing } },
|
||||
select: { userId: true, commissionPercent: true },
|
||||
})
|
||||
: [];
|
||||
const percentOf = new Map(memberships.map((m) => [m.userId, Number(m.commissionPercent ?? 0)]));
|
||||
for (const [userId, extra] of owners) {
|
||||
let row = experts.find((e) => e.userId === userId);
|
||||
if (!row) {
|
||||
row = {
|
||||
userId,
|
||||
name: extra.name,
|
||||
percent: percentOf.get(userId) ?? 0,
|
||||
silverWeight: 0,
|
||||
silverMali: 0,
|
||||
silverCommission: 0,
|
||||
investWeight: 0,
|
||||
investMali: 0,
|
||||
investCommission: 0,
|
||||
};
|
||||
experts.push(row);
|
||||
}
|
||||
row.investWeight += extra.weight;
|
||||
row.investMali += extra.mali;
|
||||
row.investCommission = (row.investMali * row.percent) / 100;
|
||||
}
|
||||
experts.sort((a, b) => b.silverMali + b.investMali - (a.silverMali + a.investMali));
|
||||
const active = experts.filter(
|
||||
(e) => e.silverWeight > 0 || e.investWeight > 0 || e.silverMali > 0 || e.investMali > 0,
|
||||
);
|
||||
experts.splice(0, experts.length, ...active);
|
||||
}
|
||||
|
||||
/** null یعنی گروه و شخص اشتراکی ندارند و سفارشی نباید بیاید. */
|
||||
private async internetPersonWhere(
|
||||
organizationId: string,
|
||||
personId?: string,
|
||||
groupIds?: string[],
|
||||
): Promise<{ personId: string | { in: string[] } } | Record<string, never> | null> {
|
||||
if (!groupIds?.length) {
|
||||
return personId ? { personId } : {};
|
||||
}
|
||||
const expanded = await this.expandGroupIds(organizationId, groupIds);
|
||||
const links = await this.prisma.personGroupLink.findMany({
|
||||
where: {
|
||||
groupId: { in: expanded },
|
||||
person: { organizationId },
|
||||
},
|
||||
select: { personId: true },
|
||||
distinct: ['personId'],
|
||||
});
|
||||
let ids = links.map((link) => link.personId);
|
||||
if (personId) ids = ids.filter((id) => id === personId);
|
||||
if (!ids.length) return null;
|
||||
return { personId: { in: ids } };
|
||||
}
|
||||
|
||||
private async internetOverview(
|
||||
organizationId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
rows: Array<{ factorCode: string | null }>,
|
||||
personId?: string,
|
||||
groupIds?: string[],
|
||||
): Promise<{
|
||||
public: SalesInternetOverviewDto;
|
||||
owners: Map<string, { name: string; weight: number; mali: number }>;
|
||||
}> {
|
||||
const empty: SalesInternetOverviewDto = {
|
||||
orderCount: 0,
|
||||
returnCount: 0,
|
||||
netGold: 0,
|
||||
netSilver: 0,
|
||||
returnGold: 0,
|
||||
returnSilver: 0,
|
||||
netMali: 0,
|
||||
returnMali: 0,
|
||||
linkedSkipped: 0,
|
||||
};
|
||||
const start = shamsiStart(from);
|
||||
const end = shamsiEnd(to);
|
||||
const owners = new Map<string, { name: string; weight: number; mali: number }>();
|
||||
if (!start || !end) return { public: empty, owners };
|
||||
const personWhere = await this.internetPersonWhere(organizationId, personId, groupIds);
|
||||
if (!personWhere) return { public: empty, owners };
|
||||
const orders = await this.prisma.montifyOrder.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
deletedAt: null,
|
||||
...personWhere,
|
||||
externalCreatedAt: { gte: start, lte: end },
|
||||
},
|
||||
select: {
|
||||
externalId: true,
|
||||
orderReference: true,
|
||||
orderStatus: true,
|
||||
cancelledAt: true,
|
||||
totalPrice: true,
|
||||
discountTotalPrice: true,
|
||||
sumGold: true,
|
||||
sumSilver: true,
|
||||
personId: true,
|
||||
},
|
||||
});
|
||||
const personIds = [...new Set(orders.map((order) => order.personId).filter((id): id is string => Boolean(id)))];
|
||||
const people = personIds.length
|
||||
? await this.prisma.person.findMany({
|
||||
where: { organizationId, id: { in: personIds } },
|
||||
select: { id: true, ownerId: true, owner: { select: { id: true, fullName: true } } },
|
||||
})
|
||||
: [];
|
||||
const personById = new Map(people.map((person) => [person.id, person]));
|
||||
const linked = new Set(rows.map((r) => r.factorCode).filter((v): v is string => Boolean(v)));
|
||||
const result = { ...empty };
|
||||
for (const order of orders) {
|
||||
const ref = (order.orderReference || order.externalId || '').trim();
|
||||
if (ref && linked.has(ref)) {
|
||||
result.linkedSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
const cancelled =
|
||||
Boolean(order.cancelledAt) ||
|
||||
/cancel|لغو|مرجوع/i.test(order.orderStatus ?? '');
|
||||
const gold = Number(order.sumGold ?? 0);
|
||||
const silver = Number(order.sumSilver ?? 0);
|
||||
const mali = Number(order.discountTotalPrice ?? order.totalPrice ?? 0);
|
||||
if (cancelled) {
|
||||
result.returnCount += 1;
|
||||
result.returnGold += gold;
|
||||
result.returnSilver += silver;
|
||||
result.returnMali += mali;
|
||||
} else {
|
||||
result.orderCount += 1;
|
||||
result.netGold += gold;
|
||||
result.netSilver += silver;
|
||||
result.netMali += mali;
|
||||
const person = order.personId ? personById.get(order.personId) : undefined;
|
||||
const ownerId = person?.owner?.id ?? person?.ownerId ?? '__none__';
|
||||
const name = person?.owner?.fullName || 'بدون کارشناس';
|
||||
const bag = owners.get(ownerId) ?? { name, weight: 0, mali: 0 };
|
||||
bag.weight += gold + silver;
|
||||
bag.mali += mali;
|
||||
owners.set(ownerId, bag);
|
||||
}
|
||||
}
|
||||
return { public: result, owners };
|
||||
}
|
||||
}
|
||||
|
||||
function shamsiStart(ymd: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
|
||||
if (!m) return null;
|
||||
const g = toGregorian(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||
return new Date(g.gy, g.gm - 1, g.gd, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function shamsiEnd(ymd: string): Date | null {
|
||||
const start = shamsiStart(ymd);
|
||||
if (!start) return null;
|
||||
return new Date(start.getFullYear(), start.getMonth(), start.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/** طبقهبندی و خالصکردن فروش/خرید بعد از مرجوعی، برای گزارش تهحساب. */
|
||||
|
||||
export const SALES_FACTOR_KEYS = [
|
||||
'karsakhte',
|
||||
'etiket_gold',
|
||||
'etiket_jewel',
|
||||
'bullion',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
export type SalesFactorKey = (typeof SALES_FACTOR_KEYS)[number];
|
||||
|
||||
export const SALES_FACTOR_LABELS: Record<SalesFactorKey, string> = {
|
||||
karsakhte: 'کار ساخته',
|
||||
etiket_gold: 'اتیکت طلا',
|
||||
etiket_jewel: 'اتیکت جواهر',
|
||||
bullion: 'شمش و سکه',
|
||||
other: 'سایر',
|
||||
};
|
||||
|
||||
export type DocDirection = 'in' | 'out' | 'purchase_return' | 'sale_return' | 'other';
|
||||
|
||||
export type SalesDoc = {
|
||||
bucketKey: string;
|
||||
factor: SalesFactorKey;
|
||||
direction: DocDirection;
|
||||
weight: number;
|
||||
mali: number;
|
||||
count: number;
|
||||
percent: number | null;
|
||||
tarikh: string;
|
||||
};
|
||||
|
||||
export type PercentStats = {
|
||||
first: number | null;
|
||||
last: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
avg: number | null;
|
||||
};
|
||||
|
||||
export type MovementStats = {
|
||||
grossWeight: number;
|
||||
returnWeight: number;
|
||||
netWeight: number;
|
||||
grossMali: number;
|
||||
returnMali: number;
|
||||
netMali: number;
|
||||
grossCount: number;
|
||||
returnCount: number;
|
||||
netCount: number;
|
||||
percent: PercentStats | null;
|
||||
};
|
||||
|
||||
const EMPTY_MOVE: MovementStats = {
|
||||
grossWeight: 0,
|
||||
returnWeight: 0,
|
||||
netWeight: 0,
|
||||
grossMali: 0,
|
||||
returnMali: 0,
|
||||
netMali: 0,
|
||||
grossCount: 0,
|
||||
returnCount: 0,
|
||||
netCount: 0,
|
||||
percent: null,
|
||||
};
|
||||
|
||||
function norm(raw: string): string {
|
||||
return raw.replace(/\u064A/g, '\u06CC').replace(/\u0643/g, '\u06A9').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function classifyDirection(noRaw: string): DocDirection {
|
||||
const no = norm(noRaw);
|
||||
if (no.includes('ورود کارساخته مرجوع')) return 'sale_return';
|
||||
if (no.includes('خروج کارساخته مرجوع')) return 'purchase_return';
|
||||
if (no.includes('ورود کارساخته')) return 'in';
|
||||
if (no.includes('خروج کارساخته')) return 'out';
|
||||
const isReturn = /مرجوع|برگشت/.test(no);
|
||||
const isBuy = /خرید/.test(no);
|
||||
const isSell = /فروش/.test(no);
|
||||
if (isReturn && isBuy) return 'purchase_return';
|
||||
if (isReturn) return 'sale_return';
|
||||
if (isBuy || (/ورود/.test(no) && !isReturn)) return 'in';
|
||||
if (isSell || (/خروج/.test(no) && !isReturn)) return 'out';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function classifyFactor(noRaw: string, noKar: string | null | undefined): SalesFactorKey {
|
||||
const blob = norm(`${noRaw} ${noKar ?? ''}`);
|
||||
if (blob.includes('کارساخته') || blob.includes('کار ساخته')) return 'karsakhte';
|
||||
if (/شمش|سکه/.test(blob)) return 'bullion';
|
||||
if (/جواهر|ساچمه/.test(blob)) return 'etiket_jewel';
|
||||
if (blob.includes('اتیکت')) return 'etiket_gold';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function combinedPercent(
|
||||
darsad: number | null | undefined,
|
||||
talayee: number | null | undefined,
|
||||
maliat: number | null | undefined,
|
||||
): number | null {
|
||||
const parts = [darsad, talayee, maliat].filter(
|
||||
(n): n is number => n != null && Number.isFinite(n),
|
||||
);
|
||||
if (!parts.length) return null;
|
||||
return parts.reduce((sum, n) => sum + n, 0);
|
||||
}
|
||||
|
||||
export function positiveAmount(vazn: number | null | undefined, tabdil: number | null | undefined): number {
|
||||
if (vazn != null && Number.isFinite(vazn) && vazn !== 0) return Math.abs(vazn);
|
||||
if (tabdil != null && Number.isFinite(tabdil)) return Math.abs(tabdil);
|
||||
return 0;
|
||||
}
|
||||
|
||||
type Piece = {
|
||||
tarikh: string;
|
||||
seq: number;
|
||||
weight: number;
|
||||
mali: number;
|
||||
count: number;
|
||||
percent: number | null;
|
||||
};
|
||||
|
||||
/** مرجوعی از آخرین سندهای همان نام کم میشود تا فروش/خریدِ برگشتی در آمار نماند. */
|
||||
function remainingAfterReturns(gross: Piece[], returns: Piece[]): Piece[] {
|
||||
const docs = gross.map((d) => ({ ...d }));
|
||||
let leftW = returns.reduce((s, d) => s + d.weight, 0);
|
||||
let leftM = returns.reduce((s, d) => s + d.mali, 0);
|
||||
let leftC = returns.reduce((s, d) => s + d.count, 0);
|
||||
const order = [...docs].sort((a, b) => {
|
||||
if (a.tarikh !== b.tarikh) return b.tarikh.localeCompare(a.tarikh);
|
||||
return b.seq - a.seq;
|
||||
});
|
||||
for (const doc of order) {
|
||||
if (leftW <= 0 && leftM <= 0 && leftC <= 0) break;
|
||||
const takeW = Math.min(doc.weight, Math.max(leftW, 0));
|
||||
const takeM = Math.min(doc.mali, Math.max(leftM, 0));
|
||||
const takeC = Math.min(doc.count, Math.max(leftC, 0));
|
||||
doc.weight -= takeW;
|
||||
doc.mali -= takeM;
|
||||
doc.count -= takeC;
|
||||
leftW -= takeW;
|
||||
leftM -= takeM;
|
||||
leftC -= takeC;
|
||||
}
|
||||
return docs.filter((d) => d.weight > 1e-9 || d.mali > 1e-6 || d.count > 1e-9);
|
||||
}
|
||||
|
||||
function percentOf(docs: Piece[]): PercentStats | null {
|
||||
const withPercent = docs.filter((d) => d.percent != null && d.weight > 1e-9);
|
||||
if (!withPercent.length) return null;
|
||||
const ordered = [...withPercent].sort((a, b) => {
|
||||
if (a.tarikh !== b.tarikh) return a.tarikh.localeCompare(b.tarikh);
|
||||
return a.seq - b.seq;
|
||||
});
|
||||
const values = ordered.map((d) => d.percent!);
|
||||
const weight = ordered.reduce((s, d) => s + d.weight, 0);
|
||||
const avg =
|
||||
weight > 0
|
||||
? ordered.reduce((s, d) => s + d.percent! * d.weight, 0) / weight
|
||||
: null;
|
||||
return {
|
||||
first: ordered[0]?.percent ?? null,
|
||||
last: ordered[ordered.length - 1]?.percent ?? null,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
avg,
|
||||
};
|
||||
}
|
||||
|
||||
export function movementStats(lines: SalesDoc[], side: 'in' | 'out'): MovementStats {
|
||||
const returnDir: DocDirection = side === 'in' ? 'purchase_return' : 'sale_return';
|
||||
const groups = new Map<string, { gross: Piece[]; ret: Piece[] }>();
|
||||
let seq = 0;
|
||||
for (const line of lines) {
|
||||
if (line.direction !== side && line.direction !== returnDir) continue;
|
||||
const bag = groups.get(line.bucketKey) ?? { gross: [], ret: [] };
|
||||
const piece: Piece = {
|
||||
tarikh: line.tarikh,
|
||||
seq: seq++,
|
||||
weight: Math.abs(line.weight),
|
||||
mali: Math.abs(line.mali),
|
||||
count: Math.abs(line.count),
|
||||
percent: line.percent,
|
||||
};
|
||||
if (line.direction === side) bag.gross.push(piece);
|
||||
else bag.ret.push(piece);
|
||||
groups.set(line.bucketKey, bag);
|
||||
}
|
||||
if (!groups.size) return { ...EMPTY_MOVE };
|
||||
|
||||
let grossWeight = 0;
|
||||
let returnWeight = 0;
|
||||
let grossMali = 0;
|
||||
let returnMali = 0;
|
||||
let grossCount = 0;
|
||||
let returnCount = 0;
|
||||
const kept: Piece[] = [];
|
||||
for (const bag of groups.values()) {
|
||||
grossWeight += bag.gross.reduce((s, d) => s + d.weight, 0);
|
||||
returnWeight += bag.ret.reduce((s, d) => s + d.weight, 0);
|
||||
grossMali += bag.gross.reduce((s, d) => s + d.mali, 0);
|
||||
returnMali += bag.ret.reduce((s, d) => s + d.mali, 0);
|
||||
grossCount += bag.gross.reduce((s, d) => s + d.count, 0);
|
||||
returnCount += bag.ret.reduce((s, d) => s + d.count, 0);
|
||||
kept.push(...remainingAfterReturns(bag.gross, bag.ret));
|
||||
}
|
||||
return {
|
||||
grossWeight,
|
||||
returnWeight,
|
||||
netWeight: Math.max(0, grossWeight - returnWeight),
|
||||
grossMali,
|
||||
returnMali,
|
||||
netMali: Math.max(0, grossMali - returnMali),
|
||||
grossCount,
|
||||
returnCount,
|
||||
netCount: Math.max(0, grossCount - returnCount),
|
||||
percent: percentOf(kept),
|
||||
};
|
||||
}
|
||||
@@ -320,6 +320,27 @@ export class TahesabController {
|
||||
return this.asnadReport.getSyncJob(user.organizationId, jobId);
|
||||
}
|
||||
|
||||
@Get('reports/asnad/overview')
|
||||
asnadReportOverview(
|
||||
@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('jensFelez') jensFelez?: string,
|
||||
) {
|
||||
if (!from || !to) {
|
||||
throw new BadRequestException('پارامتر from و to الزامی است.');
|
||||
}
|
||||
return this.asnadReport.overview(user.organizationId, {
|
||||
connectionId,
|
||||
from,
|
||||
to,
|
||||
...this.parseAsnadFilters(groupIds, personId, undefined, jensFelez),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('reports/asnad/summary')
|
||||
asnadReportSummary(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
classifyDirection,
|
||||
classifyFactor,
|
||||
movementStats,
|
||||
type SalesDoc,
|
||||
} from '../src/integrations/tahesab/sales-factors';
|
||||
|
||||
function doc(partial: Partial<SalesDoc> & Pick<SalesDoc, 'direction' | 'weight'>): SalesDoc {
|
||||
return {
|
||||
bucketKey: 'A',
|
||||
factor: 'karsakhte',
|
||||
mali: 0,
|
||||
count: 0,
|
||||
percent: null,
|
||||
tarikh: '1405-01-01',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('classify', () => {
|
||||
it('مرجوعی کارساخته را قبل از ورود و خروج تشخیص میدهد', () => {
|
||||
assert.equal(classifyDirection('ورود کارساخته مرجوعی'), 'sale_return');
|
||||
assert.equal(classifyDirection('خروج کارساخته مرجوعی'), 'purchase_return');
|
||||
assert.equal(classifyDirection('ورود کارساخته'), 'in');
|
||||
assert.equal(classifyDirection('خروج کارساخته'), 'out');
|
||||
});
|
||||
|
||||
it('شمش و جواهر و اتیکت را جدا میکند', () => {
|
||||
assert.equal(classifyFactor('فروش', 'شمش نقره'), 'bullion');
|
||||
assert.equal(classifyFactor('فروش اتیکت جواهر', null), 'etiket_jewel');
|
||||
assert.equal(classifyFactor('فروش اتیکت', 'طلا'), 'etiket_gold');
|
||||
assert.equal(classifyFactor('خروج کارساخته', 'نگین'), 'karsakhte');
|
||||
});
|
||||
});
|
||||
|
||||
describe('movementStats', () => {
|
||||
it('فروش کامل مرجوعشده از آمار فروش خارج و در مرجوعی میماند', () => {
|
||||
const stats = movementStats(
|
||||
[
|
||||
doc({ direction: 'out', weight: 10, mali: 100, percent: 5, tarikh: '1405-01-01' }),
|
||||
doc({ direction: 'sale_return', weight: 10, mali: 100, percent: 5, tarikh: '1405-01-02' }),
|
||||
],
|
||||
'out',
|
||||
);
|
||||
assert.equal(stats.grossWeight, 10);
|
||||
assert.equal(stats.returnWeight, 10);
|
||||
assert.equal(stats.netWeight, 0);
|
||||
assert.equal(stats.netMali, 0);
|
||||
assert.equal(stats.percent, null);
|
||||
});
|
||||
|
||||
it('مرجوعی جزئی فقط همان وزن را از میانگین درصد کم میکند', () => {
|
||||
const stats = movementStats(
|
||||
[
|
||||
doc({ direction: 'out', weight: 10, percent: 4, tarikh: '1405-01-01' }),
|
||||
doc({ direction: 'out', weight: 10, percent: 8, tarikh: '1405-01-10' }),
|
||||
doc({ direction: 'sale_return', weight: 10, tarikh: '1405-01-11' }),
|
||||
],
|
||||
'out',
|
||||
);
|
||||
assert.equal(stats.netWeight, 10);
|
||||
assert.equal(stats.percent?.avg, 4);
|
||||
assert.equal(stats.percent?.first, 4);
|
||||
assert.equal(stats.percent?.last, 4);
|
||||
});
|
||||
|
||||
it('میانگین درصد وزنی است', () => {
|
||||
const stats = movementStats(
|
||||
[
|
||||
doc({ direction: 'in', weight: 10, percent: 2, tarikh: '1405-01-01' }),
|
||||
doc({ direction: 'in', weight: 30, percent: 6, tarikh: '1405-02-01' }),
|
||||
],
|
||||
'in',
|
||||
);
|
||||
assert.equal(stats.percent?.first, 2);
|
||||
assert.equal(stats.percent?.last, 6);
|
||||
assert.equal(stats.percent?.min, 2);
|
||||
assert.equal(stats.percent?.max, 6);
|
||||
assert.equal(stats.percent?.avg, 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { theme } from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import type {
|
||||
SalesMovementStatsDto,
|
||||
SalesNamedMovementDto,
|
||||
TahesabSalesOverviewDto,
|
||||
} from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { formatTahesabBedBes } from '@/lib/tahesab';
|
||||
|
||||
const props = defineProps<{
|
||||
overview: TahesabSalesOverviewDto | null;
|
||||
}>();
|
||||
|
||||
const { token } = theme.useToken();
|
||||
|
||||
function formatNum(n: number | null | undefined) {
|
||||
if (n == null || Number.isNaN(n)) return '—';
|
||||
return toFaDigits(new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n));
|
||||
}
|
||||
|
||||
function formatMali(n: number | null | undefined) {
|
||||
if (n == null) return '—';
|
||||
return formatTahesabBedBes(n);
|
||||
}
|
||||
|
||||
function percentText(stats: SalesMovementStatsDto, empty = 'درصدی ثبت نشده') {
|
||||
const p = stats.percent;
|
||||
if (!p || p.avg == null) return empty;
|
||||
return `اول ${formatNum(p.first)} · آخر ${formatNum(p.last)} · میانگین ${formatNum(p.avg)} · کم ${formatNum(p.min)} · بیش ${formatNum(p.max)}`;
|
||||
}
|
||||
|
||||
const factors = computed(() =>
|
||||
(props.overview?.factors ?? []).filter((f) => f.key !== 'other' || f.sale.grossWeight > 0 || f.purchase.grossWeight > 0),
|
||||
);
|
||||
|
||||
const karColumns: TableColumnsType<SalesNamedMovementDto> = [
|
||||
{ title: 'کارساخته', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{
|
||||
title: 'خالص فروش',
|
||||
key: 'saleNet',
|
||||
customRender: ({ record }) => formatNum(record.sale.netWeight),
|
||||
},
|
||||
{
|
||||
title: 'مرجوعی فروش',
|
||||
key: 'saleRet',
|
||||
customRender: ({ record }) => formatNum(record.sale.returnWeight),
|
||||
},
|
||||
{
|
||||
title: 'درصد خروج',
|
||||
key: 'salePct',
|
||||
customRender: ({ record }) => percentText(record.sale),
|
||||
},
|
||||
{
|
||||
title: 'خالص خرید',
|
||||
key: 'buyNet',
|
||||
customRender: ({ record }) => formatNum(record.purchase.netWeight),
|
||||
},
|
||||
{
|
||||
title: 'مرجوعی خرید',
|
||||
key: 'buyRet',
|
||||
customRender: ({ record }) => formatNum(record.purchase.returnWeight),
|
||||
},
|
||||
{
|
||||
title: 'درصد ورود',
|
||||
key: 'buyPct',
|
||||
customRender: ({ record }) => percentText(record.purchase),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-row v-if="overview" :gutter="[16, 16]">
|
||||
<a-col v-if="!overview.percentReady" :span="24">
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
message="درصد ورود و خروج بعد از یک بهروزرسانی تازه از تهحساب پر میشود. وزن و مرجوعی با سندهای فعلی حساب شدهاند."
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<a-col v-for="factor in factors" :key="factor.key" :xs="24" :sm="12" :xl="6">
|
||||
<a-card :bordered="false" :title="factor.label">
|
||||
<a-row :gutter="[12, 8]">
|
||||
<a-col :span="12">
|
||||
<a-statistic title="خالص فروش" :value="formatNum(factor.sale.netWeight)" />
|
||||
<a-typography-text type="secondary">
|
||||
مرجوعی {{ formatNum(factor.sale.returnWeight) }}
|
||||
<template v-if="factor.key === 'bullion'">
|
||||
· تعداد {{ formatNum(factor.sale.netCount) }}
|
||||
</template>
|
||||
</a-typography-text>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-statistic title="خالص خرید" :value="formatNum(factor.purchase.netWeight)" />
|
||||
<a-typography-text type="secondary">
|
||||
مرجوعی {{ formatNum(factor.purchase.returnWeight) }}
|
||||
</a-typography-text>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-typography-text type="secondary">مبلغ فروش {{ formatMali(factor.sale.netMali) }}</a-typography-text>
|
||||
</a-col>
|
||||
<a-col v-if="percentText(factor.sale, '') || percentText(factor.purchase, '')" :span="24">
|
||||
<div v-if="percentText(factor.sale, '')">خروج: {{ percentText(factor.sale, '') }}</div>
|
||||
<div v-if="percentText(factor.purchase, '')">ورود: {{ percentText(factor.purchase, '') }}</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-card :bordered="false" title="کارساخته، خالص بعد از مرجوعی">
|
||||
<a-table
|
||||
:columns="karColumns"
|
||||
:data-source="overview.karsakhte"
|
||||
row-key="name"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
:scroll="{ x: 980 }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty description="در این بازه سند کارساخته نیست." />
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" title="گروه اصلی و فرعی">
|
||||
<a-empty v-if="!overview.groups.length" description="گروهی در این بازه فروش ندارد." />
|
||||
<div v-for="group in overview.groups" :key="group.id">
|
||||
<a-flex justify="space-between" :style="{ marginTop: token.marginSM }">
|
||||
<a-typography-text strong>{{ group.label }}</a-typography-text>
|
||||
<a-typography-text>
|
||||
خالص {{ formatNum(group.netSaleWeight) }} · مرجوعی {{ formatNum(group.saleReturnWeight) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-list v-if="group.children.length" :data-source="group.children">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :title="item.label">
|
||||
<template #description>
|
||||
خالص {{ formatNum(item.netSaleWeight) }} · مبلغ {{ formatMali(item.netSaleMali) }} · مرجوعی
|
||||
{{ formatNum(item.saleReturnWeight) }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" title="پورسانت کارشناس">
|
||||
<a-typography-paragraph type="secondary">
|
||||
کارساختهٔ نقره و ساچمه (اتیکت جواهر و فروش اینترنتی) جدا حساب میشوند.
|
||||
</a-typography-paragraph>
|
||||
<a-list
|
||||
:data-source="overview.experts"
|
||||
item-layout="vertical"
|
||||
:locale="{ emptyText: 'کارشناسی برای این بازه نیست.' }"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :title="item.name" :description="`درصد ${formatNum(item.percent)}`" />
|
||||
<div>
|
||||
کارساخته نقره: وزن {{ formatNum(item.silverWeight) }} · پورسانت
|
||||
{{ formatMali(item.silverCommission) }}
|
||||
</div>
|
||||
<div>
|
||||
سرمایهگذاری: وزن {{ formatNum(item.investWeight) }} · پورسانت
|
||||
{{ formatMali(item.investCommission) }}
|
||||
</div>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-card :bordered="false" title="فروش اینترنتی (مونتیفای)">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="8">
|
||||
<a-statistic title="سفارش" :value="formatNum(overview.internet.orderCount)" />
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="8">
|
||||
<a-statistic title="خالص نقره" :value="formatNum(overview.internet.netSilver)" />
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="8">
|
||||
<a-statistic title="خالص طلا" :value="formatNum(overview.internet.netGold)" />
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-typography-text type="secondary">
|
||||
مبلغ {{ formatMali(overview.internet.netMali) }} · لغوشده
|
||||
{{ formatNum(overview.internet.returnCount) }} سفارش، نقره
|
||||
{{ formatNum(overview.internet.returnSilver) }} ·
|
||||
{{ formatNum(overview.internet.linkedSkipped) }} سفارش چون در تهحساب سند دارد اینجا دوباره نیامده
|
||||
</a-typography-text>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col v-if="overview.otherNos.length" :span="24">
|
||||
<a-card :bordered="false" title="سندهایی که در چهار بخش جا نگرفتند">
|
||||
<a-space wrap>
|
||||
<a-tag v-for="name in overview.otherNos" :key="name">{{ name }}</a-tag>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
@@ -1,17 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Collapse as ACollapse,
|
||||
CollapsePanel as ACollapsePanel,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Statistic as AStatistic,
|
||||
@@ -45,17 +48,22 @@ import {
|
||||
type TahesabAsnadSummaryDto,
|
||||
type TahesabAsnadSyncJobDto,
|
||||
type TahesabConnectionDto,
|
||||
type TahesabSalesOverviewDto,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
dateToJalali,
|
||||
formatLocalDateInput,
|
||||
jalaliToDate,
|
||||
parseLocalInput,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import { formatTahesabBedBes } from '@/lib/tahesab';
|
||||
import { useCustomerGroups } from '@/lib/customer-groups';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import SalesReportBoard from '@/components/tahesab/SalesReportBoard.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
|
||||
const router = useRouter();
|
||||
const { token } = theme.useToken();
|
||||
@@ -68,7 +76,15 @@ 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 kinds = ref<TahesabAsnadKind[]>([]);
|
||||
const period = ref<'custom' | 'week' | 'month' | 'season'>('month');
|
||||
const overview = ref<TahesabSalesOverviewDto | null>(null);
|
||||
const auth = useAuthStore();
|
||||
const canGroups = computed(() => canManageSettings(auth.user?.role));
|
||||
const childGroupId = ref<string | undefined>();
|
||||
const parentGroupId = ref<string | undefined>();
|
||||
const overviewError = ref('');
|
||||
let applyingPeriod = false;
|
||||
const jensFelez = ref<number | undefined>();
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -100,11 +116,96 @@ function toShamsiYmd(localIso: string): string | null {
|
||||
}
|
||||
|
||||
function defaultRange() {
|
||||
const now = new Date();
|
||||
const from = new Date(now);
|
||||
from.setDate(from.getDate() - 30);
|
||||
fromDate.value = formatLocalDateInput(from);
|
||||
toDate.value = formatLocalDateInput(now);
|
||||
applyNamedPeriod('month', false);
|
||||
}
|
||||
|
||||
function applyNamedPeriod(key: 'week' | 'month' | 'season' | 'custom', reload: boolean) {
|
||||
applyingPeriod = true;
|
||||
period.value = key;
|
||||
if (key !== 'custom') {
|
||||
const now = new Date();
|
||||
const j = dateToJalali(now);
|
||||
let start = now;
|
||||
if (key === 'week') {
|
||||
const delta = (now.getDay() + 1) % 7;
|
||||
start = new Date(now);
|
||||
start.setDate(now.getDate() - delta);
|
||||
} else if (key === 'month') {
|
||||
start = jalaliToDate(j.jy, j.jm, 1);
|
||||
} else {
|
||||
const sm = j.jm <= 3 ? 1 : j.jm <= 6 ? 4 : j.jm <= 9 ? 7 : 10;
|
||||
start = jalaliToDate(j.jy, sm, 1);
|
||||
}
|
||||
fromDate.value = formatLocalDateInput(start);
|
||||
toDate.value = formatLocalDateInput(now);
|
||||
}
|
||||
void nextTick(() => {
|
||||
applyingPeriod = false;
|
||||
});
|
||||
if (reload) applyFilters();
|
||||
}
|
||||
|
||||
function markCustomPeriod() {
|
||||
if (applyingPeriod) return;
|
||||
period.value = 'custom';
|
||||
}
|
||||
|
||||
function groupOptionLabel(g: CustomerGroupDto) {
|
||||
return g.parentName ? `${g.parentName} / ${g.name}` : g.name;
|
||||
}
|
||||
|
||||
function parentChoices(groupId: string) {
|
||||
return (customerGroups.value as CustomerGroupDto[])
|
||||
.filter((g) => g.id !== groupId && !g.parentId)
|
||||
.map((g) => ({ value: g.id, label: g.name }));
|
||||
}
|
||||
|
||||
function groupHasChildren(groupId: string) {
|
||||
return (customerGroups.value as CustomerGroupDto[]).some((g) => g.parentId === groupId);
|
||||
}
|
||||
|
||||
const childGroupOptions = computed(() =>
|
||||
(customerGroups.value as CustomerGroupDto[])
|
||||
.filter((g) => !groupHasChildren(g.id))
|
||||
.map((g) => ({ value: g.id, label: groupOptionLabel(g) })),
|
||||
);
|
||||
|
||||
const linkedGroups = computed(() =>
|
||||
(customerGroups.value as CustomerGroupDto[]).filter((g) => g.parentId && g.parentName),
|
||||
);
|
||||
|
||||
function onChildGroup(id: string | undefined) {
|
||||
childGroupId.value = id;
|
||||
const current = (customerGroups.value as CustomerGroupDto[]).find((g) => g.id === id);
|
||||
parentGroupId.value = current?.parentId ?? undefined;
|
||||
}
|
||||
|
||||
async function saveGroupParent() {
|
||||
const groupId = childGroupId.value;
|
||||
if (!groupId) return;
|
||||
const current = (customerGroups.value as CustomerGroupDto[]).find((g) => g.id === groupId);
|
||||
const next = parentGroupId.value || null;
|
||||
if ((current?.parentId ?? null) === next) return;
|
||||
try {
|
||||
await api.patch(`/customer-groups/${groupId}`, { parentId: next });
|
||||
await loadCustomerGroups(true);
|
||||
message.success('گروه اصلی ذخیره شد.');
|
||||
await loadReport();
|
||||
} catch (e: unknown) {
|
||||
message.error(apiErrMessage(e, 'ذخیره گروه اصلی ناموفق بود.'));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearGroupParent(groupId: string) {
|
||||
try {
|
||||
await api.patch(`/customer-groups/${groupId}`, { parentId: null });
|
||||
if (childGroupId.value === groupId) parentGroupId.value = undefined;
|
||||
await loadCustomerGroups(true);
|
||||
message.success('گروه از زیرمجموعه خارج شد.');
|
||||
await loadReport();
|
||||
} catch (e: unknown) {
|
||||
message.error(apiErrMessage(e, 'برداشتن گروه اصلی ناموفق بود.'));
|
||||
}
|
||||
}
|
||||
|
||||
function kindLabel(k: string) {
|
||||
@@ -269,6 +370,21 @@ async function loadReport(opts?: { notifyEmpty?: boolean }) {
|
||||
]);
|
||||
report.value = pageRes.data;
|
||||
summary.value = sumRes.data;
|
||||
try {
|
||||
const overRes = await api.get<TahesabSalesOverviewDto>(
|
||||
'/integrations/tahesab/reports/asnad/overview',
|
||||
{ params },
|
||||
);
|
||||
overview.value = overRes.data;
|
||||
overviewError.value = '';
|
||||
} catch (e: unknown) {
|
||||
overview.value = null;
|
||||
const status = axios.isAxiosError(e) ? e.response?.status : undefined;
|
||||
overviewError.value =
|
||||
status === 404
|
||||
? 'خلاصه فروش، گروه و پورسانت بعد از بهروزرسانی سرور اینجا میآید. جدول سندها با دادهٔ فعلی کار میکند.'
|
||||
: apiErrMessage(e, 'خلاصه فروش، گروه و پورسانت بارگذاری نشد.');
|
||||
}
|
||||
if (opts?.notifyEmpty && (pageRes.data.total ?? 0) === 0) {
|
||||
message.info(
|
||||
'ردیفی نیست. اول «بهروزرسانی از تهحساب» را برای همین بازه بزنید.',
|
||||
@@ -279,6 +395,8 @@ async function loadReport(opts?: { notifyEmpty?: boolean }) {
|
||||
message.error(error.value);
|
||||
report.value = null;
|
||||
summary.value = null;
|
||||
overview.value = null;
|
||||
overviewError.value = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -306,7 +424,7 @@ async function pollJob(jobId: string) {
|
||||
error.value = data.errorMessage || 'همگامسازی ناموفق بود.';
|
||||
} else {
|
||||
if (data.truncatedPersonIds?.length) {
|
||||
syncHint.value += ` · هشدار: برای ${toFaDigits(String(data.truncatedPersonIds.length))} مشتری ممکن است اسناد ناقص باشد (سقف ۲۰۰).`;
|
||||
syncHint.value += ` · هشدار: برای ${toFaDigits(String(data.truncatedPersonIds.length))} مشتری سندهای بازه کامل نیامده است.`;
|
||||
} else {
|
||||
syncHint.value = `همگامسازی تمام شد · ${toFaDigits(String(data.linesUpserted))} ردیف`;
|
||||
}
|
||||
@@ -394,7 +512,7 @@ onUnmounted(() => stopPoll());
|
||||
گزارش فروش تهحساب
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
فروش و مرجوعی مشتریان لینکشده بر اساس بازه تاریخ
|
||||
وزن خالص فروش و خرید بعد از مرجوعی، به تفکیک گروه، کارشناس و فروش اینترنتی
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space :size="8" wrap>
|
||||
@@ -439,14 +557,29 @@ onUnmounted(() => stopPoll());
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-form-item label="بازه">
|
||||
<a-segmented
|
||||
v-model:value="period"
|
||||
block
|
||||
:options="[
|
||||
{ label: 'هفته', value: 'week' },
|
||||
{ label: 'ماه', value: 'month' },
|
||||
{ label: 'فصل', value: 'season' },
|
||||
{ label: 'تاریخ دلخواه', value: 'custom' },
|
||||
]"
|
||||
@change="(value: string | number) => applyNamedPeriod(value as 'week' | 'month' | 'season' | 'custom', true)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<a-form-item label="از تاریخ">
|
||||
<JalaliDateInput v-model="fromDate" placeholder="از تاریخ" />
|
||||
<JalaliDateInput v-model="fromDate" placeholder="از تاریخ" @update:model-value="markCustomPeriod" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<a-form-item label="تا تاریخ">
|
||||
<JalaliDateInput v-model="toDate" placeholder="تا تاریخ" />
|
||||
<JalaliDateInput v-model="toDate" placeholder="تا تاریخ" @update:model-value="markCustomPeriod" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
@@ -459,7 +592,7 @@ onUnmounted(() => stopPoll());
|
||||
:options="
|
||||
(customerGroups as CustomerGroupDto[]).map((g) => ({
|
||||
value: g.id,
|
||||
label: g.name,
|
||||
label: groupOptionLabel(g),
|
||||
}))
|
||||
"
|
||||
:max-tag-count="2"
|
||||
@@ -472,7 +605,7 @@ onUnmounted(() => stopPoll());
|
||||
v-model:value="kinds"
|
||||
mode="multiple"
|
||||
:options="kindOptions"
|
||||
placeholder="فروش / مرجوعی / …"
|
||||
placeholder="همه انواع"
|
||||
:max-tag-count="2"
|
||||
/>
|
||||
</a-form-item>
|
||||
@@ -518,6 +651,73 @@ onUnmounted(() => stopPoll());
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col v-if="canGroups" :span="24">
|
||||
<a-collapse :bordered="false">
|
||||
<a-collapse-panel key="parents" header="چیدمان گروه اصلی و فرعی">
|
||||
<a-typography-paragraph type="secondary">
|
||||
گروه فرعی را به یک گروه اصلی وصل کنید تا فیلتر فروش، زیرگروهها را هم بیاورد.
|
||||
</a-typography-paragraph>
|
||||
<a-row :gutter="[12, 12]" align="bottom">
|
||||
<a-col :xs="24" :md="9">
|
||||
<a-form-item label="گروه فرعی">
|
||||
<a-select
|
||||
:value="childGroupId"
|
||||
show-search
|
||||
allow-clear
|
||||
placeholder="جستجوی گروه"
|
||||
option-filter-prop="label"
|
||||
:options="childGroupOptions"
|
||||
@change="(value: string | undefined) => onChildGroup(value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="9">
|
||||
<a-form-item label="گروه اصلی">
|
||||
<a-select
|
||||
v-model:value="parentGroupId"
|
||||
show-search
|
||||
allow-clear
|
||||
placeholder="بدون گروه اصلی"
|
||||
option-filter-prop="label"
|
||||
:disabled="!childGroupId"
|
||||
:options="childGroupId ? parentChoices(childGroupId) : []"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" block :disabled="!childGroupId" @click="saveGroupParent">
|
||||
ذخیره ارتباط
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-list
|
||||
v-if="linkedGroups.length"
|
||||
:data-source="linkedGroups"
|
||||
:locale="{ emptyText: 'هنوز گروهی زیرمجموعه نشده.' }"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-typography-text>{{ item.parentName }} / {{ item.name }}</a-typography-text>
|
||||
<template #actions>
|
||||
<a-button type="link" @click="clearGroupParent(item.id)">جدا کردن</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-col>
|
||||
|
||||
<a-col v-if="overviewError" :span="24">
|
||||
<a-alert type="warning" show-icon :message="overviewError" />
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<SalesReportBoard :overview="overview" />
|
||||
</a-col>
|
||||
|
||||
<a-col v-if="summary" :xs="24" :sm="8">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic
|
||||
|
||||
Reference in New Issue
Block a user