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