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:
2026-10-26 11:05:34 +03:30
co-authored by Cursor
parent 15f89aec04
commit 2ff35e55fc
12 changed files with 1408 additions and 18 deletions
+1 -1
View File
@@ -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;
+9
View File
@@ -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 },
+83
View File
@@ -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);
});
});