Add food products and their ingredients, with their carbon footprint calculator.
This commit is contained in:
parent
b29356bb8b
commit
a038de4294
16 changed files with 606 additions and 2 deletions
19
migrations/1738106535803-foodProduct.ts
Normal file
19
migrations/1738106535803-foodProduct.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class FoodProduct1738106535803 implements MigrationInterface {
|
||||||
|
name = 'FoodProduct1738106535803'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`CREATE TABLE "food_products" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "footprint" double precision, CONSTRAINT "PK_3aca8796e89325904061ed18b12" PRIMARY KEY ("id"))`);
|
||||||
|
await queryRunner.query(`CREATE TABLE "food_products_ingredients" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "quantity" double precision NOT NULL, "unit" character varying NOT NULL, "foodProductId" integer NOT NULL, "carbonEmissionFactorId" integer, CONSTRAINT "PK_fbf9b2563664e5f4dbb4f2dbac6" PRIMARY KEY ("id"))`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "food_products_ingredients" ADD CONSTRAINT "FK_34cb9f791c7b07183b47521431e" FOREIGN KEY ("foodProductId") REFERENCES "food_products"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "food_products_ingredients" ADD CONSTRAINT "FK_311390501389e8467a7cd001ff3" FOREIGN KEY ("carbonEmissionFactorId") REFERENCES "carbon_emission_factors"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "food_products_ingredients" DROP CONSTRAINT "FK_311390501389e8467a7cd001ff3"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "food_products_ingredients" DROP CONSTRAINT "FK_34cb9f791c7b07183b47521431e"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "food_products_ingredients"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "food_products"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,7 +6,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"init-project": "yarn build && yarn start-docker && yarn migration:run && yarn seed",
|
"init-project": "yarn build && yarn start-docker && sleep 10 && yarn migration:run && yarn seed",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
"test:e2e": "NODE_ENV=test jest --config ./test-e2e/jest-e2e.json",
|
"test:e2e": "NODE_ENV=test jest --config ./test-e2e/jest-e2e.json",
|
||||||
"typeorm": "ts-node ./node_modules/typeorm/cli -d ./config/dataSource.ts",
|
"typeorm": "ts-node ./node_modules/typeorm/cli -d ./config/dataSource.ts",
|
||||||
"migration:run": "yarn build && yarn typeorm migration:run",
|
"migration:run": "yarn build && yarn typeorm migration:run",
|
||||||
"migration:generate": "yarn build && yarn typeorm -- migration:generate ./src/migrations/$migrationName",
|
"migration:generate": "yarn build && yarn typeorm -- migration:generate ./migrations/$migrationName",
|
||||||
"seed": "ts-node ./src/seed-dev-data.ts"
|
"seed": "ts-node ./src/seed-dev-data.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { typeorm } from "../config/dataSource";
|
import { typeorm } from "../config/dataSource";
|
||||||
import { CarbonEmissionFactorsModule } from "./carbonEmissionFactor/carbonEmissionFactors.module";
|
import { CarbonEmissionFactorsModule } from "./carbonEmissionFactor/carbonEmissionFactors.module";
|
||||||
|
import { FoodProductFootprintModule } from "./foodProductFootprint/foodProductFootprint.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -16,6 +17,7 @@ import { CarbonEmissionFactorsModule } from "./carbonEmissionFactor/carbonEmissi
|
||||||
configService.getOrThrow("typeorm"),
|
configService.getOrThrow("typeorm"),
|
||||||
}),
|
}),
|
||||||
CarbonEmissionFactorsModule,
|
CarbonEmissionFactorsModule,
|
||||||
|
FoodProductFootprintModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
7
src/foodProduct/dto/create-foodProduct.dto.ts
Normal file
7
src/foodProduct/dto/create-foodProduct.dto.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import {CreateFoodProductIngredientDto} from "./create-foodProductIngredient.dto";
|
||||||
|
|
||||||
|
export interface CreateFoodProductDto
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
ingredients: CreateFoodProductIngredientDto[];
|
||||||
|
}
|
7
src/foodProduct/dto/create-foodProductIngredient.dto.ts
Normal file
7
src/foodProduct/dto/create-foodProductIngredient.dto.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
|
||||||
|
export interface CreateFoodProductIngredientDto
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
}
|
55
src/foodProduct/foodProduct.entity.test.ts
Normal file
55
src/foodProduct/foodProduct.entity.test.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import {FoodProduct} from "./foodProduct.entity";
|
||||||
|
import {getTestEmissionFactor} from "../seed-dev-data";
|
||||||
|
import {FoodProductIngredient} from "./foodProductIngredient.entity";
|
||||||
|
|
||||||
|
let hamCheesePizza: FoodProduct;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
hamCheesePizza = new FoodProduct({
|
||||||
|
name: "hamCheesePizza",
|
||||||
|
ingredients: [
|
||||||
|
{ name: "ham", quantity: 0.1, unit: "kg" },
|
||||||
|
{ name: "cheese", quantity: 0.15, unit: "kg" },
|
||||||
|
{ name: "tomato", quantity: 0.4, unit: "kg" },
|
||||||
|
{ name: "flour", quantity: 0.7, unit: "kg" },
|
||||||
|
{ name: "oliveOil", quantity: 0.3, unit: "kg" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FoodProduct", () => {
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("should create a food product with its ingredients", () => {
|
||||||
|
expect(hamCheesePizza.name).toBe("hamCheesePizza");
|
||||||
|
expect(hamCheesePizza.ingredients).toHaveLength(5);
|
||||||
|
expect(hamCheesePizza.ingredients?.[1]?.name).toBe("cheese");
|
||||||
|
expect(hamCheesePizza.ingredients?.[2]?.quantity).toBe(0.4);
|
||||||
|
expect(hamCheesePizza.ingredients?.[3]?.unit).toBe("kg");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compute footprint", () => {
|
||||||
|
it("shouldn't be able to compute the carbon footprint as carbon emission factors are unknown", () => {
|
||||||
|
expect(hamCheesePizza.computeFootprint()).toBeNull();
|
||||||
|
expect(hamCheesePizza.footprint).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should compute the carbon footprint for one ingredient but not the others", () => {
|
||||||
|
// Set one ingredient test emission factor.
|
||||||
|
(hamCheesePizza.ingredients as FoodProductIngredient[])[0].carbonEmissionFactor = getTestEmissionFactor("ham");
|
||||||
|
// Compute its footprint.
|
||||||
|
expect((hamCheesePizza.ingredients as FoodProductIngredient[])[0].computeFootprint()).toBeCloseTo(0.011, 3);
|
||||||
|
expect(hamCheesePizza.computeFootprint()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should compute the product carbon footprint", () => {
|
||||||
|
// Set ingredients test emission factors.
|
||||||
|
for (const ingredient of (hamCheesePizza.ingredients as FoodProductIngredient[]))
|
||||||
|
ingredient.carbonEmissionFactor = getTestEmissionFactor(ingredient.name ?? "");
|
||||||
|
|
||||||
|
// Compute product footprint.
|
||||||
|
expect(hamCheesePizza.computeFootprint()).toBeCloseTo(0.224, 3);
|
||||||
|
expect(hamCheesePizza.footprint).toBeCloseTo(0.224, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
68
src/foodProduct/foodProduct.entity.ts
Normal file
68
src/foodProduct/foodProduct.entity.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import {BaseEntity, Column, Entity, OneToMany, PrimaryGeneratedColumn} from "typeorm";
|
||||||
|
import {FoodProductIngredient} from "./foodProductIngredient.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Food product entity.
|
||||||
|
*/
|
||||||
|
@Entity("food_products")
|
||||||
|
export class FoodProduct extends BaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of ingredients of the food product.
|
||||||
|
*/
|
||||||
|
@OneToMany(() => FoodProductIngredient, (ingredient) => ingredient.foodProduct, {
|
||||||
|
cascade: true,
|
||||||
|
})
|
||||||
|
ingredients?: FoodProductIngredient[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed carbon footprint of the product.
|
||||||
|
* Set to null if the carbon footprint couldn't be computed (e.g. because an ingredient is unknown).
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
type: "float",
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
footprint: number|null = null;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(props?: {
|
||||||
|
name: string,
|
||||||
|
ingredients: {
|
||||||
|
name: string,
|
||||||
|
quantity: number,
|
||||||
|
unit: string,
|
||||||
|
}[],
|
||||||
|
})
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = props?.name;
|
||||||
|
this.ingredients = props?.ingredients?.map((ingredientProps) => new FoodProductIngredient(ingredientProps));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to compute (and set) the carbon footprint of the food product from its ingredients.
|
||||||
|
* If the carbon footprint cannot be computed, it's set to NULL.
|
||||||
|
* @return The computed carbon footprint.
|
||||||
|
*/
|
||||||
|
computeFootprint(): number|null
|
||||||
|
{
|
||||||
|
let totalFootprint: number = 0;
|
||||||
|
if (this.ingredients) for (const ingredient of this.ingredients) {
|
||||||
|
// Compute each ingredient footprint and add it to the total footprint.
|
||||||
|
const ingredientFootprint = ingredient.computeFootprint();
|
||||||
|
if (ingredientFootprint === null) return this.footprint = null;
|
||||||
|
totalFootprint += ingredientFootprint;
|
||||||
|
}
|
||||||
|
return this.footprint = totalFootprint;
|
||||||
|
}
|
||||||
|
}
|
79
src/foodProduct/foodProduct.service.test.ts
Normal file
79
src/foodProduct/foodProduct.service.test.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import {dataSource, GreenlyDataSource} from "../../config/dataSource";
|
||||||
|
import {FoodProductService} from "./foodProduct.service";
|
||||||
|
import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity";
|
||||||
|
import {seedTestCarbonEmissionFactors} from "../seed-dev-data";
|
||||||
|
import {FoodProductIngredient} from "./foodProductIngredient.entity";
|
||||||
|
import {FoodProduct} from "./foodProduct.entity";
|
||||||
|
|
||||||
|
let foodProductService: FoodProductService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await dataSource.initialize();
|
||||||
|
foodProductService = new FoodProductService(
|
||||||
|
dataSource.getRepository(FoodProduct),
|
||||||
|
dataSource.getRepository(CarbonEmissionFactor),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FoodProduct.service", () => {
|
||||||
|
it("should initialize a food product from DTO", async () => {
|
||||||
|
const hamCheesePizza = await foodProductService.initialize({
|
||||||
|
name: "hamCheesePizza",
|
||||||
|
ingredients: [
|
||||||
|
{ name: "ham", quantity: 0.1, unit: "kg" },
|
||||||
|
{ name: "cheese", quantity: 0.15, unit: "kg" },
|
||||||
|
{ name: "tomato", quantity: 0.4, unit: "kg" },
|
||||||
|
{ name: "flour", quantity: 0.7, unit: "kg" },
|
||||||
|
{ name: "oliveOil", quantity: 0.3, unit: "kg" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hamCheesePizza.ingredients).toHaveLength(5);
|
||||||
|
expect(hamCheesePizza.ingredients?.[0].carbonEmissionFactor?.name).toBe("ham");
|
||||||
|
expect(hamCheesePizza.ingredients?.[0].carbonEmissionFactor?.unit).toBe("kg");
|
||||||
|
for (const ingredient of (hamCheesePizza.ingredients as FoodProductIngredient[]))
|
||||||
|
expect(ingredient.carbonEmissionFactor?.id).not.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a food product from DTO", async () => {
|
||||||
|
const noFoodProducts = await foodProductService.findAll();
|
||||||
|
expect(noFoodProducts).toHaveLength(0);
|
||||||
|
|
||||||
|
const hamCheesePizza = await foodProductService.create({
|
||||||
|
name: "hamCheesePizza",
|
||||||
|
ingredients: [
|
||||||
|
{ name: "ham", quantity: 0.1, unit: "kg" },
|
||||||
|
{ name: "cheese", quantity: 0.15, unit: "kg" },
|
||||||
|
{ name: "tomato", quantity: 0.4, unit: "kg" },
|
||||||
|
{ name: "flour", quantity: 0.7, unit: "kg" },
|
||||||
|
{ name: "oliveOil", quantity: 0.3, unit: "kg" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const retrievedHamCheesePizza = await dataSource
|
||||||
|
.getRepository(FoodProduct)
|
||||||
|
.findOne({
|
||||||
|
where: { name: "hamCheesePizza" },
|
||||||
|
relations: { ingredients: { carbonEmissionFactor: true } }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(retrievedHamCheesePizza?.name).toBe("hamCheesePizza");
|
||||||
|
expect(retrievedHamCheesePizza?.ingredients).toHaveLength(5);
|
||||||
|
expect(retrievedHamCheesePizza?.ingredients?.[0].carbonEmissionFactor?.name).toBe("ham");
|
||||||
|
expect(retrievedHamCheesePizza?.ingredients?.[0].carbonEmissionFactor?.unit).toBe("kg");
|
||||||
|
for (const ingredient of (retrievedHamCheesePizza?.ingredients as FoodProductIngredient[]))
|
||||||
|
expect(ingredient.carbonEmissionFactor?.id).not.toBeUndefined();
|
||||||
|
|
||||||
|
const allFoodProducts = await foodProductService.findAll();
|
||||||
|
expect(allFoodProducts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await GreenlyDataSource.cleanDatabase();
|
||||||
|
await seedTestCarbonEmissionFactors();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await dataSource.destroy();
|
||||||
|
});
|
56
src/foodProduct/foodProduct.service.ts
Normal file
56
src/foodProduct/foodProduct.service.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import {InjectRepository} from "@nestjs/typeorm";
|
||||||
|
import {Repository} from "typeorm";
|
||||||
|
import {FoodProduct} from "./foodProduct.entity";
|
||||||
|
import {CreateFoodProductDto} from "./dto/create-foodProduct.dto";
|
||||||
|
import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity";
|
||||||
|
|
||||||
|
export class FoodProductService
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(FoodProduct) private foodProductRepository: Repository<FoodProduct>,
|
||||||
|
@InjectRepository(CarbonEmissionFactor) private carbonEmissionFactorRepository: Repository<CarbonEmissionFactor>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all created food products, with their foodprint.
|
||||||
|
*/
|
||||||
|
findAll(): Promise<FoodProduct[]>
|
||||||
|
{
|
||||||
|
return this.foodProductRepository.find();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a new food product object from a provided food product creation object.
|
||||||
|
* @param rawFoodProduct Food product creation object.
|
||||||
|
*/
|
||||||
|
async initialize(rawFoodProduct: CreateFoodProductDto): Promise<FoodProduct>
|
||||||
|
{
|
||||||
|
// Try to find all matching ingredients emission factors.
|
||||||
|
const ingredientsEmissionFactors = await this.carbonEmissionFactorRepository.find({
|
||||||
|
where: rawFoodProduct.ingredients.map((ingredient) => (
|
||||||
|
{ name: ingredient.name, unit: ingredient.unit }
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert a simple map to retrieve ingredients emission factors from their "name + unit" key.
|
||||||
|
const ingredientsEmissionFactorsMap = Object.fromEntries(
|
||||||
|
ingredientsEmissionFactors.map((ingredient) => [ingredient.name + ";" + ingredient.unit, ingredient])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create the food product object with its ingredients.
|
||||||
|
const foodProduct = new FoodProduct(rawFoodProduct);
|
||||||
|
foodProduct.ingredients?.forEach((ingredient) => {
|
||||||
|
ingredient.carbonEmissionFactor = ingredientsEmissionFactorsMap[ingredient.name + ";" + ingredient.unit] ?? null;
|
||||||
|
});
|
||||||
|
return foodProduct;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new food product object from a provided food product creation object.
|
||||||
|
* @param rawFoodProduct Food product creation object.
|
||||||
|
*/
|
||||||
|
async create(rawFoodProduct: CreateFoodProductDto): Promise<FoodProduct>
|
||||||
|
{ // Initialize and save the food product.
|
||||||
|
return (await this.initialize(rawFoodProduct)).save();
|
||||||
|
}
|
||||||
|
}
|
17
src/foodProduct/foodProductIngredient.entity.test.ts
Normal file
17
src/foodProduct/foodProductIngredient.entity.test.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import {FoodProductIngredient} from "./foodProductIngredient.entity";
|
||||||
|
import {getTestEmissionFactor} from "../seed-dev-data";
|
||||||
|
|
||||||
|
let testIngredient: FoodProductIngredient;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testIngredient = new FoodProductIngredient();
|
||||||
|
testIngredient.carbonEmissionFactor = getTestEmissionFactor("ham");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FoodProductIngredient", () => {
|
||||||
|
it("should compute a 0 footprint with undefined quantity", () => {
|
||||||
|
expect(testIngredient.quantity).toBeUndefined();
|
||||||
|
expect(testIngredient.carbonEmissionFactor).not.toBeNull();
|
||||||
|
expect(testIngredient.computeFootprint()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
70
src/foodProduct/foodProductIngredient.entity.ts
Normal file
70
src/foodProduct/foodProductIngredient.entity.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import {BaseEntity, Column, Entity, ManyToOne, PrimaryGeneratedColumn} from "typeorm";
|
||||||
|
import {FoodProduct} from "./foodProduct.entity";
|
||||||
|
import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Food product entity.
|
||||||
|
*/
|
||||||
|
@Entity("food_products_ingredients")
|
||||||
|
export class FoodProductIngredient extends BaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The food product of the ingredient.
|
||||||
|
*/
|
||||||
|
@ManyToOne(() => FoodProduct, (product) => product.ingredients, {
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
foodProduct: FoodProduct;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The carbon emission factor of the ingredient.
|
||||||
|
*/
|
||||||
|
@ManyToOne(() => CarbonEmissionFactor, {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
carbonEmissionFactor: CarbonEmissionFactor|null = null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: "float",
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
quantity?: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
unit?: string;
|
||||||
|
|
||||||
|
constructor(props?: {
|
||||||
|
name: string,
|
||||||
|
quantity: number,
|
||||||
|
unit: string,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = props?.name;
|
||||||
|
this.quantity = props?.quantity;
|
||||||
|
this.unit = props?.unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to compute the carbon footprint of the food product ingredient.
|
||||||
|
* If the carbon footprint cannot be computed, it's set to NULL.
|
||||||
|
* @return The computed carbon footprint.
|
||||||
|
*/
|
||||||
|
computeFootprint(): number|null
|
||||||
|
{
|
||||||
|
if (this.carbonEmissionFactor)
|
||||||
|
return (this.quantity ?? 0) * this.carbonEmissionFactor.emissionCO2eInKgPerUnit;
|
||||||
|
else
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
34
src/foodProductFootprint/foodProductFootprint.controller.ts
Normal file
34
src/foodProductFootprint/foodProductFootprint.controller.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import {Body, Controller, Get, Logger, Post} from "@nestjs/common";
|
||||||
|
import {FoodProductFootprintService} from "./foodProductFootprint.service";
|
||||||
|
import {FoodProductService} from "../foodProduct/foodProduct.service";
|
||||||
|
import {FoodProduct} from "../foodProduct/foodProduct.entity";
|
||||||
|
import {CreateFoodProductDto} from "../foodProduct/dto/create-foodProduct.dto";
|
||||||
|
|
||||||
|
@Controller("food-product-footprint")
|
||||||
|
export class FoodProductFootprintController {
|
||||||
|
constructor(
|
||||||
|
private readonly foodProductService: FoodProductService,
|
||||||
|
private readonly foodProductFootprintService: FoodProductFootprintService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getFoodProductFootprint(): Promise<FoodProduct[]> {
|
||||||
|
Logger.log(
|
||||||
|
`[food-product-footprint] [GET] FoodProduct: getting all FoodProducts`
|
||||||
|
);
|
||||||
|
return this.foodProductService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createFoodProductFoodprint(
|
||||||
|
@Body() foodProductDto: CreateFoodProductDto
|
||||||
|
): Promise<FoodProduct> {
|
||||||
|
``;
|
||||||
|
Logger.log(
|
||||||
|
`[food-product-footprint] [POST] FoodProduct: ${foodProductDto} created`
|
||||||
|
);
|
||||||
|
return this.foodProductFootprintService.computeAndSave(
|
||||||
|
await this.foodProductService.initialize(foodProductDto)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
15
src/foodProductFootprint/foodProductFootprint.module.ts
Normal file
15
src/foodProductFootprint/foodProductFootprint.module.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import {Module} from "@nestjs/common";
|
||||||
|
import {TypeOrmModule} from "@nestjs/typeorm";
|
||||||
|
import {FoodProduct} from "../foodProduct/foodProduct.entity";
|
||||||
|
import {FoodProductIngredient} from "../foodProduct/foodProductIngredient.entity";
|
||||||
|
import {FoodProductFootprintService} from "./foodProductFootprint.service";
|
||||||
|
import {FoodProductService} from "../foodProduct/foodProduct.service";
|
||||||
|
import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity";
|
||||||
|
import {FoodProductFootprintController} from "./foodProductFootprint.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([FoodProduct, FoodProductIngredient, CarbonEmissionFactor])],
|
||||||
|
providers: [FoodProductService, FoodProductFootprintService],
|
||||||
|
controllers: [FoodProductFootprintController],
|
||||||
|
})
|
||||||
|
export class FoodProductFootprintModule {}
|
|
@ -0,0 +1,77 @@
|
||||||
|
import {GreenlyDataSource, dataSource} from "../../config/dataSource";
|
||||||
|
import {seedTestCarbonEmissionFactors} from "../seed-dev-data";
|
||||||
|
import {FoodProductService} from "../foodProduct/foodProduct.service";
|
||||||
|
import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity";
|
||||||
|
import {FoodProductFootprintService} from "./foodProductFootprint.service";
|
||||||
|
import {FoodProduct} from "../foodProduct/foodProduct.entity";
|
||||||
|
|
||||||
|
let foodProductService: FoodProductService;
|
||||||
|
let foodProductFootprintService: FoodProductFootprintService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await dataSource.initialize();
|
||||||
|
foodProductService = new FoodProductService(
|
||||||
|
dataSource.getRepository(FoodProduct),
|
||||||
|
dataSource.getRepository(CarbonEmissionFactor),
|
||||||
|
);
|
||||||
|
foodProductFootprintService = new FoodProductFootprintService();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await GreenlyDataSource.cleanDatabase();
|
||||||
|
await seedTestCarbonEmissionFactors();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FoodProductFootprint.service", () => {
|
||||||
|
it("should compute and save carbon footprint", async () => {
|
||||||
|
let hamCheesePizza = await foodProductService.initialize({
|
||||||
|
name: "hamCheesePizza",
|
||||||
|
ingredients: [
|
||||||
|
{ name: "ham", quantity: 0.1, unit: "kg" },
|
||||||
|
{ name: "cheese", quantity: 0.15, unit: "kg" },
|
||||||
|
{ name: "tomato", quantity: 0.4, unit: "kg" },
|
||||||
|
{ name: "flour", quantity: 0.7, unit: "kg" },
|
||||||
|
{ name: "oliveOil", quantity: 0.3, unit: "kg" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await foodProductFootprintService.computeAndSave(hamCheesePizza);
|
||||||
|
|
||||||
|
expect(hamCheesePizza.footprint).toBeCloseTo(0.224, 3);
|
||||||
|
|
||||||
|
const retrievedHamCheesePizza = await dataSource
|
||||||
|
.getRepository(FoodProduct)
|
||||||
|
.findOne({ where: { name: "hamCheesePizza" } });
|
||||||
|
|
||||||
|
expect(retrievedHamCheesePizza?.name).toBe("hamCheesePizza");
|
||||||
|
expect(retrievedHamCheesePizza?.footprint).toBeCloseTo(0.224, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail to compute and save NULL carbon footprint", async () => {
|
||||||
|
let hamCheesePizza = await foodProductService.initialize({
|
||||||
|
name: "hamCheesePizza",
|
||||||
|
ingredients: [
|
||||||
|
{ name: "ham", quantity: 0.1, unit: "kg" },
|
||||||
|
{ name: "cheese", quantity: 0.15, unit: "kg" },
|
||||||
|
{ name: "tomato", quantity: 0.4, unit: "kg" },
|
||||||
|
{ name: "flour", quantity: 0.7, unit: "kg" },
|
||||||
|
{ name: "anyOil", quantity: 0.3, unit: "kg" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await foodProductFootprintService.computeAndSave(hamCheesePizza);
|
||||||
|
|
||||||
|
expect(hamCheesePizza.footprint).toBeNull();
|
||||||
|
|
||||||
|
const retrievedHamCheesePizza = await dataSource
|
||||||
|
.getRepository(FoodProduct)
|
||||||
|
.findOne({ where: { name: "hamCheesePizza" } });
|
||||||
|
|
||||||
|
expect(retrievedHamCheesePizza?.name).toBe("hamCheesePizza");
|
||||||
|
expect(retrievedHamCheesePizza?.footprint).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await dataSource.destroy();
|
||||||
|
});
|
18
src/foodProductFootprint/foodProductFootprint.service.ts
Normal file
18
src/foodProductFootprint/foodProductFootprint.service.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import {FoodProduct} from "../foodProduct/foodProduct.entity";
|
||||||
|
import {Injectable} from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FoodProductFootprintService
|
||||||
|
{
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the carbon footprint of a food product from its ingredients and save it.
|
||||||
|
* @param foodProduct The food product for which to compute the carbon footprint.
|
||||||
|
*/
|
||||||
|
async computeAndSave(foodProduct: FoodProduct): Promise<FoodProduct>
|
||||||
|
{
|
||||||
|
foodProduct.computeFootprint();
|
||||||
|
return foodProduct.save();
|
||||||
|
}
|
||||||
|
}
|
80
test-e2e/foodProductFootprint.e2e-test.ts
Normal file
80
test-e2e/foodProductFootprint.e2e-test.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import {INestApplication} from "@nestjs/common";
|
||||||
|
import {Test, TestingModule} from "@nestjs/testing";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import {dataSource, GreenlyDataSource} from "../config/dataSource";
|
||||||
|
import {AppModule} from "../src/app.module";
|
||||||
|
import {CarbonEmissionFactor} from "../src/carbonEmissionFactor/carbonEmissionFactor.entity";
|
||||||
|
import {seedTestCarbonEmissionFactors} from "../src/seed-dev-data";
|
||||||
|
import {FoodProductService} from "../src/foodProduct/foodProduct.service";
|
||||||
|
import {FoodProduct} from "../src/foodProduct/foodProduct.entity";
|
||||||
|
import {FoodProductFootprintService} from "../src/foodProductFootprint/foodProductFootprint.service";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await dataSource.initialize();
|
||||||
|
await GreenlyDataSource.cleanDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await dataSource.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FoodProductFootprintController", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let defaultProducts: FoodProduct[];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
await seedTestCarbonEmissionFactors();
|
||||||
|
|
||||||
|
const foodProductService = new FoodProductService(dataSource.getRepository(FoodProduct), dataSource.getRepository(CarbonEmissionFactor));
|
||||||
|
const foodProductFootprintService = new FoodProductFootprintService();
|
||||||
|
await foodProductFootprintService.computeAndSave(await foodProductService.initialize({
|
||||||
|
name: "ketchup",
|
||||||
|
ingredients: [
|
||||||
|
{ name: "tomato", quantity: 0.9, unit: "kg" },
|
||||||
|
{ name: "vinegar", quantity: 0.1, unit: "kg" },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
defaultProducts = await dataSource
|
||||||
|
.getRepository(FoodProduct)
|
||||||
|
.find();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /food-product-footprint", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get("/food-product-footprint")
|
||||||
|
.expect(200)
|
||||||
|
.expect(({ body }) => {
|
||||||
|
expect(body).toEqual(defaultProducts);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /food-product-footprint", async () => {
|
||||||
|
const hamCheesePizza = {
|
||||||
|
name: "hamCheesePizza",
|
||||||
|
ingredients: [
|
||||||
|
{ name: "ham", quantity: 0.1, unit: "kg" },
|
||||||
|
{ name: "cheese", quantity: 0.15, unit: "kg" },
|
||||||
|
{ name: "tomato", quantity: 0.4, unit: "kg" },
|
||||||
|
{ name: "flour", quantity: 0.7, unit: "kg" },
|
||||||
|
{ name: "oliveOil", quantity: 0.3, unit: "kg" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/food-product-footprint")
|
||||||
|
.send(hamCheesePizza)
|
||||||
|
.expect(201)
|
||||||
|
.expect(({ body }) => {
|
||||||
|
expect(body).toMatchObject(hamCheesePizza);
|
||||||
|
expect(body.footprint).not.toBeNull();
|
||||||
|
expect(body.footprint).toBeCloseTo(0.224, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Add table
Reference in a new issue