Add agent level progression and extend backend services.
Introduce agent level utilities/service, expand auth and notification flows, update category definitions and seed data, and wire level-aware order and suggestion handling.
This commit is contained in:
+50
-2
@@ -42,6 +42,8 @@ async function main() {
|
|||||||
await prisma.product.deleteMany();
|
await prisma.product.deleteMany();
|
||||||
await prisma.catalog.deleteMany();
|
await prisma.catalog.deleteMany();
|
||||||
await prisma.bankAccount.deleteMany();
|
await prisma.bankAccount.deleteMany();
|
||||||
|
await prisma.levelBenefit.deleteMany();
|
||||||
|
await prisma.customerLevelDef.deleteMany();
|
||||||
await prisma.customer.deleteMany();
|
await prisma.customer.deleteMany();
|
||||||
await prisma.company.deleteMany();
|
await prisma.company.deleteMany();
|
||||||
await prisma.adminUser.deleteMany();
|
await prisma.adminUser.deleteMany();
|
||||||
@@ -332,6 +334,51 @@ async function main() {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.customerLevelDef.create({
|
||||||
|
data: {
|
||||||
|
code: 'bronze',
|
||||||
|
label: 'برنزی',
|
||||||
|
sortOrder: 1,
|
||||||
|
criteriaJson: { minSalesToman: 0 },
|
||||||
|
benefits: {
|
||||||
|
create: [{ title: 'دسترسی به کاتالوگ پایه' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.customerLevelDef.create({
|
||||||
|
data: {
|
||||||
|
code: 'silver',
|
||||||
|
label: 'نقرهای',
|
||||||
|
sortOrder: 2,
|
||||||
|
criteriaJson: { minSalesToman: 500_000_000 },
|
||||||
|
benefits: {
|
||||||
|
create: [{ title: 'پشتیبانی اولویتدار' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const goldLevel = await prisma.customerLevelDef.create({
|
||||||
|
data: {
|
||||||
|
code: 'gold',
|
||||||
|
label: 'طلایی',
|
||||||
|
sortOrder: 3,
|
||||||
|
criteriaJson: { minSalesToman: 1_500_000_000 },
|
||||||
|
benefits: {
|
||||||
|
create: [{ title: 'تخفیف ویژه سفارشهای عمده' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.customerLevelDef.create({
|
||||||
|
data: {
|
||||||
|
code: 'vip',
|
||||||
|
label: 'VIP',
|
||||||
|
sortOrder: 4,
|
||||||
|
criteriaJson: { minSalesToman: 3_000_000_000 },
|
||||||
|
benefits: {
|
||||||
|
create: [{ title: 'مشاوره تخصصی و ارسال سریع' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.customer.create({
|
await prisma.customer.create({
|
||||||
data: {
|
data: {
|
||||||
id: 'agent-1',
|
id: 'agent-1',
|
||||||
@@ -342,10 +389,11 @@ async function main() {
|
|||||||
customerType: CustomerType.wholesale,
|
customerType: CustomerType.wholesale,
|
||||||
appRole: AppUserRole.agent,
|
appRole: AppUserRole.agent,
|
||||||
agentCode: 'W-1247',
|
agentCode: 'W-1247',
|
||||||
|
levelDefId: goldLevel.id,
|
||||||
levelLabel: 'طلایی',
|
levelLabel: 'طلایی',
|
||||||
nextLevelLabel: 'VIP',
|
nextLevelLabel: 'VIP',
|
||||||
pointsToNext: 550_000_000,
|
pointsToNext: 0,
|
||||||
levelProgress: 72,
|
levelProgress: 0,
|
||||||
companyId: 'company-par',
|
companyId: 'company-par',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
avatarLetter: 'ع',
|
avatarLetter: 'ع',
|
||||||
|
|||||||
+39
-2
@@ -133240,6 +133240,25 @@ var require_notifications_service = __commonJS({
|
|||||||
dispatchAdmin(input) {
|
dispatchAdmin(input) {
|
||||||
void this.notifyAdmin(input);
|
void this.notifyAdmin(input);
|
||||||
}
|
}
|
||||||
|
async notifyCustomer(input) {
|
||||||
|
try {
|
||||||
|
await this.prisma.customerNotification.create({
|
||||||
|
data: {
|
||||||
|
customerId: input.customerId,
|
||||||
|
event: input.event,
|
||||||
|
title: input.title.trim(),
|
||||||
|
body: input.body.trim(),
|
||||||
|
tone: input.tone ?? client_1.NotificationTone.info,
|
||||||
|
link: input.link ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`notifyCustomer(${input.event}) failed for customer ${input.customerId}`, err instanceof Error ? err.stack : err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dispatchCustomer(input) {
|
||||||
|
void this.notifyCustomer(input);
|
||||||
|
}
|
||||||
async listForCustomer(customerId, limit = 50) {
|
async listForCustomer(customerId, limit = 50) {
|
||||||
const rows = await this.prisma.customerNotification.findMany({
|
const rows = await this.prisma.customerNotification.findMany({
|
||||||
where: { customerId },
|
where: { customerId },
|
||||||
@@ -139500,14 +139519,18 @@ var require_suggestions_service = __commonJS({
|
|||||||
Object.defineProperty(exports2, "__esModule", { value: true });
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
||||||
exports2.SuggestionsService = void 0;
|
exports2.SuggestionsService = void 0;
|
||||||
var common_12 = require_common();
|
var common_12 = require_common();
|
||||||
|
var client_1 = require_default2();
|
||||||
|
var notifications_service_1 = require_notifications_service();
|
||||||
var prisma_service_1 = require_prisma_service();
|
var prisma_service_1 = require_prisma_service();
|
||||||
var SuggestionsService = class SuggestionsService {
|
var SuggestionsService = class SuggestionsService {
|
||||||
static {
|
static {
|
||||||
__name(this, "SuggestionsService");
|
__name(this, "SuggestionsService");
|
||||||
}
|
}
|
||||||
prisma;
|
prisma;
|
||||||
constructor(prisma) {
|
notifications;
|
||||||
|
constructor(prisma, notifications) {
|
||||||
this.prisma = prisma;
|
this.prisma = prisma;
|
||||||
|
this.notifications = notifications;
|
||||||
}
|
}
|
||||||
async listAdmin(customerId) {
|
async listAdmin(customerId) {
|
||||||
const rows = await this.prisma.productSuggestion.findMany({
|
const rows = await this.prisma.productSuggestion.findMany({
|
||||||
@@ -139549,6 +139572,15 @@ var require_suggestions_service = __commonJS({
|
|||||||
product: { select: { name: true, code: true, color: true } }
|
product: { select: { name: true, code: true, color: true } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const note = row.note?.trim();
|
||||||
|
this.notifications.dispatchCustomer({
|
||||||
|
customerId: input.customerId,
|
||||||
|
event: "product_suggestion",
|
||||||
|
title: "\u067E\u06CC\u0634\u0646\u0647\u0627\u062F \u0645\u062D\u0635\u0648\u0644 \u0628\u0631\u0627\u06CC \u0634\u0645\u0627",
|
||||||
|
body: note ? `\u06A9\u0627\u0631\u0634\u0646\u0627\u0633\u0627\u0646 \u0645\u0627 \u0645\u062D\u0635\u0648\u0644 \xAB${row.product.name}\xBB \u0631\u0627 \u0628\u0647 \u0634\u0645\u0627 \u067E\u06CC\u0634\u0646\u0647\u0627\u062F \u062F\u0627\u062F\u0647\u200C\u0627\u0646\u062F: ${note}` : `\u06A9\u0627\u0631\u0634\u0646\u0627\u0633\u0627\u0646 \u0645\u0627 \u0645\u062D\u0635\u0648\u0644 \xAB${row.product.name}\xBB \u0631\u0627 \u0628\u0647 \u0634\u0645\u0627 \u067E\u06CC\u0634\u0646\u0647\u0627\u062F \u062F\u0627\u062F\u0647\u200C\u0627\u0646\u062F.`,
|
||||||
|
tone: client_1.NotificationTone.info,
|
||||||
|
link: `/product?id=${input.productId}`
|
||||||
|
});
|
||||||
return this.map(row);
|
return this.map(row);
|
||||||
}
|
}
|
||||||
async setActive(id, isActive) {
|
async setActive(id, isActive) {
|
||||||
@@ -139596,7 +139628,10 @@ var require_suggestions_service = __commonJS({
|
|||||||
exports2.SuggestionsService = SuggestionsService;
|
exports2.SuggestionsService = SuggestionsService;
|
||||||
exports2.SuggestionsService = SuggestionsService = __decorate2([
|
exports2.SuggestionsService = SuggestionsService = __decorate2([
|
||||||
(0, common_12.Injectable)(),
|
(0, common_12.Injectable)(),
|
||||||
__metadata2("design:paramtypes", [prisma_service_1.PrismaService])
|
__metadata2("design:paramtypes", [
|
||||||
|
prisma_service_1.PrismaService,
|
||||||
|
notifications_service_1.NotificationsService
|
||||||
|
])
|
||||||
], SuggestionsService);
|
], SuggestionsService);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -139738,6 +139773,7 @@ var require_suggestions_module = __commonJS({
|
|||||||
Object.defineProperty(exports2, "__esModule", { value: true });
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
||||||
exports2.SuggestionsModule = void 0;
|
exports2.SuggestionsModule = void 0;
|
||||||
var common_12 = require_common();
|
var common_12 = require_common();
|
||||||
|
var notifications_module_1 = require_notifications_module();
|
||||||
var suggestions_controller_1 = require_suggestions_controller();
|
var suggestions_controller_1 = require_suggestions_controller();
|
||||||
var suggestions_service_1 = require_suggestions_service();
|
var suggestions_service_1 = require_suggestions_service();
|
||||||
var SuggestionsModule = class SuggestionsModule {
|
var SuggestionsModule = class SuggestionsModule {
|
||||||
@@ -139748,6 +139784,7 @@ var require_suggestions_module = __commonJS({
|
|||||||
exports2.SuggestionsModule = SuggestionsModule;
|
exports2.SuggestionsModule = SuggestionsModule;
|
||||||
exports2.SuggestionsModule = SuggestionsModule = __decorate2([
|
exports2.SuggestionsModule = SuggestionsModule = __decorate2([
|
||||||
(0, common_12.Module)({
|
(0, common_12.Module)({
|
||||||
|
imports: [notifications_module_1.NotificationsModule],
|
||||||
controllers: [suggestions_controller_1.SuggestionsController],
|
controllers: [suggestions_controller_1.SuggestionsController],
|
||||||
providers: [suggestions_service_1.SuggestionsService],
|
providers: [suggestions_service_1.SuggestionsService],
|
||||||
exports: [suggestions_service_1.SuggestionsService]
|
exports: [suggestions_service_1.SuggestionsService]
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AppUserRole } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
AgentLevelSnapshot,
|
||||||
|
DEFAULT_AGENT_LEVEL_TIERS,
|
||||||
|
LevelTier,
|
||||||
|
computeAgentLevel,
|
||||||
|
parseLevelMinSales,
|
||||||
|
} from './agent-level.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentLevelService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getLevelTiers(): Promise<LevelTier[]> {
|
||||||
|
const rows = await this.prisma.customerLevelDef.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { label: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return DEFAULT_AGENT_LEVEL_TIERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
label: row.label,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
minSalesToman: parseLevelMinSales(row.criteriaJson),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAgentSalesVolume(agentId: string): Promise<number> {
|
||||||
|
const sales = await this.prisma.order.aggregate({
|
||||||
|
where: {
|
||||||
|
user: { assignedAgentId: agentId },
|
||||||
|
finalAmount: { not: null },
|
||||||
|
},
|
||||||
|
_sum: { finalAmount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return sales._sum.finalAmount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncAgentLevel(agentId: string): Promise<AgentLevelSnapshot> {
|
||||||
|
const agent = await this.prisma.customer.findUnique({
|
||||||
|
where: { id: agentId },
|
||||||
|
select: { id: true, appRole: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent || agent.appRole !== AppUserRole.agent) {
|
||||||
|
return {
|
||||||
|
levelLabel: 'برنزی',
|
||||||
|
nextLevelLabel: 'نقرهای',
|
||||||
|
pointsToNext: 0,
|
||||||
|
levelProgress: 0,
|
||||||
|
levelDefId: null,
|
||||||
|
salesVolumeToman: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [salesVolumeToman, tiers] = await Promise.all([
|
||||||
|
this.getAgentSalesVolume(agentId),
|
||||||
|
this.getLevelTiers(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const computed = computeAgentLevel(salesVolumeToman, tiers);
|
||||||
|
|
||||||
|
await this.prisma.customer.update({
|
||||||
|
where: { id: agentId },
|
||||||
|
data: {
|
||||||
|
levelLabel: computed.levelLabel,
|
||||||
|
nextLevelLabel: computed.nextLevelLabel,
|
||||||
|
pointsToNext: computed.pointsToNext,
|
||||||
|
levelProgress: computed.levelProgress,
|
||||||
|
levelDefId: computed.levelDefId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...computed,
|
||||||
|
salesVolumeToman,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncAgentForOrder(orderId: string): Promise<void> {
|
||||||
|
const order = await this.prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
user: {
|
||||||
|
select: { assignedAgentId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentId = order?.user?.assignedAgentId;
|
||||||
|
if (!agentId) return;
|
||||||
|
|
||||||
|
await this.syncAgentLevel(agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { computeAgentLevel, DEFAULT_AGENT_LEVEL_TIERS } from './agent-level.util';
|
||||||
|
|
||||||
|
describe('computeAgentLevel', () => {
|
||||||
|
it('starts at bronze with empty progress', () => {
|
||||||
|
const result = computeAgentLevel(0, DEFAULT_AGENT_LEVEL_TIERS);
|
||||||
|
expect(result.levelLabel).toBe('برنزی');
|
||||||
|
expect(result.nextLevelLabel).toBe('نقرهای');
|
||||||
|
expect(result.pointsToNext).toBe(500_000_000);
|
||||||
|
expect(result.levelProgress).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates progress between silver and gold', () => {
|
||||||
|
const result = computeAgentLevel(1_000_000_000, DEFAULT_AGENT_LEVEL_TIERS);
|
||||||
|
expect(result.levelLabel).toBe('نقرهای');
|
||||||
|
expect(result.nextLevelLabel).toBe('طلایی');
|
||||||
|
expect(result.pointsToNext).toBe(500_000_000);
|
||||||
|
expect(result.levelProgress).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at vip with full progress', () => {
|
||||||
|
const result = computeAgentLevel(4_000_000_000, DEFAULT_AGENT_LEVEL_TIERS);
|
||||||
|
expect(result.levelLabel).toBe('VIP');
|
||||||
|
expect(result.nextLevelLabel).toBe('VIP');
|
||||||
|
expect(result.pointsToNext).toBe(0);
|
||||||
|
expect(result.levelProgress).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
export type LevelTier = {
|
||||||
|
id?: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
sortOrder: number;
|
||||||
|
minSalesToman: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentLevelSnapshot = {
|
||||||
|
levelLabel: string;
|
||||||
|
nextLevelLabel: string;
|
||||||
|
pointsToNext: number;
|
||||||
|
levelProgress: number;
|
||||||
|
levelDefId: string | null;
|
||||||
|
salesVolumeToman: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_AGENT_LEVEL_TIERS: LevelTier[] = [
|
||||||
|
{ code: 'bronze', label: 'برنزی', sortOrder: 1, minSalesToman: 0 },
|
||||||
|
{ code: 'silver', label: 'نقرهای', sortOrder: 2, minSalesToman: 500_000_000 },
|
||||||
|
{ code: 'gold', label: 'طلایی', sortOrder: 3, minSalesToman: 1_500_000_000 },
|
||||||
|
{ code: 'vip', label: 'VIP', sortOrder: 4, minSalesToman: 3_000_000_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function parseLevelMinSales(criteriaJson: unknown): number {
|
||||||
|
if (!criteriaJson || typeof criteriaJson !== 'object') return 0;
|
||||||
|
const value =
|
||||||
|
(criteriaJson as Record<string, unknown>).minSalesToman ??
|
||||||
|
(criteriaJson as Record<string, unknown>).minSales ??
|
||||||
|
(criteriaJson as Record<string, unknown>).salesVolumeToman ??
|
||||||
|
0;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortLevelTiers(tiers: LevelTier[]): LevelTier[] {
|
||||||
|
return [...tiers].sort(
|
||||||
|
(a, b) => a.minSalesToman - b.minSalesToman || a.sortOrder - b.sortOrder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeAgentLevel(
|
||||||
|
salesVolumeToman: number,
|
||||||
|
tiers: LevelTier[],
|
||||||
|
): Omit<AgentLevelSnapshot, 'salesVolumeToman'> {
|
||||||
|
const sorted = sortLevelTiers(tiers);
|
||||||
|
const safeSales = Math.max(0, Math.round(salesVolumeToman));
|
||||||
|
|
||||||
|
if (!sorted.length) {
|
||||||
|
return {
|
||||||
|
levelLabel: 'برنزی',
|
||||||
|
nextLevelLabel: 'نقرهای',
|
||||||
|
pointsToNext: 0,
|
||||||
|
levelProgress: 0,
|
||||||
|
levelDefId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentIndex = 0;
|
||||||
|
for (let index = 0; index < sorted.length; index += 1) {
|
||||||
|
if (safeSales >= sorted[index].minSalesToman) {
|
||||||
|
currentIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = sorted[currentIndex];
|
||||||
|
const next = sorted[currentIndex + 1];
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
return {
|
||||||
|
levelLabel: current.label,
|
||||||
|
nextLevelLabel: current.label,
|
||||||
|
pointsToNext: 0,
|
||||||
|
levelProgress: 100,
|
||||||
|
levelDefId: current.id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = next.minSalesToman - current.minSalesToman;
|
||||||
|
const progressInRange = safeSales - current.minSalesToman;
|
||||||
|
const levelProgress =
|
||||||
|
range <= 0
|
||||||
|
? 100
|
||||||
|
: Math.min(100, Math.max(0, Math.round((progressInRange / range) * 100)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
levelLabel: current.label,
|
||||||
|
nextLevelLabel: next.label,
|
||||||
|
pointsToNext: Math.max(0, next.minSalesToman - safeSales),
|
||||||
|
levelProgress,
|
||||||
|
levelDefId: current.id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AgentController } from './agent.controller';
|
import { AgentController } from './agent.controller';
|
||||||
|
import { AgentLevelService } from './agent-level.service';
|
||||||
import { AgentService } from './agent.service';
|
import { AgentService } from './agent.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [AgentController],
|
controllers: [AgentController],
|
||||||
providers: [AgentService],
|
providers: [AgentService, AgentLevelService],
|
||||||
exports: [AgentService],
|
exports: [AgentService, AgentLevelService],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
+12
-15
@@ -19,12 +19,16 @@ import {
|
|||||||
CreateReminderDto,
|
CreateReminderDto,
|
||||||
} from './dto/agent.dto';
|
} from './dto/agent.dto';
|
||||||
import { loadCatalogCategories } from '../catalog/category-defs';
|
import { loadCatalogCategories } from '../catalog/category-defs';
|
||||||
|
import { AgentLevelService } from './agent-level.service';
|
||||||
|
|
||||||
const AVATAR_COLORS = ['#f9a8d4', '#c4b5fd', '#93c5fd', '#86efac', '#fcd34d', '#fdba74'];
|
const AVATAR_COLORS = ['#f9a8d4', '#c4b5fd', '#93c5fd', '#86efac', '#fcd34d', '#fdba74'];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AgentService {
|
export class AgentService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly agentLevelService: AgentLevelService,
|
||||||
|
) {}
|
||||||
|
|
||||||
private async requireAgent(agentId: string) {
|
private async requireAgent(agentId: string) {
|
||||||
const agent = await this.prisma.customer.findUnique({
|
const agent = await this.prisma.customer.findUnique({
|
||||||
@@ -38,7 +42,7 @@ export class AgentService {
|
|||||||
|
|
||||||
async getProfile(agentId: string) {
|
async getProfile(agentId: string) {
|
||||||
const agent = await this.requireAgent(agentId);
|
const agent = await this.requireAgent(agentId);
|
||||||
const [customersCount, ordersCount] = await Promise.all([
|
const [customersCount, ordersCount, levelSnapshot] = await Promise.all([
|
||||||
this.prisma.customer.count({
|
this.prisma.customer.count({
|
||||||
where: { assignedAgentId: agentId, isActive: true },
|
where: { assignedAgentId: agentId, isActive: true },
|
||||||
}),
|
}),
|
||||||
@@ -47,27 +51,20 @@ export class AgentService {
|
|||||||
user: { assignedAgentId: agentId },
|
user: { assignedAgentId: agentId },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
this.agentLevelService.syncAgentLevel(agentId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sales = await this.prisma.order.aggregate({
|
const salesVolume = levelSnapshot.salesVolumeToman;
|
||||||
where: {
|
|
||||||
user: { assignedAgentId: agentId },
|
|
||||||
finalAmount: { not: null },
|
|
||||||
},
|
|
||||||
_sum: { finalAmount: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const salesVolume = sales._sum.finalAmount ?? 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
firstName: agent.firstName,
|
firstName: agent.firstName,
|
||||||
lastName: agent.lastName,
|
lastName: agent.lastName,
|
||||||
code: agent.agentCode ?? '',
|
code: agent.agentCode ?? '',
|
||||||
levelLabel: agent.levelLabel ?? 'برنزی',
|
levelLabel: levelSnapshot.levelLabel,
|
||||||
nextLevelLabel: agent.nextLevelLabel ?? 'نقرهای',
|
nextLevelLabel: levelSnapshot.nextLevelLabel,
|
||||||
pointsToNext: agent.pointsToNext ?? 0,
|
pointsToNext: levelSnapshot.pointsToNext,
|
||||||
levelProgress: agent.levelProgress ?? 0,
|
levelProgress: levelSnapshot.levelProgress,
|
||||||
phone: agent.phone,
|
phone: agent.phone,
|
||||||
customersCount,
|
customersCount,
|
||||||
ordersCount,
|
ordersCount,
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { SmsModule } from '../sms/sms.module';
|
import { SmsModule } from '../sms/sms.module';
|
||||||
|
import { AgentModule } from '../agent/agent.module';
|
||||||
import { resolveJwtSecret } from '../common/utils/jwt-secret';
|
import { resolveJwtSecret } from '../common/utils/jwt-secret';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
SmsModule,
|
SmsModule,
|
||||||
|
AgentModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { randomInt } from 'node:crypto';
|
import { randomInt } from 'node:crypto';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { AppUserRole } from '@prisma/client';
|
||||||
|
import { AgentLevelService } from '../agent/agent-level.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../sms/sms.service';
|
import { SmsService } from '../sms/sms.service';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +33,7 @@ export class AuthService {
|
|||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
|
private readonly agentLevelService: AgentLevelService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async requestOtp(dto: RequestOtpDto) {
|
async requestOtp(dto: RequestOtpDto) {
|
||||||
@@ -153,6 +156,13 @@ export class AuthService {
|
|||||||
|
|
||||||
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
|
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
|
||||||
|
|
||||||
|
if (user.appRole === AppUserRole.agent) {
|
||||||
|
await this.agentLevelService.syncAgentLevel(user.id);
|
||||||
|
user =
|
||||||
|
(await this.prisma.customer.findUnique({ where: { id: user.id } })) ??
|
||||||
|
user;
|
||||||
|
}
|
||||||
|
|
||||||
const accessToken = await this.signCustomerToken(
|
const accessToken = await this.signCustomerToken(
|
||||||
user.id,
|
user.id,
|
||||||
user.phone,
|
user.phone,
|
||||||
@@ -215,10 +225,18 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.prisma.customer.findUnique({
|
let user = await this.prisma.customer.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
});
|
});
|
||||||
if (!user) throw new UnauthorizedException();
|
if (!user) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
if (user.appRole === AppUserRole.agent) {
|
||||||
|
await this.agentLevelService.syncAgentLevel(user.id);
|
||||||
|
user =
|
||||||
|
(await this.prisma.customer.findUnique({ where: { id: userId } })) ??
|
||||||
|
user;
|
||||||
|
}
|
||||||
|
|
||||||
return this.mapCustomer(user);
|
return this.mapCustomer(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const CATEGORY_ICONS = [
|
|||||||
'catalog',
|
'catalog',
|
||||||
'headset',
|
'headset',
|
||||||
'home',
|
'home',
|
||||||
|
'shirt',
|
||||||
|
'suit',
|
||||||
|
'trousers',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type CategoryIconId = (typeof CATEGORY_ICONS)[number]
|
export type CategoryIconId = (typeof CATEGORY_ICONS)[number]
|
||||||
@@ -46,7 +49,7 @@ export const USAGE_CATEGORIES: UsageCategoryDef[] = [
|
|||||||
id: 'trousers',
|
id: 'trousers',
|
||||||
title: 'پارچههای شلواری',
|
title: 'پارچههای شلواری',
|
||||||
subtitle: 'مشاهده محصولات',
|
subtitle: 'مشاهده محصولات',
|
||||||
icon: 'grid',
|
icon: 'trousers',
|
||||||
showTitle: true,
|
showTitle: true,
|
||||||
showSubtitle: true,
|
showSubtitle: true,
|
||||||
showIcon: true,
|
showIcon: true,
|
||||||
@@ -55,7 +58,7 @@ export const USAGE_CATEGORIES: UsageCategoryDef[] = [
|
|||||||
id: 'suit',
|
id: 'suit',
|
||||||
title: 'پارچههای کتشلواری',
|
title: 'پارچههای کتشلواری',
|
||||||
subtitle: 'مشاهده محصولات',
|
subtitle: 'مشاهده محصولات',
|
||||||
icon: 'bag',
|
icon: 'suit',
|
||||||
showTitle: true,
|
showTitle: true,
|
||||||
showSubtitle: true,
|
showSubtitle: true,
|
||||||
showIcon: true,
|
showIcon: true,
|
||||||
@@ -64,7 +67,7 @@ export const USAGE_CATEGORIES: UsageCategoryDef[] = [
|
|||||||
id: 'shirt',
|
id: 'shirt',
|
||||||
title: 'پارچههای پیراهنی',
|
title: 'پارچههای پیراهنی',
|
||||||
subtitle: 'مشاهده محصولات',
|
subtitle: 'مشاهده محصولات',
|
||||||
icon: 'id-card',
|
icon: 'shirt',
|
||||||
showTitle: true,
|
showTitle: true,
|
||||||
showSubtitle: true,
|
showSubtitle: true,
|
||||||
showIcon: true,
|
showIcon: true,
|
||||||
@@ -140,11 +143,19 @@ function parseIcon(value: unknown, fallback: CategoryIconId): CategoryIconId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function defaultUsageIcon(id: string): CategoryIconId {
|
function defaultUsageIcon(id: string): CategoryIconId {
|
||||||
if (id === 'shirt') return 'id-card'
|
if (id === 'shirt') return 'shirt'
|
||||||
if (id === 'suit') return 'bag'
|
if (id === 'suit') return 'suit'
|
||||||
|
if (id === 'trousers') return 'trousers'
|
||||||
return 'grid'
|
return 'grid'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveUsageIconForApi(item: UsageCategoryDef): CategoryIconId {
|
||||||
|
if (item.id === 'shirt' || item.id === 'suit' || item.id === 'trousers') {
|
||||||
|
return defaultUsageIcon(item.id)
|
||||||
|
}
|
||||||
|
return item.icon
|
||||||
|
}
|
||||||
|
|
||||||
function defaultSeasonIcon(id: string): CategoryIconId {
|
function defaultSeasonIcon(id: string): CategoryIconId {
|
||||||
if (id === 'summer') return 'bolt'
|
if (id === 'summer') return 'bolt'
|
||||||
if (id === 'winter') return 'feather'
|
if (id === 'winter') return 'feather'
|
||||||
@@ -229,6 +240,7 @@ export async function loadCatalogCategories(prisma: PrismaService) {
|
|||||||
return {
|
return {
|
||||||
usage: defs.usage.map((item) => ({
|
usage: defs.usage.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
icon: resolveUsageIconForApi(item),
|
||||||
imageUrl: images[item.id] || undefined,
|
imageUrl: images[item.id] || undefined,
|
||||||
})),
|
})),
|
||||||
seasons: defs.seasons.map((item) => ({
|
seasons: defs.seasons.map((item) => ({
|
||||||
|
|||||||
@@ -133,6 +133,44 @@ export class NotificationsService {
|
|||||||
void this.notifyAdmin(input);
|
void this.notifyAdmin(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async notifyCustomer(input: {
|
||||||
|
customerId: string;
|
||||||
|
event: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
tone?: NotificationTone;
|
||||||
|
link?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.prisma.customerNotification.create({
|
||||||
|
data: {
|
||||||
|
customerId: input.customerId,
|
||||||
|
event: input.event,
|
||||||
|
title: input.title.trim(),
|
||||||
|
body: input.body.trim(),
|
||||||
|
tone: input.tone ?? NotificationTone.info,
|
||||||
|
link: input.link ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`notifyCustomer(${input.event}) failed for customer ${input.customerId}`,
|
||||||
|
err instanceof Error ? err.stack : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchCustomer(input: {
|
||||||
|
customerId: string;
|
||||||
|
event: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
tone?: NotificationTone;
|
||||||
|
link?: string;
|
||||||
|
}): void {
|
||||||
|
void this.notifyCustomer(input);
|
||||||
|
}
|
||||||
|
|
||||||
async listForCustomer(customerId: string, limit = 50) {
|
async listForCustomer(customerId: string, limit = 50) {
|
||||||
const rows = await this.prisma.customerNotification.findMany({
|
const rows = await this.prisma.customerNotification.findMany({
|
||||||
where: { customerId },
|
where: { customerId },
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import {
|
|||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
import { CashInvoiceSchedulerService } from './cash-invoice-scheduler.service';
|
import { CashInvoiceSchedulerService } from './cash-invoice-scheduler.service';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { AgentModule } from '../agent/agent.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule],
|
imports: [NotificationsModule, AgentModule],
|
||||||
controllers: [CustomerOrdersController, AdminOrdersController],
|
controllers: [CustomerOrdersController, AdminOrdersController],
|
||||||
providers: [OrdersService, CashInvoiceSchedulerService],
|
providers: [OrdersService, CashInvoiceSchedulerService],
|
||||||
exports: [OrdersService],
|
exports: [OrdersService],
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
assertTransition,
|
assertTransition,
|
||||||
} from '../common/utils/order-transitions';
|
} from '../common/utils/order-transitions';
|
||||||
import { resolveMaxMeterage } from '../common/utils/meterage';
|
import { resolveMaxMeterage } from '../common/utils/meterage';
|
||||||
|
import { AgentLevelService } from '../agent/agent-level.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { eventForStatus } from '../notifications/notification-events';
|
import { eventForStatus } from '../notifications/notification-events';
|
||||||
import { joinFullName, splitFullName } from '../common/utils/name';
|
import { joinFullName, splitFullName } from '../common/utils/name';
|
||||||
@@ -81,6 +82,7 @@ export class OrdersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly agentLevelService: AgentLevelService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findMyOrders(userId: string) {
|
async findMyOrders(userId: string) {
|
||||||
@@ -498,6 +500,8 @@ export class OrdersService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.agentLevelService.syncAgentForOrder(id);
|
||||||
|
|
||||||
return this.findAdminOrder(id);
|
return this.findAdminOrder(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { SuggestionsController } from './suggestions.controller';
|
import { SuggestionsController } from './suggestions.controller';
|
||||||
import { SuggestionsService } from './suggestions.service';
|
import { SuggestionsService } from './suggestions.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [NotificationsModule],
|
||||||
controllers: [SuggestionsController],
|
controllers: [SuggestionsController],
|
||||||
providers: [SuggestionsService],
|
providers: [SuggestionsService],
|
||||||
exports: [SuggestionsService],
|
exports: [SuggestionsService],
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { NotificationTone } from '@prisma/client';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SuggestionsService {
|
export class SuggestionsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async listAdmin(customerId?: string) {
|
async listAdmin(customerId?: string) {
|
||||||
const rows = await this.prisma.productSuggestion.findMany({
|
const rows = await this.prisma.productSuggestion.findMany({
|
||||||
@@ -52,6 +57,19 @@ export class SuggestionsService {
|
|||||||
product: { select: { name: true, code: true, color: true } },
|
product: { select: { name: true, code: true, color: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const note = row.note?.trim();
|
||||||
|
this.notifications.dispatchCustomer({
|
||||||
|
customerId: input.customerId,
|
||||||
|
event: 'product_suggestion',
|
||||||
|
title: 'پیشنهاد محصول برای شما',
|
||||||
|
body: note
|
||||||
|
? `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد دادهاند: ${note}`
|
||||||
|
: `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد دادهاند.`,
|
||||||
|
tone: NotificationTone.info,
|
||||||
|
link: `/product?id=${input.productId}`,
|
||||||
|
});
|
||||||
|
|
||||||
return this.map(row);
|
return this.map(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user