Add a small internal warehouse and party balances when Tahesab is off.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user