From 212d02f66468a6786b08e4fe856802fbe252b1f6 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 10 Sep 2026 22:24:16 +0330 Subject: [PATCH] Add a small internal warehouse and party balances when Tahesab is off. Co-authored-by: Cursor --- .../migration.sql | 80 +++ apps/api/prisma/schema.prisma | 60 ++ apps/api/src/app.module.ts | 2 + apps/api/src/inventory/dto.ts | 38 ++ .../api/src/inventory/inventory.controller.ts | 69 +++ apps/api/src/inventory/inventory.module.ts | 12 + apps/api/src/inventory/inventory.service.ts | 515 ++++++++++++++++++ apps/api/src/invoices/invoices.module.ts | 3 +- apps/api/src/invoices/invoices.service.ts | 5 + apps/api/src/products/products.module.ts | 3 +- apps/api/src/products/products.service.ts | 20 +- .../person-assets/InternalPartyBalance.vue | 173 ++++++ apps/web/src/layouts/AppLayout.vue | 5 +- apps/web/src/router/index.ts | 5 + apps/web/src/views/InventoryView.vue | 374 +++++++++++++ apps/web/src/views/PersonProfileView.vue | 2 + .../2026-09-10-internal-inventory-design.md | 23 + docs/وضعیت-پیشرفت.md | 6 +- packages/shared/src/index.ts | 60 ++ 19 files changed, 1449 insertions(+), 6 deletions(-) create mode 100644 apps/api/prisma/migrations/20260910220000_internal_inventory/migration.sql create mode 100644 apps/api/src/inventory/dto.ts create mode 100644 apps/api/src/inventory/inventory.controller.ts create mode 100644 apps/api/src/inventory/inventory.module.ts create mode 100644 apps/api/src/inventory/inventory.service.ts create mode 100644 apps/web/src/components/person-assets/InternalPartyBalance.vue create mode 100644 apps/web/src/views/InventoryView.vue create mode 100644 docs/superpowers/specs/2026-09-10-internal-inventory-design.md diff --git a/apps/api/prisma/migrations/20260910220000_internal_inventory/migration.sql b/apps/api/prisma/migrations/20260910220000_internal_inventory/migration.sql new file mode 100644 index 0000000..3351080 --- /dev/null +++ b/apps/api/prisma/migrations/20260910220000_internal_inventory/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f1985b3..3eb78dd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b3388cd..cc3c60c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/inventory/dto.ts b/apps/api/src/inventory/dto.ts new file mode 100644 index 0000000..22137dd --- /dev/null +++ b/apps/api/src/inventory/dto.ts @@ -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; +} diff --git a/apps/api/src/inventory/inventory.controller.ts b/apps/api/src/inventory/inventory.controller.ts new file mode 100644 index 0000000..5e8873f --- /dev/null +++ b/apps/api/src/inventory/inventory.controller.ts @@ -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, + ); + } +} diff --git a/apps/api/src/inventory/inventory.module.ts b/apps/api/src/inventory/inventory.module.ts new file mode 100644 index 0000000..0d9256e --- /dev/null +++ b/apps/api/src/inventory/inventory.module.ts @@ -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 {} diff --git a/apps/api/src/inventory/inventory.service.ts b/apps/api/src/inventory/inventory.service.ts new file mode 100644 index 0000000..55a1956 --- /dev/null +++ b/apps/api/src/inventory/inventory.service.ts @@ -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 { + const n = await this.prisma.accountingConnection.count({ + where: { organizationId, enabled: true }, + }); + return n === 0; + } + + async getSummary(organizationId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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> { + const map = new Map(); + 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 { + 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 { + 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; +} diff --git a/apps/api/src/invoices/invoices.module.ts b/apps/api/src/invoices/invoices.module.ts index 20a1408..2313ca8 100644 --- a/apps/api/src/invoices/invoices.module.ts +++ b/apps/api/src/invoices/invoices.module.ts @@ -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], diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts index 5527246..f45296e 100644 --- a/apps/api/src/invoices/invoices.service.ts +++ b/apps/api/src/invoices/invoices.service.ts @@ -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; @@ -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); } diff --git a/apps/api/src/products/products.module.ts b/apps/api/src/products/products.module.ts index cdc091f..dc5a015 100644 --- a/apps/api/src/products/products.module.ts +++ b/apps/api/src/products/products.module.ts @@ -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], diff --git a/apps/api/src/products/products.service.ts b/apps/api/src/products/products.service.ts index d93e4f4..a0de739 100644 --- a/apps/api/src/products/products.service.ts +++ b/apps/api/src/products/products.service.ts @@ -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); } diff --git a/apps/web/src/components/person-assets/InternalPartyBalance.vue b/apps/web/src/components/person-assets/InternalPartyBalance.vue new file mode 100644 index 0000000..5e35c79 --- /dev/null +++ b/apps/web/src/components/person-assets/InternalPartyBalance.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 7442dad..0d10b87 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -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" >فاکتورها + انبار هدف و پیش‌بینی یادداشت‌ها تیکت‌ها @@ -477,6 +479,7 @@ onUnmounted(() => { :class="{ 'router-link-active': route.path.startsWith('/invoices') || route.path.startsWith('/quotes') }" @click="closeMenu" >فاکتورها + انبار هدف و پیش‌بینی یادداشت‌ها تیکت‌ها diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 4ad14fc..1cc8407 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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', diff --git a/apps/web/src/views/InventoryView.vue b/apps/web/src/views/InventoryView.vue new file mode 100644 index 0000000..b528c93 --- /dev/null +++ b/apps/web/src/views/InventoryView.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/apps/web/src/views/PersonProfileView.vue b/apps/web/src/views/PersonProfileView.vue index a592468..498971e 100644 --- a/apps/web/src/views/PersonProfileView.vue +++ b/apps/web/src/views/PersonProfileView.vue @@ -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 });
+
diff --git a/docs/superpowers/specs/2026-09-10-internal-inventory-design.md b/docs/superpowers/specs/2026-09-10-internal-inventory-design.md new file mode 100644 index 0000000..9e48ba1 --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-internal-inventory-design.md @@ -0,0 +1,23 @@ +# انبار و مانده داخلی (وقتی ته‌حساب نیست) + +تاریخ: ۱۴۰۵/۶/۱۰ +وضعیت: تأییدشده برای پیاده‌سازی + +## هدف + +اگر سازمان اتصال فعال ته‌حساب نداشته باشد، CRM یک انبار کوچک و مانده طرف‌حساب نگه می‌دارد. اگر ته‌حساب وصل باشد، این ماژول خاموش است؛ کاتالوگ محصول برای معامله/فاکتور می‌ماند. + +## قوانین + +- یک انبار پیش‌فرض به‌ازای سازمان +- موجودی = جمع گردش (ورود مثبت، خروج منفی) +- خدمات موجودی ندارند +- صدور فاکتور (`issued` / `partially_paid` / `paid`) موجودی کالا را کم می‌کند +- بستانکار موجودی را زیاد می‌کند +- پیش‌نویس و پیش‌فاکتور موجودی را عوض نمی‌کنند +- ابطال فاکتور گردش آن فاکتور را برمی‌دارد +- مانده شخص = مانده اول دوره + فاکتورهای صادرشده − پرداخت‌ها + +## خارج از نسخهٔ اول + +چند انبار، سریال، وزن طلا، دفترکل دوطرفه، همگام‌سازی با ته‌حساب diff --git a/docs/وضعیت-پیشرفت.md b/docs/وضعیت-پیشرفت.md index f41e5a1..fd7b41b 100644 --- a/docs/وضعیت-پیشرفت.md +++ b/docs/وضعیت-پیشرفت.md @@ -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 جدا | بعد از ثبات محصول | --- diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4c5181a..62fdf97 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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;