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[]
|
noteAiSuggestions NoteAiSuggestion[]
|
||||||
accountingConnections AccountingConnection[]
|
accountingConnections AccountingConnection[]
|
||||||
accountingSyncJobs AccountingSyncJob[]
|
accountingSyncJobs AccountingSyncJob[]
|
||||||
|
warehouses Warehouse[]
|
||||||
|
stockMovements StockMovement[]
|
||||||
|
partyOpenings PartyOpeningBalance[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -283,6 +286,7 @@ model Person {
|
|||||||
memoryItems MemoryItem[]
|
memoryItems MemoryItem[]
|
||||||
accountingLinks PersonAccountingLink[]
|
accountingLinks PersonAccountingLink[]
|
||||||
accountingDocuments AccountingDocumentLog[]
|
accountingDocuments AccountingDocumentLog[]
|
||||||
|
openingBalances PartyOpeningBalance[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -545,6 +549,7 @@ model Product {
|
|||||||
lineItems DealLineItem[]
|
lineItems DealLineItem[]
|
||||||
quoteLineItems QuoteLineItem[]
|
quoteLineItems QuoteLineItem[]
|
||||||
invoiceLineItems InvoiceLineItem[]
|
invoiceLineItems InvoiceLineItem[]
|
||||||
|
stockMovements StockMovement[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -725,6 +730,7 @@ model Invoice {
|
|||||||
lineItems InvoiceLineItem[]
|
lineItems InvoiceLineItem[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
installments InvoiceInstallment[]
|
installments InvoiceInstallment[]
|
||||||
|
stockMovements StockMovement[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1210,3 +1216,57 @@ model AccountingSyncJob {
|
|||||||
|
|
||||||
@@index([organizationId, createdAt])
|
@@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 { AiModule } from './ai/ai.module';
|
||||||
import { MemoryModule } from './memory/memory.module';
|
import { MemoryModule } from './memory/memory.module';
|
||||||
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||||
|
import { InventoryModule } from './inventory/inventory.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -51,6 +52,7 @@ import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
|||||||
LeadFormsModule,
|
LeadFormsModule,
|
||||||
QuotesModule,
|
QuotesModule,
|
||||||
InvoicesModule,
|
InvoicesModule,
|
||||||
|
InventoryModule,
|
||||||
SalesModule,
|
SalesModule,
|
||||||
TicketsModule,
|
TicketsModule,
|
||||||
FaqModule,
|
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 { Module } from '@nestjs/common';
|
||||||
import { AutomationsModule } from '../automations/automations.module';
|
import { AutomationsModule } from '../automations/automations.module';
|
||||||
|
import { InventoryModule } from '../inventory/inventory.module';
|
||||||
import { InvoicesController } from './invoices.controller';
|
import { InvoicesController } from './invoices.controller';
|
||||||
import { InvoicesService } from './invoices.service';
|
import { InvoicesService } from './invoices.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AutomationsModule],
|
imports: [AutomationsModule, InventoryModule],
|
||||||
controllers: [InvoicesController],
|
controllers: [InvoicesController],
|
||||||
providers: [InvoicesService],
|
providers: [InvoicesService],
|
||||||
exports: [InvoicesService],
|
exports: [InvoicesService],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||||
import { AutomationsService } from '../automations/automations.service';
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
|
import { InventoryService } from '../inventory/inventory.service';
|
||||||
|
|
||||||
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
|
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export class InvoicesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly automations: AutomationsService,
|
private readonly automations: AutomationsService,
|
||||||
|
private readonly inventory: InventoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -269,6 +271,7 @@ export class InvoicesService {
|
|||||||
include,
|
include,
|
||||||
});
|
});
|
||||||
const created = this.toDto(row);
|
const created = this.toDto(row);
|
||||||
|
await this.inventory.syncInvoiceStock(organizationId, created.id);
|
||||||
if (created.status !== 'draft' && created.status !== 'canceled') {
|
if (created.status !== 'draft' && created.status !== 'canceled') {
|
||||||
await this.fireIssued(organizationId, created);
|
await this.fireIssued(organizationId, created);
|
||||||
}
|
}
|
||||||
@@ -383,6 +386,7 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const row = await this.get(organizationId, id, actor);
|
const row = await this.get(organizationId, id, actor);
|
||||||
|
await this.inventory.syncInvoiceStock(organizationId, row.id);
|
||||||
const leftDraft =
|
const leftDraft =
|
||||||
existing.status === 'draft' &&
|
existing.status === 'draft' &&
|
||||||
row.status !== 'draft' &&
|
row.status !== 'draft' &&
|
||||||
@@ -460,6 +464,7 @@ export class InvoicesService {
|
|||||||
|
|
||||||
await this.recomputeStatus(invoiceId);
|
await this.recomputeStatus(invoiceId);
|
||||||
const row = await this.get(organizationId, invoiceId, actor);
|
const row = await this.get(organizationId, invoiceId, actor);
|
||||||
|
await this.inventory.syncInvoiceStock(organizationId, row.id);
|
||||||
if (invoice.status === 'draft') {
|
if (invoice.status === 'draft') {
|
||||||
await this.fireIssued(organizationId, row);
|
await this.fireIssued(organizationId, row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { InventoryModule } from '../inventory/inventory.module';
|
||||||
import { ProductsController } from './products.controller';
|
import { ProductsController } from './products.controller';
|
||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, InventoryModule],
|
||||||
controllers: [ProductsController],
|
controllers: [ProductsController],
|
||||||
providers: [ProductsService],
|
providers: [ProductsService],
|
||||||
exports: [ProductsService],
|
exports: [ProductsService],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { InventoryService } from '../inventory/inventory.service';
|
||||||
|
|
||||||
type ProductRow = {
|
type ProductRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,7 +34,10 @@ type ProductRow = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProductsService {
|
export class ProductsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly inventory: InventoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
@@ -115,6 +119,12 @@ export class ProductsService {
|
|||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.inventory.onProductCreated(
|
||||||
|
organizationId,
|
||||||
|
row.id,
|
||||||
|
row.stockQty,
|
||||||
|
row.kind,
|
||||||
|
);
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +164,14 @@ export class ProductsService {
|
|||||||
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||||
|
|
||||||
const row = await this.prisma.product.update({ where: { id }, data });
|
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);
|
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 globalSearch = ref('');
|
||||||
const activityBadge = ref(0);
|
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 morePaths = ['/activities/calendar', '/projects', '/project-cards', '/loyalty'];
|
||||||
const settingsPaths = [
|
const settingsPaths = [
|
||||||
'/settings/team',
|
'/settings/team',
|
||||||
@@ -63,6 +63,7 @@ const extraActive = computed(() =>
|
|||||||
pathIn([
|
pathIn([
|
||||||
'/quotes',
|
'/quotes',
|
||||||
'/invoices',
|
'/invoices',
|
||||||
|
'/inventory',
|
||||||
'/sales/forecast',
|
'/sales/forecast',
|
||||||
'/notes',
|
'/notes',
|
||||||
'/tickets',
|
'/tickets',
|
||||||
@@ -390,6 +391,7 @@ onUnmounted(() => {
|
|||||||
:class="{ 'router-link-active': route.path.startsWith('/invoices') || route.path.startsWith('/quotes') }"
|
:class="{ 'router-link-active': route.path.startsWith('/invoices') || route.path.startsWith('/quotes') }"
|
||||||
@click="closeMenu"
|
@click="closeMenu"
|
||||||
>فاکتورها</RouterLink>
|
>فاکتورها</RouterLink>
|
||||||
|
<RouterLink to="/inventory" @click="closeMenu">انبار</RouterLink>
|
||||||
<RouterLink to="/sales/forecast" @click="closeMenu">هدف و پیشبینی</RouterLink>
|
<RouterLink to="/sales/forecast" @click="closeMenu">هدف و پیشبینی</RouterLink>
|
||||||
<RouterLink to="/notes" @click="closeMenu">یادداشتها</RouterLink>
|
<RouterLink to="/notes" @click="closeMenu">یادداشتها</RouterLink>
|
||||||
<RouterLink to="/tickets" @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') }"
|
:class="{ 'router-link-active': route.path.startsWith('/invoices') || route.path.startsWith('/quotes') }"
|
||||||
@click="closeMenu"
|
@click="closeMenu"
|
||||||
>فاکتورها</RouterLink>
|
>فاکتورها</RouterLink>
|
||||||
|
<RouterLink to="/inventory" @click="closeMenu">انبار</RouterLink>
|
||||||
<RouterLink to="/sales/forecast" @click="closeMenu">هدف و پیشبینی</RouterLink>
|
<RouterLink to="/sales/forecast" @click="closeMenu">هدف و پیشبینی</RouterLink>
|
||||||
<RouterLink to="/notes" @click="closeMenu">یادداشتها</RouterLink>
|
<RouterLink to="/notes" @click="closeMenu">یادداشتها</RouterLink>
|
||||||
<RouterLink to="/tickets" @click="closeMenu">تیکتها</RouterLink>
|
<RouterLink to="/tickets" @click="closeMenu">تیکتها</RouterLink>
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ export const router = createRouter({
|
|||||||
name: 'invoice-detail',
|
name: 'invoice-detail',
|
||||||
component: () => import('@/views/InvoiceFormView.vue'),
|
component: () => import('@/views/InvoiceFormView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'inventory',
|
||||||
|
name: 'inventory',
|
||||||
|
component: () => import('@/views/InventoryView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'pipeline',
|
path: 'pipeline',
|
||||||
name: '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 ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue';
|
||||||
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
|
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
|
||||||
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.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 { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
|
||||||
import type {
|
import type {
|
||||||
ActivityDto,
|
ActivityDto,
|
||||||
@@ -837,6 +838,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="tab === 'assets'" class="panel assets-panel">
|
<div v-show="tab === 'assets'" class="panel assets-panel">
|
||||||
|
<InternalPartyBalance :person-id="id" />
|
||||||
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
|
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# انبار و مانده داخلی (وقتی تهحساب نیست)
|
||||||
|
|
||||||
|
تاریخ: ۱۴۰۵/۶/۱۰
|
||||||
|
وضعیت: تأییدشده برای پیادهسازی
|
||||||
|
|
||||||
|
## هدف
|
||||||
|
|
||||||
|
اگر سازمان اتصال فعال تهحساب نداشته باشد، CRM یک انبار کوچک و مانده طرفحساب نگه میدارد. اگر تهحساب وصل باشد، این ماژول خاموش است؛ کاتالوگ محصول برای معامله/فاکتور میماند.
|
||||||
|
|
||||||
|
## قوانین
|
||||||
|
|
||||||
|
- یک انبار پیشفرض بهازای سازمان
|
||||||
|
- موجودی = جمع گردش (ورود مثبت، خروج منفی)
|
||||||
|
- خدمات موجودی ندارند
|
||||||
|
- صدور فاکتور (`issued` / `partially_paid` / `paid`) موجودی کالا را کم میکند
|
||||||
|
- بستانکار موجودی را زیاد میکند
|
||||||
|
- پیشنویس و پیشفاکتور موجودی را عوض نمیکنند
|
||||||
|
- ابطال فاکتور گردش آن فاکتور را برمیدارد
|
||||||
|
- مانده شخص = مانده اول دوره + فاکتورهای صادرشده − پرداختها
|
||||||
|
|
||||||
|
## خارج از نسخهٔ اول
|
||||||
|
|
||||||
|
چند انبار، سریال، وزن طلا، دفترکل دوطرفه، همگامسازی با تهحساب
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# وضعیت پیشرفت منتکت (Montact CRM)
|
# وضعیت پیشرفت منتکت (Montact CRM)
|
||||||
|
|
||||||
سند زندهٔ کارها — با هر قابلیت جدید همین فایل بهروز میشود.
|
سند زندهٔ کارها — با هر قابلیت جدید همین فایل بهروز میشود.
|
||||||
آخرین بهروزرسانی: ۱۴ شهریور ۱۴۰۵
|
آخرین بهروزرسانی: ۱۹ شهریور ۱۴۰۵
|
||||||
|
|
||||||
**راهنما:**
|
**راهنما:**
|
||||||
- `[x]` انجام شده
|
- `[x]` انجام شده
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
| تاریخ | مورد |
|
| تاریخ | مورد |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| ۱۴۰۵/۶/۱۹ | انبار داخلی + مانده طرفحساب وقتی تهحساب وصل نیست؛ خروج کالا با صدور فاکتور |
|
||||||
| ۱۴۰۵/۶/۱۴ | همتراز کردن ظاهر صفحات قدیمی با زبان بصری منتکت (لیست/فرم/تنظیمات/گزارش) + جداکننده هزارگان روی فیلدهای مبلغ |
|
| ۱۴۰۵/۶/۱۴ | همتراز کردن ظاهر صفحات قدیمی با زبان بصری منتکت (لیست/فرم/تنظیمات/گزارش) + جداکننده هزارگان روی فیلدهای مبلغ |
|
||||||
| ۱۴۰۵/۶/۱۴ | یادآور فعالیت: کانال اعلان داخلبرنامه و/یا پیامک |
|
| ۱۴۰۵/۶/۱۴ | یادآور فعالیت: کانال اعلان داخلبرنامه و/یا پیامک |
|
||||||
| ۱۴۰۵/۶/۱۴ | انتخابگر زمان دایرهای در `JalaliDateInput` |
|
| ۱۴۰۵/۶/۱۴ | انتخابگر زمان دایرهای در `JalaliDateInput` |
|
||||||
@@ -95,6 +96,7 @@
|
|||||||
| [x] | کاریز پروژه | صف/انجام/بازبینی/تمام |
|
| [x] | کاریز پروژه | صف/انجام/بازبینی/تمام |
|
||||||
| [x] | باشگاه مشتریان | امتیاز و سطوح |
|
| [x] | باشگاه مشتریان | امتیاز و سطوح |
|
||||||
| [x] | اتصال تهحساب | چند اتصال DB، لینک شخص، مانده، اسناد، ثبت سند (طلا/جواهر) |
|
| [x] | اتصال تهحساب | چند اتصال DB، لینک شخص، مانده، اسناد، ثبت سند (طلا/جواهر) |
|
||||||
|
| [x] | انبار و مانده داخلی | وقتی تهحساب نیست: موجودی کالا، ورود/اصلاح، خروج با صدور فاکتور، مانده شخص |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [ ] | `platform_admin` SaaS | وقتی فروش چندمستأجری عمومی شروع شود |
|
| [ ] | `platform_admin` SaaS | وقتی فروش چندمستأجری عمومی شروع شود |
|
||||||
| [ ] | اپ بومی اندروید/iOS | وب RTL + موبایلفرندلی کافی است |
|
| [ ] | اپ بومی اندروید/iOS | وب RTL + موبایلفرندلی کافی است |
|
||||||
| [ ] | حسابداری کامل / انبار ERP | اتصال تهحساب جایگزین حسابداری داخلی است |
|
| [ ] | حسابداری کامل / انبار ERP | انبار کوچک داخلی هست؛ دفترکل و چندانبار عمداً نه |
|
||||||
| [ ] | لندینگ Nuxt جدا | بعد از ثبات محصول |
|
| [ ] | لندینگ Nuxt جدا | بعد از ثبات محصول |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -783,6 +783,66 @@ export interface UpdateProductRequest {
|
|||||||
isActive?: boolean;
|
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 {
|
export interface DealLineItemDto {
|
||||||
id: string;
|
id: string;
|
||||||
productId: string | null;
|
productId: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user