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);
}
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import type { PartyBalanceDto } from '@mento/shared';
const props = defineProps<{
personId: string;
}>();
const auth = useAuthStore();
const canWrite = computed(() => canManageSettings(auth.user?.role));
const loading = ref(false);
const error = ref('');
const data = ref<PartyBalanceDto | null>(null);
const opening = ref('');
const saving = ref(false);
async function load() {
loading.value = true;
error.value = '';
try {
const { data: row } = await api.get<PartyBalanceDto>(
`/inventory/party/${props.personId}`,
);
data.value = row;
opening.value = String(row.openingAmount);
} catch {
error.value = 'بارگذاری مانده ناموفق بود.';
} finally {
loading.value = false;
}
}
async function saveOpening() {
if (!canWrite.value || saving.value) return;
const amount = Number(opening.value);
if (!Number.isFinite(amount)) return;
saving.value = true;
error.value = '';
try {
const { data: row } = await api.patch<PartyBalanceDto>(
`/inventory/party/${props.personId}/opening`,
{ amount },
);
data.value = row;
} catch (e) {
const msg = axios.isAxiosError(e)
? (e.response?.data as { message?: string })?.message
: null;
error.value = typeof msg === 'string' ? msg : 'ذخیره مانده اول دوره ناموفق بود.';
} finally {
saving.value = false;
}
}
watch(
() => props.personId,
() => {
void load();
},
);
onMounted(load);
</script>
<template>
<article v-if="data?.enabled" class="box">
<header>
<h3>مانده حساب داخلی</h3>
<p>از فاکتور و پرداخت‌های منتکت — وقتی ته‌حساب وصل نیست</p>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else>
<dl>
<div>
<dt>اول دوره</dt>
<dd>{{ formatMoney(data.openingAmount, data.currency) }}</dd>
</div>
<div>
<dt>فاکتورها</dt>
<dd>{{ formatMoney(data.invoicedAmount, data.currency) }}</dd>
</div>
<div>
<dt>پرداخت‌ها</dt>
<dd>{{ formatMoney(data.paidAmount, data.currency) }}</dd>
</div>
<div class="total">
<dt>مانده</dt>
<dd>{{ formatMoney(data.balance, data.currency) }}</dd>
</div>
</dl>
<form v-if="canWrite" class="open-form" @submit.prevent="saveOpening">
<label>
مانده اول دوره
<UiInput v-model="opening" type="number" />
</label>
<UiButton type="submit" :disabled="saving">
{{ saving ? 'ذخیره…' : 'ثبت' }}
</UiButton>
</form>
</template>
</article>
</template>
<style scoped>
.box {
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.1rem;
margin-bottom: 0.85rem;
}
h3 {
margin: 0;
font-size: 0.95rem;
}
header p,
.muted {
margin: 0.25rem 0 0;
color: var(--muted);
font-size: 0.8rem;
}
.error {
color: var(--danger, #dc2626);
font-weight: 600;
}
dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
margin: 0.85rem 0 0;
}
dt {
color: var(--muted);
font-size: 0.78rem;
}
dd {
margin: 0.15rem 0 0;
font-weight: 700;
}
.total dd {
color: var(--accent-deep, #0f766e);
}
.open-form {
display: flex;
align-items: end;
gap: 0.5rem;
margin-top: 0.85rem;
}
.open-form label {
display: grid;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--muted);
flex: 1;
}
</style>
+4 -1
View File
@@ -36,7 +36,7 @@ const settingsOpen = ref(false);
const globalSearch = ref('');
const activityBadge = ref(0);
const salesPaths = ['/quotes', '/invoices', '/sales/forecast', '/reports', '/notes', '/tickets'];
const salesPaths = ['/quotes', '/invoices', '/inventory', '/sales/forecast', '/reports', '/notes', '/tickets'];
const morePaths = ['/activities/calendar', '/projects', '/project-cards', '/loyalty'];
const settingsPaths = [
'/settings/team',
@@ -63,6 +63,7 @@ const extraActive = computed(() =>
pathIn([
'/quotes',
'/invoices',
'/inventory',
'/sales/forecast',
'/notes',
'/tickets',
@@ -390,6 +391,7 @@ onUnmounted(() => {
:class="{ 'router-link-active': route.path.startsWith('/invoices') || route.path.startsWith('/quotes') }"
@click="closeMenu"
>فاکتورها</RouterLink>
<RouterLink to="/inventory" @click="closeMenu">انبار</RouterLink>
<RouterLink to="/sales/forecast" @click="closeMenu">هدف و پیش‌بینی</RouterLink>
<RouterLink to="/notes" @click="closeMenu">یادداشت‌ها</RouterLink>
<RouterLink to="/tickets" @click="closeMenu">تیکت‌ها</RouterLink>
@@ -477,6 +479,7 @@ onUnmounted(() => {
:class="{ 'router-link-active': route.path.startsWith('/invoices') || route.path.startsWith('/quotes') }"
@click="closeMenu"
>فاکتورها</RouterLink>
<RouterLink to="/inventory" @click="closeMenu">انبار</RouterLink>
<RouterLink to="/sales/forecast" @click="closeMenu">هدف و پیش‌بینی</RouterLink>
<RouterLink to="/notes" @click="closeMenu">یادداشت‌ها</RouterLink>
<RouterLink to="/tickets" @click="closeMenu">تیکت‌ها</RouterLink>
+5
View File
@@ -123,6 +123,11 @@ export const router = createRouter({
name: 'invoice-detail',
component: () => import('@/views/InvoiceFormView.vue'),
},
{
path: 'inventory',
name: 'inventory',
component: () => import('@/views/InventoryView.vue'),
},
{
path: 'pipeline',
name: 'pipeline',
+374
View File
@@ -0,0 +1,374 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import type {
InventorySummaryDto,
StockMovementDto,
StockMovementType,
} from '@mento/shared';
const auth = useAuthStore();
const canWrite = computed(() => canManageSettings(auth.user?.role));
const loading = ref(false);
const error = ref('');
const summary = ref<InventorySummaryDto | null>(null);
const movements = ref<StockMovementDto[]>([]);
const productId = ref('');
const moveType = ref<'purchase' | 'adjustment'>('purchase');
const quantity = ref('');
const note = ref('');
const saving = ref(false);
const typeLabel: Record<StockMovementType, string> = {
opening: 'اول دوره',
purchase: 'ورود خرید',
sale: 'خروج فروش',
credit: 'برگشت',
adjustment: 'اصلاح',
};
const selectedStock = computed(
() =>
summary.value?.products.find((p) => p.productId === productId.value)
?.stockQty ?? 0,
);
async function load() {
loading.value = true;
error.value = '';
try {
const [sum, moves] = await Promise.all([
api.get<InventorySummaryDto>('/inventory'),
api.get<StockMovementDto[]>('/inventory/movements'),
]);
summary.value = sum.data;
movements.value = moves.data;
if (!productId.value && sum.data.products[0]) {
productId.value = sum.data.products[0].productId;
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری انبار ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function submitMove() {
if (!canWrite.value || !productId.value || saving.value) return;
const qty = Number(quantity.value);
if (!Number.isFinite(qty)) {
error.value = 'مقدار را وارد کنید.';
return;
}
let sendQty = qty;
if (moveType.value === 'adjustment') {
sendQty = qty - selectedStock.value;
if (Math.abs(sendQty) < 0.0001) return;
}
saving.value = true;
error.value = '';
try {
await api.post('/inventory/movements', {
productId: productId.value,
type: moveType.value,
quantity: sendQty,
note: note.value.trim() || undefined,
});
quantity.value = '';
note.value = '';
await load();
} catch (e) {
const msg = axios.isAxiosError(e)
? (e.response?.data as { message?: string })?.message
: null;
error.value = typeof msg === 'string' ? msg : 'ثبت گردش ناموفق بود.';
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="page" dir="rtl">
<header class="head">
<div>
<h1>انبار</h1>
<p class="sub">
موجودی کالا، ورود خرید و مانده وقتی ته‌حساب وصل نباشد
</p>
</div>
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</UiButton>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !summary" class="muted">در حال بارگذاری…</p>
<div v-else-if="summary && !summary.enabled" class="notice">
اتصال ته‌حساب فعال است؛ موجودی و مانده از حسابداری خارجی خوانده می‌شود.
</div>
<template v-else-if="summary">
<div class="kpis">
<article>
<span>صندوق (وصول‌ها)</span>
<strong>{{ formatMoney(summary.cashTotal, summary.currency) }}</strong>
</article>
<article>
<span>مانده طرف‌حساب‌ها</span>
<strong>{{
formatMoney(summary.receivablesTotal, summary.currency)
}}</strong>
</article>
<article>
<span>انبار</span>
<strong>{{ summary.warehouseName }}</strong>
</article>
</div>
<form v-if="canWrite" class="move-form" @submit.prevent="submitMove">
<select v-model="moveType">
<option value="purchase">ورود خرید</option>
<option value="adjustment">اصلاح موجودی (عدد جدید)</option>
</select>
<select v-model="productId">
<option value="" disabled>محصول</option>
<option
v-for="p in summary.products"
:key="p.productId"
:value="p.productId"
>
{{ p.name }}
</option>
</select>
<UiInput
v-model="quantity"
type="number"
:placeholder="moveType === 'adjustment' ? 'موجودی جدید' : 'تعداد'"
/>
<UiInput v-model="note" placeholder="توضیح (اختیاری)" />
<UiButton type="submit" :disabled="saving || !productId">
{{ saving ? 'ثبت…' : 'ثبت گردش' }}
</UiButton>
</form>
<div class="grid">
<div class="card">
<h2>موجودی کالا</h2>
<p v-if="!summary.products.length" class="muted">محصولی نیست.</p>
<table v-else>
<thead>
<tr>
<th>کالا</th>
<th>واحد</th>
<th>موجودی</th>
</tr>
</thead>
<tbody>
<tr v-for="p in summary.products" :key="p.productId">
<td>
<strong>{{ p.name }}</strong>
<small v-if="p.sku">{{ p.sku }}</small>
</td>
<td>{{ p.unit || 'عدد' }}</td>
<td class="num" :class="{ low: p.stockQty <= 0 }">
{{ toFaDigits(String(p.stockQty)) }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>گردش اخیر</h2>
<p v-if="!movements.length" class="muted">گردشی ثبت نشده.</p>
<ul v-else class="moves">
<li v-for="m in movements" :key="m.id">
<div>
<strong>{{ m.productName }}</strong>
<span>{{ typeLabel[m.type] }}</span>
<small v-if="m.note">{{ m.note }}</small>
</div>
<em :class="{ in: m.quantity > 0, out: m.quantity < 0 }">
{{ m.quantity > 0 ? '+' : '' }}{{ toFaDigits(String(m.quantity)) }}
</em>
<time>{{ formatJalaliNumeric(new Date(m.occurredAt)) }}</time>
</li>
</ul>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.25rem;
}
.sub,
.muted {
margin: 0.25rem 0 0;
color: var(--muted);
font-size: 0.88rem;
}
.error {
color: var(--danger, #dc2626);
font-weight: 600;
}
.notice,
.kpis article,
.card,
.move-form {
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.1rem;
}
.kpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.kpis span {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-bottom: 0.35rem;
}
.kpis strong {
font-size: 1.05rem;
}
.move-form {
display: grid;
grid-template-columns: 10rem 1fr 8rem 1fr auto;
gap: 0.5rem;
align-items: center;
}
.move-form select {
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.6rem;
font: inherit;
background: #fff;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
h2 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
th,
td {
text-align: right;
padding: 0.45rem 0;
border-bottom: 1px solid #f1f5f9;
}
td small {
display: block;
color: var(--muted);
}
.num {
font-weight: 700;
}
.low {
color: var(--danger, #dc2626);
}
.moves {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.65rem;
}
.moves li {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.15rem 0.75rem;
font-size: 0.86rem;
}
.moves span,
.moves small,
.moves time {
display: block;
color: var(--muted);
font-size: 0.78rem;
}
.moves em {
font-style: normal;
font-weight: 800;
grid-row: span 2;
align-self: center;
}
.moves .in {
color: #16a34a;
}
.moves .out {
color: #dc2626;
}
@media (max-width: 900px) {
.kpis,
.grid,
.move-form {
grid-template-columns: 1fr;
}
}
</style>
+2
View File
@@ -23,6 +23,7 @@ import AskMentoPanel from '@/components/AskMentoPanel.vue';
import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue';
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue';
import InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
import type {
ActivityDto,
@@ -837,6 +838,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div>
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
</div>
@@ -0,0 +1,23 @@
# انبار و مانده داخلی (وقتی ته‌حساب نیست)
تاریخ: ۱۴۰۵/۶/۱۰
وضعیت: تأییدشده برای پیاده‌سازی
## هدف
اگر سازمان اتصال فعال ته‌حساب نداشته باشد، CRM یک انبار کوچک و مانده طرف‌حساب نگه می‌دارد. اگر ته‌حساب وصل باشد، این ماژول خاموش است؛ کاتالوگ محصول برای معامله/فاکتور می‌ماند.
## قوانین
- یک انبار پیش‌فرض به‌ازای سازمان
- موجودی = جمع گردش (ورود مثبت، خروج منفی)
- خدمات موجودی ندارند
- صدور فاکتور (`issued` / `partially_paid` / `paid`) موجودی کالا را کم می‌کند
- بستانکار موجودی را زیاد می‌کند
- پیش‌نویس و پیش‌فاکتور موجودی را عوض نمی‌کنند
- ابطال فاکتور گردش آن فاکتور را برمی‌دارد
- مانده شخص = مانده اول دوره + فاکتورهای صادرشده − پرداخت‌ها
## خارج از نسخهٔ اول
چند انبار، سریال، وزن طلا، دفترکل دوطرفه، همگام‌سازی با ته‌حساب
+4 -2
View File
@@ -1,7 +1,7 @@
# وضعیت پیشرفت منتکت (Montact CRM)
سند زندهٔ کارها — با هر قابلیت جدید همین فایل به‌روز می‌شود.
آخرین به‌روزرسانی: ۱۴ شهریور ۱۴۰۵
آخرین به‌روزرسانی: ۱۹ شهریور ۱۴۰۵
**راهنما:**
- `[x]` انجام شده
@@ -14,6 +14,7 @@
| تاریخ | مورد |
|---|---|
| ۱۴۰۵/۶/۱۹ | انبار داخلی + مانده طرف‌حساب وقتی ته‌حساب وصل نیست؛ خروج کالا با صدور فاکتور |
| ۱۴۰۵/۶/۱۴ | هم‌تراز کردن ظاهر صفحات قدیمی با زبان بصری منتکت (لیست/فرم/تنظیمات/گزارش) + جداکننده هزارگان روی فیلدهای مبلغ |
| ۱۴۰۵/۶/۱۴ | یادآور فعالیت: کانال اعلان داخل‌برنامه و/یا پیامک |
| ۱۴۰۵/۶/۱۴ | انتخابگر زمان دایره‌ای در `JalaliDateInput` |
@@ -95,6 +96,7 @@
| [x] | کاریز پروژه | صف/انجام/بازبینی/تمام |
| [x] | باشگاه مشتریان | امتیاز و سطوح |
| [x] | اتصال ته‌حساب | چند اتصال DB، لینک شخص، مانده، اسناد، ثبت سند (طلا/جواهر) |
| [x] | انبار و مانده داخلی | وقتی ته‌حساب نیست: موجودی کالا، ورود/اصلاح، خروج با صدور فاکتور، مانده شخص |
---
@@ -118,7 +120,7 @@
|---|---|---|
| [ ] | `platform_admin` SaaS | وقتی فروش چندمستأجری عمومی شروع شود |
| [ ] | اپ بومی اندروید/iOS | وب RTL + موبایل‌فرندلی کافی است |
| [ ] | حسابداری کامل / انبار ERP | اتصال ته‌حساب جایگزین حسابداری داخلی است |
| [ ] | حسابداری کامل / انبار ERP | انبار کوچک داخلی هست؛ دفترکل و چندانبار عمداً نه |
| [ ] | لندینگ Nuxt جدا | بعد از ثبات محصول |
---
+60
View File
@@ -783,6 +783,66 @@ export interface UpdateProductRequest {
isActive?: boolean;
}
export type StockMovementType =
| "opening"
| "purchase"
| "sale"
| "credit"
| "adjustment";
export interface InventoryStockRowDto {
productId: string;
name: string;
sku: string | null;
unit: string | null;
kind: "product" | "service";
stockQty: number;
currency: string;
unitPrice: number | null;
}
export interface StockMovementDto {
id: string;
productId: string;
productName: string;
type: StockMovementType;
quantity: number;
note: string | null;
invoiceId: string | null;
occurredAt: string;
}
export interface InventorySummaryDto {
enabled: boolean;
warehouseName: string | null;
products: InventoryStockRowDto[];
cashTotal: number;
receivablesTotal: number;
currency: string;
}
export interface CreateStockMovementRequest {
productId: string;
type: "purchase" | "adjustment";
quantity: number;
note?: string;
}
export interface PartyBalanceDto {
enabled: boolean;
personId: string;
openingAmount: number;
invoicedAmount: number;
paidAmount: number;
balance: number;
currency: string;
}
export interface UpdatePartyOpeningRequest {
amount: number;
note?: string;
}
export interface DealLineItemDto {
id: string;
productId: string | null;