Files
tehran_taj/apps/api/src/modules/admin-invoices/admin-invoices.service.ts
T
s.esmaeiliandClaude Sonnet 5.5 06c8a5f434 feat(auth): split panel admins out of AdminUser into their own Admin table
AdminUser held both panel staff and sales experts, so one mobile could not be
both. Staff now live in "Admin" (panel only); sales experts stay in "AdminUser"
untouched. Uniqueness is per table.

- migration 20261026120000_split_admin_table: additive only. Creates "Admin",
  copies non-expert rows with their ids, re-points the "who did this" foreign
  keys (ledger, order status, sms, notification, upload, allocation, workshop,
  admin session) to "Admin" without moving data. Never writes to "AdminUser".
- login, JWT, SSO, branch/workshop supervisors and the users grid use "Admin";
  SALES_EXPERT create/edit still goes through admin/users into "AdminUser".
- an admin cannot be turned into an expert or the reverse (different tables).
- manual/: optional phase-2 script to drop the dormant admin copies from
  "AdminUser", and a read-only expert-login diagnostic.
- docs/ADMIN-TABLE-SPLIT.md: what changed, deploy checklist, rollback.

Tested on a copy with 12,000 experts: expert rows byte-identical (md5) before
and after; full migration chain applies and matches schema.prisma.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:52:33 +03:30

246 lines
8.2 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Branch stock allocations — legacy views/invoice/* ("فاکتور انتقال"), served by
* BranchController as `branch/product`:
* POST branch/product/table -> the grid
* POST branch/product -> create { branch_id, products: [id...] }
* GET branch/product/{id} -> one allocation
* DELETE branch/product/{id} -> delete
*
* Despite the screen being labelled an invoice, it is not a sales document: it
* records which products head office allocated to a branch, and who did it.
*/
@Injectable()
export class AdminInvoicesService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'note',
'createdAt',
'branchId',
'branch.name',
'allocatorAdmin.fullName',
// legacy filter keys
'name',
'caj',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
name: 'branch.name',
caj: 'createdAt',
'allocator_user.name': 'allocatorAdmin.fullName',
};
/** Legacy `POST branch/product/table`. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) => (f ? (AdminInvoicesService.FIELD_ALIASES[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => ({ ...f, field: mapField(f.field) })),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['note', 'branch.name'],
allowed: AdminInvoicesService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.branchAllocation.findMany({
where: where as Prisma.BranchAllocationWhereInput,
orderBy: orderBy as Prisma.BranchAllocationOrderByWithRelationInput,
skip,
take,
include: {
branch: true,
allocatorAdmin: true,
items: { include: { product: true } },
},
}),
this.prisma.branchAllocation.count({ where: where as Prisma.BranchAllocationWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
private toRow(
a: Prisma.BranchAllocationGetPayload<{
include: {
branch: true;
allocatorAdmin: true;
items: { include: { product: true } };
};
}>,
) {
const sumWeight = a.items.reduce(
(sum, it) => sum.add(it.weightGrams ?? new Prisma.Decimal(0)),
new Prisma.Decimal(0),
);
return {
id: a.id,
// legacy column key is `name` — the receiving branch
name: a.branch.name,
branch_id: a.branchId,
branch: { id: a.branch.id, name: a.branch.name },
sum_weight: sumWeight,
count_item: a.items.length,
note: a.note,
allocator_user: a.allocatorAdmin
? { id: a.allocatorAdmin.id, name: a.allocatorAdmin.fullName }
: null,
caj: a.createdAt,
products: a.items.map((it) => ({
id: it.productId,
pid: it.productId,
title: it.product.title,
product_code: it.product.productCode,
grams: it.weightGrams,
})),
};
}
/** Legacy `GET branch/product/{id}`. */
async findOne(id: string) {
const a = await this.prisma.branchAllocation.findUnique({
where: { id },
include: {
branch: true,
allocatorAdmin: true,
items: { include: { product: true } },
},
});
if (!a) throw new NotFoundException('فاکتور یافت نشد');
return this.toRow(a);
}
/**
* Legacy `POST branch/product` with `{ branch_id, products: [id...] }`.
*
* Each product's current lightest variant weight is snapshotted, so a later
* catalog edit does not silently rewrite what this document says was moved.
*/
async create(data: { branch_id?: string; products?: string[]; note?: string }, actorId: string) {
const branchId = String(data.branch_id ?? '');
if (!branchId) throw new BadRequestException('شعبه انتخاب نشده است');
const branch = await this.prisma.branch.findUnique({ where: { id: branchId } });
if (!branch) throw new NotFoundException('شعبه یافت نشد');
const productIds = [...new Set((data.products ?? []).map((p) => String(p)).filter(Boolean))];
if (productIds.length === 0) throw new BadRequestException('هیچ محصولی انتخاب نشده است');
const products = await this.prisma.product.findMany({
where: { id: { in: productIds } },
include: { variants: { where: { isActive: true } } },
});
if (products.length !== productIds.length) {
throw new BadRequestException('برخی از محصولات انتخاب‌شده یافت نشدند');
}
const allocation = await this.prisma.branchAllocation.create({
data: {
branchId,
allocatorAdminId: actorId,
note: data.note ?? null,
items: {
create: products.map((p) => ({
productId: p.id,
weightGrams: p.variants.length
? p.variants.reduce(
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
p.variants[0].rashWeightGrams,
)
: null,
})),
},
},
});
return this.findOne(allocation.id);
}
/** Legacy `PUT branch/product/{id}` — the edit screen replaces the product set. */
async update(id: string, data: { branch_id?: string; products?: string[]; note?: string }) {
const existing = await this.prisma.branchAllocation.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('فاکتور یافت نشد');
if (data.branch_id) {
const branch = await this.prisma.branch.findUnique({
where: { id: String(data.branch_id) },
});
if (!branch) throw new NotFoundException('شعبه یافت نشد');
}
if (Array.isArray(data.products)) {
const productIds = [...new Set(data.products.map((p) => String(p)).filter(Boolean))];
if (productIds.length === 0) {
throw new BadRequestException('هیچ محصولی انتخاب نشده است');
}
const products = await this.prisma.product.findMany({
where: { id: { in: productIds } },
include: { variants: { where: { isActive: true } } },
});
await this.prisma.$transaction([
this.prisma.branchAllocationItem.deleteMany({ where: { allocationId: id } }),
this.prisma.branchAllocationItem.createMany({
data: products.map((p) => ({
allocationId: id,
productId: p.id,
weightGrams: p.variants.length
? p.variants.reduce(
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
p.variants[0].rashWeightGrams,
)
: null,
})),
skipDuplicates: true,
}),
]);
}
await this.prisma.branchAllocation.update({
where: { id },
data: {
...(data.branch_id ? { branchId: String(data.branch_id) } : {}),
...(data.note !== undefined ? { note: data.note } : {}),
},
});
return this.findOne(id);
}
/** Legacy `DELETE branch/product/{id}`. Items cascade. */
async remove(id: string) {
const a = await this.prisma.branchAllocation.findUnique({ where: { id } });
if (!a) throw new NotFoundException('فاکتور یافت نشد');
await this.prisma.branchAllocation.delete({ where: { id } });
return { success: true };
}
/** Legacy `GET branch/list` — the branch picker for the invoice form. */
async branchList() {
const rows = await this.prisma.branch.findMany({
where: { isActive: true },
orderBy: { name: 'asc' },
select: { id: true, name: true },
});
return rows;
}
}