diff --git a/apps/api/package.json b/apps/api/package.json index 8699030..a3b53cd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": "*", diff --git a/apps/api/prisma/migrations/20261026120000_sales_report_factors/migration.sql b/apps/api/prisma/migrations/20261026120000_sales_report_factors/migration.sql new file mode 100644 index 0000000..f797a06 --- /dev/null +++ b/apps/api/prisma/migrations/20261026120000_sales_report_factors/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 939272f..d60140e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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? diff --git a/apps/api/src/customer-groups/customer-groups.controller.ts b/apps/api/src/customer-groups/customer-groups.controller.ts index fac7798..ec7e416 100644 --- a/apps/api/src/customer-groups/customer-groups.controller.ts +++ b/apps/api/src/customer-groups/customer-groups.controller.ts @@ -38,6 +38,11 @@ class UpdateCustomerGroupDto { @IsOptional() @IsIn([...CUSTOMER_GROUP_COLORS]) color?: CustomerGroupColor; + + /** null گروه را اصلی می‌کند */ + @IsOptional() + @IsString() + parentId?: string | null; } @Controller('customer-groups') diff --git a/apps/api/src/customer-groups/customer-groups.service.ts b/apps/api/src/customer-groups/customer-groups.service.ts index 3140d50..859396d 100644 --- a/apps/api/src/customer-groups/customer-groups.service.ts +++ b/apps/api/src/customer-groups/customer-groups.service.ts @@ -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 { 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 { await this.require(organizationId, id); diff --git a/apps/api/src/integrations/tahesab/asnad-report.service.ts b/apps/api/src/integrations/tahesab/asnad-report.service.ts index fd6ece4..6bb81bb 100644 --- a/apps/api/src/integrations/tahesab/asnad-report.service.ts +++ b/apps/api/src/integrations/tahesab/asnad-report.service.ts @@ -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(['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 { + const rows = await this.prisma.customerGroup.findMany({ + where: { organizationId }, + select: { id: true, parentId: true }, + }); + const children = new Map(); + 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 { + 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(); + const otherNos = new Set(); + 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(); + 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(); + 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 { + type Bag = { name: string; silverW: number; silverM: number; silverRetW: number; silverRetM: number; investW: number; investM: number; investRetW: number; investRetM: number }; + const bags = new Map(); + 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, + ) { + 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 | 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; + }> { + 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(); + 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); } diff --git a/apps/api/src/integrations/tahesab/sales-factors.ts b/apps/api/src/integrations/tahesab/sales-factors.ts new file mode 100644 index 0000000..8d5f28c --- /dev/null +++ b/apps/api/src/integrations/tahesab/sales-factors.ts @@ -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 = { + 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(); + 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), + }; +} diff --git a/apps/api/src/integrations/tahesab/tahesab.controller.ts b/apps/api/src/integrations/tahesab/tahesab.controller.ts index 2a9c232..4fab981 100644 --- a/apps/api/src/integrations/tahesab/tahesab.controller.ts +++ b/apps/api/src/integrations/tahesab/tahesab.controller.ts @@ -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 }, diff --git a/apps/api/test/sales-factors.test.ts b/apps/api/test/sales-factors.test.ts new file mode 100644 index 0000000..fbd7ef0 --- /dev/null +++ b/apps/api/test/sales-factors.test.ts @@ -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 & Pick): 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); + }); +}); diff --git a/apps/web/src/components/tahesab/SalesReportBoard.vue b/apps/web/src/components/tahesab/SalesReportBoard.vue new file mode 100644 index 0000000..7caaff6 --- /dev/null +++ b/apps/web/src/components/tahesab/SalesReportBoard.vue @@ -0,0 +1,214 @@ + + + diff --git a/apps/web/src/views/TahesabSalesReportView.vue b/apps/web/src/views/TahesabSalesReportView.vue index 6c6f6f4..3344dde 100644 --- a/apps/web/src/views/TahesabSalesReportView.vue +++ b/apps/web/src/views/TahesabSalesReportView.vue @@ -1,17 +1,20 @@