diff --git a/database/migrations/20240404063150_completedFishstockingsView.js b/database/migrations/20240404063150_completedFishstockingsView.js new file mode 100644 index 0000000..d7b7e02 --- /dev/null +++ b/database/migrations/20240404063150_completedFishstockingsView.js @@ -0,0 +1,57 @@ +exports.up = function (knex) { + return knex.schema.createViewOrReplace('fishStockingsCompleted', function (view) { + view.as( + knex.raw(` + WITH fb AS ( + SELECT + fb.fish_stocking_id, + json_agg( + json_build_object( + 'fish_type', + json_build_object('id', ft.id, 'label', ft.label), + 'fish_age', + json_build_object('id', fa.id, 'label', fa.label), + 'count', + fb.review_amount, + 'weight', + fb.review_weight + ) + ) AS fish_batches + FROM + public.fish_batches fb + LEFT JOIN public.fish_types ft ON ft.id = fb.fish_type_id + LEFT JOIN public.fish_ages fa ON fa.id = fb.fish_age_id + GROUP BY + fb.fish_stocking_id + ) + SELECT + s.id, + s.event_time, + s.review_time, + s.geom, + s.location::json, + fb.fish_batches + FROM + public.fish_stockings s + LEFT JOIN fb ON fb.fish_stocking_id = s.id + WHERE + EXISTS ( + SELECT + 1 + FROM + public.fish_batches fb + WHERE + fb.fish_stocking_id = s.id + AND fb.review_amount IS NOT NULL + AND fb.deleted_at IS NULL + ) + AND s.review_time IS NOT NULL + AND s.deleted_at IS NULL + `), + ); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropViewIfExists('fishStockingsCompleted'); +}; diff --git a/services/fishBatches.service.ts b/services/fishBatches.service.ts index 3b9dd47..68fc134 100644 --- a/services/fishBatches.service.ts +++ b/services/fishBatches.service.ts @@ -1,7 +1,7 @@ 'use strict'; import moleculer, { Context } from 'moleculer'; -import {Action, Method, Service} from 'moleculer-decorators'; +import { Action, Method, Service } from 'moleculer-decorators'; import { filter, map } from 'lodash'; import DbConnection from '../mixins/database.mixin'; @@ -13,11 +13,10 @@ import { CommonPopulates, Table, } from '../types'; +import { UserAuthMeta } from './api.service'; import { FishAge } from './fishAges.service'; import { FishStocking } from './fishStockings.service'; import { FishType } from './fishTypes.service'; -import {UserAuthMeta} from "./api.service"; - interface Fields extends CommonFields { id: number; @@ -95,7 +94,6 @@ export type FishBatch< }, }) export default class FishBatchesService extends moleculer.Service { - @Action({ params: { batches: { @@ -107,8 +105,8 @@ export default class FishBatchesService extends moleculer.Service { fishAge: 'number|integer|positive', amount: 'number|integer|positive', weight: 'number|positive|optional', - } - } + }, + }, }, fishStocking: 'number|integer|positive', }, @@ -121,13 +119,12 @@ export default class FishBatchesService extends moleculer.Service { ) { if (ctx.params.batches) { const batches = ctx.params.batches?.map((batch) => ({ - fishType: batch.fishType, - fishAge: batch.fishAge, - amount: batch.amount, - weight: batch.weight, - fishStocking: ctx.params.fishStocking, - }) - ) + fishType: batch.fishType, + fishAge: batch.fishAge, + amount: batch.amount, + weight: batch.weight, + fishStocking: ctx.params.fishStocking, + })); await ctx.call('fishBatches.createMany', batches); } } @@ -147,20 +144,22 @@ export default class FishBatchesService extends moleculer.Service { weight: 'number|optional', reviewAmount: 'number|integer|positive|optional', reviewWeight: 'number|optional', - } - } + }, + }, }, fishStocking: 'number|integer|positive', }, }) //for admin async updateBatches( - ctx: Context<{ - batches: FishBatch[]; - fishStocking: number; - }, UserAuthMeta>, + ctx: Context< + { + batches: FishBatch[]; + fishStocking: number; + }, + UserAuthMeta + >, ) { - await this.deleteExistingBatches(ctx, ctx.params.fishStocking, ctx.params.batches); await this.createOrUpdateBatches(ctx, ctx.params.fishStocking, ctx.params.batches); return await this.findEntities(ctx, { @@ -182,20 +181,22 @@ export default class FishBatchesService extends moleculer.Service { fishType: 'number|integer|positive|optional', fishAge: 'number|integer|positive|optional', amount: 'number|integer|positive', - weight: 'number|optional' - } - } + weight: 'number|optional', + }, + }, }, fishStocking: 'number|integer|positive', }, }) async updateRegisteredBatches( - ctx: Context<{ + ctx: Context< + { batches: FishBatch[]; fishStocking: number; - }, UserAuthMeta>, + }, + UserAuthMeta + >, ) { - await this.deleteExistingBatches(ctx, ctx.params.fishStocking, ctx.params.batches); await this.createOrUpdateBatches(ctx, ctx.params.fishStocking, ctx.params.batches); @@ -216,18 +217,21 @@ export default class FishBatchesService extends moleculer.Service { properties: { id: 'number|integer|positive', reviewAmount: 'number|integer|positive', - reviewWeight: 'number|optional' - } - } + reviewWeight: 'number|optional', + }, + }, }, fishStocking: 'number|integer|positive', }, }) async reviewBatches( - ctx: Context<{ + ctx: Context< + { batches: FishBatch[]; fishStocking: number; - }, UserAuthMeta>, + }, + UserAuthMeta + >, ) { await this.deleteExistingBatches(ctx, ctx.params.fishStocking, ctx.params.batches); await this.createOrUpdateBatches(ctx, ctx.params.fishStocking, ctx.params.batches); @@ -249,8 +253,9 @@ export default class FishBatchesService extends moleculer.Service { query: { fishStocking: fishStockingId }, }); const deleteBatches = filter( - existingBatches, - (existingBatch: FishBatch) => batches?.find((batch) => batch.id && existingBatch.id == batch.id) + existingBatches, + (existingBatch: FishBatch) => + !batches?.find((batch) => batch.id && existingBatch.id == batch.id), ); const promises = map(deleteBatches, (batch: FishBatch) => this.removeEntity(ctx, batch)); await Promise.all(promises); @@ -258,7 +263,7 @@ export default class FishBatchesService extends moleculer.Service { @Method async createOrUpdateBatches(ctx: Context, fishStocking: number, batches: any[]) { - const promises = batches?.map( (batch: FishBatch) => { + const promises = batches?.map((batch: FishBatch) => { if (batch.id) { return this.updateEntity(ctx, batch); } diff --git a/services/fishStockings.service.ts b/services/fishStockings.service.ts index 3b8f0e3..c2c0546 100644 --- a/services/fishStockings.service.ts +++ b/services/fishStockings.service.ts @@ -1,6 +1,13 @@ 'use strict'; +import { isEmpty, map } from 'lodash'; +import moleculer, { Context } from 'moleculer'; +import { DbContextParameters } from 'moleculer-db'; import { Action, Event, Method, Service } from 'moleculer-decorators'; +import ApiGateway from 'moleculer-web'; +import XLSX from 'xlsx'; +import DbConnection from '../mixins/database.mixin'; +import GeometriesMixin from '../mixins/geometries.mixin'; import { GeomFeatureCollection, coordinatesToGeometry, geometryToGeom } from '../modules/geometry'; import { COMMON_DEFAULT_SCOPES, @@ -9,32 +16,30 @@ import { CommonFields, CommonPopulates, EntityChangedParams, - FieldHookCallback, FishOrigin, FishStockingErrorMessages, FishStockingStatus, - RestrictionType, StatusLabels, + FieldHookCallback, + FishOrigin, + FishStockingErrorMessages, + FishStockingStatus, + RestrictionType, + StatusLabels, Table, } from '../types'; +import { + canProfileModifyFishStocking, + getStatus, + isTimeBeforeReview, + validateAssignedTo, + validateFishData, + validateFishOrigin, + validateStockingCustomer, +} from '../utils/functions'; import { AuthUserRole, UserAuthMeta } from './api.service'; -import { isEmpty, map } from 'lodash'; -import moleculer, { Context, RestSchema } from 'moleculer'; -import { DbContextParameters } from 'moleculer-db'; -import ApiGateway from 'moleculer-web'; -import XLSX from 'xlsx'; -import DbConnection from '../mixins/database.mixin'; -import GeometriesMixin from '../mixins/geometries.mixin'; import { FishBatch } from './fishBatches.service'; import { FishStockingPhoto } from './fishStockingPhotos.service'; import { FishType } from './fishTypes.service'; import { Setting } from './settings.service'; import { Tenant } from './tenants.service'; import { User } from './users.service'; -import { - canProfileModifyFishStocking, getStatus, - isTimeBeforeReview, - validateAssignedTo, - validateFishData, - validateFishOrigin, - validateStockingCustomer -} from "../utils/functions"; const Readable = require('stream').Readable; @@ -98,7 +103,7 @@ interface Fields extends CommonFields { coordinates?: any; oldId: number; fishTypes: any; - status: FishStockingStatus + status: FishStockingStatus; } interface Populates extends CommonPopulates { @@ -156,17 +161,17 @@ export type FishStocking< action: 'tenants.resolve', }, }, - fishOrigin: { type: "enum", values: Object.values(FishOrigin), required: true}, + fishOrigin: { type: 'enum', values: Object.values(FishOrigin), required: true }, fishOriginCompanyName: 'string', fishOriginReservoir: { type: 'object', required: false, properties: { area: 'number', - name: "string", - cadastral_id: "string", - municipality: "string" - } + name: 'string', + cadastral_id: 'string', + municipality: 'string', + }, }, location: { type: 'object', @@ -201,7 +206,8 @@ export type FishStocking< }); }, }, - batches: { // TODO: could be actual jsonb field instead of batches table. This would make selection and updates much easier. + batches: { + // TODO: could be actual jsonb field instead of batches table. This would make selection and updates much easier. type: 'array', readonly: false, required: true, @@ -239,7 +245,7 @@ export type FishStocking< phone: { type: 'string', required: false, - pattern: /^(86|\+3706)\d{7}$/ + pattern: /^(86|\+3706)\d{7}$/, }, reviewedBy: { type: 'number', @@ -337,7 +343,7 @@ export type FishStocking< phone: { type: 'string', required: true, - pattern: /^(86|\+3706)\d{7}$/ + pattern: /^(86|\+3706)\d{7}$/, }, organization: 'string', }, @@ -370,7 +376,8 @@ export type FishStocking< ); }, }, - mandatory: { //TODO: mandatory flag could be part of location object + mandatory: { + //TODO: mandatory flag could be part of location object virtual: true, get: async ({ entity, ctx }: FieldHookCallback) => { const area = entity.location.area; @@ -414,11 +421,10 @@ export type FishStocking< actions: { remove: { auth: RestrictionType.ADMIN, - } - } + }, + }, }) export default class FishStockingsService extends moleculer.Service { - @Action({ rest: 'PATCH /:id', auth: RestrictionType.ADMIN, @@ -427,17 +433,17 @@ export default class FishStockingsService extends moleculer.Service { comment: 'string|optional', tenant: 'number|optional', stockingCustomer: 'number|optional', - fishOrigin: { type: "enum", values: Object.values(FishOrigin), optional: true }, + fishOrigin: { type: 'enum', values: Object.values(FishOrigin), optional: true }, fishOriginCompanyName: 'string|optional', fishOriginReservoir: { type: 'object', optional: true, properties: { area: 'number', - name: "string", - cadastral_id: "string", - municipality: "string" - } + name: 'string', + cadastral_id: 'string', + municipality: 'string', + }, }, location: 'object|optional', geom: 'any|optional', @@ -454,16 +460,16 @@ export default class FishStockingsService extends moleculer.Service { amount: 'number|integer|positive|convert', weight: 'number|optional|convert', reviewAmount: 'number|integer|positive|optional', - reviewWeight: 'number|optional' - } - } + reviewWeight: 'number|optional', + }, + }, }, assignedTo: 'number|optional', phone: { // TODO: freelancer might not have phone number and currently it is not required for freelancer to enter phone number in FishStocking registration form. type: 'string', optional: true, - pattern: /^(86|\+3706)\d{7}$/ + pattern: /^(86|\+3706)\d{7}$/, }, waybillNo: 'string|optional', veterinaryApprovalNo: 'string|optional', @@ -477,62 +483,68 @@ export default class FishStockingsService extends moleculer.Service { type: 'object', properties: { signedBy: 'string', - signature: 'string|base64' - } - } + signature: 'string|base64', + }, + }, }, inspector: 'number|optional', canceledAt: 'string|optional', }, }) async updateFishStocking(ctx: Context) { - const existingFishStocking: FishStocking = await this.resolveEntities(ctx, {id: ctx.params.id, populate: 'status'}); + const existingFishStocking: FishStocking = await this.resolveEntities(ctx, { + id: ctx.params.id, + populate: 'status', + }); // Validate tenant - if(ctx.params.tenant) { - await ctx.call('tenants.resolve', {id: ctx.params.tenant, throwIfNotExist: true}) + if (ctx.params.tenant) { + await ctx.call('tenants.resolve', { id: ctx.params.tenant, throwIfNotExist: true }); } // Validate stockingCustomer - if(ctx.params.stockingCustomer) { - const stockingCustomer = await ctx.call('tenants.get', {id: ctx.params.stockingCustomer}); - if(!stockingCustomer) { + if (ctx.params.stockingCustomer) { + const stockingCustomer = await ctx.call('tenants.get', { id: ctx.params.stockingCustomer }); + if (!stockingCustomer) { throw new moleculer.Errors.ValidationError('Invalid stocking customer'); } } // Validate fishType & fishAge - if(ctx.params.batches) { + if (ctx.params.batches) { await validateFishData(ctx); } // Validate assignedTo - if(ctx.params.assignedTo) { + if (ctx.params.assignedTo) { const tenant = ctx.params.tenant || existingFishStocking.tenant; - if (tenant) { // Tenant fish stocking + if (tenant) { + // Tenant fish stocking const tenantUser = await ctx.call('tenantUsers.findOne', { query: { tenant, user: ctx.params.assignedTo, - } + }, }); - if(!tenantUser) { + if (!tenantUser) { throw new moleculer.Errors.ValidationError('Invalid "assignedTo" id'); } - } else { // Freelancers fish stocking + } else { + // Freelancers fish stocking const user: User = await ctx.call('users.get', { id: ctx.params.assignedTo, }); //if user does not exist or is not freelancer - if(!user || !user.isFreelancer) { + if (!user || !user.isFreelancer) { throw new moleculer.Errors.ValidationError('Invalid "assignedTo" id'); } } } // Validate canceledAt time - if(ctx.params.canceledAt) { - const eventTime: Date = ctx.params.eventTime && new Date(ctx.params.eventTime) || existingFishStocking.eventTime; + if (ctx.params.canceledAt) { + const eventTime: Date = + (ctx.params.eventTime && new Date(ctx.params.eventTime)) || existingFishStocking.eventTime; const canceledAtTime = new Date(ctx.params.canceledAt); - if(eventTime.getTime() - canceledAtTime.getTime() <= 0) { + if (eventTime.getTime() - canceledAtTime.getTime() <= 0) { throw new moleculer.Errors.ValidationError('Invalid "canceledAt" time'); } } @@ -546,7 +558,7 @@ export default class FishStockingsService extends moleculer.Service { id: ctx.params.inspector, }); // Validate inspector - if(!inspector) { + if (!inspector) { throw new moleculer.Errors.ValidationError('Invalid inspector id'); } const fishStocking = await this.updateEntity(ctx, { @@ -577,7 +589,8 @@ export default class FishStockingsService extends moleculer.Service { } return fishStocking; } - return this.updateEntity(ctx); + + return await this.updateEntity(ctx); } @Action({ @@ -585,9 +598,12 @@ export default class FishStockingsService extends moleculer.Service { auth: RestrictionType.USER, }) async cancel(ctx: Context) { - const fishStocking = await this.resolveEntities(ctx, { id: ctx.params.id, populate: ['status'] }); + const fishStocking = await this.resolveEntities(ctx, { + id: ctx.params.id, + populate: ['status'], + }); - if(!fishStocking) { + if (!fishStocking) { throw new moleculer.Errors.ValidationError(FishStockingErrorMessages.INVALID_ID); } @@ -602,8 +618,8 @@ export default class FishStockingsService extends moleculer.Service { } //if fish stocking is still in upcoming state, then it can be deleted. - if(fishStocking.status === FishStockingStatus.UPCOMING) { - return this.removeEntity(ctx, {id: fishStocking.id}); + if (fishStocking.status === FishStockingStatus.UPCOMING) { + return this.removeEntity(ctx, { id: fishStocking.id }); } //else it should be canceled return this.updateEntity(ctx, { @@ -622,7 +638,7 @@ export default class FishStockingsService extends moleculer.Service { // TODO: freelancer might not have phone number and currently it is not required for freelancer to enter phone number in FishStocking registration form. type: 'string', optional: true, - pattern: /^(86|\+3706)\d{7}$/ + pattern: /^(86|\+3706)\d{7}$/, }, assignedTo: 'number|integer|convert', location: { @@ -644,31 +660,30 @@ export default class FishStockingsService extends moleculer.Service { fishType: 'number|integer|positive|convert', fishAge: 'number|integer|positive|convert', amount: 'number|integer|positive|convert', - weight: 'number|positive|optional|convert' + weight: 'number|positive|optional|convert', }, - } + }, }, - fishOrigin: { type: "enum", values: Object.values(FishOrigin) }, + fishOrigin: { type: 'enum', values: Object.values(FishOrigin) }, fishOriginCompanyName: 'string|optional', fishOriginReservoir: { type: 'object', optional: true, properties: { area: 'number', - name: "string", - cadastral_id: "string", - municipality: "string" - } + name: 'string', + cadastral_id: 'string', + municipality: 'string', + }, }, tenant: 'number|integer|optional|optional', stockingCustomer: 'number|integer|optional|convert', }, }) async register(ctx: Context) { - // Validate eventTime const timeBeforeReview = await isTimeBeforeReview(ctx, new Date(ctx.params.eventTime)); - if(!timeBeforeReview) { + if (!timeBeforeReview) { throw new moleculer.Errors.ValidationError(FishStockingErrorMessages.INVALID_EVENT_TIME); } @@ -690,16 +705,13 @@ export default class FishStockingsService extends moleculer.Service { const fishStocking: FishStocking = await this.createEntity(ctx); try { - await ctx.call( - 'fishBatches.createBatches', - { - batches: ctx.params.batches, - fishStocking: fishStocking.id, - } - ); + await ctx.call('fishBatches.createBatches', { + batches: ctx.params.batches, + fishStocking: fishStocking.id, + }); } catch (e) { await this.removeEntity(ctx, { id: fishStocking.id }); - throw e; + throw e; } // Send email to notify about new fish stocking @@ -731,7 +743,7 @@ export default class FishStockingsService extends moleculer.Service { // TODO: freelancer might not have phone number and currently it is not required for freelancer to enter phone number in FishStocking registration form. type: 'string', optional: true, - pattern: /^(86|\+3706)\d{7}$/ + pattern: /^(86|\+3706)\d{7}$/, }, assignedTo: { type: 'number', @@ -763,9 +775,9 @@ export default class FishStockingsService extends moleculer.Service { fishType: 'number|convert', fishAge: 'number|convert', amount: 'number|convert', - weight: 'number|optional' - } - } + weight: 'number|optional', + }, + }, }, fishOrigin: 'string', fishOriginCompanyName: 'string|optional', @@ -774,50 +786,58 @@ export default class FishStockingsService extends moleculer.Service { optional: true, properties: { area: 'number', - name: "string", - cadastral_id: "string", - municipality: "string" - } + name: 'string', + cadastral_id: 'string', + municipality: 'string', + }, }, tenant: 'number|optional', stockingCustomer: 'number|optional', }, }) async updateRegistration(ctx: Context) { - const existingFishStocking: FishStocking = await this.resolveEntities(ctx, {id: ctx.params.id, populate: 'status'});; - if(!existingFishStocking) { + const existingFishStocking: FishStocking = await this.resolveEntities(ctx, { + id: ctx.params.id, + populate: 'status', + }); + if (!existingFishStocking) { throw new moleculer.Errors.ValidationError(FishStockingErrorMessages.INVALID_ID); } // Validate fish stocking status - if(![FishStockingStatus.UPCOMING, FishStockingStatus.ONGOING].some(status => status ===existingFishStocking.status)) { + if ( + ![FishStockingStatus.UPCOMING, FishStockingStatus.ONGOING].some( + (status) => status === existingFishStocking.status, + ) + ) { throw new moleculer.Errors.ValidationError(FishStockingErrorMessages.INVALID_STATUS); } //Validate if user can edit fishStocking canProfileModifyFishStocking(ctx, existingFishStocking); // Validate assignedTo - const assignedToChanged = !!ctx.params.assignedTo && ctx.params.assignedTo !== existingFishStocking.assignedTo; + const assignedToChanged = + !!ctx.params.assignedTo && ctx.params.assignedTo !== existingFishStocking.assignedTo; await validateAssignedTo(ctx); // If existing fish stocking time is within the time interval indicating that it is time to review fish stocking, // then most of the data cannot be edited except assignedTo. - if(existingFishStocking.status === FishStockingStatus.ONGOING) { - if(assignedToChanged) { - try{ - return this.updateEntity(ctx, {assignedTo: ctx.params.assignedTo}); + if (existingFishStocking.status === FishStockingStatus.ONGOING) { + if (assignedToChanged) { + try { + return this.updateEntity(ctx, { assignedTo: ctx.params.assignedTo }); } catch (e) { throw new moleculer.Errors.ValidationError('Could not update fishStocking'); } } } - if(existingFishStocking.status === FishStockingStatus.UPCOMING) { + if (existingFishStocking.status === FishStockingStatus.UPCOMING) { // Validate event time - if(ctx.params.eventTime ) { + if (ctx.params.eventTime) { const timeBeforeReview = await isTimeBeforeReview(ctx, new Date(ctx.params.eventTime)); - if(!timeBeforeReview) { + if (!timeBeforeReview) { throw new moleculer.Errors.ValidationError(FishStockingErrorMessages.INVALID_EVENT_TIME); } } // Validate fishType & fishAge - if(ctx.params.batches) { + if (ctx.params.batches) { await validateFishData(ctx); } // Validate stocking customer @@ -858,8 +878,8 @@ export default class FishStockingsService extends moleculer.Service { id: 'number|integer|positive|convert', reviewAmount: 'number|integer|positive|convert', reviewWeight: 'number|optional|convert', - } - } + }, + }, }, signatures: 'any|optional', comment: 'string|optional', @@ -891,10 +911,12 @@ export default class FishStockingsService extends moleculer.Service { UserAuthMeta >, ) { + const existingFishStocking: FishStocking = await this.resolveEntities(ctx, { + id: ctx.params.id, + populate: 'status', + }); - const existingFishStocking: FishStocking = await this.resolveEntities(ctx, {id: ctx.params.id, populate: 'status'}); - - if(!existingFishStocking) { + if (!existingFishStocking) { throw new moleculer.Errors.ValidationError(FishStockingErrorMessages.INVALID_ID); } @@ -902,7 +924,7 @@ export default class FishStockingsService extends moleculer.Service { canProfileModifyFishStocking(ctx, existingFishStocking); // Validate if fishStocking status, it must be ONGOING. - if(existingFishStocking.status !== FishStockingStatus.ONGOING) { + if (existingFishStocking.status !== FishStockingStatus.ONGOING) { throw new moleculer.Errors.ValidationError(FishStockingErrorMessages.INVALID_STATUS); } @@ -961,7 +983,7 @@ export default class FishStockingsService extends moleculer.Service { const adapter = await this.getAdapter(); const knex = adapter.client; return knex.raw( - `select distinct on ("location"::jsonb->'cadastral_id') "location" from "fish_stockings"`, + `select distinct on ("location"::jsonb->'cadastral_id') "location" from "fish_stockings"`, ); } @Action() @@ -986,89 +1008,6 @@ export default class FishStockingsService extends moleculer.Service { return Number(response.rows[0].sum); } - @Action({ - rest: { - method: 'GET', - basePath: '/public', - path: '/uetk/statistics', - }, - params: { - date: [ - { - type: 'string', - optional: true, - }, - { - type: 'object', - optional: true, - }, - ], - fishType: { - type: 'number', - convert: true, - optional: true, - }, - }, - auth: RestrictionType.PUBLIC, - }) - async getStatisticsForUETK(ctx: Context<{ date: any; fishType: number }>) { - const { fishType, date } = ctx.params; - const query: any = { reviewAmount: { $exists: true } }; - - if (fishType) { - query.fishType = fishType; - } - - if (date) { - query.createdAt = date; - try { - query.createdAt = JSON.parse(date); - } catch (err) {} - } - - const fishBatches: FishBatch<'fishStocking' | 'fishType'>[] = await ctx.call( - 'fishBatches.find', - { - query, - populate: ['fishStocking', 'fishType'], - }, - ); - - return Object.values( - fishBatches.reduce((groupedFishBatch, fishBatch) => { - const cadastralId = fishBatch?.fishStocking?.location?.cadastral_id; - const fishTypeId = fishBatch?.fishType?.id; - if (!cadastralId) return groupedFishBatch; - - groupedFishBatch[cadastralId] = groupedFishBatch[cadastralId] || { - count: 0, - cadastralId: cadastralId, - }; - - groupedFishBatch[cadastralId].count += fishBatch.reviewAmount; - - groupedFishBatch[cadastralId][fishTypeId] = groupedFishBatch[cadastralId][fishTypeId] || { - count: 0, - fishType: { id: fishTypeId, label: fishBatch?.fishType?.label }, - }; - - groupedFishBatch[cadastralId][fishTypeId].count += fishBatch.reviewAmount; - - return groupedFishBatch; - }, {} as any), - ).reduce( - ( - groupedFishBatch: { [key: string]: any }, - currentGroupedFishBatch: { [key: string]: any }, - ) => { - const { cadastralId, count, ...rest } = currentGroupedFishBatch; - groupedFishBatch[cadastralId] = { count, byFishes: Object.values(rest) }; - return groupedFishBatch; - }, - {}, - ); - } - @Action({ rest: 'GET /export', }) @@ -1079,7 +1018,7 @@ export default class FishStockingsService extends moleculer.Service { populate: ['assignedTo', 'reviewedBy', 'batches', 'status'], }); const mappedData: any[] = []; - data.map((fishStocking: FishStocking<'reviewedBy'|'assignedTo' |'batches'>) => { + data.map((fishStocking: FishStocking<'reviewedBy' | 'assignedTo' | 'batches'>) => { const fishOrigin = fishStocking.fishOrigin === 'GROWN' ? fishStocking?.fishOriginCompanyName @@ -1103,13 +1042,13 @@ export default class FishStockingsService extends moleculer.Service { Amžius: batch.fishAge?.label, 'Planuojamas kiekis, vnt': batch.amount || 0, 'Kiekis, vnt.': batch.reviewAmount || 0, - 'Planuojamas svoris, kg' : batch.weight || 0, + 'Planuojamas svoris, kg': batch.weight || 0, 'Svoris, kg': batch.reviewWeight || 0, 'Žuvys išaugintos': fishOrigin, 'Važtaraščio nr.': waybillNo || '', 'Atsakingas asmuo': assignedTo || '', 'Veterinarinio pažymėjimo Nr.': veterinaryApprovalNo || '', - 'Būsena': StatusLabels[status], + Būsena: StatusLabels[status], }); } }); @@ -1144,7 +1083,6 @@ export default class FishStockingsService extends moleculer.Service { geom?: GeomFeatureCollection; }>, ) { - const { geom, id } = ctx.params; if (geom?.features?.length) { @@ -1261,7 +1199,6 @@ export default class FishStockingsService extends moleculer.Service { } } if (filters.status) { - const settings: Setting = await ctx.call('settings.getSettings'); const statusQueries: any = getStatusQueries(settings.maxTimeForRegistration); @@ -1344,7 +1281,6 @@ export default class FishStockingsService extends moleculer.Service { return q; } - @Event() async 'fishBatches.*'(ctx: Context>) { //Generates an object with amounts of fish stocked and stores in the database. diff --git a/services/fishStockingsCompleted.service.ts b/services/fishStockingsCompleted.service.ts new file mode 100644 index 0000000..ca4199a --- /dev/null +++ b/services/fishStockingsCompleted.service.ts @@ -0,0 +1,92 @@ +'use strict'; + +import moleculer from 'moleculer'; +import { Service } from 'moleculer-decorators'; +import PostgisMixin from 'moleculer-postgis'; +import DbConnection from '../mixins/database.mixin'; +import { FishAge } from './fishAges.service'; +import { FishType } from './fishTypes.service'; + +export interface CompletedFishBatch { + fish_type: FishType; + fish_age: FishAge; + count: number; + weight: number; +} +export interface FishStockingsCompleted { + id: number; + eventTime: Date; + reviewTime: Date; + geom: any; + location: { + name: string; + area: number; + cadastral_id: string; + municipality: { + id: number; + name: string; + }; + }; + fishBatches: { + fish_type: FishType; + fish_age: FishAge; + count: number; + weight: number; + }[]; +} + +//TODO: might be unnecessary if fishBatches refactored +@Service({ + name: 'fishStockingsCompleted', + mixins: [ + DbConnection({ + collection: 'fishStockingsCompleted', + rest: false, + createActions: { + create: false, + update: false, + remove: false, + get: false, + createMany: false, + removeAllEntities: false, + }, + }), + PostgisMixin({ + srid: 3346, + }), + ], + settings: { + fields: { + id: { + type: 'number', + primaryKey: true, + secure: true, + }, + eventTime: { + type: 'date', + columnType: 'datetime', + }, + reviewTime: { + type: 'date', + columnType: 'datetime', + }, + geom: { + type: 'any', + geom: { + type: 'geom', + }, + }, + location: { + type: 'object', + columnType: 'json', + }, + fishBatches: { + type: 'array', + columnType: 'json', + items: { type: 'object' }, + }, + }, + defaultPopulates: ['geom'], + }, +}) +export default class FishStockingsCompletedService extends moleculer.Service {} diff --git a/services/public.service.ts b/services/public.service.ts index 804973d..39f9887 100644 --- a/services/public.service.ts +++ b/services/public.service.ts @@ -1,68 +1,109 @@ 'use strict'; +import { format } from 'date-fns'; import moleculer, { Context } from 'moleculer'; -import { Action, Service } from 'moleculer-decorators'; -import { CommonFields, CommonPopulates, RestrictionType, Table } from '../types'; - -import { GeomFeature } from '../modules/geometry'; -import { FishBatch } from './fishBatches.service'; -import { FishStockingPhoto } from './fishStockingPhotos.service'; +import { Action, Event, Method, Service } from 'moleculer-decorators'; +import { EntityChangedParams, RestrictionType } from '../types'; import { FishStocking } from './fishStockings.service'; -import { Tenant } from './tenants.service'; -import { User } from './users.service'; - -interface Fields extends CommonFields { - id: number; - eventTime: Date; - comment?: string; - tenant?: Tenant['id']; - stockingCustomer?: Tenant['id']; - fishOrigin: string; - fishOriginCompanyName?: string; - fishOriginReservoir?: { - id: string; - name: string; - municipality: { id: string; label: string }; +import { CompletedFishBatch, FishStockingsCompleted } from './fishStockingsCompleted.service'; +import { TenantUser } from './tenantUsers.service'; + +const uetkStatisticsParams = { + date: [ + { + type: 'string', + optional: true, + }, + { + type: 'object', + optional: true, + }, + ], + fishType: { + type: 'number', + convert: true, + optional: true, + }, + year: 'number|convert|optional', + cadastralId: 'string|optional', +}; + +type StatsById = { + [id: string]: { + count: number; + fishType: { + id: number; + label: string; + }; }; - location: GeomFeature; - geom: any; - batches: Array; - assignedTo: User['id']; - phone: string; - reviewedBy?: User['id']; - reviewLocation?: { lat: number; lng: number }; - reviewTime?: Date; - waybillNo?: string; - veterinaryApprovalNo?: string; - veterinaryApprovalOrderNo?: string; - containerWaterTemp?: number; - waterTemp?: number; - images?: FishStockingPhoto['id']; - signatures?: { - organization: string; - signedBy: string; - signature: string; - }[]; - assignedToInspector?: number; -} +}; + +type StatsByYear = { + [year: string]: StatsByCadastralId; +}; + +type StatsByCadastralId = { + count: number; + byFish?: StatsById; + byYear?: StatsByYear; +}; + +type Statistics = { + [cadastralId: string]: StatsByCadastralId; +}; + +type Batches = Array; + +type BatchesById = { + [id: string]: Batches; +}; + +const getByFish = (batches: Batches) => { + return batches?.reduce( + (aggregate, value) => { + aggregate.count += value.count; + let fishTypeData = aggregate.byFish[value.fish_type.id]; + if (fishTypeData) { + fishTypeData.count += value.count; + } else { + fishTypeData = { count: value.count, fishType: value.fish_type }; + } + aggregate.byFish[value.fish_type.id] = fishTypeData; + return aggregate; + }, + { count: 0, byFish: {} } as StatsByCadastralId, + ); +}; -interface Populates extends CommonPopulates {} +const getStatsByYear = (batches: Batches) => { + const batchesByYear = batches?.reduce((aggregate, value) => { + aggregate[value.year] = [...(aggregate[value.year] || []), value]; + return aggregate; + }, {} as BatchesById); -export type FishAge< - P extends keyof Populates = never, - F extends keyof (Fields & Populates) = keyof Fields, -> = Table; + const statistics: StatsByYear = {}; + + for (const year in batchesByYear) { + const yearBatches = batchesByYear[year]; + const stats: Omit = getByFish(yearBatches); + statistics[year] = stats; + } + + return statistics; +}; + +const getCount = (batches: Batches) => { + return batches?.reduce((aggregate, batch) => batch.count + aggregate, 0); +}; @Service({ name: 'public', }) export default class FishAgesService extends moleculer.Service { - @Action({ rest: 'GET /fishStockings', auth: RestrictionType.PUBLIC, }) - //TODO: could be moved to fishStockings service async getPublicFishStockings(ctx: Context) { const params = { ...ctx.params, @@ -85,7 +126,6 @@ export default class FishAgesService extends moleculer.Service { rest: 'GET /statistics', auth: RestrictionType.PUBLIC, }) - //TODO: could be moved to fishStockings service async getStatistics(ctx: Context) { const completedFishStockings: FishStocking[] = await ctx.call('fishStockings.count', { filter: { @@ -94,7 +134,6 @@ export default class FishAgesService extends moleculer.Service { }); const locationsCount = await ctx.call('fishStockings.getLocationsCount'); - const fishCount = await ctx.call('fishStockings.getFishCount'); return { @@ -103,4 +142,118 @@ export default class FishAgesService extends moleculer.Service { fish_count: fishCount, }; } + + @Action({ + rest: 'GET /uetk/statistics', + params: uetkStatisticsParams, + auth: RestrictionType.PUBLIC, + cache: { + ttl: 24 * 60 * 60, + }, + }) + async uetkStatistics( + ctx: Context<{ date: any; fishType: number; year: number; cadastralId: string } & any>, + ) { + const batchesByCadastralId = await this.getFilteredBatches(ctx); + + const statistics: Statistics = {}; + + for (const cadastralId in batchesByCadastralId) { + const cadastralIdBatches = batchesByCadastralId[cadastralId]; + statistics[cadastralId] = getByFish(cadastralIdBatches); + } + + return statistics; + } + + @Action({ + rest: 'GET /uetk/statistics/byYear', + // params: uetkStatisticsParams, + auth: RestrictionType.PUBLIC, + cache: { + ttl: 24 * 60 * 60, + }, + }) + async uetkStatisticsByYear( + ctx: Context<{ date: any; fishType: number; year: number; cadastralId: string } & any>, + ) { + const batchesByCadastralId = await this.getFilteredBatches(ctx); + + const statistics: Statistics = {}; + + for (const cadastralId in batchesByCadastralId) { + const batches = batchesByCadastralId[cadastralId]; + const statsByYear: StatsByYear = getStatsByYear(batches); + statistics[cadastralId] = { + count: getCount(batches), + byYear: statsByYear, + }; + } + + return statistics; + } + + @Method + async getFilteredBatches( + ctx: Context<{ date: any; fishType: number; year: number; cadastralId: string } & any>, + ) { + const { fishType, date, cadastralId } = ctx.params; + + const query: any = {}; + + if (fishType) { + const condition = `fish_batches::jsonb @> '[{"fish_type": {"id": ${fishType} }}]'`; + query.$raw = { + condition, + }; + } + + if (date) { + query.reviewTime = date; + try { + query.reviewTime = JSON.parse(date); + } catch (err) {} + } + + if (cadastralId) { + let condition = `location::jsonb @> '{"cadastral_id": "${cadastralId}"}'`; + if (query.$raw?.condition) { + condition = query.$raw?.condition + ' AND ' + condition; + } + query.$raw = { + condition, + }; + } + + const completedFishStockings: FishStockingsCompleted[] = await ctx.call( + 'fishStockingsCompleted.find', + { + query, + }, + ); + + const filteredBatches = completedFishStockings + ?.map((stocking) => + stocking.fishBatches?.map((batch) => ({ + ...batch, + cadastralId: stocking.location.cadastral_id, + year: format(new Date(stocking.reviewTime), 'yyyy'), + })), + ) + ?.flat(); + return filteredBatches.reduce((aggregate, value) => { + aggregate[value.cadastralId] = [...(aggregate[value.cadastralId] || []), value]; + return aggregate; + }, {} as BatchesById); + } + + @Event() + async 'fishStockings.*'(ctx: Context>) { + switch (ctx.params.type) { + case 'create': + case 'update': + case 'remove': + await this.broker.cacher.clean('public.**'); + } + } }