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>
651 lines
18 KiB
TypeScript
651 lines
18 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
}
|
|
}
|