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);
});
});
@@ -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>
+213 -13
View File
@@ -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