Add a small internal warehouse and party balances when Tahesab is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 22:24:16 +03:30
co-authored by Cursor
parent 55432ff979
commit 212d02f664
19 changed files with 1449 additions and 6 deletions
+2
View File
@@ -27,6 +27,7 @@ import { AutomationsModule } from './automations/automations.module';
import { AiModule } from './ai/ai.module';
import { MemoryModule } from './memory/memory.module';
import { TahesabModule } from './integrations/tahesab/tahesab.module';
import { InventoryModule } from './inventory/inventory.module';
@Module({
imports: [
@@ -51,6 +52,7 @@ import { TahesabModule } from './integrations/tahesab/tahesab.module';
LeadFormsModule,
QuotesModule,
InvoicesModule,
InventoryModule,
SalesModule,
TicketsModule,
FaqModule,
+38
View File
@@ -0,0 +1,38 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsNumber,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateStockMovementDto {
@IsString()
@MinLength(1)
productId!: string;
@IsIn(['purchase', 'adjustment'])
type!: 'purchase' | 'adjustment';
@Type(() => Number)
@IsNumber()
quantity!: number;
@IsOptional()
@IsString()
@MaxLength(400)
note?: string;
}
export class UpdatePartyOpeningDto {
@Type(() => Number)
@IsNumber()
amount!: number;
@IsOptional()
@IsString()
@MaxLength(400)
note?: string;
}
@@ -0,0 +1,69 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { CreateStockMovementDto, UpdatePartyOpeningDto } from './dto';
import { InventoryService } from './inventory.service';
type AuthUser = { organizationId: string };
@Controller('inventory')
@UseGuards(JwtAuthGuard, RolesGuard)
export class InventoryController {
constructor(private readonly inventory: InventoryService) {}
@Get()
summary(@CurrentUser() user: AuthUser) {
return this.inventory.getSummary(user.organizationId);
}
@Get('movements')
movements(
@CurrentUser() user: AuthUser,
@Query('productId') productId?: string,
) {
return this.inventory.listMovements(user.organizationId, productId);
}
@Post('movements')
@Roles('owner', 'admin')
addMovement(
@CurrentUser() user: AuthUser,
@Body() dto: CreateStockMovementDto,
) {
return this.inventory.addManualMovement(user.organizationId, dto);
}
@Get('party/:personId')
partyBalance(
@CurrentUser() user: AuthUser,
@Param('personId') personId: string,
) {
return this.inventory.getPartyBalance(user.organizationId, personId);
}
@Patch('party/:personId/opening')
@Roles('owner', 'admin')
setOpening(
@CurrentUser() user: AuthUser,
@Param('personId') personId: string,
@Body() dto: UpdatePartyOpeningDto,
) {
return this.inventory.setPartyOpening(
user.organizationId,
personId,
dto.amount,
dto.note,
);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
@Module({
imports: [AuthModule],
controllers: [InventoryController],
providers: [InventoryService],
exports: [InventoryService],
})
export class InventoryModule {}
+515
View File
@@ -0,0 +1,515 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
InventorySummaryDto,
PartyBalanceDto,
StockMovementDto,
StockMovementType,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const MOVEMENT_TYPES = new Set([
'opening',
'purchase',
'sale',
'credit',
'adjustment',
]);
@Injectable()
export class InventoryService {
constructor(private readonly prisma: PrismaService) {}
async isInternalEnabled(organizationId: string): Promise<boolean> {
const n = await this.prisma.accountingConnection.count({
where: { organizationId, enabled: true },
});
return n === 0;
}
async getSummary(organizationId: string): Promise<InventorySummaryDto> {
const enabled = await this.isInternalEnabled(organizationId);
const org = await this.prisma.organization.findFirst({
where: { id: organizationId },
select: { baseCurrency: true },
});
const currency = org?.baseCurrency ?? 'IRR';
if (!enabled) {
return {
enabled: false,
warehouseName: null,
products: [],
cashTotal: 0,
receivablesTotal: 0,
currency,
};
}
const warehouse = await this.ensureReady(organizationId);
const products = await this.prisma.product.findMany({
where: { organizationId, kind: 'product' },
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
});
const stocks = await this.stockByProduct(
organizationId,
products.map((p) => p.id),
);
const [cashTotal, receivablesTotal] = await Promise.all([
this.cashTotal(organizationId),
this.receivablesTotal(organizationId),
]);
return {
enabled: true,
warehouseName: warehouse.name,
products: products.map((p) => ({
productId: p.id,
name: p.name,
sku: p.sku,
unit: p.unit,
kind: 'product',
stockQty: stocks.get(p.id) ?? 0,
currency: p.currency,
unitPrice: p.unitPrice == null ? null : Number(p.unitPrice),
})),
cashTotal,
receivablesTotal,
currency,
};
}
async listMovements(
organizationId: string,
productId?: string,
): Promise<StockMovementDto[]> {
if (!(await this.isInternalEnabled(organizationId))) return [];
await this.ensureReady(organizationId);
const rows = await this.prisma.stockMovement.findMany({
where: {
organizationId,
...(productId ? { productId } : {}),
},
include: { product: { select: { name: true } } },
orderBy: [{ occurredAt: 'desc' }, { createdAt: 'desc' }],
take: 200,
});
return rows.map((row) => ({
id: row.id,
productId: row.productId,
productName: row.product.name,
type: asMovementType(row.type),
quantity: Number(row.quantity),
note: row.note,
invoiceId: row.invoiceId,
occurredAt: row.occurredAt.toISOString(),
}));
}
async addManualMovement(
organizationId: string,
dto: { productId: string; type: 'purchase' | 'adjustment'; quantity: number; note?: string },
): Promise<StockMovementDto> {
if (!(await this.isInternalEnabled(organizationId))) {
throw new BadRequestException(
'با اتصال ته‌حساب، انبار داخلی غیرفعال است',
);
}
const warehouse = await this.ensureReady(organizationId);
const product = await this.prisma.product.findFirst({
where: { id: dto.productId, organizationId },
});
if (!product) throw new NotFoundException('محصول پیدا نشد');
if (product.kind === 'service') {
throw new BadRequestException('خدمات موجودی انبار ندارند');
}
let quantity = Number(dto.quantity);
if (!Number.isFinite(quantity) || quantity === 0) {
throw new BadRequestException('مقدار نامعتبر است');
}
if (dto.type === 'purchase' && quantity < 0) {
throw new BadRequestException('ورود خرید باید مثبت باشد');
}
const row = await this.prisma.stockMovement.create({
data: {
organizationId,
warehouseId: warehouse.id,
productId: product.id,
type: dto.type,
quantity: new Prisma.Decimal(quantity),
note: dto.note?.trim() || null,
},
include: { product: { select: { name: true } } },
});
await this.refreshProductStock(organizationId, product.id);
return {
id: row.id,
productId: row.productId,
productName: row.product.name,
type: asMovementType(row.type),
quantity: Number(row.quantity),
note: row.note,
invoiceId: row.invoiceId,
occurredAt: row.occurredAt.toISOString(),
};
}
async getPartyBalance(
organizationId: string,
personId: string,
): Promise<PartyBalanceDto> {
const enabled = await this.isInternalEnabled(organizationId);
const org = await this.prisma.organization.findFirst({
where: { id: organizationId },
select: { baseCurrency: true },
});
const currency = org?.baseCurrency ?? 'IRR';
if (!enabled) {
return {
enabled: false,
personId,
openingAmount: 0,
invoicedAmount: 0,
paidAmount: 0,
balance: 0,
currency,
};
}
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { id: true },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const { openingAmount, invoicedAmount, paidAmount } =
await this.personLedger(organizationId, personId);
return {
enabled: true,
personId,
openingAmount,
invoicedAmount,
paidAmount,
balance: roundMoney(openingAmount + invoicedAmount - paidAmount),
currency,
};
}
async setPartyOpening(
organizationId: string,
personId: string,
amount: number,
note?: string,
): Promise<PartyBalanceDto> {
if (!(await this.isInternalEnabled(organizationId))) {
throw new BadRequestException(
'با اتصال ته‌حساب، مانده داخلی غیرفعال است',
);
}
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { id: true },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
if (!Number.isFinite(amount)) {
throw new BadRequestException('مبلغ نامعتبر است');
}
await this.prisma.partyOpeningBalance.upsert({
where: { organizationId_personId: { organizationId, personId } },
create: {
organizationId,
personId,
amount: new Prisma.Decimal(amount),
note: note?.trim() || null,
},
update: {
amount: new Prisma.Decimal(amount),
note: note?.trim() || null,
},
});
return this.getPartyBalance(organizationId, personId);
}
async syncInvoiceStock(organizationId: string, invoiceId: string) {
if (!(await this.isInternalEnabled(organizationId))) {
await this.prisma.stockMovement.deleteMany({ where: { invoiceId } });
return;
}
const warehouse = await this.ensureReady(organizationId);
const invoice = await this.prisma.invoice.findFirst({
where: { id: invoiceId, organizationId },
include: {
lineItems: { include: { product: { select: { id: true, kind: true } } } },
},
});
if (!invoice) return;
const productIds = [
...new Set(
invoice.lineItems
.map((l) => l.productId)
.filter((id): id is string => Boolean(id)),
),
];
await this.prisma.stockMovement.deleteMany({ where: { invoiceId } });
const active =
invoice.status !== 'draft' && invoice.status !== 'canceled';
if (active) {
const isCredit = invoice.kind === 'credit';
const sign = isCredit ? 1 : -1;
const type = isCredit ? 'credit' : 'sale';
const rows = invoice.lineItems
.filter((l) => l.productId && l.product?.kind !== 'service')
.map((l) => ({
organizationId,
warehouseId: warehouse.id,
productId: l.productId!,
type,
quantity: new Prisma.Decimal(sign * Number(l.quantity)),
note: invoice.number,
invoiceId,
occurredAt: invoice.issuedAt ?? new Date(),
}));
if (rows.length) {
await this.prisma.stockMovement.createMany({ data: rows });
}
}
await Promise.all(
productIds.map((id) => this.refreshProductStock(organizationId, id)),
);
}
async onProductCreated(
organizationId: string,
productId: string,
stockQty: number | null,
kind: string,
) {
if (kind === 'service') return;
if (!(await this.isInternalEnabled(organizationId))) return;
if (stockQty == null || stockQty === 0) return;
const warehouse = await this.ensureReady(organizationId);
const existing = await this.prisma.stockMovement.count({
where: { productId },
});
if (existing) return;
await this.prisma.stockMovement.create({
data: {
organizationId,
warehouseId: warehouse.id,
productId,
type: 'opening',
quantity: new Prisma.Decimal(stockQty),
note: 'موجودی اول دوره',
},
});
await this.refreshProductStock(organizationId, productId);
}
async onProductStockEdited(
organizationId: string,
productId: string,
nextQty: number | null,
kind: string,
) {
if (kind === 'service') return;
if (!(await this.isInternalEnabled(organizationId))) return;
const warehouse = await this.ensureReady(organizationId);
const current = await this.liveStock(organizationId, productId);
const target = nextQty ?? 0;
const delta = target - current;
if (Math.abs(delta) < 0.0001) {
await this.refreshProductStock(organizationId, productId);
return;
}
await this.prisma.stockMovement.create({
data: {
organizationId,
warehouseId: warehouse.id,
productId,
type: 'adjustment',
quantity: new Prisma.Decimal(delta),
note: 'اصلاح از فرم محصول',
},
});
await this.refreshProductStock(organizationId, productId);
}
private async ensureReady(organizationId: string) {
let warehouse = await this.prisma.warehouse.findFirst({
where: { organizationId, isDefault: true },
});
if (!warehouse) {
warehouse = await this.prisma.warehouse.create({
data: {
organizationId,
name: 'انبار اصلی',
isDefault: true,
},
});
}
const products = await this.prisma.product.findMany({
where: { organizationId, kind: 'product', stockQty: { not: null } },
select: { id: true, stockQty: true },
});
for (const product of products) {
const count = await this.prisma.stockMovement.count({
where: { productId: product.id },
});
if (count > 0 || product.stockQty == null || product.stockQty === 0) {
continue;
}
await this.prisma.stockMovement.create({
data: {
organizationId,
warehouseId: warehouse.id,
productId: product.id,
type: 'opening',
quantity: new Prisma.Decimal(product.stockQty),
note: 'موجودی اول دوره',
},
});
}
return warehouse;
}
private async stockByProduct(
organizationId: string,
productIds: string[],
): Promise<Map<string, number>> {
const map = new Map<string, number>();
if (!productIds.length) return map;
const groups = await this.prisma.stockMovement.groupBy({
by: ['productId'],
where: { organizationId, productId: { in: productIds } },
_sum: { quantity: true },
});
for (const g of groups) {
map.set(g.productId, Number(g._sum.quantity ?? 0));
}
return map;
}
private async liveStock(organizationId: string, productId: string) {
const agg = await this.prisma.stockMovement.aggregate({
where: { organizationId, productId },
_sum: { quantity: true },
});
return Number(agg._sum.quantity ?? 0);
}
private async refreshProductStock(organizationId: string, productId: string) {
const live = await this.liveStock(organizationId, productId);
await this.prisma.product.update({
where: { id: productId },
data: { stockQty: Math.round(live) },
});
}
private async cashTotal(organizationId: string): Promise<number> {
const payments = await this.prisma.payment.findMany({
where: {
invoice: {
organizationId,
status: { not: 'canceled' },
},
},
select: { amount: true },
});
return roundMoney(payments.reduce((s, p) => s + Number(p.amount), 0));
}
private async receivablesTotal(organizationId: string): Promise<number> {
const openings = await this.prisma.partyOpeningBalance.findMany({
where: { organizationId },
select: { amount: true },
});
const openingSum = openings.reduce((s, r) => s + Number(r.amount), 0);
const invoices = await this.prisma.invoice.findMany({
where: {
organizationId,
status: { in: ['issued', 'partially_paid', 'paid'] },
},
include: { lineItems: true, payments: true },
});
let remaining = 0;
for (const inv of invoices) {
remaining += invoiceRemaining(inv);
}
return roundMoney(openingSum + remaining);
}
private async personLedger(organizationId: string, personId: string) {
const opening = await this.prisma.partyOpeningBalance.findFirst({
where: { organizationId, personId },
});
const invoices = await this.prisma.invoice.findMany({
where: {
organizationId,
personId,
status: { in: ['issued', 'partially_paid', 'paid'] },
},
include: { lineItems: true, payments: true },
});
let invoicedAmount = 0;
let paidAmount = 0;
for (const inv of invoices) {
invoicedAmount += invoicePayable(inv);
paidAmount += inv.payments.reduce((s, p) => s + Number(p.amount), 0);
}
return {
openingAmount: Number(opening?.amount ?? 0),
invoicedAmount: roundMoney(invoicedAmount),
paidAmount: roundMoney(paidAmount),
};
}
}
function asMovementType(type: string): StockMovementType {
if (MOVEMENT_TYPES.has(type)) return type as StockMovementType;
return 'adjustment';
}
function invoicePayable(inv: {
kind: string;
discountAmount: Prisma.Decimal;
taxPercent: Prisma.Decimal;
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
}): number {
const subtotal = inv.lineItems.reduce(
(s, i) => s + Number(i.quantity) * Number(i.unitPrice),
0,
);
const discount = Number(inv.discountAmount ?? 0);
const taxPercent = Number(inv.taxPercent ?? 0);
const tax = Math.max(0, subtotal - discount) * (taxPercent / 100);
const signed = roundMoney(subtotal - discount + tax);
return inv.kind === 'credit' ? -signed : signed;
}
function invoiceRemaining(inv: {
kind: string;
discountAmount: Prisma.Decimal;
taxPercent: Prisma.Decimal;
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
payments: Array<{ amount: Prisma.Decimal }>;
}): number {
const payable = invoicePayable(inv);
const paid = inv.payments.reduce((s, p) => s + Number(p.amount), 0);
return payable - paid;
}
function roundMoney(n: number) {
return Math.round(n * 100) / 100;
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AutomationsModule } from '../automations/automations.module';
import { InventoryModule } from '../inventory/inventory.module';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
@Module({
imports: [AutomationsModule],
imports: [AutomationsModule, InventoryModule],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
@@ -17,6 +17,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
import { AutomationsService } from '../automations/automations.service';
import { InventoryService } from '../inventory/inventory.service';
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
@@ -41,6 +42,7 @@ export class InvoicesService {
constructor(
private readonly prisma: PrismaService,
private readonly automations: AutomationsService,
private readonly inventory: InventoryService,
) {}
async list(
@@ -269,6 +271,7 @@ export class InvoicesService {
include,
});
const created = this.toDto(row);
await this.inventory.syncInvoiceStock(organizationId, created.id);
if (created.status !== 'draft' && created.status !== 'canceled') {
await this.fireIssued(organizationId, created);
}
@@ -383,6 +386,7 @@ export class InvoicesService {
}
const row = await this.get(organizationId, id, actor);
await this.inventory.syncInvoiceStock(organizationId, row.id);
const leftDraft =
existing.status === 'draft' &&
row.status !== 'draft' &&
@@ -460,6 +464,7 @@ export class InvoicesService {
await this.recomputeStatus(invoiceId);
const row = await this.get(organizationId, invoiceId, actor);
await this.inventory.syncInvoiceStock(organizationId, row.id);
if (invoice.status === 'draft') {
await this.fireIssued(organizationId, row);
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { InventoryModule } from '../inventory/inventory.module';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
imports: [AuthModule],
imports: [AuthModule, InventoryModule],
controllers: [ProductsController],
providers: [ProductsService],
exports: [ProductsService],
+19 -1
View File
@@ -10,6 +10,7 @@ import type {
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { InventoryService } from '../inventory/inventory.service';
type ProductRow = {
id: string;
@@ -33,7 +34,10 @@ type ProductRow = {
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly inventory: InventoryService,
) {}
async list(
organizationId: string,
@@ -115,6 +119,12 @@ export class ProductsService {
isActive: dto.isActive ?? true,
},
});
await this.inventory.onProductCreated(
organizationId,
row.id,
row.stockQty,
row.kind,
);
return this.toDto(row);
}
@@ -154,6 +164,14 @@ export class ProductsService {
if (dto.isActive !== undefined) data.isActive = dto.isActive;
const row = await this.prisma.product.update({ where: { id }, data });
if ('stockQty' in dto) {
await this.inventory.onProductStockEdited(
organizationId,
row.id,
row.stockQty,
row.kind,
);
}
return this.toDto(row);
}