feat(inventory): Add AlmacenProyectoService for project warehouse management

Implements missing service for project-specific warehouse assignments:
- CRUD operations for project warehouse assignments
- Stock queries by project (fraccionamiento)
- Transfer between project warehouses with transaction support
- Project inventory summary with stock value calculations
- Integration with ERP Core inventory tables

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-30 19:25:17 -06:00
parent 3b4bb3d80e
commit 6a64edf4c8
2 changed files with 651 additions and 0 deletions

View File

@ -0,0 +1,650 @@
/**
* AlmacenProyectoService - Project Warehouse Management Service
*
* Manages project-specific warehouse assignments, stock queries by project,
* transfers between project warehouses, and project inventory summaries.
*
* @module Inventory
*/
import { Repository, FindOptionsWhere, DataSource } from 'typeorm';
import { AlmacenProyecto, WarehouseTypeConstruction } from '../entities/almacen-proyecto.entity';
interface ServiceContext {
tenantId: string;
userId?: string;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateAlmacenProyectoDto {
warehouseId: string;
fraccionamientoId: string;
warehouseType?: WarehouseTypeConstruction;
locationDescription?: string;
responsibleId?: string;
}
export interface UpdateAlmacenProyectoDto {
warehouseType?: WarehouseTypeConstruction;
locationDescription?: string;
responsibleId?: string;
isActive?: boolean;
}
export interface AlmacenProyectoFilters {
fraccionamientoId?: string;
warehouseType?: WarehouseTypeConstruction;
responsibleId?: string;
isActive?: boolean;
}
export interface TransferRequest {
sourceWarehouseId: string;
destinationWarehouseId: string;
productId: string;
quantity: number;
notes?: string;
}
export interface TransferResult {
transferId: string;
sourceWarehouseId: string;
destinationWarehouseId: string;
productId: string;
quantity: number;
transferredAt: Date;
transferredById: string;
}
export interface StockByProject {
productId: string;
productName?: string;
warehouseId: string;
warehouseName?: string;
quantity: number;
reservedQuantity: number;
availableQuantity: number;
}
export interface ProjectInventorySummary {
fraccionamientoId: string;
totalWarehouses: number;
activeWarehouses: number;
warehousesByType: { type: WarehouseTypeConstruction; count: number }[];
totalProducts: number;
totalStockValue: number;
}
export class AlmacenProyectoService {
constructor(
private readonly repository: Repository<AlmacenProyecto>,
private readonly dataSource?: DataSource
) {}
/**
* Find project warehouses with filters and pagination
*/
async findWithFilters(
ctx: ServiceContext,
filters: AlmacenProyectoFilters = {},
page: number = 1,
limit: number = 20
): Promise<PaginatedResult<AlmacenProyecto>> {
const skip = (page - 1) * limit;
const queryBuilder = this.repository
.createQueryBuilder('ap')
.leftJoinAndSelect('ap.fraccionamiento', 'fraccionamiento')
.leftJoinAndSelect('ap.responsible', 'responsible')
.where('ap.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('ap.deleted_at IS NULL');
if (filters.fraccionamientoId) {
queryBuilder.andWhere('ap.fraccionamiento_id = :fraccionamientoId', {
fraccionamientoId: filters.fraccionamientoId,
});
}
if (filters.warehouseType) {
queryBuilder.andWhere('ap.warehouse_type = :warehouseType', {
warehouseType: filters.warehouseType,
});
}
if (filters.responsibleId) {
queryBuilder.andWhere('ap.responsible_id = :responsibleId', {
responsibleId: filters.responsibleId,
});
}
if (filters.isActive !== undefined) {
queryBuilder.andWhere('ap.is_active = :isActive', {
isActive: filters.isActive,
});
}
queryBuilder
.orderBy('ap.created_at', 'DESC')
.skip(skip)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Find a project warehouse by ID
*/
async findById(ctx: ServiceContext, id: string): Promise<AlmacenProyecto | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<AlmacenProyecto>,
relations: ['fraccionamiento', 'responsible', 'createdBy'],
});
}
/**
* Find a project warehouse by warehouse ID
*/
async findByWarehouseId(ctx: ServiceContext, warehouseId: string): Promise<AlmacenProyecto | null> {
return this.repository.findOne({
where: {
warehouseId,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<AlmacenProyecto>,
relations: ['fraccionamiento', 'responsible'],
});
}
/**
* Find all warehouses for a specific project (fraccionamiento)
*/
async findByProject(ctx: ServiceContext, fraccionamientoId: string): Promise<AlmacenProyecto[]> {
return this.repository.find({
where: {
fraccionamientoId,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<AlmacenProyecto>,
relations: ['responsible'],
order: { createdAt: 'ASC' },
});
}
/**
* Find active warehouses by type for a project
*/
async findByProjectAndType(
ctx: ServiceContext,
fraccionamientoId: string,
warehouseType: WarehouseTypeConstruction
): Promise<AlmacenProyecto[]> {
return this.repository.find({
where: {
fraccionamientoId,
warehouseType,
isActive: true,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<AlmacenProyecto>,
relations: ['responsible'],
});
}
/**
* Create a new project warehouse assignment
*/
async create(ctx: ServiceContext, dto: CreateAlmacenProyectoDto): Promise<AlmacenProyecto> {
// Check if warehouse is already assigned to a project
const existing = await this.findByWarehouseId(ctx, dto.warehouseId);
if (existing) {
throw new Error('Warehouse is already assigned to a project');
}
const almacenProyecto = this.repository.create({
tenantId: ctx.tenantId,
createdById: ctx.userId,
warehouseId: dto.warehouseId,
fraccionamientoId: dto.fraccionamientoId,
warehouseType: dto.warehouseType || 'obra',
locationDescription: dto.locationDescription,
responsibleId: dto.responsibleId,
isActive: true,
});
return this.repository.save(almacenProyecto);
}
/**
* Update a project warehouse
*/
async update(
ctx: ServiceContext,
id: string,
dto: UpdateAlmacenProyectoDto
): Promise<AlmacenProyecto | null> {
const almacenProyecto = await this.findById(ctx, id);
if (!almacenProyecto) {
return null;
}
if (dto.warehouseType !== undefined) {
almacenProyecto.warehouseType = dto.warehouseType;
}
if (dto.locationDescription !== undefined) {
almacenProyecto.locationDescription = dto.locationDescription;
}
if (dto.responsibleId !== undefined) {
almacenProyecto.responsibleId = dto.responsibleId;
}
if (dto.isActive !== undefined) {
almacenProyecto.isActive = dto.isActive;
}
if (ctx.userId) {
almacenProyecto.updatedById = ctx.userId;
}
return this.repository.save(almacenProyecto);
}
/**
* Activate a project warehouse
*/
async activate(ctx: ServiceContext, id: string): Promise<AlmacenProyecto | null> {
return this.update(ctx, id, { isActive: true });
}
/**
* Deactivate a project warehouse
*/
async deactivate(ctx: ServiceContext, id: string): Promise<AlmacenProyecto | null> {
return this.update(ctx, id, { isActive: false });
}
/**
* Get stock by project (all warehouses)
* Note: This queries against ERP Core inventory tables
*/
async getStockByProject(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<StockByProject[]> {
if (!this.dataSource) {
throw new Error('DataSource is required for stock queries');
}
// Get all warehouse IDs for this project
const projectWarehouses = await this.findByProject(ctx, fraccionamientoId);
if (projectWarehouses.length === 0) {
return [];
}
const warehouseIds = projectWarehouses.map((w) => w.warehouseId);
// Query stock from ERP Core inventory.stock table
const stockQuery = `
SELECT
s.product_id as "productId",
p.name as "productName",
s.warehouse_id as "warehouseId",
w.name as "warehouseName",
COALESCE(s.quantity, 0) as quantity,
COALESCE(s.reserved_quantity, 0) as "reservedQuantity",
COALESCE(s.quantity, 0) - COALESCE(s.reserved_quantity, 0) as "availableQuantity"
FROM inventory.stock s
LEFT JOIN inventory.products p ON s.product_id = p.id
LEFT JOIN inventory.warehouses w ON s.warehouse_id = w.id
WHERE s.tenant_id = $1
AND s.warehouse_id = ANY($2::uuid[])
ORDER BY p.name, w.name
`;
try {
const result = await this.dataSource.query(stockQuery, [ctx.tenantId, warehouseIds]);
return result.map((row: Record<string, unknown>) => ({
productId: row.productId as string,
productName: row.productName as string | undefined,
warehouseId: row.warehouseId as string,
warehouseName: row.warehouseName as string | undefined,
quantity: parseFloat(String(row.quantity) || '0'),
reservedQuantity: parseFloat(String(row.reservedQuantity) || '0'),
availableQuantity: parseFloat(String(row.availableQuantity) || '0'),
}));
} catch {
// If inventory.stock table doesn't exist (ERP Core not fully set up),
// return empty array
return [];
}
}
/**
* Get stock for a specific product across all project warehouses
*/
async getProductStockByProject(
ctx: ServiceContext,
fraccionamientoId: string,
productId: string
): Promise<StockByProject[]> {
const allStock = await this.getStockByProject(ctx, fraccionamientoId);
return allStock.filter((s) => s.productId === productId);
}
/**
* Transfer stock between project warehouses
* Note: This creates movement records in ERP Core inventory
*/
async transferBetweenWarehouses(
ctx: ServiceContext,
request: TransferRequest
): Promise<TransferResult> {
if (!ctx.userId) {
throw new Error('User ID is required for transfer operations');
}
if (!this.dataSource) {
throw new Error('DataSource is required for transfer operations');
}
// Validate source warehouse belongs to a project
const sourceWarehouse = await this.findByWarehouseId(ctx, request.sourceWarehouseId);
if (!sourceWarehouse) {
throw new Error('Source warehouse is not assigned to any project');
}
// Validate destination warehouse belongs to a project
const destWarehouse = await this.findByWarehouseId(ctx, request.destinationWarehouseId);
if (!destWarehouse) {
throw new Error('Destination warehouse is not assigned to any project');
}
// Validate quantity
if (request.quantity <= 0) {
throw new Error('Transfer quantity must be positive');
}
// Check available stock in source warehouse
const sourceStock = await this.getWarehouseProductStock(
ctx,
request.sourceWarehouseId,
request.productId
);
if (sourceStock.availableQuantity < request.quantity) {
throw new Error(
`Insufficient stock. Available: ${sourceStock.availableQuantity}, Requested: ${request.quantity}`
);
}
// Create transfer movement in ERP Core
const transferId = await this.createTransferMovement(ctx, request);
return {
transferId,
sourceWarehouseId: request.sourceWarehouseId,
destinationWarehouseId: request.destinationWarehouseId,
productId: request.productId,
quantity: request.quantity,
transferredAt: new Date(),
transferredById: ctx.userId,
};
}
/**
* Get stock for a specific product in a warehouse
*/
private async getWarehouseProductStock(
ctx: ServiceContext,
warehouseId: string,
productId: string
): Promise<{ quantity: number; reservedQuantity: number; availableQuantity: number }> {
if (!this.dataSource) {
return { quantity: 0, reservedQuantity: 0, availableQuantity: 0 };
}
const query = `
SELECT
COALESCE(quantity, 0) as quantity,
COALESCE(reserved_quantity, 0) as "reservedQuantity"
FROM inventory.stock
WHERE tenant_id = $1
AND warehouse_id = $2
AND product_id = $3
`;
try {
const result = await this.dataSource.query(query, [ctx.tenantId, warehouseId, productId]);
if (result.length === 0) {
return { quantity: 0, reservedQuantity: 0, availableQuantity: 0 };
}
const row = result[0];
const quantity = parseFloat(row.quantity || '0');
const reservedQuantity = parseFloat(row.reservedQuantity || '0');
return {
quantity,
reservedQuantity,
availableQuantity: quantity - reservedQuantity,
};
} catch {
return { quantity: 0, reservedQuantity: 0, availableQuantity: 0 };
}
}
/**
* Create a transfer movement between warehouses
*/
private async createTransferMovement(
ctx: ServiceContext,
request: TransferRequest
): Promise<string> {
if (!this.dataSource) {
throw new Error('DataSource is required');
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Generate transfer ID
const transferId = this.generateUUID();
const now = new Date();
// Create outbound movement from source
await queryRunner.query(
`
INSERT INTO inventory.stock_moves (
id, tenant_id, product_id, warehouse_id,
quantity, movement_type, reference_type, reference_id,
notes, created_at, created_by
) VALUES ($1, $2, $3, $4, $5, 'out', 'transfer', $6, $7, $8, $9)
`,
[
this.generateUUID(),
ctx.tenantId,
request.productId,
request.sourceWarehouseId,
-request.quantity,
transferId,
request.notes || 'Transfer between project warehouses',
now,
ctx.userId,
]
);
// Create inbound movement to destination
await queryRunner.query(
`
INSERT INTO inventory.stock_moves (
id, tenant_id, product_id, warehouse_id,
quantity, movement_type, reference_type, reference_id,
notes, created_at, created_by
) VALUES ($1, $2, $3, $4, $5, 'in', 'transfer', $6, $7, $8, $9)
`,
[
this.generateUUID(),
ctx.tenantId,
request.productId,
request.destinationWarehouseId,
request.quantity,
transferId,
request.notes || 'Transfer between project warehouses',
now,
ctx.userId,
]
);
// Update stock in source warehouse (decrease)
await queryRunner.query(
`
UPDATE inventory.stock
SET quantity = quantity - $1, updated_at = $2
WHERE tenant_id = $3 AND warehouse_id = $4 AND product_id = $5
`,
[request.quantity, now, ctx.tenantId, request.sourceWarehouseId, request.productId]
);
// Update or insert stock in destination warehouse (increase)
await queryRunner.query(
`
INSERT INTO inventory.stock (id, tenant_id, warehouse_id, product_id, quantity, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (tenant_id, warehouse_id, product_id)
DO UPDATE SET quantity = inventory.stock.quantity + $5, updated_at = $6
`,
[
this.generateUUID(),
ctx.tenantId,
request.destinationWarehouseId,
request.productId,
request.quantity,
now,
]
);
await queryRunner.commitTransaction();
return transferId;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* Get project inventory summary
*/
async getProjectInventorySummary(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<ProjectInventorySummary> {
// Get warehouses for the project
const warehouses = await this.findByProject(ctx, fraccionamientoId);
const activeWarehouses = warehouses.filter((w) => w.isActive);
// Count by type
const typeCount: Record<WarehouseTypeConstruction, number> = {
central: 0,
obra: 0,
temporal: 0,
transito: 0,
};
for (const wh of activeWarehouses) {
typeCount[wh.warehouseType]++;
}
const warehousesByType = Object.entries(typeCount)
.filter(([, count]) => count > 0)
.map(([type, count]) => ({
type: type as WarehouseTypeConstruction,
count,
}));
// Get stock summary if dataSource available
let totalProducts = 0;
let totalStockValue = 0;
if (this.dataSource && activeWarehouses.length > 0) {
const warehouseIds = activeWarehouses.map((w) => w.warehouseId);
try {
const stockSummary = await this.dataSource.query(
`
SELECT
COUNT(DISTINCT s.product_id) as "totalProducts",
COALESCE(SUM(s.quantity * COALESCE(p.cost, 0)), 0) as "totalValue"
FROM inventory.stock s
LEFT JOIN inventory.products p ON s.product_id = p.id
WHERE s.tenant_id = $1
AND s.warehouse_id = ANY($2::uuid[])
AND s.quantity > 0
`,
[ctx.tenantId, warehouseIds]
);
if (stockSummary.length > 0) {
totalProducts = parseInt(stockSummary[0].totalProducts || '0', 10);
totalStockValue = parseFloat(stockSummary[0].totalValue || '0');
}
} catch {
// ERP Core tables may not exist
}
}
return {
fraccionamientoId,
totalWarehouses: warehouses.length,
activeWarehouses: activeWarehouses.length,
warehousesByType,
totalProducts,
totalStockValue,
};
}
/**
* Soft delete a project warehouse assignment
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const almacenProyecto = await this.findById(ctx, id);
if (!almacenProyecto) {
return false;
}
await this.repository.update(
{ id, tenantId: ctx.tenantId } as unknown as FindOptionsWhere<AlmacenProyecto>,
{ deletedAt: new Date(), deletedById: ctx.userId }
);
return true;
}
/**
* Generate a UUID v4
*/
private generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}

View File

@ -3,5 +3,6 @@
* @module Inventory
*/
export * from './almacen-proyecto.service';
export * from './requisicion.service';
export * from './consumo-obra.service';