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
@@ -0,0 +1,80 @@
-- CreateTable
CREATE TABLE "Warehouse" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Warehouse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockMovement" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"quantity" DECIMAL(18,2) NOT NULL,
"note" TEXT,
"invoiceId" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartyOpeningBalance" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'IRR',
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PartyOpeningBalance_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Warehouse_organizationId_isDefault_idx" ON "Warehouse"("organizationId", "isDefault");
-- CreateIndex
CREATE INDEX "StockMovement_organizationId_productId_occurredAt_idx" ON "StockMovement"("organizationId", "productId", "occurredAt");
-- CreateIndex
CREATE INDEX "StockMovement_invoiceId_idx" ON "StockMovement"("invoiceId");
-- CreateIndex
CREATE INDEX "StockMovement_warehouseId_idx" ON "StockMovement"("warehouseId");
-- CreateIndex
CREATE UNIQUE INDEX "PartyOpeningBalance_organizationId_personId_key" ON "PartyOpeningBalance"("organizationId", "personId");
-- CreateIndex
CREATE INDEX "PartyOpeningBalance_personId_idx" ON "PartyOpeningBalance"("personId");
-- AddForeignKey
ALTER TABLE "Warehouse" ADD CONSTRAINT "Warehouse_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartyOpeningBalance" ADD CONSTRAINT "PartyOpeningBalance_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartyOpeningBalance" ADD CONSTRAINT "PartyOpeningBalance_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+60
View File
@@ -180,6 +180,9 @@ model Organization {
noteAiSuggestions NoteAiSuggestion[]
accountingConnections AccountingConnection[]
accountingSyncJobs AccountingSyncJob[]
warehouses Warehouse[]
stockMovements StockMovement[]
partyOpenings PartyOpeningBalance[]
}
model User {
@@ -283,6 +286,7 @@ model Person {
memoryItems MemoryItem[]
accountingLinks PersonAccountingLink[]
accountingDocuments AccountingDocumentLog[]
openingBalances PartyOpeningBalance[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -545,6 +549,7 @@ model Product {
lineItems DealLineItem[]
quoteLineItems QuoteLineItem[]
invoiceLineItems InvoiceLineItem[]
stockMovements StockMovement[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -725,6 +730,7 @@ model Invoice {
lineItems InvoiceLineItem[]
payments Payment[]
installments InvoiceInstallment[]
stockMovements StockMovement[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1210,3 +1216,57 @@ model AccountingSyncJob {
@@index([organizationId, createdAt])
}
/// انبار داخلی — وقتی ته‌حساب وصل نیست
model Warehouse {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
isDefault Boolean @default(true)
movements StockMovement[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, isDefault])
}
/// گردش کالا؛ مقدار مثبت ورود، منفی خروج
model StockMovement {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
warehouseId String
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
/// opening | purchase | sale | credit | adjustment
type String
quantity Decimal @db.Decimal(18, 2)
note String?
invoiceId String?
invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([organizationId, productId, occurredAt])
@@index([invoiceId])
@@index([warehouseId])
}
/// مانده اول دوره طرف‌حساب (مثبت = بدهکار ما)
model PartyOpeningBalance {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
amount Decimal @db.Decimal(18, 2)
currency String @default("IRR")
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, personId])
@@index([personId])
}
+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);
}