feat(nestjs): migrate to NestJS

This commit is contained in:
Ahmed Bouhuolia
2025-04-07 11:51:24 +02:00
parent f068218a16
commit 55fcc908ef
3779 changed files with 631 additions and 195332 deletions

View File

@@ -0,0 +1,45 @@
import { Knex } from 'knex';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Inject, Injectable } from '@nestjs/common';
import { Item } from './models/Item';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class ActivateItemService {
constructor(
@Inject(Item.name)
private readonly itemModel: TenantModelProxy<typeof Item>,
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
) {}
/**
* Activates the given item on the storage.
* @param {number} itemId -
* @return {Promise<void>}
*/
public async activateItem(
itemId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieves the given item or throw not found error.
const oldItem = await this.itemModel()
.query()
.findById(itemId)
.throwIfNotFound();
// Activate the given item with associated transactions under unit-of-work environment.
return this.uow.withTransaction(async (trx) => {
// Mutate item on the storage.
await this.itemModel()
.query(trx)
.findById(itemId)
.patch({ active: true });
// Triggers `onItemActivated` event.
await this.eventEmitter.emitAsync(events.item.onActivated, {});
}, trx);
}
}

View File

@@ -0,0 +1,123 @@
import { Knex } from 'knex';
import { defaultTo } from 'lodash';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { IItemDTO, IItemEventCreatedPayload } from '@/interfaces/Item';
import { events } from '@/common/events/events';
import { ItemsValidators } from './ItemValidator.service';
import { Item } from './models/Item';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { CreateItemDto } from './dtos/Item.dto';
@Injectable({ scope: Scope.REQUEST })
export class CreateItemService {
/**
* Constructor for the CreateItemService class.
* @param {EventEmitter2} eventEmitter - Event emitter for publishing item creation events.
* @param {UnitOfWork} uow - Unit of Work for tenant database transactions.
* @param {ItemsValidators} validators - Service for validating item data.
* @param {typeof Item} itemModel - The Item model class for database operations.
*/
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validators: ItemsValidators,
@Inject(Item.name)
private readonly itemModel: TenantModelProxy<typeof Item>,
) {}
/**
* Authorize the creating item.
* @param {number} tenantId
* @param {IItemDTO} itemDTO
*/
async authorize(itemDTO: CreateItemDto) {
// Validate whether the given item name already exists on the storage.
await this.validators.validateItemNameUniquiness(itemDTO.name);
if (itemDTO.categoryId) {
await this.validators.validateItemCategoryExistance(itemDTO.categoryId);
}
if (itemDTO.sellAccountId) {
await this.validators.validateItemSellAccountExistance(
itemDTO.sellAccountId,
);
}
// Validate the income account id existance if the item is sellable.
this.validators.validateIncomeAccountExistance(
itemDTO.sellable,
itemDTO.sellAccountId,
);
if (itemDTO.costAccountId) {
await this.validators.validateItemCostAccountExistance(
itemDTO.costAccountId,
);
}
// Validate the cost account id existance if the item is purchasable.
this.validators.validateCostAccountExistance(
itemDTO.purchasable,
itemDTO.costAccountId,
);
if (itemDTO.inventoryAccountId) {
await this.validators.validateItemInventoryAccountExistance(
itemDTO.inventoryAccountId,
);
}
if (itemDTO.purchaseTaxRateId) {
await this.validators.validatePurchaseTaxRateExistance(
itemDTO.purchaseTaxRateId,
);
}
if (itemDTO.sellTaxRateId) {
await this.validators.validateSellTaxRateExistance(itemDTO.sellTaxRateId);
}
}
/**
* Transforms the item DTO to model.
* @param {CreateItemDto} itemDTO - Item DTO.
* @return {IItem}
*/
private transformNewItemDTOToModel(itemDTO: CreateItemDto) {
return {
...itemDTO,
active: defaultTo(itemDTO.active, 1),
quantityOnHand: itemDTO.type === 'inventory' ? 0 : null,
};
}
/**
* Creates a new item.
* @param {IItemDTO} itemDTO
* @return {Promise<number>} - The created item id.
*/
public async createItem(
itemDTO: CreateItemDto,
trx?: Knex.Transaction,
): Promise<number> {
// Authorize the item before creating.
await this.authorize(itemDTO);
// Creates a new item with associated transactions under unit-of-work envirement.
return this.uow.withTransaction<number>(async (trx: Knex.Transaction) => {
const itemInsert = this.transformNewItemDTOToModel(itemDTO);
// Inserts a new item and fetch the created item.
const item = await this.itemModel()
.query(trx)
.insertAndFetch({
...itemInsert,
});
// Triggers `onItemCreated` event.
await this.eventEmitter.emitAsync(events.item.onCreated, {
item,
itemId: item.id,
trx,
} as IItemEventCreatedPayload);
return item.id;
}, trx);
}
}

View File

@@ -0,0 +1,67 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
IItemEventDeletedPayload,
IItemEventDeletingPayload,
} from '@/interfaces/Item';
import { events } from '@/common/events/events';
import { Item } from './models/Item';
import { ERRORS } from './Items.constants';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class DeleteItemService {
/**
* Constructor for the class.
* @param {EventEmitter2} eventEmitter - Event emitter for publishing item deletion events.
* @param {UnitOfWork} uow - Unit of Work for tenant database transactions.
* @param {typeof Item} itemModel - The Item model class for database operations.
*/
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
@Inject(Item.name)
private readonly itemModel: TenantModelProxy<typeof Item>,
) {}
/**
* Delete the given item from the storage.
* @param {number} itemId - Item id.
* @return {Promise<void>}
*/
public async deleteItem(
itemId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve the given item or throw not found service error.
const oldItem = await this.itemModel()
.query()
.findById(itemId)
.throwIfNotFound();
// .queryAndThrowIfHasRelations({
// type: ERRORS.ITEM_HAS_ASSOCIATED_TRANSACTIONS,
// });
// Delete item in unit of work.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onItemDeleting` event.
await this.eventEmitter.emitAsync(events.item.onDeleting, {
trx,
oldItem,
} as IItemEventDeletingPayload);
// Deletes the item.
await this.itemModel().query(trx).findById(itemId).delete();
// Triggers `onItemDeleted` event.
await this.eventEmitter.emitAsync(events.item.onDeleted, {
itemId,
oldItem,
trx,
} as IItemEventDeletedPayload);
}, trx);
}
}

View File

@@ -0,0 +1,146 @@
import { Knex } from 'knex';
import { Injectable, Inject } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { IItemDTO, IItemEventEditedPayload } from '@/interfaces/Item';
import { events } from '@/common/events/events';
import { ItemsValidators } from './ItemValidator.service';
import { Item } from './models/Item';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { EditItemDto } from './dtos/Item.dto';
@Injectable()
export class EditItemService {
/**
* Constructor for the class.
* @param {EventEmitter2} eventEmitter - Event emitter for publishing item edit events.
* @param {UnitOfWork} uow - Unit of Work for tenant database transactions.
* @param {ItemsValidators} validators - Service for validating item data.
* @param {typeof Item} itemModel - The Item model class for database operations.
*/
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validators: ItemsValidators,
@Inject(Item.name)
private readonly itemModel: TenantModelProxy<typeof Item>,
) {}
/**
* Authorize the editing item.
* @param {IItemDTO} itemDTO
* @param {Item} oldItem
*/
async authorize(itemDTO: EditItemDto, oldItem: Item) {
// Validate edit item type from inventory type.
this.validators.validateEditItemFromInventory(itemDTO, oldItem);
// Validate edit item type to inventory type.
await this.validators.validateEditItemTypeToInventory(oldItem, itemDTO);
// Validate whether the given item name already exists on the storage.
await this.validators.validateItemNameUniquiness(itemDTO.name, oldItem.id);
if (itemDTO.categoryId) {
await this.validators.validateItemCategoryExistance(itemDTO.categoryId);
}
if (itemDTO.sellAccountId) {
await this.validators.validateItemSellAccountExistance(
itemDTO.sellAccountId,
);
}
// Validate the income account id existance if the item is sellable.
this.validators.validateIncomeAccountExistance(
itemDTO.sellable,
itemDTO.sellAccountId,
);
if (itemDTO.costAccountId) {
await this.validators.validateItemCostAccountExistance(
itemDTO.costAccountId,
);
}
// Validate the cost account id existance if the item is purchasable.
this.validators.validateCostAccountExistance(
itemDTO.purchasable,
itemDTO.costAccountId,
);
if (itemDTO.inventoryAccountId) {
await this.validators.validateItemInventoryAccountExistance(
itemDTO.inventoryAccountId,
);
}
if (itemDTO.purchaseTaxRateId) {
await this.validators.validatePurchaseTaxRateExistance(
itemDTO.purchaseTaxRateId,
);
}
if (itemDTO.sellTaxRateId) {
await this.validators.validateSellTaxRateExistance(itemDTO.sellTaxRateId);
}
}
/**
* Transforms the edit item DTO to model.
* @param {IItemDTO} itemDTO - Item DTO.
* @param {Item} oldItem - Old item.
* @return {Partial<Item>}
*/
private transformEditItemDTOToModel(
itemDTO: EditItemDto,
oldItem: Item,
): Partial<Item> {
return {
...itemDTO,
...(itemDTO.type === 'inventory' && oldItem.type !== 'inventory'
? {
quantityOnHand: 0,
}
: {}),
};
}
/**
* Edits the item metadata.
* @param {number} itemId - The item id.
* @param {IItemDTO} itemDTO - The item DTO.
* @return {Promise<number>} - The updated item id.
*/
public async editItem(
itemId: number,
itemDTO: EditItemDto,
trx?: Knex.Transaction,
): Promise<number> {
// Validates the given item existance on the storage.
const oldItem = await this.itemModel()
.query()
.findById(itemId)
.throwIfNotFound();
// Authorize before editing item.
await this.authorize(itemDTO, oldItem);
// Transform the edit item DTO to model.
const itemModel = this.transformEditItemDTOToModel(itemDTO, oldItem);
// Edits the item with associated transactions under unit-of-work environment.
return this.uow.withTransaction<number>(async (trx: Knex.Transaction) => {
// Updates the item on the storage and fetches the updated one.
const newItem = await this.itemModel()
.query(trx)
.patchAndFetchById(itemId, itemModel);
// Edit event payload.
const eventPayload: IItemEventEditedPayload = {
item: newItem,
oldItem,
itemId: newItem.id,
trx,
};
// Triggers `onItemEdited` event.
await this.eventEmitter.emitAsync(events.item.onEdited, eventPayload);
return newItem.id;
}, trx);
}
}

View File

@@ -0,0 +1,49 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Inject, Injectable } from '@nestjs/common';
import { Item } from './models/Item';
import { events } from '@/common/events/events';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { ItemTransformer } from './Item.transformer';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { ClsService } from 'nestjs-cls';
@Injectable()
export class GetItemService {
constructor(
@Inject(Item.name)
private readonly itemModel: TenantModelProxy<typeof Item>,
private readonly eventEmitter2: EventEmitter2,
private readonly transformerInjectable: TransformerInjectable,
private readonly clsService: ClsService,
) {}
/**
* Retrieve the item details of the given id with associated details.
* @param {number} tenantId - The tenant id.
* @param {number} itemId - The item id.
*/
public async getItem(itemId: number): Promise<any> {
const item = await this.itemModel()
.query()
.findById(itemId)
.withGraphFetched('sellAccount')
.withGraphFetched('inventoryAccount')
.withGraphFetched('category')
.withGraphFetched('costAccount')
.withGraphFetched('itemWarehouses.warehouse')
.withGraphFetched('sellTaxRate')
.withGraphFetched('purchaseTaxRate')
.throwIfNotFound();
const transformed = await this.transformerInjectable.transform(
item,
new ItemTransformer(),
);
const eventPayload = { itemId };
// Triggers the `onItemViewed` event.
await this.eventEmitter2.emitAsync(events.item.onViewed, eventPayload);
return transformed;
}
}

View File

@@ -0,0 +1,67 @@
import * as R from 'ramda';
import { Inject, Injectable } from '@nestjs/common';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { DynamicListService } from '../DynamicListing/DynamicList.service';
import { Item } from './models/Item';
import { IItemsFilter } from './types/Items.types';
import { ItemTransformer } from './Item.transformer';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class GetItemsService {
constructor(
private readonly dynamicListService: DynamicListService,
private readonly transformer: TransformerInjectable,
@Inject(Item.name)
private readonly itemModel: TenantModelProxy<typeof Item>,
) {}
/**
* Parses items list filter DTO.
* @param {} filterDTO - Filter DTO.
*/
private parseItemsListFilterDTO(filterDTO: IItemsFilter) {
return R.compose(
this.dynamicListService.parseStringifiedFilter<IItemsFilter>,
)(filterDTO);
}
/**
* Retrieves items datatable list.
* @param {IItemsFilter} itemsFilter - Items filter.
*/
public async getItems(filterDTO: IItemsFilter) {
// Parses items list filter DTO.
const filter = this.parseItemsListFilterDTO(filterDTO);
// Dynamic list service.
const dynamicFilter = await this.dynamicListService.dynamicList(
Item,
filter,
);
const { results: items, pagination } = await this.itemModel()
.query()
.onBuild((builder) => {
builder.modify('inactiveMode', filter.inactiveMode);
builder.withGraphFetched('inventoryAccount');
builder.withGraphFetched('sellAccount');
builder.withGraphFetched('costAccount');
builder.withGraphFetched('category');
dynamicFilter.buildQuery()(builder);
})
.pagination(filter.page - 1, filter.pageSize);
// Retrieves the transformed items.
const transformedItems = await this.transformer.transform(
items,
new ItemTransformer(),
);
return {
items: transformedItems,
pagination,
filterMeta: dynamicFilter.getResponseMeta(),
};
}
}

View File

@@ -0,0 +1,44 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Item } from './models/Item';
import { events } from '@/common/events/events';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class InactivateItem {
constructor(
@Inject(Item.name) private itemModel: TenantModelProxy<typeof Item>,
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
) {}
/**
* Inactivates the given item on the storage.
* @param {number} itemId
* @return {Promise<void>}
*/
public async inactivateItem(
itemId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieves the item or throw not found error.
const oldItem = await this.itemModel()
.query()
.findById(itemId)
.throwIfNotFound();
// Inactivate item under unit-of-work environment.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Inactivate item on the storage.
await this.itemModel()
.query(trx)
.findById(itemId)
.patch({ active: false });
// Triggers `onItemInactivated` event.
await this.eventEmitter.emitAsync(events.item.onInactivated, { trx });
}, trx);
}
}

View File

@@ -0,0 +1,335 @@
import {
Body,
Controller,
Delete,
Param,
Post,
UseGuards,
Patch,
Get,
Put,
Query,
} from '@nestjs/common';
import { TenantController } from '../Tenancy/Tenant.controller';
import { SubscriptionGuard } from '../Subscription/interceptors/Subscription.guard';
import { JwtAuthGuard } from '../Auth/guards/jwt.guard';
import { ItemsApplicationService } from './ItemsApplication.service';
import {
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { IItemsFilter } from './types/Items.types';
import { CreateItemDto, EditItemDto } from './dtos/Item.dto';
@Controller('/items')
@UseGuards(SubscriptionGuard)
@ApiTags('items')
export class ItemsController extends TenantController {
constructor(private readonly itemsApplication: ItemsApplicationService) {
super();
}
@Get()
@ApiOperation({ summary: 'Retrieves the item list.' })
@ApiResponse({
status: 200,
description: 'The item list has been successfully retrieved.',
})
@ApiQuery({
name: 'customViewId',
required: false,
type: Number,
description: 'Custom view ID for filtering',
})
@ApiQuery({
name: 'filterRoles',
required: false,
type: Array,
description: 'Array of filter roles',
})
@ApiQuery({
name: 'columnSortBy',
required: false,
type: String,
description: 'Column sort direction',
})
@ApiQuery({
name: 'sortOrder',
required: false,
type: String,
enum: ['DESC', 'ASC'],
description: 'Sort order direction',
})
@ApiQuery({
name: 'stringifiedFilterRoles',
required: false,
type: String,
description: 'Stringified filter roles',
})
@ApiQuery({
name: 'searchKeyword',
required: false,
type: String,
description: 'Search keyword',
})
@ApiQuery({
name: 'viewSlug',
required: false,
type: String,
description: 'View slug for filtering',
})
@ApiQuery({
name: 'page',
required: false,
type: Number,
description: 'Page number for pagination',
})
@ApiQuery({
name: 'pageSize',
required: false,
type: Number,
description: 'Number of items per page',
})
@ApiQuery({
name: 'inactiveMode',
required: false,
type: Boolean,
description: 'Filter for inactive items',
})
async getItems(@Query() filterDTO: IItemsFilter): Promise<any> {
return this.itemsApplication.getItems(filterDTO);
}
/**
* Edit item.
* @param id - The item id.
* @param editItemDto - The item DTO.
* @returns The updated item id.
*/
@Put(':id')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Edit the given item (product or service).' })
@ApiResponse({
status: 200,
description: 'The item has been successfully updated.',
})
@ApiResponse({ status: 404, description: 'The item not found.' })
async editItem(
@Param('id') id: string,
@Body() editItemDto: EditItemDto,
): Promise<number> {
const itemId = parseInt(id, 10);
return this.itemsApplication.editItem(itemId, editItemDto);
}
/**
* Create item.
* @param createItemDto - The item DTO.
* @returns The created item id.
*/
@Post()
@ApiOperation({ summary: 'Create a new item (product or service).' })
@ApiResponse({
status: 200,
description: 'The item has been successfully created.',
})
// @UsePipes(new ZodValidationPipe(createItemSchema))
async createItem(@Body() createItemDto: CreateItemDto): Promise<number> {
return this.itemsApplication.createItem(createItemDto);
}
/**
* Delete item.
* @param id - The item id.
*/
@Delete(':id')
@ApiOperation({ summary: 'Delete the given item (product or service).' })
@ApiResponse({
status: 200,
description: 'The item has been successfully deleted.',
})
@ApiResponse({ status: 404, description: 'The item not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The item id',
})
async deleteItem(@Param('id') id: string): Promise<void> {
const itemId = parseInt(id, 10);
return this.itemsApplication.deleteItem(itemId);
}
/**
* Inactivate item.
* @param id - The item id.
*/
@Patch(':id/inactivate')
@ApiOperation({ summary: 'Inactivate the given item (product or service).' })
@ApiResponse({
status: 200,
description: 'The item has been successfully inactivated.',
})
@ApiResponse({ status: 404, description: 'The item not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The item id',
})
async inactivateItem(@Param('id') id: string): Promise<void> {
const itemId = parseInt(id, 10);
return this.itemsApplication.inactivateItem(itemId);
}
/**
* Activate item.
* @param id - The item id.
*/
@Patch(':id/activate')
@ApiOperation({ summary: 'Activate the given item (product or service).' })
@ApiResponse({
status: 200,
description: 'The item has been successfully activated.',
})
@ApiResponse({ status: 404, description: 'The item not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The item id',
})
async activateItem(@Param('id') id: string): Promise<void> {
const itemId = parseInt(id, 10);
return this.itemsApplication.activateItem(itemId);
}
/**
* Get item.
* @param id - The item id.
*/
@Get(':id')
@ApiOperation({ summary: 'Get the given item (product or service).' })
@ApiResponse({
status: 200,
description: 'The item has been successfully retrieved.',
})
@ApiResponse({ status: 404, description: 'The item not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The item id',
})
async getItem(@Param('id') id: string): Promise<any> {
const itemId = parseInt(id, 10);
return this.itemsApplication.getItem(itemId);
}
/**
* Retrieves the item associated invoices transactions.
* @param {string} id
* @returns {Promise<any>}
*/
@Get(':id/invoices')
@ApiOperation({
summary: 'Retrieves the item associated invoices transactions.',
})
@ApiResponse({
status: 200,
description:
'The item associated invoices transactions have been successfully retrieved.',
})
@ApiResponse({ status: 404, description: 'The item not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The item id',
})
async getItemInvoicesTransactions(@Param('id') id: string): Promise<any> {
const itemId = parseInt(id, 10);
return this.itemsApplication.getItemInvoicesTransactions(itemId);
}
/**
* Retrieves the item associated bills transactions.
* @param {string} id
* @returns {Promise<any>}
*/
@Get(':id/bills')
@ApiOperation({
summary: 'Retrieves the item associated bills transactions.',
})
@ApiResponse({
status: 200,
description:
'The item associated bills transactions have been successfully retrieved.',
})
@ApiResponse({ status: 404, description: 'The item not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The item id',
})
async getItemBillTransactions(@Param('id') id: string): Promise<any> {
const itemId = parseInt(id, 10);
return this.itemsApplication.getItemBillTransactions(itemId);
}
/**
* Retrieves the item associated estimates transactions.
* @param {string} id
* @returns {Promise<any>}
*/
@Get(':id/estimates')
@ApiOperation({
summary: 'Retrieves the item associated estimates transactions.',
})
@ApiResponse({
status: 200,
description:
'The item associated estimate transactions have been successfully retrieved.',
})
@ApiResponse({ status: 404, description: 'The item not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The item id',
})
async getItemEstimatesTransactions(@Param('id') id: string): Promise<any> {
const itemId = parseInt(id, 10);
return this.itemsApplication.getItemEstimatesTransactions(itemId);
}
/**
* Retrieves the item associated receipts transactions.
* @param {string} id
* @returns {Promise<any>}
*/
@Get(':id/receipts')
@ApiOperation({
summary: 'Retrieves the item associated receipts transactions.',
})
@ApiResponse({
status: 200,
description:
'The item associated receipts transactions have been successfully retrieved.',
})
@ApiResponse({ status: 404, description: 'The item not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The item id',
})
async getItemReceiptTransactions(@Param('id') id: string): Promise<any> {
const itemId = parseInt(id, 10);
return this.itemsApplication.getItemReceiptsTransactions(itemId);
}
}

View File

@@ -0,0 +1,112 @@
import { DATATYPES_LENGTH } from '@/constants/data-types';
import z from 'zod';
export const createItemSchema = z
.object({
name: z.string().max(DATATYPES_LENGTH.STRING),
type: z.enum(['service', 'non-inventory', 'inventory']),
code: z.string().max(DATATYPES_LENGTH.STRING).nullable().optional(),
purchasable: z.boolean().optional(),
cost_price: z
.number()
.min(0)
.max(DATATYPES_LENGTH.DECIMAL_13_3)
.nullable()
.optional(),
cost_account_id: z
.number()
.int()
.min(0)
.max(DATATYPES_LENGTH.INT_10)
.nullable()
.optional(),
sellable: z.boolean().optional(),
sell_price: z
.number()
.min(0)
.max(DATATYPES_LENGTH.DECIMAL_13_3)
.nullable()
.optional(),
sell_account_id: z
.number()
.int()
.min(0)
.max(DATATYPES_LENGTH.INT_10)
.nullable()
.optional(),
inventory_account_id: z
.number()
.int()
.min(0)
.max(DATATYPES_LENGTH.INT_10)
.nullable()
.optional(),
sell_description: z
.string()
.max(DATATYPES_LENGTH.TEXT)
.nullable()
.optional(),
purchase_description: z
.string()
.max(DATATYPES_LENGTH.TEXT)
.nullable()
.optional(),
sell_tax_rate_id: z.number().int().nullable().optional(),
purchase_tax_rate_id: z.number().int().nullable().optional(),
category_id: z
.number()
.int()
.min(0)
.max(DATATYPES_LENGTH.INT_10)
.nullable()
.optional(),
note: z.string().max(DATATYPES_LENGTH.TEXT).optional(),
active: z.boolean().optional(),
media_ids: z.array(z.number().int()).optional(),
})
.refine(
(data) => {
if (data.purchasable) {
return (
data.cost_price !== undefined && data.cost_account_id !== undefined
);
}
return true;
},
{
message:
'Cost price and cost account ID are required when item is purchasable',
path: ['cost_price', 'cost_account_id'],
},
)
.refine(
(data) => {
if (data.sellable) {
return (
data.sell_price !== undefined && data.sell_account_id !== undefined
);
}
return true;
},
{
message:
'Sell price and sell account ID are required when item is sellable',
path: ['sell_price', 'sell_account_id'],
},
)
.refine(
(data) => {
if (data.type === 'inventory') {
return data.inventory_account_id !== undefined;
}
return true;
},
{
message: 'Inventory account ID is required for inventory items',
path: ['inventory_account_id'],
},
);
export type createItemDTO = z.infer<typeof createItemSchema>;
export type editItemDTOSchema = z.infer<typeof createItemSchema>;

View File

@@ -0,0 +1,62 @@
import { Transformer } from '../Transformer/Transformer';
import { Item } from './models/Item';
// import { GetItemWarehouseTransformer } from '@/services/Warehouses/Items/GettItemWarehouseTransformer';
export class ItemTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'typeFormatted',
'sellPriceFormatted',
'costPriceFormatted',
'itemWarehouses',
];
};
/**
* Formatted item type.
* @param {IItem} item
* @returns {string}
*/
public typeFormatted(item: Item): string {
return this.context.i18n.t(`item.field.type.${item.type}`);
}
/**
* Formatted sell price.
* @param item
* @returns {string}
*/
public sellPriceFormatted(item: Item): string {
return this.formatNumber(item.sellPrice, {
currencyCode: this.context.organization.baseCurrency,
});
}
/**
* Formatted cost price.
* @param item
* @returns {string}
*/
public costPriceFormatted(item: Item): string {
return this.formatNumber(item.costPrice, {
currencyCode: this.context.organization.baseCurrency,
});
}
/**
* Associate the item warehouses quantity.
* @param item
* @returns
*/
// public itemWarehouses = (item) => {
// return this.item(
// item.itemWarehouses,
// new GetItemWarehouseTransformer(),
// {},
// );
// };
}

View File

@@ -0,0 +1,96 @@
import { Transformer } from '../Transformer/Transformer';
export class ItemBillTransactionTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'formattedAmount',
'formattedBillDate',
'formattedRate',
'formattedCost',
];
};
/**
* Formatted sell price.
* @param item
* @returns {string}
*/
public formattedAmount(item): string {
return this.formatNumber(item.amount, {
currencyCode: item.bill.currencyCode,
});
}
/**
* Formatted bill date.
* @param item
* @returns {string}
*/
public formattedBillDate = (entry): string => {
return this.formatDate(entry.bill.billDate);
};
/**
* Formatted quantity.
* @returns {string}
*/
public formattedQuantity = (entry): string => {
return entry.quantity;
};
/**
* Formatted rate.
* @param entry
* @returns {string}
*/
public formattedRate = (entry): string => {
return this.formatNumber(entry.rate, {
currencyCode: entry.bill.currencyCode,
});
};
/**
* Formatted bill due date.
* @param entry
* @returns
*/
public formattedBillDueDate = (entry): string => {
return this.formatDate(entry.bill.dueDate);
};
/**
*
* @param entry
* @returns
*/
public transform = (entry) => {
return {
billId: entry.bill.id,
billNumber: entry.bill.billNumber,
referenceNumber: entry.bill.referenceNo,
billDate: entry.bill.billDate,
formattedBillDate: entry.formattedBillDate,
billDueDate: entry.bill.dueDate,
formattedBillDueDate: entry.formattedBillDueDate,
amount: entry.amount,
formattedAmount: entry.formattedAmount,
quantity: entry.quantity,
formattedQuantity: entry.formattedQuantity,
rate: entry.rate,
formattedRate: entry.formattedRate,
vendorDisplayName: entry.bill.vendor.displayName,
vendorCurrencyCode: entry.bill.vendor.currencyCode,
};
};
}

View File

@@ -0,0 +1,81 @@
import { Transformer } from '../Transformer/Transformer';
export class ItemEstimateTransactionTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'formattedAmount',
'formattedEstimateDate',
'formattedRate',
'formattedCost',
];
};
/**
* Formatted sell price.
* @returns {string}
*/
public formattedAmount(item): string {
return this.formatNumber(item.amount, {
currencyCode: item.estimate.currencyCode,
});
}
/**
* Formatted estimate date.
* @returns {string}
*/
public formattedEstimateDate = (entry): string => {
return this.formatDate(entry.estimate.estimateDate);
};
/**
* Formatted quantity.
* @returns {string}
*/
public formattedQuantity = (entry): string => {
return entry.quantity;
};
/**
* Formatted rate.
* @returns {string}
*/
public formattedRate = (entry): string => {
return this.formatNumber(entry.rate, {
currencyCode: entry.estimate.currencyCode,
});
};
/**
* Transform the entry.
* @param {any} entry
* @returns {any}
*/
public transform = (entry) => {
return {
estimateId: entry.estimate.id,
estimateNumber: entry.estimate.estimateNumber,
referenceNumber: entry.estimate.referenceNo,
estimateDate: entry.estimate.estimateDate,
formattedEstimateDate: entry.formattedEstimateDate,
amount: entry.amount,
formattedAmount: entry.formattedAmount,
quantity: entry.quantity,
formattedQuantity: entry.formattedQuantity,
rate: entry.rate,
formattedRate: entry.formattedRate,
customerDisplayName: entry.estimate.customer.displayName,
customerCurrencyCode: entry.estimate.customer.currencyCode,
};
};
}

View File

@@ -0,0 +1,84 @@
import { Transformer } from '../Transformer/Transformer';
export class ItemInvoicesTransactionsTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'formattedAmount',
'formattedInvoiceDate',
'formattedRate',
'formattedCost',
];
};
/**
* Formatted sell price.
* @param item
* @returns {string}
*/
public formattedAmount(item): string {
return this.formatNumber(item.amount, {
currencyCode: item.invoice.currencyCode,
});
}
/**
* Formatted invoice date.
* @param item
* @returns
*/
public formattedInvoiceDate = (entry): string => {
return this.formatDate(entry.invoice.invoiceDate);
};
/**
* Formatted item quantity.
* @returns {string}
*/
public formattedQuantity = (entry): string => {
return entry.quantity;
};
/**
* Formatted date.
* @param entry
* @returns {string}
*/
public formattedRate = (entry): string => {
return this.formatNumber(entry.rate, {
currencyCode: entry.invoice.currencyCode,
});
};
/**
*
* @param {any} entry
* @returns {any}
*/
public transform = (entry) => {
return {
invoiceId: entry.invoice.id,
invoiceNumber: entry.invoice.invoiceNo,
referenceNumber: entry.invoice.referenceNo,
invoiceDate: entry.invoice.invoiceDate,
formattedInvoiceDate: entry.formattedInvoiceDate,
amount: entry.amount,
formattedAmount: entry.formattedAmount,
quantity: entry.quantity,
formattedQuantity: entry.formattedQuantity,
rate: entry.rate,
formattedRate: entry.formattedRate,
customerDisplayName: entry.invoice.customer.displayName,
customerCurrencyCode: entry.invoice.customer.currencyCode,
};
};
}

View File

@@ -0,0 +1,85 @@
import { Transformer } from '../Transformer/Transformer';
export class ItemReceiptTransactionTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'formattedAmount',
'formattedReceiptDate',
'formattedRate',
'formattedCost',
];
};
/**
* Formatted sell price.
* @param item
* @returns {string}
*/
public formattedAmount(item): string {
return this.formatNumber(item.amount, {
currencyCode: item.receipt.currencyCode,
});
}
/**
* Formatted receipt date.
* @param {any} entry
* @returns {string}
*/
public formattedReceiptDate = (entry): string => {
return this.formatDate(entry.receipt.receiptDate);
};
/**
* Formatted quantity.
* @param {any} entry
* @returns {string}
*/
public formattedQuantity = (entry): string => {
return entry.quantity;
};
/**
* Formatted rate.
* @param {any} entry
* @returns {string}
*/
public formattedRate = (entry): string => {
return this.formatNumber(entry.rate, {
currencyCode: entry.receipt.currencyCode,
});
};
/**
* Transform the entry.
* @param {any} entry
* @returns {any}
*/
public transform = (entry) => {
return {
receiptId: entry.receipt.id,
receipNumber: entry.receipt.receiptNumber,
referenceNumber: entry.receipt.referenceNo,
receiptDate: entry.receipt.receiptDate,
formattedReceiptDate: entry.formattedReceiptDate,
amount: entry.amount,
formattedAmount: entry.formattedAmount,
quantity: entry.quantity,
formattedQuantity: entry.formattedQuantity,
rate: entry.rate,
formattedRate: entry.formattedRate,
customerDisplayName: entry.receipt.customer.displayName,
customerCurrencyCode: entry.receipt.customer.currencyCode,
};
};
}

View File

@@ -0,0 +1,113 @@
import { Inject, Injectable } from '@nestjs/common';
import { ItemInvoicesTransactionsTransformer } from './ItemInvoicesTransactions.transformer';
import { ItemEstimateTransactionTransformer } from './ItemEstimatesTransaction.transformer';
import { ItemBillTransactionTransformer } from './ItemBillsTransactions.transformer';
import { ItemReceiptTransactionTransformer } from './ItemReceiptsTransactions.transformer';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { ItemEntry } from '../TransactionItemEntry/models/ItemEntry';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class ItemTransactionsService {
constructor(
private transformer: TransformerInjectable,
@Inject(ItemEntry.name)
private readonly itemEntry: TenantModelProxy<typeof ItemEntry>,
) {}
/**
* Retrieves the item associated invoices transactions.
* @param {number} itemId -
*/
public async getItemInvoicesTransactions(itemId: number) {
const invoiceEntries = await this.itemEntry()
.query()
.where('itemId', itemId)
.where('referenceType', 'SaleInvoice')
.withGraphJoined('invoice.customer(selectCustomerColumns)')
.orderBy('invoice:invoiceDate', 'ASC')
.modifiers({
selectCustomerColumns: (builder) => {
builder.select('displayName', 'currencyCode', 'id');
},
});
// Retrieves the transformed invoice entries.
return this.transformer.transform(
invoiceEntries,
new ItemInvoicesTransactionsTransformer(),
);
}
/**
* Retrieve the item associated invoices transactions.
* @param {number} itemId
* @returns
*/
public async getItemBillTransactions(itemId: number) {
const billEntries = await this.itemEntry()
.query()
.where('itemId', itemId)
.where('referenceType', 'Bill')
.withGraphJoined('bill.vendor(selectVendorColumns)')
.orderBy('bill:billDate', 'ASC')
.modifiers({
selectVendorColumns: (builder) => {
builder.select('displayName', 'currencyCode', 'id');
},
});
// Retrieves the transformed bill entries.
return this.transformer.transform(
billEntries,
new ItemBillTransactionTransformer(),
);
}
/**
* Retrieves the item associated estimates transactions.
* @param {number} itemId
* @returns
*/
public async getItemEstimateTransactions(itemId: number) {
const estimatesEntries = await this.itemEntry()
.query()
.where('itemId', itemId)
.where('referenceType', 'SaleEstimate')
.withGraphJoined('estimate.customer(selectCustomerColumns)')
.orderBy('estimate:estimateDate', 'ASC')
.modifiers({
selectCustomerColumns: (builder) => {
builder.select('displayName', 'currencyCode', 'id');
},
});
// Retrieves the transformed estimates entries.
return this.transformer.transform(
estimatesEntries,
new ItemEstimateTransactionTransformer(),
);
}
/**
* Retrieves the item associated receipts transactions.
* @param {number} itemId
* @returns
*/
public async getItemReceiptTransactions(itemId: number) {
const receiptsEntries = await this.itemEntry()
.query()
.where('itemId', itemId)
.where('referenceType', 'SaleReceipt')
.withGraphJoined('receipt.customer(selectCustomerColumns)')
.orderBy('receipt:receiptDate', 'ASC')
.modifiers({
selectCustomerColumns: (builder) => {
builder.select('displayName', 'currencyCode', 'id');
},
});
// Retrieves the transformed receipts entries.
return this.transformer.transform(
receiptsEntries,
new ItemReceiptTransactionTransformer(),
);
}
}

View File

@@ -0,0 +1,321 @@
import { Inject, Injectable } from '@nestjs/common';
import {
ACCOUNT_PARENT_TYPE,
ACCOUNT_ROOT_TYPE,
ACCOUNT_TYPE,
} from '@/constants/accounts';
import { ServiceError } from './ServiceError';
import { ERRORS } from './Items.constants';
import { Item } from './models/Item';
import { Account } from '../Accounts/models/Account.model';
import { TaxRateModel } from '../TaxRates/models/TaxRate.model';
import { ItemEntry } from '../TransactionItemEntry/models/ItemEntry';
import { ItemCategory } from '../ItemCategories/models/ItemCategory.model';
import { AccountTransaction } from '../Accounts/models/AccountTransaction.model';
import { InventoryAdjustment } from '../InventoryAdjutments/models/InventoryAdjustment';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { CreateItemDto, EditItemDto } from './dtos/Item.dto';
@Injectable()
export class ItemsValidators {
/**
* @param {typeof Item} itemModel - The Item model.
* @param {typeof Account} accountModel - The Account model.
* @param {typeof TaxRateModel} taxRateModel - The TaxRateModel model.
* @param {typeof ItemEntry} itemEntryModel - The ItemEntry model.
* @param {typeof ItemCategory} itemCategoryModel - The ItemCategory model.
* @param {typeof AccountTransaction} accountTransactionModel - The AccountTransaction model.
* @param {typeof InventoryAdjustment} inventoryAdjustmentEntryModel - The InventoryAdjustment model.
*/
constructor(
@Inject(Item.name) private itemModel: TenantModelProxy<typeof Item>,
@Inject(Account.name)
private accountModel: TenantModelProxy<typeof Account>,
@Inject(TaxRateModel.name)
private taxRateModel: TenantModelProxy<typeof TaxRateModel>,
@Inject(ItemEntry.name)
private itemEntryModel: TenantModelProxy<typeof ItemEntry>,
@Inject(ItemCategory.name)
private itemCategoryModel: TenantModelProxy<typeof ItemCategory>,
@Inject(AccountTransaction.name)
private accountTransactionModel: TenantModelProxy<
typeof AccountTransaction
>,
@Inject(InventoryAdjustment.name)
private inventoryAdjustmentEntryModel: TenantModelProxy<
typeof InventoryAdjustment
>,
) {}
/**
* Validate wether the given item name already exists on the storage.
* @param {string} itemName
* @param {number} notItemId
* @return {Promise<void>}
*/
public async validateItemNameUniquiness(
itemName: string,
notItemId?: number,
): Promise<void> {
const foundItems = await this.itemModel()
.query()
.onBuild((builder: any) => {
builder.where('name', itemName);
if (notItemId) {
builder.whereNot('id', notItemId);
}
});
if (foundItems.length > 0) {
throw new ServiceError(
ERRORS.ITEM_NAME_EXISTS,
'The item name is already exist.',
);
}
}
/**
* Validate item COGS account existance and type.
* @param {number} costAccountId
* @return {Promise<void>}
*/
public async validateItemCostAccountExistance(
costAccountId: number,
): Promise<void> {
const foundAccount = await this.accountModel()
.query()
.findById(costAccountId);
if (!foundAccount) {
throw new ServiceError(ERRORS.COST_ACCOUNT_NOT_FOUMD);
// Detarmines the cost of goods sold account.
} else if (!foundAccount.isParentType(ACCOUNT_PARENT_TYPE.EXPENSE)) {
throw new ServiceError(ERRORS.COST_ACCOUNT_NOT_COGS);
}
}
/**
* Validate item sell account existance and type.
* @param {number} sellAccountId - Sell account id.
*/
public async validateItemSellAccountExistance(sellAccountId: number) {
const foundAccount = await this.accountModel()
.query()
.findById(sellAccountId);
if (!foundAccount) {
throw new ServiceError(ERRORS.SELL_ACCOUNT_NOT_FOUND);
} else if (!foundAccount.isParentType(ACCOUNT_ROOT_TYPE.INCOME)) {
throw new ServiceError(ERRORS.SELL_ACCOUNT_NOT_INCOME);
}
}
/**
* Validates income account existance.
* @param {number|null} sellable - Detarmines if the item sellable.
* @param {number|null} incomeAccountId - Income account id.
* @throws {ServiceError(ERRORS.INCOME_ACCOUNT_REQUIRED_WITH_SELLABLE_ITEM)}
*/
public validateIncomeAccountExistance(
sellable?: boolean,
incomeAccountId?: number,
) {
if (sellable && !incomeAccountId) {
throw new ServiceError(
ERRORS.INCOME_ACCOUNT_REQUIRED_WITH_SELLABLE_ITEM,
'Income account is require with sellable item.',
);
}
}
/**
* Validates the cost account existance.
* @param {boolean|null} purchasable - Detarmines if the item purchasble.
* @param {number|null} costAccountId - Cost account id.
* @throws {ServiceError(ERRORS.COST_ACCOUNT_REQUIRED_WITH_PURCHASABLE_ITEM)}
*/
public validateCostAccountExistance(
purchasable: boolean,
costAccountId?: number,
) {
if (purchasable && !costAccountId) {
throw new ServiceError(
ERRORS.COST_ACCOUNT_REQUIRED_WITH_PURCHASABLE_ITEM,
'The cost account is required with purchasable item.',
);
}
}
/**
* Validate item inventory account existance and type.
* @param {number} inventoryAccountId
*/
public async validateItemInventoryAccountExistance(
inventoryAccountId: number,
) {
const foundAccount = await this.accountModel()
.query()
.findById(inventoryAccountId);
if (!foundAccount) {
throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_NOT_FOUND);
} else if (!foundAccount.isAccountType(ACCOUNT_TYPE.INVENTORY)) {
throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_NOT_INVENTORY);
}
}
/**
* Validate item category existance.
* @param {number} itemCategoryId
*/
public async validateItemCategoryExistance(itemCategoryId: number) {
const foundCategory = await this.itemCategoryModel()
.query()
.findById(itemCategoryId);
if (!foundCategory) {
throw new ServiceError(ERRORS.ITEM_CATEOGRY_NOT_FOUND);
}
}
/**
* Validates the given item or items have no associated invoices or bills.
* @param {number|number[]} itemId - Item id.
* @throws {ServiceError}
*/
public async validateHasNoInvoicesOrBills(itemId: number[] | number) {
const ids = Array.isArray(itemId) ? itemId : [itemId];
const foundItemEntries = await this.itemEntryModel()
.query()
.whereIn('item_id', ids);
if (foundItemEntries.length > 0) {
throw new ServiceError(
ids.length > 1
? ERRORS.ITEMS_HAVE_ASSOCIATED_TRANSACTIONS
: ERRORS.ITEM_HAS_ASSOCIATED_TRANSACTINS,
);
}
}
/**
* Validates the given item has no associated inventory adjustment transactions.
* @param {number} itemId -
*/
public async validateHasNoInventoryAdjustments(
itemId: number[] | number,
): Promise<void> {
const itemsIds = Array.isArray(itemId) ? itemId : [itemId];
const inventoryAdjEntries = await this.inventoryAdjustmentEntryModel()
.query()
.whereIn('item_id', itemsIds);
if (inventoryAdjEntries.length > 0) {
throw new ServiceError(ERRORS.ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT);
}
}
/**
* Validates edit item type from service/non-inventory to inventory.
* Should item has no any relations with accounts transactions.
* @param {number} itemId - Item id.
*/
public async validateEditItemTypeToInventory(
oldItem: Item,
newItemDTO: CreateItemDto | EditItemDto,
) {
// We have no problem in case the item type not modified.
if (newItemDTO.type === oldItem.type || oldItem.type === 'inventory') {
return;
}
// Retrieve all transactions that associated to the given item id.
const itemTransactionsCount = await this.accountTransactionModel()
.query()
.where('item_id', oldItem.id)
.count('item_id', { as: 'transactions' })
.first();
// @ts-ignore
if (itemTransactionsCount.transactions > 0) {
throw new ServiceError(
ERRORS.TYPE_CANNOT_CHANGE_WITH_ITEM_HAS_TRANSACTIONS,
);
}
}
/**
* Validate the item inventory account whether modified and item
* has associated inventory transactions.
* @param {Item} oldItem - Old item.
* @param {CreateItemDto | EditItemDto} newItemDTO - New item DTO.
* @returns {Promise<void>}
*/
async validateItemInvnetoryAccountModified(
oldItem: Item,
newItemDTO: CreateItemDto | EditItemDto,
) {
if (
newItemDTO.type !== 'inventory' ||
oldItem.inventoryAccountId === newItemDTO.inventoryAccountId
) {
return;
}
// Inventory transactions associated to the given item id.
const transactions = await this.accountTransactionModel().query().where({
itemId: oldItem.id,
});
// Throw the service error in case item has associated inventory transactions.
if (transactions.length > 0) {
throw new ServiceError(ERRORS.INVENTORY_ACCOUNT_CANNOT_MODIFIED);
}
}
/**
* Validate edit item type from inventory to another type that not allowed.
* @param {CreateItemDto | EditItemDto} itemDTO - Item DTO.
* @param {IItem} oldItem - Old item.
*/
public validateEditItemFromInventory(
itemDTO: CreateItemDto | EditItemDto,
oldItem: Item,
) {
if (
itemDTO.type &&
oldItem.type === 'inventory' &&
itemDTO.type !== oldItem.type
) {
throw new ServiceError(ERRORS.ITEM_CANNOT_CHANGE_INVENTORY_TYPE);
}
}
/**
* Validate the purchase tax rate id existance.
* @param {number} taxRateId - Tax rate id.
*/
public async validatePurchaseTaxRateExistance(taxRateId: number) {
const foundTaxRate = await this.taxRateModel().query().findById(taxRateId);
if (!foundTaxRate) {
throw new ServiceError(ERRORS.PURCHASE_TAX_RATE_NOT_FOUND);
}
}
/**
* Validate the sell tax rate id existance.
* @param {number} taxRateId - Tax rate id.
*/
public async validateSellTaxRateExistance(taxRateId: number) {
const foundTaxRate = await this.taxRateModel().query().findById(taxRateId);
if (!foundTaxRate) {
throw new ServiceError(ERRORS.SELL_TAX_RATE_NOT_FOUND);
}
}
}

View File

@@ -0,0 +1,141 @@
export const ERRORS = {
NOT_FOUND: 'NOT_FOUND',
ITEMS_NOT_FOUND: 'ITEMS_NOT_FOUND',
ITEM_NAME_EXISTS: 'ITEM_NAME_EXISTS',
ITEM_CATEOGRY_NOT_FOUND: 'ITEM_CATEOGRY_NOT_FOUND',
COST_ACCOUNT_NOT_COGS: 'COST_ACCOUNT_NOT_COGS',
COST_ACCOUNT_NOT_FOUMD: 'COST_ACCOUNT_NOT_FOUMD',
SELL_ACCOUNT_NOT_FOUND: 'SELL_ACCOUNT_NOT_FOUND',
SELL_ACCOUNT_NOT_INCOME: 'SELL_ACCOUNT_NOT_INCOME',
INVENTORY_ACCOUNT_NOT_FOUND: 'INVENTORY_ACCOUNT_NOT_FOUND',
INVENTORY_ACCOUNT_NOT_INVENTORY: 'INVENTORY_ACCOUNT_NOT_INVENTORY',
ITEMS_HAVE_ASSOCIATED_TRANSACTIONS: 'ITEMS_HAVE_ASSOCIATED_TRANSACTIONS',
ITEM_HAS_ASSOCIATED_TRANSACTINS: 'ITEM_HAS_ASSOCIATED_TRANSACTINS',
ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT:
'ITEM_HAS_ASSOCIATED_INVENTORY_ADJUSTMENT',
ITEM_CANNOT_CHANGE_INVENTORY_TYPE: 'ITEM_CANNOT_CHANGE_INVENTORY_TYPE',
TYPE_CANNOT_CHANGE_WITH_ITEM_HAS_TRANSACTIONS:
'TYPE_CANNOT_CHANGE_WITH_ITEM_HAS_TRANSACTIONS',
INVENTORY_ACCOUNT_CANNOT_MODIFIED: 'INVENTORY_ACCOUNT_CANNOT_MODIFIED',
ITEM_HAS_ASSOCIATED_TRANSACTIONS: 'ITEM_HAS_ASSOCIATED_TRANSACTIONS',
PURCHASE_TAX_RATE_NOT_FOUND: 'PURCHASE_TAX_RATE_NOT_FOUND',
SELL_TAX_RATE_NOT_FOUND: 'SELL_TAX_RATE_NOT_FOUND',
INCOME_ACCOUNT_REQUIRED_WITH_SELLABLE_ITEM:
'INCOME_ACCOUNT_REQUIRED_WITH_SELLABLE_ITEM',
COST_ACCOUNT_REQUIRED_WITH_PURCHASABLE_ITEM:
'COST_ACCOUNT_REQUIRED_WITH_PURCHASABLE_ITEM',
};
export const DEFAULT_VIEW_COLUMNS = [];
export const DEFAULT_VIEWS = [
{
name: 'Services',
slug: 'services',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'type', comparator: 'equals', value: 'service' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Inventory',
slug: 'inventory',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'type', comparator: 'equals', value: 'inventory' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Non Inventory',
slug: 'non-inventory',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'type',
comparator: 'equals',
value: 'non-inventory',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
];
export const ItemsSampleData = [
{
'Item Type': 'Inventory',
'Item Name': 'Hettinger, Schumm and Bartoletti',
'Item Code': '1000',
Sellable: 'T',
Purchasable: 'T',
'Cost Price': '10000',
'Sell Price': '1000',
'Cost Account': 'Cost of Goods Sold',
'Sell Account': 'Other Income',
'Inventory Account': 'Inventory Asset',
'Sell Description': 'Description ....',
'Purchase Description': 'Description ....',
Category: 'sdafasdfsadf',
Note: 'At dolor est non tempore et quisquam.',
Active: 'TRUE',
},
{
'Item Type': 'Inventory',
'Item Name': 'Schmitt Group',
'Item Code': '1001',
Sellable: 'T',
Purchasable: 'T',
'Cost Price': '10000',
'Sell Price': '1000',
'Cost Account': 'Cost of Goods Sold',
'Sell Account': 'Other Income',
'Inventory Account': 'Inventory Asset',
'Sell Description': 'Description ....',
'Purchase Description': 'Description ....',
Category: 'sdafasdfsadf',
Note: 'Id perspiciatis at adipisci minus accusamus dolor iure dolore.',
Active: 'TRUE',
},
{
'Item Type': 'Inventory',
'Item Name': 'Marks - Carroll',
'Item Code': '1002',
Sellable: 'T',
Purchasable: 'T',
'Cost Price': '10000',
'Sell Price': '1000',
'Cost Account': 'Cost of Goods Sold',
'Sell Account': 'Other Income',
'Inventory Account': 'Inventory Asset',
'Sell Description': 'Description ....',
'Purchase Description': 'Description ....',
Category: 'sdafasdfsadf',
Note: 'Odio odio minus similique.',
Active: 'TRUE',
},
{
'Item Type': 'Inventory',
'Item Name': 'VonRueden, Ruecker and Hettinger',
'Item Code': '1003',
Sellable: 'T',
Purchasable: 'T',
'Cost Price': '10000',
'Sell Price': '1000',
'Cost Account': 'Cost of Goods Sold',
'Sell Account': 'Other Income',
'Inventory Account': 'Inventory Asset',
'Sell Description': 'Description ....',
'Purchase Description': 'Description ....',
Category: 'sdafasdfsadf',
Note: 'Quibusdam dolores illo.',
Active: 'TRUE',
},
];

View File

@@ -0,0 +1,44 @@
import { Module } from '@nestjs/common';
import { ItemsController } from './Item.controller';
import { CreateItemService } from './CreateItem.service';
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
import { ItemsValidators } from './ItemValidator.service';
import { DeleteItemService } from './DeleteItem.service';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { EditItemService } from './EditItem.service';
import { InactivateItem } from './InactivateItem.service';
import { ActivateItemService } from './ActivateItem.service';
import { ItemsApplicationService } from './ItemsApplication.service';
import { ItemTransactionsService } from './ItemTransactions.service';
import { GetItemService } from './GetItem.service';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { ItemsEntriesService } from './ItemsEntries.service';
import { GetItemsService } from './GetItems.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { InventoryAdjustmentsModule } from '../InventoryAdjutments/InventoryAdjustments.module';
@Module({
imports: [
TenancyDatabaseModule,
DynamicListModule,
InventoryAdjustmentsModule,
],
controllers: [ItemsController],
providers: [
ItemsValidators,
CreateItemService,
EditItemService,
InactivateItem,
ActivateItemService,
DeleteItemService,
ItemsApplicationService,
GetItemService,
GetItemsService,
ItemTransactionsService,
TenancyContext,
TransformerInjectable,
ItemsEntriesService,
],
exports: [ItemsEntriesService],
})
export class ItemsModule {}

View File

@@ -0,0 +1,136 @@
import { Item } from './models/Item';
import { CreateItemService } from './CreateItem.service';
import { DeleteItemService } from './DeleteItem.service';
import { EditItemService } from './EditItem.service';
import { IItem, IItemDTO } from '@/interfaces/Item';
import { Knex } from 'knex';
import { InactivateItem } from './InactivateItem.service';
import { ActivateItemService } from './ActivateItem.service';
import { GetItemService } from './GetItem.service';
import { ItemTransactionsService } from './ItemTransactions.service';
import { Injectable } from '@nestjs/common';
import { GetItemsService } from './GetItems.service';
import { IItemsFilter } from './types/Items.types';
import { EditItemDto, CreateItemDto } from './dtos/Item.dto';
@Injectable()
export class ItemsApplicationService {
constructor(
private readonly createItemService: CreateItemService,
private readonly editItemService: EditItemService,
private readonly deleteItemService: DeleteItemService,
private readonly activateItemService: ActivateItemService,
private readonly inactivateItemService: InactivateItem,
private readonly getItemService: GetItemService,
private readonly getItemsService: GetItemsService,
private readonly itemTransactionsService: ItemTransactionsService,
) {}
/**
* Creates a new item.
* @param {IItemDTO} createItemDto - The item DTO.
* @param {Knex.Transaction} [trx] - The transaction.
* @return {Promise<number>} - The created item id.
*/
async createItem(
createItemDto: CreateItemDto,
trx?: Knex.Transaction,
): Promise<number> {
return this.createItemService.createItem(createItemDto);
}
/**
* Edits an existing item.
* @param {number} itemId - The item id.
* @param {IItemDTO} editItemDto - The item DTO.
* @param {Knex.Transaction} [trx] - The transaction.
* @return {Promise<number>} - The updated item id.
*/
async editItem(
itemId: number,
editItemDto: EditItemDto,
trx?: Knex.Transaction,
): Promise<number> {
return this.editItemService.editItem(itemId, editItemDto, trx);
}
/**
* Deletes an existing item.
* @param {number} itemId - The item id.
* @return {Promise<void>}
*/
async deleteItem(itemId: number): Promise<void> {
return this.deleteItemService.deleteItem(itemId);
}
/**
* Activates an item.
* @param {number} itemId - The item id.
* @returns {Promise<void>}
*/
async activateItem(itemId: number): Promise<void> {
return this.activateItemService.activateItem(itemId);
}
/**
* Inactivates an item.
* @param {number} itemId - The item id.
* @returns {Promise<void>}
*/
async inactivateItem(itemId: number): Promise<void> {
return this.inactivateItemService.inactivateItem(itemId);
}
/**
* Retrieves the item details of the given id with associated details.
* @param {number} itemId - The item id.
* @returns {Promise<IItem>} - The item details.
*/
async getItem(itemId: number): Promise<any> {
return this.getItemService.getItem(itemId);
}
/**
* Retrieves the paginated filterable items list.
* @param {IItemsFilter} filterDTO
*/
async getItems(filterDTO: IItemsFilter) {
return this.getItemsService.getItems(filterDTO)
}
/**
* Retrieves the item associated invoices transactions.
* @param {number} itemId
* @returns {Promise<any>}
*/
async getItemInvoicesTransactions(itemId: number): Promise<any> {
return this.itemTransactionsService.getItemInvoicesTransactions(itemId);
}
/**
* Retrieves the item associated bills transactions.
* @param {number} itemId
* @returns {Promise<any>}
*/
async getItemBillTransactions(itemId: number): Promise<any> {
return this.itemTransactionsService.getItemBillTransactions(itemId);
}
/**
* Retrieves the item associated estimates transactions.
* @param {number} itemId
* @returns {Promise<any>}
*/
async getItemEstimatesTransactions(itemId: number): Promise<any> {
return this.itemTransactionsService.getItemEstimateTransactions(itemId);
}
/**
* Retrieves the item associated receipts transactions.
* @param {number} itemId
* @returns {Promise<any>}
*/
async getItemReceiptsTransactions(itemId: number): Promise<any> {
return this.itemTransactionsService.getItemReceiptTransactions(itemId);
}
}

View File

@@ -0,0 +1,256 @@
import { Knex } from 'knex';
import { sumBy, difference, map } from 'lodash';
import { Inject, Injectable } from '@nestjs/common';
import { Item } from './models/Item';
import { ItemEntry } from '../TransactionItemEntry/models/ItemEntry';
import { ServiceError } from './ServiceError';
import { IItemEntryDTO } from '../TransactionItemEntry/ItemEntry.types';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { entriesAmountDiff } from '@/utils/entries-amount-diff';
import { ItemEntryDto } from '../TransactionItemEntry/dto/ItemEntry.dto';
const ERRORS = {
ITEMS_NOT_FOUND: 'ITEMS_NOT_FOUND',
ENTRIES_IDS_NOT_FOUND: 'ENTRIES_IDS_NOT_FOUND',
NOT_PURCHASE_ABLE_ITEMS: 'NOT_PURCHASE_ABLE_ITEMS',
NOT_SELL_ABLE_ITEMS: 'NOT_SELL_ABLE_ITEMS',
};
@Injectable()
export class ItemsEntriesService {
/**
* @param {TenantModelProxy<typeof Item>} itemModel - Item model.
* @param {TenantModelProxy<typeof ItemEntry>} itemEntryModel - Item entry model.
*/
constructor(
@Inject(Item.name)
private readonly itemModel: TenantModelProxy<typeof Item>,
@Inject(ItemEntry.name)
private readonly itemEntryModel: TenantModelProxy<typeof ItemEntry>,
) {}
/**
* Retrieve the inventory items entries of the reference id and type.
* @param {string} referenceType - Reference type.
* @param {number} referenceId - Reference id.
* @return {Promise<IItemEntry[]>}
*/
public async getInventoryEntries(
referenceType: string,
referenceId: number,
): Promise<ItemEntry[]> {
const itemsEntries = await this.itemEntryModel()
.query()
.where('reference_type', referenceType)
.where('reference_id', referenceId);
const inventoryItems = await this.itemModel()
.query()
.whereIn('id', map(itemsEntries, 'itemId'))
.where('type', 'inventory');
const inventoryItemsIds = map(inventoryItems, 'id');
return itemsEntries.filter(
(itemEntry) => inventoryItemsIds.indexOf(itemEntry.itemId) !== -1,
);
}
/**
* Filter the given entries to inventory entries.
* @param {IItemEntry[]} entries - Items entries.
* @param {Knex.Transaction} trx - Knex transaction.
* @returns {Promise<IItemEntry[]>}
*/
public async filterInventoryEntries(
entries: ItemEntry[],
trx?: Knex.Transaction,
): Promise<ItemEntry[]> {
const entriesItemsIds = entries.map((e) => e.itemId);
const inventoryItems = await this.itemModel()
.query(trx)
.whereIn('id', entriesItemsIds)
.where('type', 'inventory');
return entries.filter((entry) =>
inventoryItems.some((item) => item.id === entry.itemId),
);
}
/**
* Validates the entries items ids.
* @param {IItemEntryDTO[]} itemEntries - Items entries.
* @returns {Promise<Item[]>}
*/
public async validateItemsIdsExistance(itemEntries: Array<{ itemId: number }>) {
const itemsIds = itemEntries.map((e) => e.itemId);
const foundItems = await this.itemModel().query().whereIn('id', itemsIds);
const foundItemsIds = foundItems.map((item: Item) => item.id);
const notFoundItemsIds = difference(itemsIds, foundItemsIds);
if (notFoundItemsIds.length > 0) {
throw new ServiceError(ERRORS.ITEMS_NOT_FOUND);
}
return foundItems;
}
/**
* Validates the entries ids existance on the storage.
* @param {number} referenceId -
* @param {string} referenceType -
* @param {IItemEntryDTO[]} entries -
*/
public async validateEntriesIdsExistance(
referenceId: number,
referenceType: string,
billEntries: ItemEntryDto[],
) {
const entriesIds = billEntries
.filter((e: ItemEntry) => e.id)
.map((e: ItemEntry) => e.id);
const storedEntries = await this.itemEntryModel()
.query()
.whereIn('reference_id', [referenceId])
.whereIn('reference_type', [referenceType]);
const storedEntriesIds = storedEntries.map((entry) => entry.id);
const notFoundEntriesIds = difference(entriesIds, storedEntriesIds);
if (notFoundEntriesIds.length > 0) {
throw new ServiceError(ERRORS.ENTRIES_IDS_NOT_FOUND);
}
}
/**
* Validate the entries items that not purchase-able.
* @param {IItemEntryDTO[]} itemEntries -
*/
public async validateNonPurchasableEntriesItems(
itemEntries: ItemEntryDto[],
) {
const itemsIds = itemEntries.map((e: ItemEntryDto) => e.itemId);
const purchasbleItems = await this.itemModel()
.query()
.where('purchasable', true)
.whereIn('id', itemsIds);
const purchasbleItemsIds = purchasbleItems.map((item: Item) => item.id);
const notPurchasableItems = difference(itemsIds, purchasbleItemsIds);
if (notPurchasableItems.length > 0) {
throw new ServiceError(ERRORS.NOT_PURCHASE_ABLE_ITEMS);
}
}
/**
* Validate the entries items that not sell-able.
* @param {IItemEntryDTO[]} itemEntries -
*/
public async validateNonSellableEntriesItems(itemEntries: ItemEntryDto[]) {
const itemsIds = itemEntries.map((e: ItemEntryDto) => e.itemId);
const sellableItems = await this.itemModel()
.query()
.where('sellable', true)
.whereIn('id', itemsIds);
const sellableItemsIds = sellableItems.map((item: Item) => item.id);
const nonSellableItems = difference(itemsIds, sellableItemsIds);
if (nonSellableItems.length > 0) {
throw new ServiceError(ERRORS.NOT_SELL_ABLE_ITEMS);
}
}
/**
* Changes items quantity from the given items entries the new and old onces.
* @param {IItemEntry[]} entries - Items entries.
* @param {IItemEntry[]} oldEntries - Old items entries.
*/
public async changeItemsQuantity(
entries: ItemEntry[],
oldEntries?: ItemEntry[],
): Promise<void> {
const opers = [];
const diffEntries = entriesAmountDiff(
entries,
oldEntries,
'quantity',
'itemId',
);
diffEntries.forEach((entry: ItemEntry) => {
const changeQuantityOper = this.itemModel()
.query()
.where({ id: entry.itemId, type: 'inventory' })
.modify('quantityOnHand', entry.quantity);
opers.push(changeQuantityOper);
});
await Promise.all(opers);
}
/**
* Increment items quantity from the given items entries.
* @param {IItemEntry[]} entries - Items entries.
*/
public async incrementItemsEntries(entries: ItemEntry[]): Promise<void> {
return this.changeItemsQuantity(entries);
}
/**
* Decrement items quantity from the given items entries.
* @param {IItemEntry[]} entries - Items entries.
*/
public async decrementItemsQuantity(entries: ItemEntry[]): Promise<void> {
// return this.changeItemsQuantity(
// entries.map((entry) => ({
// ...entry,
// quantity: entry.quantity * -1,
// })),
// );
}
/**
* Sets the cost/sell accounts to the invoice entries.
*/
public setItemsEntriesDefaultAccounts = async (entries: ItemEntryDto[]) => {
const entriesItemsIds = entries.map((e) => e.itemId);
const items = await this.itemModel().query().whereIn('id', entriesItemsIds);
return entries.map((entry) => {
const item = items.find((i) => i.id === entry.itemId);
return {
...entry,
sellAccountId: entry.sellAccountId || item.sellAccountId,
...(item.type === 'inventory' && {
costAccountId: entry.costAccountId || item.costAccountId,
}),
};
});
};
/**
* Retrieve the total items entries.
* @param {ItemEntry[]} entries - Items entries.
* @returns {number}
*/
public getTotalItemsEntries(entries: ItemEntryDto[]): number {
return sumBy(entries, (e) => ItemEntry.calcAmount(e));
}
/**
* Retrieve the non-zero tax items entries.
* @param {ItemEntry[]} entries -
* @returns {ItemEntry[]}
*/
public getNonZeroEntries(entries: ItemEntry[]): ItemEntry[] {
return entries.filter((e) => e.taxRate > 0);
}
}

View File

@@ -0,0 +1,19 @@
import { HttpStatus } from '@nestjs/common';
export class ServiceError extends Error {
errorType: string;
message: string;
payload: any;
constructor(errorType: string, message?: string, payload?: any) {
super(message);
this.errorType = errorType;
this.message = message || null;
this.payload = payload;
}
getStatus(): HttpStatus {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}

View File

@@ -0,0 +1,204 @@
import {
IsString,
IsIn,
IsOptional,
IsBoolean,
IsNumber,
IsInt,
IsArray,
ValidateIf,
MaxLength,
Min,
Max,
IsNotEmpty,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class CommandItemDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
@ApiProperty({ description: 'Item name', example: 'Office Chair' })
name: string;
@IsString()
@IsIn(['service', 'non-inventory', 'inventory'])
@ApiProperty({
description: 'Item type',
enum: ['service', 'non-inventory', 'inventory'],
example: 'inventory',
})
type: 'service' | 'non-inventory' | 'inventory';
@IsOptional()
@IsString()
@MaxLength(255)
@ApiProperty({
description: 'Item code/SKU',
required: false,
example: 'ITEM-001',
})
code?: string;
// Purchase attributes
@IsOptional()
@IsBoolean()
@ApiProperty({
description: 'Whether the item can be purchased',
required: false,
example: true,
})
purchasable?: boolean;
@IsOptional()
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@ValidateIf((o) => o.purchasable === true)
@ApiProperty({
description: 'Cost price of the item',
required: false,
minimum: 0,
example: 100.5,
})
costPrice?: number;
@IsOptional()
@IsInt()
@Min(0)
@ValidateIf((o) => o.purchasable === true)
@ApiProperty({
description: 'ID of the cost account',
required: false,
minimum: 0,
example: 1,
})
costAccountId?: number;
// Sell attributes
@IsOptional()
@IsBoolean()
@ApiProperty({
description: 'Whether the item can be sold',
required: false,
example: true,
})
sellable?: boolean;
@IsOptional()
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@ValidateIf((o) => o.sellable === true)
@ApiProperty({
description: 'Selling price of the item',
required: false,
minimum: 0,
example: 150.75,
})
sellPrice?: number;
@IsOptional()
@IsInt()
@Min(0)
@ValidateIf((o) => o.sellable === true)
@ApiProperty({
description: 'ID of the sell account',
required: false,
minimum: 0,
example: 2,
})
sellAccountId?: number;
@IsOptional()
@IsInt()
@Min(0)
@ValidateIf((o) => o.type === 'inventory')
@ApiProperty({
description: 'ID of the inventory account (required for inventory items)',
required: false,
minimum: 0,
example: 3,
})
inventoryAccountId?: number;
@IsOptional()
@IsString()
@ApiProperty({
description: 'Description shown on sales documents',
required: false,
example: 'High-quality ergonomic office chair',
})
sellDescription?: string;
@IsOptional()
@IsString()
@ApiProperty({
description: 'Description shown on purchase documents',
required: false,
example: 'Ergonomic office chair with adjustable height',
})
purchaseDescription?: string;
@IsOptional()
@IsInt()
@ApiProperty({
description: 'ID of the tax rate applied to sales',
required: false,
example: 1,
})
sellTaxRateId?: number;
@IsOptional()
@IsInt()
@ApiProperty({
description: 'ID of the tax rate applied to purchases',
required: false,
example: 1,
})
purchaseTaxRateId?: number;
@IsOptional()
@IsInt()
@Min(0)
@ApiProperty({
description: 'ID of the item category',
required: false,
minimum: 0,
example: 5,
})
categoryId?: number;
@IsOptional()
@IsString()
@ApiProperty({
description: 'Additional notes about the item',
required: false,
example: 'Available in multiple colors',
})
note?: string;
@IsOptional()
@IsBoolean()
@ApiProperty({
description: 'Whether the item is active',
required: false,
default: true,
example: true,
})
active?: boolean;
@IsOptional()
@IsArray()
@Type(() => Number)
@IsInt({ each: true })
@ApiProperty({
description: 'IDs of media files associated with the item',
required: false,
type: [Number],
example: [1, 2, 3],
})
mediaIds?: number[];
}
export class CreateItemDto extends CommandItemDto {}
export class EditItemDto extends CommandItemDto {}

View File

@@ -0,0 +1,4 @@
export class ItemCreatedEvent {
name: string;
description: string;
}

View File

@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ItemCreatedEvent } from '../events/ItemCreated.event';
@Injectable()
export class ItemCreatedListener {
@OnEvent('order.created')
handleItemCreatedEvent(event: ItemCreatedEvent) {
// handle and process "OrderCreatedEvent" event
console.log(event);
}
}

View File

@@ -0,0 +1,213 @@
import { Warehouse } from '@/modules/Warehouses/models/Warehouse.model';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { Model } from 'objection';
export class Item extends TenantBaseModel {
public readonly quantityOnHand: number;
public readonly name: string;
public readonly active: boolean;
public readonly type: string;
public readonly code: string;
public readonly sellable: boolean;
public readonly purchasable: boolean;
public readonly costPrice: number;
public readonly sellPrice: number;
public readonly currencyCode: string;
public readonly costAccountId: number;
public readonly inventoryAccountId: number;
public readonly categoryId: number;
public readonly pictureUri: string;
public readonly sellAccountId: number;
public readonly sellDescription: string;
public readonly purchaseDescription: string;
public readonly landedCost: boolean;
public readonly note: string;
public readonly userId: number;
public readonly sellTaxRateId: number;
public readonly purchaseTaxRateId: number;
public readonly warehouse!: Warehouse;
static get tableName() {
return 'items';
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
updateQuantityOnHand(query, value: number) {
const q = query.where('type', 'inventory');
if (value > 0) {
q.increment('quantityOnHand', value);
}
if (value < 0) {
q.decrement('quantityOnHand', Math.abs(value));
}
return q;
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
// const { Media } = require('../../Media/models/Media.model');
const { Account } = require('../../Accounts/models/Account.model');
const {
ItemCategory,
} = require('../../ItemCategories/models/ItemCategory.model');
const {
ItemWarehouseQuantity,
} = require('../../Warehouses/models/ItemWarehouseQuantity');
const {
ItemEntry,
} = require('../../TransactionItemEntry/models/ItemEntry');
// const WarehouseTransferEntry = require('../../Warehouses/');
const {
InventoryAdjustmentEntry,
} = require('../../InventoryAdjutments/models/InventoryAdjustmentEntry');
const { TaxRateModel } = require('../../TaxRates/models/TaxRate.model');
return {
/**
* Item may belongs to cateogory model.
*/
category: {
relation: Model.BelongsToOneRelation,
modelClass: ItemCategory,
join: {
from: 'items.categoryId',
to: 'items_categories.id',
},
},
/**
* Item may belongs to cost account.
*/
costAccount: {
relation: Model.BelongsToOneRelation,
modelClass: Account,
join: {
from: 'items.costAccountId',
to: 'accounts.id',
},
},
/**
* Item may belongs to sell account.
*/
sellAccount: {
relation: Model.BelongsToOneRelation,
modelClass: Account,
join: {
from: 'items.sellAccountId',
to: 'accounts.id',
},
},
/**
* Item may belongs to inventory account.
*/
inventoryAccount: {
relation: Model.BelongsToOneRelation,
modelClass: Account,
join: {
from: 'items.inventoryAccountId',
to: 'accounts.id',
},
},
/**
* Item has many warehouses quantities.
*/
itemWarehouses: {
relation: Model.HasManyRelation,
modelClass: ItemWarehouseQuantity,
join: {
from: 'items.id',
to: 'items_warehouses_quantity.itemId',
},
},
/**
* Item may has many item entries.
*/
itemEntries: {
relation: Model.HasManyRelation,
modelClass: ItemEntry,
join: {
from: 'items.id',
to: 'items_entries.itemId',
},
},
/**
* Item may has many warehouses transfers entries.
*/
// warehousesTransfersEntries: {
// relation: Model.HasManyRelation,
// modelClass: WarehouseTransferEntry,
// join: {
// from: 'items.id',
// to: 'warehouses_transfers_entries.itemId',
// },
// },
/**
* Item has many inventory adjustment entries.
*/
inventoryAdjustmentsEntries: {
relation: Model.HasManyRelation,
modelClass: InventoryAdjustmentEntry,
join: {
from: 'items.id',
to: 'inventory_adjustments_entries.itemId',
},
},
/**
*
*/
// media: {
// relation: Model.ManyToManyRelation,
// modelClass: Media.default,
// join: {
// from: 'items.id',
// through: {
// from: 'media_links.model_id',
// to: 'media_links.media_id',
// },
// to: 'media.id',
// },
// },
/**
* Item may has sell tax rate.
*/
sellTaxRate: {
relation: Model.BelongsToOneRelation,
modelClass: TaxRateModel,
join: {
from: 'items.sellTaxRateId',
to: 'tax_rates.id',
},
},
/**
* Item may has purchase tax rate.
*/
purchaseTaxRate: {
relation: Model.BelongsToOneRelation,
modelClass: TaxRateModel,
join: {
from: 'items.purchaseTaxRateId',
to: 'tax_rates.id',
},
},
};
}
}

View File

@@ -0,0 +1,8 @@
import { IDynamicListFilter } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types';
export interface IItemsFilter extends IDynamicListFilter {
page: number;
pageSize: number;
inactiveMode: boolean;
viewSlug?: string;
}