From a8a79e988dc9f0aaff072180266f7679bdd7bd54 Mon Sep 17 00:00:00 2001 From: Matias Rivas Date: Wed, 19 Aug 2026 10:52:12 -0400 Subject: [PATCH 1/5] feat(database): add explicit position to subcategory Subcategories were ordered alphabetically, which cannot express the GHG Protocol numbering that the Scope 3 category must follow. Ordering moves to a position column with a CHECK and a partial unique index per category, mirroring how category already works. Existing rows are backfilled with the alphabetical order they were already displayed in, so the migration on its own changes nothing visible: the new ordering ships as seed data. --- apps/api/test/factories/subcategoryFactory.ts | 9 +++++++ .../integration.test.ts | 1 + .../integration.test.ts | 1 + .../integration.test.ts | 1 + .../integration.test.ts | 1 + .../integration.test.ts | 1 + docs/data-model/er-diagram.mmd | 4 +++ .../migration.sql | 25 +++++++++++++++++++ packages/database/src/prisma/schema.prisma | 5 +++- 9 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 packages/database/src/prisma/migrations/20260821120000_add_subcategory_position/migration.sql diff --git a/apps/api/test/factories/subcategoryFactory.ts b/apps/api/test/factories/subcategoryFactory.ts index 2ba00ed750..8d9e9aca35 100644 --- a/apps/api/test/factories/subcategoryFactory.ts +++ b/apps/api/test/factories/subcategoryFactory.ts @@ -12,6 +12,14 @@ export async function createTestSubcategory( ): Promise { const randomSuffix = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + // Positions are unique per category, so default to appending last. Counting + // DELETED rows too keeps the generated position free even when a test has + // soft-deleted a sibling. + const { _max } = await prisma.subcategory.aggregate({ + where: { categoryId }, + _max: { position: true }, + }); + return await prisma.subcategory.create({ data: { categoryId, @@ -19,6 +27,7 @@ export async function createTestSubcategory( icon: overrides?.icon ?? "FACTORY", description: overrides?.description ?? "Test subcategory description", explanation: overrides?.explanation ?? null, + position: overrides?.position ?? (_max.position ?? 0) + 1, status: overrides?.status ?? SubcategoryStatus.ACTIVE, createdById: null, updatedById: null, diff --git a/apps/api/test/features/carbonInventories/syncCarbonInventoryLines/integration.test.ts b/apps/api/test/features/carbonInventories/syncCarbonInventoryLines/integration.test.ts index 7c4b8c39df..1cf680907d 100644 --- a/apps/api/test/features/carbonInventories/syncCarbonInventoryLines/integration.test.ts +++ b/apps/api/test/features/carbonInventories/syncCarbonInventoryLines/integration.test.ts @@ -1048,6 +1048,7 @@ describe("POST /api/carbon-inventories/:id/lines/sync - Integration Tests", () = name: "Test Subcategory", icon: "TRUCK", description: "Test Description", + position: 1, updatedAt: null, }, }); diff --git a/apps/api/test/features/carbonInventories/toggleManualTotalEmissions/integration.test.ts b/apps/api/test/features/carbonInventories/toggleManualTotalEmissions/integration.test.ts index 1060a90b25..f8bd99d5fb 100644 --- a/apps/api/test/features/carbonInventories/toggleManualTotalEmissions/integration.test.ts +++ b/apps/api/test/features/carbonInventories/toggleManualTotalEmissions/integration.test.ts @@ -247,6 +247,7 @@ describe("POST /api/carbon-inventories/:id/subcategories/:subcategoryId/manual-t name: "Test Subcategory", icon: "TRUCK", description: "Test Description", + position: 1, updatedAt: null, }, }); diff --git a/apps/api/test/features/subcategoryRecommendations/createSubcategoryRecommendation/integration.test.ts b/apps/api/test/features/subcategoryRecommendations/createSubcategoryRecommendation/integration.test.ts index 6e5d184d53..d98634084d 100644 --- a/apps/api/test/features/subcategoryRecommendations/createSubcategoryRecommendation/integration.test.ts +++ b/apps/api/test/features/subcategoryRecommendations/createSubcategoryRecommendation/integration.test.ts @@ -73,6 +73,7 @@ const seed = async (prisma: PrismaClient): Promise => { name: `Other Subcategory ${Date.now()}`, icon: "icon", description: "", + position: 1, }, }); diff --git a/apps/api/test/features/subcategoryRecommendations/getAllSubcategoryRecommendations/integration.test.ts b/apps/api/test/features/subcategoryRecommendations/getAllSubcategoryRecommendations/integration.test.ts index 3611e63ce1..48faff3f93 100644 --- a/apps/api/test/features/subcategoryRecommendations/getAllSubcategoryRecommendations/integration.test.ts +++ b/apps/api/test/features/subcategoryRecommendations/getAllSubcategoryRecommendations/integration.test.ts @@ -81,6 +81,7 @@ const seed = async (prisma: PrismaClient): Promise => { name: `Other Subcategory ${Date.now()}`, icon: "icon", description: "", + position: 1, }, }); diff --git a/apps/api/test/features/subcategoryRecommendations/updateSubcategoryRecommendation/integration.test.ts b/apps/api/test/features/subcategoryRecommendations/updateSubcategoryRecommendation/integration.test.ts index 764a1b8bd1..118ece5c5d 100644 --- a/apps/api/test/features/subcategoryRecommendations/updateSubcategoryRecommendation/integration.test.ts +++ b/apps/api/test/features/subcategoryRecommendations/updateSubcategoryRecommendation/integration.test.ts @@ -75,6 +75,7 @@ const seed = async (prisma: PrismaClient): Promise => { name: `Other Subcategory ${Date.now()}`, icon: "icon", description: "", + position: 1, }, }); diff --git a/docs/data-model/er-diagram.mmd b/docs/data-model/er-diagram.mmd index 2db6ebf789..75ac343484 100644 --- a/docs/data-model/er-diagram.mmd +++ b/docs/data-model/er-diagram.mmd @@ -229,11 +229,15 @@ erDiagram string icon "" %% just for displaying string description "" + %% Position must be > 0 (enforced by DB CHECK constraint) + int position "" enum status "ACTIVE | DELETED" timestamptz created_at "" timestamptz updated_at "" BIGINT created_by FK "" BIGINT updated_by FK "" + %% UNIQUE(category_id, name) + %% UNIQUE(category_id, position) } emission_factor_dimension { diff --git a/packages/database/src/prisma/migrations/20260821120000_add_subcategory_position/migration.sql b/packages/database/src/prisma/migrations/20260821120000_add_subcategory_position/migration.sql new file mode 100644 index 0000000000..3c1d80774d --- /dev/null +++ b/packages/database/src/prisma/migrations/20260821120000_add_subcategory_position/migration.sql @@ -0,0 +1,25 @@ +-- Subcategories are now explicitly ordered inside their category. Alphabetical +-- order cannot express the GHG Protocol numbering that Scope 3 (category 3) +-- must follow, so ordering moves to a `position` column, mirroring `category`. +-- Existing rows are backfilled with the alphabetical order they were already +-- displayed in, so this migration alone changes nothing visible: the new +-- ordering ships as seed data. +ALTER TABLE "subcategory" ADD COLUMN "position" INTEGER; + +UPDATE "subcategory" AS s +SET "position" = alphabetical."position" +FROM ( + SELECT + "id", + ROW_NUMBER() OVER (PARTITION BY "category_id" ORDER BY "name") AS "position" + FROM "subcategory" +) AS alphabetical +WHERE s."id" = alphabetical."id"; + +ALTER TABLE "subcategory" ALTER COLUMN "position" SET NOT NULL; +ALTER TABLE "subcategory" ADD CONSTRAINT "subcategory_position_check" CHECK (position > 0); + +-- CreateIndex: Partial unique index excluding DELETED rows +CREATE UNIQUE INDEX "subcategory_category_id_position_active_unique" + ON "subcategory" ("category_id", "position") + WHERE "status" <> 'DELETED'; diff --git a/packages/database/src/prisma/schema.prisma b/packages/database/src/prisma/schema.prisma index c39b3b3c20..e77f17775f 100644 --- a/packages/database/src/prisma/schema.prisma +++ b/packages/database/src/prisma/schema.prisma @@ -546,6 +546,8 @@ model Subcategory { icon String description String explanation String? @db.Text + // Position must be > 0 (enforced by DB CHECK constraint: subcategory_position_check) + position Int status SubcategoryStatus @default(ACTIVE) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime? @updatedAt @map("updated_at") @@ -562,8 +564,9 @@ model Subcategory { reductionPlanInitiatives ReductionPlanInitiative[] reductionProjects ReductionProject[] @relation("reduction_project_subcategory") - // Partial unique index exists in SQL migration: + // Partial unique indexes exist in SQL migrations: // ("category_id", "name") WHERE status <> 'DELETED' + // ("category_id", "position") WHERE status <> 'DELETED' @@map("subcategory") } From 86aac2625674cd4298ebd5daa96589bdfbf84630 Mon Sep 17 00:00:00 2001 From: Matias Rivas Date: Wed, 19 Aug 2026 10:52:26 -0400 Subject: [PATCH 2/5] refactor(api): order subcategories by position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every subcategory read (methodology tree, methodology export, emission capture, maintainer list) now sorts by position instead of name, so the order defined by the methodology data is what users see. Positions are never supplied by the client: a new subcategory is appended last inside its category, and one moved to another category is appended last in its destination — keeping its old position could collide with a subcategory already sitting there. Duplicating a methodology carries positions over. --- .../src/features/carbonInventories/helpers.ts | 2 +- .../duplicateMethodology/helpers.ts | 1 + .../getMethodologyById/service.ts | 2 +- .../api/src/features/methodologies/helpers.ts | 2 +- .../createSubcategory/service.ts | 18 +++++ apps/api/src/features/subcategories/errors.ts | 6 ++ .../getAllSubcategories/service.ts | 2 +- .../updateSubcategory/service.ts | 25 +++++++ .../duplicateMethodology/integration.test.ts | 2 + .../getMethodologyById/integration.test.ts | 6 +- .../createSubcategory/integration.test.ts | 29 ++++++++ .../getAllSubcategories/integration.test.ts | 10 +-- .../updateSubcategory/integration.test.ts | 68 +++++++++++++++++++ 13 files changed, 163 insertions(+), 10 deletions(-) diff --git a/apps/api/src/features/carbonInventories/helpers.ts b/apps/api/src/features/carbonInventories/helpers.ts index 6eb6d5b0cb..e9d1189b58 100644 --- a/apps/api/src/features/carbonInventories/helpers.ts +++ b/apps/api/src/features/carbonInventories/helpers.ts @@ -173,7 +173,7 @@ export async function fetchCategoryData( color: true, subcategories: { select: { id: true, name: true, icon: true }, - orderBy: { name: "asc" }, + orderBy: { position: "asc" }, }, }, orderBy: { position: "asc" }, diff --git a/apps/api/src/features/methodologies/duplicateMethodology/helpers.ts b/apps/api/src/features/methodologies/duplicateMethodology/helpers.ts index dce9e21242..f578d445da 100644 --- a/apps/api/src/features/methodologies/duplicateMethodology/helpers.ts +++ b/apps/api/src/features/methodologies/duplicateMethodology/helpers.ts @@ -82,6 +82,7 @@ export const cloneSubcategories = async ( icon: sub.icon, description: sub.description, explanation: sub.explanation, + position: sub.position, status: sub.status, createdById: userId, updatedAt: null, diff --git a/apps/api/src/features/methodologies/getMethodologyById/service.ts b/apps/api/src/features/methodologies/getMethodologyById/service.ts index aefa1cab10..5589ea659b 100644 --- a/apps/api/src/features/methodologies/getMethodologyById/service.ts +++ b/apps/api/src/features/methodologies/getMethodologyById/service.ts @@ -33,7 +33,7 @@ export const getMethodologyByIdService = async ( name: true, subcategories: { where: { status: SubcategoryStatus.ACTIVE }, - orderBy: { name: "asc" }, + orderBy: { position: "asc" }, select: { id: true, name: true }, }, }, diff --git a/apps/api/src/features/methodologies/helpers.ts b/apps/api/src/features/methodologies/helpers.ts index b2589dc75b..2573582fe0 100644 --- a/apps/api/src/features/methodologies/helpers.ts +++ b/apps/api/src/features/methodologies/helpers.ts @@ -34,7 +34,7 @@ export const methodologyExportSelect = { description: true, subcategories: { where: { status: SubcategoryStatus.ACTIVE }, - orderBy: { name: "asc" }, + orderBy: { position: "asc" }, select: { id: true, name: true, diff --git a/apps/api/src/features/subcategories/createSubcategory/service.ts b/apps/api/src/features/subcategories/createSubcategory/service.ts index 508722fa6d..d6416aef86 100644 --- a/apps/api/src/features/subcategories/createSubcategory/service.ts +++ b/apps/api/src/features/subcategories/createSubcategory/service.ts @@ -10,6 +10,7 @@ import { import { CategoryNotFoundForSubcategoryError, SubcategoryNameAlreadyExistsError, + SubcategoryPositionAlreadyExistsError, } from "../errors.js"; import { getDuplicatedFieldsFromP2002Error } from "@/errors/index.js"; import { UserNotFoundError } from "../../users/errors.js"; @@ -38,6 +39,17 @@ export const createSubcategoryService = async ( throw new CategoryNotFoundForSubcategoryError(); } + // Positions are not supplied by the client: a new subcategory is appended + // last inside its category. DELETED rows are excluded from both the max + // and the partial unique index, so a freed position can be reused. + const { _max } = await tx.subcategory.aggregate({ + where: { + categoryId: category.id, + status: { not: SubcategoryStatus.DELETED }, + }, + _max: { position: true }, + }); + const newSubcategory = await tx.subcategory.create({ data: { categoryId: category.id, @@ -45,6 +57,7 @@ export const createSubcategoryService = async ( icon: data.icon, description: data.description, explanation: data.explanation ?? null, + position: (_max.position ?? 0) + 1, status: SubcategoryStatus.ACTIVE, createdById: BigInt(user.id), updatedAt: null, @@ -99,6 +112,11 @@ export const createSubcategoryService = async ( if (duplicatedFields.includes("name")) { throw new SubcategoryNameAlreadyExistsError(); } + // Two concurrent creates can compute the same next position; the unique + // index rejects the loser, which should retry instead of seeing a 500. + if (duplicatedFields.includes("position")) { + throw new SubcategoryPositionAlreadyExistsError(); + } } } throw error; diff --git a/apps/api/src/features/subcategories/errors.ts b/apps/api/src/features/subcategories/errors.ts index d42ba038c9..a3dc7db06f 100644 --- a/apps/api/src/features/subcategories/errors.ts +++ b/apps/api/src/features/subcategories/errors.ts @@ -12,6 +12,12 @@ export const SubcategoryNameAlreadyExistsError = createError( 409 ); +export const SubcategoryPositionAlreadyExistsError = createError( + "SUBCATEGORY_POSITION_ALREADY_EXISTS", + "A subcategory with this position already exists for this category", + 409 +); + export const CategoryNotFoundForSubcategoryError = createError( "CATEGORY_NOT_FOUND_FOR_SUBCATEGORY", "Category not found", diff --git a/apps/api/src/features/subcategories/getAllSubcategories/service.ts b/apps/api/src/features/subcategories/getAllSubcategories/service.ts index 2db2967544..c95e9ae6c7 100644 --- a/apps/api/src/features/subcategories/getAllSubcategories/service.ts +++ b/apps/api/src/features/subcategories/getAllSubcategories/service.ts @@ -35,7 +35,7 @@ export const getAllSubcategoriesService = async ( }, }, }, - orderBy: [{ category: { position: "asc" } }, { name: "asc" }], + orderBy: [{ category: { position: "asc" } }, { position: "asc" }], }); return subcategories.map( diff --git a/apps/api/src/features/subcategories/updateSubcategory/service.ts b/apps/api/src/features/subcategories/updateSubcategory/service.ts index 2a0fc25af1..7ffc3a6a75 100644 --- a/apps/api/src/features/subcategories/updateSubcategory/service.ts +++ b/apps/api/src/features/subcategories/updateSubcategory/service.ts @@ -10,6 +10,7 @@ import { import { SubcategoryNotFoundError, SubcategoryNameAlreadyExistsError, + SubcategoryPositionAlreadyExistsError, CategoryNotFoundForSubcategoryError, CategoryFromDifferentMethodologyError, } from "../errors.js"; @@ -36,6 +37,7 @@ export const updateSubcategoryService = async ( }, select: { status: true, + categoryId: true, category: { select: { methodologyVersionId: true } }, }, }); @@ -44,6 +46,11 @@ export const updateSubcategoryService = async ( throw new SubcategoryNotFoundError(id); } + // Position within the destination category, only when the subcategory + // actually moves. Positions are unique per category, so keeping the old + // one could collide with a subcategory already sitting there. + let newPosition: number | undefined; + // Validate the target category belongs to the same methodology. if (data.categoryId !== undefined) { const newCategory = await tx.category.findFirst({ @@ -64,6 +71,17 @@ export const updateSubcategoryService = async ( ) { throw new CategoryFromDifferentMethodologyError(); } + + if (BigInt(data.categoryId) !== targetSubcategory.categoryId) { + const { _max } = await tx.subcategory.aggregate({ + where: { + categoryId: BigInt(data.categoryId), + status: { not: SubcategoryStatus.DELETED }, + }, + _max: { position: true }, + }); + newPosition = (_max.position ?? 0) + 1; + } } // Build update data dynamically based on provided fields @@ -74,6 +92,8 @@ export const updateSubcategoryService = async ( if (data.categoryId !== undefined) updateData.categoryId = BigInt(data.categoryId); + // The moved subcategory is appended last in its destination category. + if (newPosition !== undefined) updateData.position = newPosition; if (data.name !== undefined) updateData.name = data.name; if (data.icon !== undefined) updateData.icon = data.icon; if (data.description !== undefined) @@ -153,6 +173,11 @@ export const updateSubcategoryService = async ( if (duplicatedFields.includes("name")) { throw new SubcategoryNameAlreadyExistsError(); } + // A concurrent move/create into the same destination category can claim + // the computed position first; surface it as a conflict, not a 500. + if (duplicatedFields.includes("position")) { + throw new SubcategoryPositionAlreadyExistsError(); + } } } throw error; diff --git a/apps/api/test/features/methodologies/duplicateMethodology/integration.test.ts b/apps/api/test/features/methodologies/duplicateMethodology/integration.test.ts index fb12ed7d6d..425521ed7f 100644 --- a/apps/api/test/features/methodologies/duplicateMethodology/integration.test.ts +++ b/apps/api/test/features/methodologies/duplicateMethodology/integration.test.ts @@ -248,8 +248,10 @@ describe("POST /api/methodologies/:id/duplicate - Integration Tests", () => { expect(duplicatedSubcategories).toHaveLength(2); expect(duplicatedSubcategories[0].name).toBe("Test - Sub A"); expect(duplicatedSubcategories[0].description).toBe("Subcategory A"); + expect(duplicatedSubcategories[0].position).toBe(1); expect(duplicatedSubcategories[1].name).toBe("Test - Sub B"); expect(duplicatedSubcategories[1].description).toBe("Subcategory B"); + expect(duplicatedSubcategories[1].position).toBe(2); }); it("should duplicate measurement unit associations for subcategories", async () => { diff --git a/apps/api/test/features/methodologies/getMethodologyById/integration.test.ts b/apps/api/test/features/methodologies/getMethodologyById/integration.test.ts index 862d7909af..f52bd9282c 100644 --- a/apps/api/test/features/methodologies/getMethodologyById/integration.test.ts +++ b/apps/api/test/features/methodologies/getMethodologyById/integration.test.ts @@ -40,7 +40,7 @@ describe("GET /api/methodologies/:id - Integration Tests", () => { await restoreMethodologies(prisma); }); - it("should return the methodology with categories ordered by position and subcategories ordered by name", async () => { + it("should return the methodology with categories and subcategories ordered by position", async () => { const methodology = await createEmptyMethodologyVersion(prisma, { status: MethodologyVersionStatus.PUBLISHED, }); @@ -54,6 +54,8 @@ describe("GET /api/methodologies/:id - Integration Tests", () => { position: 1, }); + // Named out of alphabetical order on purpose: Z is positioned first, so a + // name-based ordering would flip these two. await createTestSubcategory(prisma, categoryA.id, { name: "Test - Subcategory Z", }); @@ -78,8 +80,8 @@ describe("GET /api/methodologies/:id - Integration Tests", () => { expect(body.categories[1].id).toBe(categoryB.id.toString()); expect(body.categories[0].subcategories.map((s) => s.name)).toEqual([ - "Test - Subcategory A", "Test - Subcategory Z", + "Test - Subcategory A", ]); expect(body.categories[1].subcategories).toEqual([]); }); diff --git a/apps/api/test/features/subcategories/createSubcategory/integration.test.ts b/apps/api/test/features/subcategories/createSubcategory/integration.test.ts index 4d7d44472c..03e9304b5f 100644 --- a/apps/api/test/features/subcategories/createSubcategory/integration.test.ts +++ b/apps/api/test/features/subcategories/createSubcategory/integration.test.ts @@ -126,6 +126,35 @@ describe("POST /api/subcategories/ - Integration Tests", () => { expect(dbRecord!.description).toBe(payload.description); }); + it("should append the new subcategory last inside its category", async () => { + const methodology = await createEmptyMethodologyVersion(prisma, { + name: "Test - Subcategory Position", + }); + const category = await createTestCategory(prisma, methodology.id, { + name: "Test - Position Parent", + position: 1, + }); + await createTestSubcategory(prisma, category.id, { + name: "Test - Existing Subcategory", + position: 1, + }); + + const response = await app.inject({ + method: "POST", + url: "/api/subcategories/", + payload: buildSubcategoryPayload(category.id.toString()), + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body) as CreateSubcategoryResponse; + + const dbRecord = await prisma.subcategory.findUnique({ + where: { id: BigInt(body.id) }, + }); + + expect(dbRecord!.position).toBe(2); + }); + it("should create measurement unit associations", async () => { const methodology = await createEmptyMethodologyVersion(prisma, { name: "Test - Subcategory With Units", diff --git a/apps/api/test/features/subcategories/getAllSubcategories/integration.test.ts b/apps/api/test/features/subcategories/getAllSubcategories/integration.test.ts index 3f7e555a19..d2f5e256dc 100644 --- a/apps/api/test/features/subcategories/getAllSubcategories/integration.test.ts +++ b/apps/api/test/features/subcategories/getAllSubcategories/integration.test.ts @@ -46,7 +46,7 @@ describe("GET /api/subcategories/?methodologyVersionId=X - Integration Tests", ( }); describe("Successful retrieval", () => { - it("should return subcategories ordered by category position first, then by name", async () => { + it("should return subcategories ordered by category position first, then by subcategory position", async () => { const methodology = await createEmptyMethodologyVersion(prisma, { name: "Test - Subcategories Ordered", status: MethodologyVersionStatus.PUBLISHED, @@ -63,6 +63,8 @@ describe("GET /api/subcategories/?methodologyVersionId=X - Integration Tests", ( await createTestSubcategory(prisma, categoryB.id, { name: "Test - Subcategory X", }); + // Named out of alphabetical order on purpose: C is created (and + // positioned) first, so a name-based ordering would flip these two. await createTestSubcategory(prisma, categoryA.id, { name: "Test - Subcategory C", }); @@ -79,9 +81,9 @@ describe("GET /api/subcategories/?methodologyVersionId=X - Integration Tests", ( const body = JSON.parse(response.body) as GetAllSubcategoriesResponse; expect(body).toHaveLength(3); - // Category A (position 1) subcategories first, sorted by name - expect(body[0].name).toBe("Test - Subcategory A"); - expect(body[1].name).toBe("Test - Subcategory C"); + // Category A (position 1) subcategories first, in their own position order + expect(body[0].name).toBe("Test - Subcategory C"); + expect(body[1].name).toBe("Test - Subcategory A"); // Category B (position 2) subcategories last expect(body[2].name).toBe("Test - Subcategory X"); }); diff --git a/apps/api/test/features/subcategories/updateSubcategory/integration.test.ts b/apps/api/test/features/subcategories/updateSubcategory/integration.test.ts index bc7cd88b47..caf6d20ef3 100644 --- a/apps/api/test/features/subcategories/updateSubcategory/integration.test.ts +++ b/apps/api/test/features/subcategories/updateSubcategory/integration.test.ts @@ -365,6 +365,74 @@ describe("PATCH /api/subcategories/:id - Integration Tests", () => { expect(body.category.id).toBe(newCategory.id.toString()); }); + it("should append the subcategory last when moved into an occupied category", async () => { + const subcategory = await createTestSubcategory(prisma, categoryId, { + name: "Test - Move Into Occupied", + position: 1, + }); + + const methodology = await prisma.category.findUniqueOrThrow({ + where: { id: categoryId }, + select: { methodologyVersionId: true }, + }); + const newCategory = await createTestCategory( + prisma, + methodology.methodologyVersionId, + { + name: "Test - Occupied Target Category", + position: 3, + } + ); + // The destination already has a subcategory at position 1, so keeping the + // moved subcategory's own position would break the unique index. + await createTestSubcategory(prisma, newCategory.id, { + name: "Test - Already At Position One", + position: 1, + }); + + const response = await app.inject({ + method: "PATCH", + url: `/api/subcategories/${subcategory.id}`, + payload: { + categoryId: newCategory.id.toString(), + }, + }); + + expect(response.statusCode).toBe(200); + + const moved = await prisma.subcategory.findUniqueOrThrow({ + where: { id: subcategory.id }, + }); + expect(moved.categoryId).toBe(newCategory.id); + expect(moved.position).toBe(2); + }); + + it("should keep the position when the update does not change the category", async () => { + const first = await createTestSubcategory(prisma, categoryId, { + name: "Test - Keeps Position First", + }); + const second = await createTestSubcategory(prisma, categoryId, { + name: "Test - Keeps Position Second", + }); + + const response = await app.inject({ + method: "PATCH", + url: `/api/subcategories/${second.id}`, + payload: { + categoryId: categoryId.toString(), + name: "Test - Keeps Position Renamed", + }, + }); + + expect(response.statusCode).toBe(200); + + const updated = await prisma.subcategory.findUniqueOrThrow({ + where: { id: second.id }, + }); + expect(updated.position).toBe(second.position); + expect(second.position).toBeGreaterThan(first.position); + }); + it("should update explanation only", async () => { const subcategory = await createTestSubcategory(prisma, categoryId, { name: "Test - Explanation Update", From 557de748c85183efacb7c8fc58157e16a3566da1 Mon Sep 17 00:00:00 2001 From: Matias Rivas Date: Wed, 19 Aug 2026 10:52:53 -0400 Subject: [PATCH 3/5] feat(seed): order scope 3 by GHG Protocol numbering The 11 subcategories of the Scope 3 category now follow the GHG Protocol Scope 3 category numbering (purchased goods, water, upstream transport, waste, business travel, commuting, downstream transport, use of sold products, other sources last) instead of being listed alphabetically, and each description opens with the GHG Protocol and ISO 14064-1 category it maps to, so the equivalence is visible where the user picks the subcategory. Scope 1 and 2 keep the order already authored in the data file, which reads better than alphabetical: "Emisiones provenientes de otras fuentes" now closes the list instead of sitting in the middle. --- docs/architecture/methodology-taxonomy.md | 5 +- load_methodologies/environment.py | 1 + tools/seed/src/data/base/methodologies.json | 977 ++++++++++-------- .../seed/src/data/testing/methodologies.json | 977 ++++++++++-------- .../seedMethodologyData/seedSubcategories.ts | 12 + tools/seed/src/scripts/shared.ts | 1 + 6 files changed, 1072 insertions(+), 901 deletions(-) diff --git a/docs/architecture/methodology-taxonomy.md b/docs/architecture/methodology-taxonomy.md index 2c683bc036..c6748736e1 100644 --- a/docs/architecture/methodology-taxonomy.md +++ b/docs/architecture/methodology-taxonomy.md @@ -95,6 +95,7 @@ model Subcategory { name String description String icon String // icon key (e.g., "FACTORY") + position Int // display order; must be > 0; unique per category status SubcategoryStatus @default(ACTIVE) dimensions EmissionFactorDimension[] @@ -109,7 +110,9 @@ model Subcategory { } ``` -Subcategory names are unique within a category (partial unique index, excluding deleted records). +Subcategory names are unique within a category (partial unique index, excluding deleted records), and so are positions. + +Subcategories are ordered by `position` ascending — never alphabetically, since the order carries meaning: inside the Scope 3 category the subcategories follow the GHG Protocol Scope 3 category numbering (purchased goods → upstream transport → waste → business travel → commuting → downstream transport → use of sold products), with the catch-all "other sources" last. Their descriptions state the GHG Protocol and ISO 14064-1 category each one maps to. The order ships as seed data (`tools/seed/src/data/*/methodologies.json`, one `position` per subcategory); subcategories created later through the maintainer are appended last. --- diff --git a/load_methodologies/environment.py b/load_methodologies/environment.py index 7a785337b8..e0cc264bdb 100644 --- a/load_methodologies/environment.py +++ b/load_methodologies/environment.py @@ -81,6 +81,7 @@ class State: subcategory_map = { "Nombre Sub categoría": json_subcategory_name_value, "Descripción": "description", + "Posición": "position", } subcategory_excluded_cols = [ 'Nombre categoría', diff --git a/tools/seed/src/data/base/methodologies.json b/tools/seed/src/data/base/methodologies.json index e923d84e48..21934969c4 100644 --- a/tools/seed/src/data/base/methodologies.json +++ b/tools/seed/src/data/base/methodologies.json @@ -17,6 +17,7 @@ { "name": "Combustiones estacionarias", "description": "Equipos no móviles bajo control de la empresa, como calderas, generadores, hornos o cocinas industriales, que operan con diésel, gas, carbón, pellets u otros combustibles.", + "position": 1, "emissionFactorDimensions": [ { "code": "Combustiones estacionarias_Tipo", @@ -665,6 +666,7 @@ { "name": "Combustiones móviles (flota propia)", "description": "Vehículos y maquinarias móviles que consumen combustibles y son operados por la empresa, como autos, camiones, buses, motocicletas, maquinaria, barcos u otros equipos móviles con combustible financiado por la empresa.", + "position": 2, "emissionFactorDimensions": [ { "code": "Combustiones móviles (flota propia)_Tipo", @@ -886,6 +888,7 @@ { "name": "Emisiones fugitivas", "description": "Emisiones no intencionales que se liberan por fugas o escapes de gases, como pérdidas de gases refrigerantes de aires acondicionados, cámaras de frío, sistemas de refrigeración, gases industriales o otras fugas de procesos.", + "position": 3, "emissionFactorDimensions": [ { "code": "Emisiones fugitivas_Gas", @@ -1366,6 +1369,7 @@ { "name": "Emisiones por uso de suelo - Ganadería", "description": "Emisiones generadas por actividades ganaderas. Incluye emisiones provenientes de la digestión del ganado y el manejo de estiércol.", + "position": 4, "emissionFactorDimensions": [ { "code": "Emisiones por uso de suelo - Ganadería_Tipo de animal", @@ -1538,6 +1542,7 @@ { "name": "Emisiones por uso de suelo - Agricultura", "description": "Emisiones asociadas a actividades agrícolas y manejo del suelo. Incluye emisiones por laboreo, cultivos y cambios en el uso del suelo.", + "position": 5, "emissionFactorDimensions": [ { "code": "Emisiones por uso de suelo - Agricultura_Tipo de cultivo", @@ -1584,6 +1589,7 @@ { "name": "Emisiones por uso de suelo - Aplicación de fertilizantes", "description": "Emisiones derivadas del uso de fertilizantes en suelos productivos. Incluye gases liberados tras la aplicación de fertilizantes químicos u orgánicos.", + "position": 6, "emissionFactorDimensions": [ { "code": "Emisiones por uso de suelo - Aplicación de fertilizantes_Componente", @@ -1644,6 +1650,7 @@ { "name": "Procesos industriales - Cinc", "description": "Registra aquí las emisiones generadas en procesos industriales de producción o refinación de cinc, como fundiciones, refinerías o plantas metalúrgicas, donde ocurren reacciones químicas propias del proceso.", + "position": 7, "emissionFactorDimensions": [ { "code": "Procesos industriales - Cinc_Proceso de producción", @@ -1738,6 +1745,7 @@ { "name": "Procesos industriales - Cemento", "description": "Emisiones generadas en la fabricación de cemento. Incluye emisiones producidas por la utilización de clinker.", + "position": 8, "emissionFactorDimensions": [ { "code": "Procesos industriales - Cemento_Material", @@ -1780,6 +1788,7 @@ { "name": "Procesos industriales - Vidrio", "description": "Incluye aquí las emisiones generadas por la producción de vidrio nuevo, considerando la cantidad de vidrio creado desde materias primas.", + "position": 9, "emissionFactorDimensions": [ { "code": "Procesos industriales - Vidrio_Tipo de vidrio", @@ -1990,6 +1999,7 @@ { "name": "Procesos industriales - Acero", "description": "Emisiones provenientes de la producción y procesamiento del acero. Incluye emisiones generadas por reacciones químicas en altos hornos, acerías y otros procesos industriales que involucran la producción de acero.", + "position": 10, "emissionFactorDimensions": [ { "code": "Procesos industriales - Acero_Método de fabricación", @@ -2108,6 +2118,7 @@ { "name": "Emisiones provenientes de otras fuentes", "description": "Incluye cualquier otra fuente de emisión que ocurra dentro de los límites físicos de la empresa y esté bajo su control operativo.", + "position": 11, "emissionFactorDimensions": [], "emissionFactors": [], "allowedMeasurementUnitsAbbreviations": [], @@ -2126,6 +2137,7 @@ { "name": "Electricidad", "description": "Emisiones asociadas al consumo de electricidad compradas a generadoras externas a tu empresa. Lo anterior puede ser consumo de la red nacional u otras generadoras externas.\nIncluye las emisiones generadas en la producción de la electricidad que utiliza tu empresa, aunque estas ocurran fuera de tus instalaciones.", + "position": 1, "emissionFactorDimensions": [ { "code": "Electricidad_Sistema eléctrico", @@ -2167,7 +2179,8 @@ "subcategories": [ { "name": "Productos comprados", - "description": "Emisiones asociadas a la compra de productos. Estas emisiones corresponden a las emisiones que se generaron para lograr la producción de los productos adquiridos.", + "description": "GHG Protocol cat. 1 · ISO 14064-1 cat. 4 — Emisiones asociadas a la compra de productos. Estas emisiones corresponden a las emisiones que se generaron para lograr la producción de los productos adquiridos.", + "position": 1, "emissionFactorDimensions": [ { "code": "Productos comprados_Material", @@ -2498,9 +2511,216 @@ "allowedMeasurementUnitsAbbreviations": ["g", "kg", "ton"], "icon": "FACTORY" }, + { + "name": "Consumo de agua y tratamiento de aguas residuales", + "description": "GHG Protocol cats. 1 y 5 · ISO 14064-1 cat. 4 — Emisiones asociadas al uso de agua y su tratamiento. Incluye las emisiones generadas por el consumo y desecho de las aguas utilizadas en tu empresa.", + "position": 2, + "emissionFactorDimensions": [ + { + "code": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", + "name": "Fuente de suministro", + "position": 1, + "isRequired": true, + "values": [ + { + "name": "Consumo de agua", + "parentValue": null + }, + { + "name": "Agua dispuesta en el alcantarillado", + "parentValue": null + } + ] + } + ], + "emissionFactors": [ + { + "dimensionValue1": { + "dimensionCode": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", + "valueName": "Agua dispuesta en el alcantarillado" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/m3", + "source": "DEFRA 2025", + "value": 0.17088 + }, + { + "dimensionValue1": { + "dimensionCode": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", + "valueName": "Consumo de agua" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/m3", + "source": "DEFRA 2025", + "value": 0.1913 + } + ], + "allowedMeasurementUnitsAbbreviations": ["L", "m3", "gal"], + "icon": "FACTORY" + }, + { + "name": "Transporte y distribución de bienes aguas arriba", + "description": "GHG Protocol cat. 4 · ISO 14064-1 cat. 3 — Emisiones asociadas al transporte de insumos hacia tu empresa. Incluye el traslado y distribución de materias primas, productos comprados e insumos desde proveedores hasta las instalaciones de la organización.", + "position": 3, + "emissionFactorDimensions": [ + { + "code": "Transporte y distribución de bienes aguas arriba_Transporte", + "name": "Transporte", + "position": 1, + "isRequired": true, + "values": [ + { + "name": "Tren de carga", + "parentValue": null + }, + { + "name": "Contenedores por barco", + "parentValue": null + }, + { + "name": "Granel por barco", + "parentValue": null + }, + { + "name": "Avión: Short haul (<2500km)", + "parentValue": null + }, + { + "name": "Avión: Medium haul (2500-5000km)", + "parentValue": null + }, + { + "name": "Avión: Long haul (<5000km)", + "parentValue": null + }, + { + "name": "Camión no refrigerado", + "parentValue": null + }, + { + "name": "Camión refrigerado", + "parentValue": null + }, + { + "name": "Van eléctrica", + "parentValue": null + }, + { + "name": "Van con motor a combustión", + "parentValue": null + } + ] + } + ], + "emissionFactors": [ + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Camión no refrigerado" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.2115 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Camión refrigerado" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.2482 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Van con motor a combustión" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.06183 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Van eléctrica" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.03758 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Avión: Long haul (<5000km)" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.1351 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Avión: Medium haul (2500-5000km)" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.1351 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Avión: Short haul (<2500km)" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.2051 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Contenedores por barco" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.01612 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Granel por barco" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.00353 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Tren de carga" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.02779 + } + ], + "allowedMeasurementUnitsAbbreviations": ["m", "km", "mi", "km-ton"], + "icon": "FACTORY" + }, { "name": "Disposición de residuos sólidos", - "description": "Emisiones generadas por el tratamiento y disposición final de residuos. Incluye emisiones enviadas a los rellenos sanitarios, reciclaje, compostaje, incineración u otros métodos de gestión de residuos generados por la empresa.", + "description": "GHG Protocol cat. 5 · ISO 14064-1 cat. 4 — Emisiones generadas por el tratamiento y disposición final de residuos. Incluye emisiones enviadas a los rellenos sanitarios, reciclaje, compostaje, incineración u otros métodos de gestión de residuos generados por la empresa.", + "position": 4, "emissionFactorDimensions": [ { "code": "Disposición de residuos sólidos_Material", @@ -2941,21 +3161,54 @@ "icon": "FACTORY" }, { - "name": "Consumo de agua y tratamiento de aguas residuales", - "description": "Emisiones asociadas al uso de agua y su tratamiento. Incluye las emisiones generadas por el consumo y desecho de las aguas utilizadas en tu empresa.", + "name": "Viajes de negocios - Traslado", + "description": "GHG Protocol cat. 6 · ISO 14064-1 cat. 3 — Emisiones producidas por el traslado en y hacia los viajes laborales. Incluye traslados en avión, bus, tren, taxi, vehículos arrendados u otros medios utilizados para viajes de negocios.", + "position": 5, "emissionFactorDimensions": [ { - "code": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", - "name": "Fuente de suministro", + "code": "Viajes de negocios - Traslado_Transporte", + "name": "Transporte", "position": 1, "isRequired": true, "values": [ { - "name": "Consumo de agua", + "name": "Transporte en auto", "parentValue": null }, { - "name": "Agua dispuesta en el alcantarillado", + "name": "Transporte en Taxi", + "parentValue": null + }, + { + "name": "Transporte en Bus", + "parentValue": null + }, + { + "name": "Transporte en Tren", + "parentValue": null + }, + { + "name": "Transporte en avión: Short haul (<3hrs) Economy", + "parentValue": null + }, + { + "name": "Transporte en avión: Short haul (<3hrs) Business", + "parentValue": null + }, + { + "name": "Transporte en avión: Medium haul (3-6hrs) Economy", + "parentValue": null + }, + { + "name": "Transporte en avión: Medium haul (3-6hrs) Business", + "parentValue": null + }, + { + "name": "Transporte en avión: Long haul (>6hrs) Economy", + "parentValue": null + }, + { + "name": "Transporte en avión: Long haul (>6hrs) Business", "parentValue": null } ] @@ -2964,331 +3217,112 @@ "emissionFactors": [ { "dimensionValue1": { - "dimensionCode": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", - "valueName": "Agua dispuesta en el alcantarillado" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en auto" }, "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/m3", + "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.17088 + "value": 0.173 }, { "dimensionValue1": { - "dimensionCode": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", - "valueName": "Consumo de agua" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Long haul (>6hrs) Business" }, "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/m3", - "source": "DEFRA 2025", - "value": 0.1913 - } - ], - "allowedMeasurementUnitsAbbreviations": ["L", "m3", "gal"], - "icon": "FACTORY" - }, - { - "name": "Desplazamiento diario de empleados", - "description": "Emisiones asociadas al traslado cotidiano de los empleados entre su hogar y el lugar de trabajo (commuting).", - "emissionFactorDimensions": [ - { - "code": "Desplazamiento diario de empleados_Tipo", - "name": "Tipo", - "position": 1, - "isRequired": true, - "values": [ - { "name": "Auto", "parentValue": null }, - { "name": "Moto", "parentValue": null }, - { "name": "Bus urbano", "parentValue": null }, - { "name": "Bus interurbano", "parentValue": null }, - { "name": "Metro", "parentValue": null }, - { "name": "Tren cercanías", "parentValue": null }, - { "name": "Tren larga distancia", "parentValue": null }, - { "name": "Taxi/Ride-share", "parentValue": null }, - { "name": "Bici", "parentValue": null }, - { "name": "Caminata", "parentValue": null } - ] - }, - { - "code": "Desplazamiento diario de empleados_Combustible", - "name": "Combustible", - "position": 2, - "isRequired": true, - "values": [ - { "name": "Gasolina", "parentValue": null }, - { "name": "Diésel", "parentValue": null }, - { "name": "Eléctrico", "parentValue": null }, - { "name": "Híbrido", "parentValue": null }, - { "name": "No aplica", "parentValue": null } - ] - } - ], - "emissionFactors": [ - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Auto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Gasolina" - }, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.173 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Auto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Diésel" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.166 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Auto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Eléctrico" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.047 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Auto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Híbrido" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.11 + "value": 0.3165 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Moto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Gasolina" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Long haul (>6hrs) Economy" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.114 + "value": 0.1091 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Moto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Eléctrico" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Medium haul (3-6hrs) Business" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.03 + "value": 0.3394 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Bus urbano" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Medium haul (3-6hrs) Economy" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", "value": 0.117 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Bus interurbano" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.027 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Metro" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.041 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Tren cercanías" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.035 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Tren larga distancia" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.035 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Taxi/Ride-share" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Gasolina" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Short haul (<3hrs) Business" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.149 + "value": 0.1886 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Taxi/Ride-share" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Eléctrico" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Short haul (<3hrs) Economy" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.06 + "value": 0.1257 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Taxi/Ride-share" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Híbrido" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en Bus" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.11 + "value": 0.1038 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Bici" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en Tren" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.0 + "value": 0.0354 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Caminata" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en Taxi" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.0 - } - ], - "allowedMeasurementUnitsAbbreviations": ["km"], - "icon": "CAR" - }, - { - "name": "Trabajo remoto de empleados", - "description": "Emisiones asociadas al consumo energético del hogar atribuible a las horas de trabajo remoto (teletrabajo).", - "emissionFactorDimensions": [ - { - "code": "Trabajo remoto de empleados_Componente", - "name": "Componente", - "position": 1, - "isRequired": true, - "values": [ - { "name": "Equipo de oficina", "parentValue": null }, - { "name": "Calefacción", "parentValue": null }, - { "name": "Refrigeración", "parentValue": null } - ] - } - ], - "emissionFactors": [ - { - "dimensionValue1": { - "dimensionCode": "Trabajo remoto de empleados_Componente", - "valueName": "Equipo de oficina" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/h", - "source": "EcoAct 2020", - "value": 0.057 - }, - { - "dimensionValue1": { - "dimensionCode": "Trabajo remoto de empleados_Componente", - "valueName": "Calefacción" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/h", - "source": "EcoAct 2020", - "value": 0.3 - }, - { - "dimensionValue1": { - "dimensionCode": "Trabajo remoto de empleados_Componente", - "valueName": "Refrigeración" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/h", - "source": "EcoAct 2020", - "value": 0.122 + "value": 0.148 } ], - "allowedMeasurementUnitsAbbreviations": ["h"], - "icon": "BOLT" + "allowedMeasurementUnitsAbbreviations": ["m", "km", "mi"], + "icon": "FACTORY" }, { "name": "Viajes de negocios - Estadía", - "description": "Emisiones generadas durante la estadía en viajes laborales. Incluye emisiones asociadas a hoteles, alojamientos y servicios utilizados durante viajes de trabajo de acuerdo al país de residencia.", + "description": "GHG Protocol cat. 6 · ISO 14064-1 cat. 3 — Emisiones generadas durante la estadía en viajes laborales. Incluye emisiones asociadas a hoteles, alojamientos y servicios utilizados durante viajes de trabajo de acuerdo al país de residencia.", + "position": 6, "emissionFactorDimensions": [ { "code": "Viajes de negocios - Estadía_País", @@ -3599,53 +3633,82 @@ "icon": "FACTORY" }, { - "name": "Viajes de negocios - Traslado", - "description": "Emisiones producidas por el traslado en y hacia los viajes laborales. Incluye traslados en avión, bus, tren, taxi, vehículos arrendados u otros medios utilizados para viajes de negocios.", + "name": "Desplazamiento diario de empleados", + "description": "GHG Protocol cat. 7 · ISO 14064-1 cat. 3 — Emisiones asociadas al traslado cotidiano de los empleados entre su hogar y el lugar de trabajo (commuting).", + "position": 7, "emissionFactorDimensions": [ { - "code": "Viajes de negocios - Traslado_Transporte", - "name": "Transporte", + "code": "Desplazamiento diario de empleados_Tipo", + "name": "Tipo", "position": 1, "isRequired": true, "values": [ { - "name": "Transporte en auto", + "name": "Auto", "parentValue": null }, { - "name": "Transporte en Taxi", + "name": "Moto", "parentValue": null }, { - "name": "Transporte en Bus", + "name": "Bus urbano", "parentValue": null }, { - "name": "Transporte en Tren", + "name": "Bus interurbano", "parentValue": null }, { - "name": "Transporte en avión: Short haul (<3hrs) Economy", + "name": "Metro", "parentValue": null }, { - "name": "Transporte en avión: Short haul (<3hrs) Business", + "name": "Tren cercanías", "parentValue": null }, { - "name": "Transporte en avión: Medium haul (3-6hrs) Economy", + "name": "Tren larga distancia", "parentValue": null }, { - "name": "Transporte en avión: Medium haul (3-6hrs) Business", + "name": "Taxi/Ride-share", "parentValue": null }, { - "name": "Transporte en avión: Long haul (>6hrs) Economy", + "name": "Bici", "parentValue": null }, { - "name": "Transporte en avión: Long haul (>6hrs) Business", + "name": "Caminata", + "parentValue": null + } + ] + }, + { + "code": "Desplazamiento diario de empleados_Combustible", + "name": "Combustible", + "position": 2, + "isRequired": true, + "values": [ + { + "name": "Gasolina", + "parentValue": null + }, + { + "name": "Diésel", + "parentValue": null + }, + { + "name": "Eléctrico", + "parentValue": null + }, + { + "name": "Híbrido", + "parentValue": null + }, + { + "name": "No aplica", "parentValue": null } ] @@ -3654,156 +3717,237 @@ "emissionFactors": [ { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en auto" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Auto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Gasolina" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.173 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Auto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Diésel" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.166 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Auto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Eléctrico" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.047 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Auto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Híbrido" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.11 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Moto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Gasolina" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.114 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Moto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Eléctrico" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.03 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Bus urbano" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.173 + "value": 0.117 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Long haul (>6hrs) Business" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Bus interurbano" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.3165 + "value": 0.027 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Long haul (>6hrs) Economy" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Metro" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.1091 + "value": 0.041 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Medium haul (3-6hrs) Business" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Tren cercanías" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.3394 + "value": 0.035 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Medium haul (3-6hrs) Economy" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Tren larga distancia" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.117 + "value": 0.035 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Short haul (<3hrs) Business" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Taxi/Ride-share" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Gasolina" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.1886 + "value": 0.149 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Short haul (<3hrs) Economy" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Taxi/Ride-share" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Eléctrico" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.1257 + "value": 0.06 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en Bus" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Taxi/Ride-share" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Híbrido" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.1038 + "value": 0.11 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en Tren" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Bici" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.0354 + "value": 0.0 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en Taxi" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Caminata" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.148 + "value": 0.0 } ], - "allowedMeasurementUnitsAbbreviations": ["m", "km", "mi"], - "icon": "FACTORY" + "allowedMeasurementUnitsAbbreviations": ["km"], + "icon": "CAR" }, { - "name": "Transporte y distribución de bienes aguas arriba", - "description": "Emisiones asociadas al transporte de insumos hacia tu empresa. Incluye el traslado y distribución de materias primas, productos comprados e insumos desde proveedores hasta las instalaciones de la organización.", + "name": "Trabajo remoto de empleados", + "description": "GHG Protocol cat. 7 · ISO 14064-1 cat. 3 — Emisiones asociadas al consumo energético del hogar atribuible a las horas de trabajo remoto (teletrabajo).", + "position": 8, "emissionFactorDimensions": [ { - "code": "Transporte y distribución de bienes aguas arriba_Transporte", - "name": "Transporte", + "code": "Trabajo remoto de empleados_Componente", + "name": "Componente", "position": 1, "isRequired": true, "values": [ { - "name": "Tren de carga", - "parentValue": null - }, - { - "name": "Contenedores por barco", - "parentValue": null - }, - { - "name": "Granel por barco", - "parentValue": null - }, - { - "name": "Avión: Short haul (<2500km)", - "parentValue": null - }, - { - "name": "Avión: Medium haul (2500-5000km)", - "parentValue": null - }, - { - "name": "Avión: Long haul (<5000km)", - "parentValue": null - }, - { - "name": "Camión no refrigerado", - "parentValue": null - }, - { - "name": "Camión refrigerado", + "name": "Equipo de oficina", "parentValue": null }, { - "name": "Van eléctrica", + "name": "Calefacción", "parentValue": null }, { - "name": "Van con motor a combustión", + "name": "Refrigeración", "parentValue": null } ] @@ -3812,111 +3956,42 @@ "emissionFactors": [ { "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Camión no refrigerado" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.2115 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Camión refrigerado" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.2482 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Van con motor a combustión" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.06183 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Van eléctrica" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.03758 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Avión: Long haul (<5000km)" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.1351 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Avión: Medium haul (2500-5000km)" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.1351 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Avión: Short haul (<2500km)" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.2051 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Contenedores por barco" + "dimensionCode": "Trabajo remoto de empleados_Componente", + "valueName": "Equipo de oficina" }, "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.01612 + "rateMeasurementUnitAbbreviation": "kg/h", + "source": "EcoAct 2020", + "value": 0.057 }, { "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Granel por barco" + "dimensionCode": "Trabajo remoto de empleados_Componente", + "valueName": "Calefacción" }, "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.00353 + "rateMeasurementUnitAbbreviation": "kg/h", + "source": "EcoAct 2020", + "value": 0.3 }, { "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Tren de carga" + "dimensionCode": "Trabajo remoto de empleados_Componente", + "valueName": "Refrigeración" }, "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.02779 + "rateMeasurementUnitAbbreviation": "kg/h", + "source": "EcoAct 2020", + "value": 0.122 } ], - "allowedMeasurementUnitsAbbreviations": ["m", "km", "mi", "km-ton"], - "icon": "FACTORY" + "allowedMeasurementUnitsAbbreviations": ["h"], + "icon": "BOLT" }, { "name": "Transporte y distribución de bienes aguas abajo", - "description": "Emisiones asociadas al transporte de productos hacia clientes desde tu empresa. Incluye el traslado y distribución de productos terminados desde tu empresa hasta el cliente final.", + "description": "GHG Protocol cat. 9 · ISO 14064-1 cat. 3 — Emisiones asociadas al transporte de productos hacia clientes desde tu empresa. Incluye el traslado y distribución de productos terminados desde tu empresa hasta el cliente final.", + "position": 9, "emissionFactorDimensions": [ { "code": "Transporte y distribución de bienes aguas abajo_Transporte", @@ -4074,7 +4149,8 @@ }, { "name": "Uso de productos de la organización", - "description": "Emisiones generadas durante el uso de los productos vendidos. Incluye las emisiones que se producen cuando los clientes utilizan los productos que la empresa confecciona a lo largo de su vida útil.", + "description": "GHG Protocol cat. 11 · ISO 14064-1 cat. 5 — Emisiones generadas durante el uso de los productos vendidos. Incluye las emisiones que se producen cuando los clientes utilizan los productos que la empresa confecciona a lo largo de su vida útil.", + "position": 10, "emissionFactorDimensions": [], "emissionFactors": [], "allowedMeasurementUnitsAbbreviations": [], @@ -4082,7 +4158,8 @@ }, { "name": "Emisiones provenientes de otras fuentes", - "description": "Incluye cualquier otras fuentes de emisión que ocurra fuera de los límites físicos de la empresa y la operación no esté bajo el control de tu empresa.", + "description": "GHG Protocol: otras categorías · ISO 14064-1 cat. 6 — Incluye cualquier otras fuentes de emisión que ocurra fuera de los límites físicos de la empresa y la operación no esté bajo el control de tu empresa.", + "position": 11, "emissionFactorDimensions": [], "emissionFactors": [], "allowedMeasurementUnitsAbbreviations": [], diff --git a/tools/seed/src/data/testing/methodologies.json b/tools/seed/src/data/testing/methodologies.json index ee25627d1b..45e480387b 100644 --- a/tools/seed/src/data/testing/methodologies.json +++ b/tools/seed/src/data/testing/methodologies.json @@ -17,6 +17,7 @@ { "name": "Combustiones estacionarias", "description": "Equipos no móviles bajo control de la empresa, como calderas, generadores, hornos o cocinas industriales, que operan con diésel, gas, carbón, pellets u otros combustibles.", + "position": 1, "emissionFactorDimensions": [ { "code": "Combustiones estacionarias_Tipo", @@ -437,6 +438,7 @@ { "name": "Combustiones móviles (flota propia)", "description": "Vehículos y maquinarias móviles que consumen combustibles y son operados por la empresa, como autos, camiones, buses, motocicletas, maquinaria, barcos u otros equipos móviles con combustible financiado por la empresa.", + "position": 2, "emissionFactorDimensions": [ { "code": "Combustiones móviles (flota propia)_Tipo", @@ -644,6 +646,7 @@ { "name": "Emisiones fugitivas", "description": "Emisiones no intencionales que se liberan por fugas o escapes de gases, como pérdidas de gases refrigerantes de aires acondicionados, cámaras de frío, sistemas de refrigeración, gases industriales o otras fugas de procesos.", + "position": 3, "emissionFactorDimensions": [ { "code": "Emisiones fugitivas_Gas", @@ -1124,6 +1127,7 @@ { "name": "Emisiones por uso de suelo - Ganadería", "description": "Emisiones generadas por actividades ganaderas. Incluye emisiones provenientes de la digestión del ganado y el manejo de estiércol.", + "position": 4, "emissionFactorDimensions": [ { "code": "Emisiones por uso de suelo - Ganadería_Tipo de animal", @@ -1296,6 +1300,7 @@ { "name": "Emisiones por uso de suelo - Agricultura", "description": "Emisiones asociadas a actividades agrícolas y manejo del suelo. Incluye emisiones por laboreo, cultivos y cambios en el uso del suelo.", + "position": 5, "emissionFactorDimensions": [ { "code": "Emisiones por uso de suelo - Agricultura_Tipo de cultivo", @@ -1342,6 +1347,7 @@ { "name": "Emisiones por uso de suelo - Aplicación de fertilizantes", "description": "Emisiones derivadas del uso de fertilizantes en suelos productivos. Incluye gases liberados tras la aplicación de fertilizantes químicos u orgánicos.", + "position": 6, "emissionFactorDimensions": [ { "code": "Emisiones por uso de suelo - Aplicación de fertilizantes_Componente", @@ -1402,6 +1408,7 @@ { "name": "Procesos industriales - Cinc", "description": "Registra aquí las emisiones generadas en procesos industriales de producción o refinación de cinc, como fundiciones, refinerías o plantas metalúrgicas, donde ocurren reacciones químicas propias del proceso.", + "position": 7, "emissionFactorDimensions": [ { "code": "Procesos industriales - Cinc_Proceso de producción", @@ -1496,6 +1503,7 @@ { "name": "Procesos industriales - Cemento", "description": "Emisiones generadas en la fabricación de cemento. Incluye emisiones producidas por la utilización de clinker.", + "position": 8, "emissionFactorDimensions": [ { "code": "Procesos industriales - Cemento_Material", @@ -1538,6 +1546,7 @@ { "name": "Procesos industriales - Vidrio", "description": "Incluye aquí las emisiones generadas por la producción de vidrio nuevo, considerando la cantidad de vidrio creado desde materias primas.", + "position": 9, "emissionFactorDimensions": [ { "code": "Procesos industriales - Vidrio_Tipo de vidrio", @@ -1748,6 +1757,7 @@ { "name": "Procesos industriales - Acero", "description": "Emisiones provenientes de la producción y procesamiento del acero. Incluye emisiones generadas por reacciones químicas en altos hornos, acerías y otros procesos industriales que involucran la producción de acero.", + "position": 10, "emissionFactorDimensions": [ { "code": "Procesos industriales - Acero_Método de fabricación", @@ -1866,6 +1876,7 @@ { "name": "Emisiones provenientes de otras fuentes", "description": "Incluye cualquier otra fuente de emisión que ocurra dentro de los límites físicos de la empresa y esté bajo su control operativo.", + "position": 11, "emissionFactorDimensions": [], "emissionFactors": [], "allowedMeasurementUnitsAbbreviations": [], @@ -1884,6 +1895,7 @@ { "name": "Electricidad", "description": "Emisiones asociadas al consumo de electricidad compradas a generadoras externas a tu empresa. Lo anterior puede ser consumo de la red nacional u otras generadoras externas.\nIncluye las emisiones generadas en la producción de la electricidad que utiliza tu empresa, aunque estas ocurran fuera de tus instalaciones.", + "position": 1, "emissionFactorDimensions": [ { "code": "Electricidad_Sistema eléctrico", @@ -1925,7 +1937,8 @@ "subcategories": [ { "name": "Productos comprados", - "description": "Emisiones asociadas a la compra de productos. Estas emisiones corresponden a las emisiones que se generaron para lograr la producción de los productos adquiridos.", + "description": "GHG Protocol cat. 1 · ISO 14064-1 cat. 4 — Emisiones asociadas a la compra de productos. Estas emisiones corresponden a las emisiones que se generaron para lograr la producción de los productos adquiridos.", + "position": 1, "emissionFactorDimensions": [ { "code": "Productos comprados_Material", @@ -2256,9 +2269,216 @@ "allowedMeasurementUnitsAbbreviations": ["g", "kg", "ton"], "icon": "FACTORY" }, + { + "name": "Consumo de agua y tratamiento de aguas residuales", + "description": "GHG Protocol cats. 1 y 5 · ISO 14064-1 cat. 4 — Emisiones asociadas al uso de agua y su tratamiento. Incluye las emisiones generadas por el consumo y desecho de las aguas utilizadas en tu empresa.", + "position": 2, + "emissionFactorDimensions": [ + { + "code": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", + "name": "Fuente de suministro", + "position": 1, + "isRequired": true, + "values": [ + { + "name": "Consumo de agua", + "parentValue": null + }, + { + "name": "Agua dispuesta en el alcantarillado", + "parentValue": null + } + ] + } + ], + "emissionFactors": [ + { + "dimensionValue1": { + "dimensionCode": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", + "valueName": "Agua dispuesta en el alcantarillado" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/m3", + "source": "DEFRA 2025", + "value": 0.17088 + }, + { + "dimensionValue1": { + "dimensionCode": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", + "valueName": "Consumo de agua" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/m3", + "source": "DEFRA 2025", + "value": 0.1913 + } + ], + "allowedMeasurementUnitsAbbreviations": ["L", "m3", "gal"], + "icon": "FACTORY" + }, + { + "name": "Transporte y distribución de bienes aguas arriba", + "description": "GHG Protocol cat. 4 · ISO 14064-1 cat. 3 — Emisiones asociadas al transporte de insumos hacia tu empresa. Incluye el traslado y distribución de materias primas, productos comprados e insumos desde proveedores hasta las instalaciones de la organización.", + "position": 3, + "emissionFactorDimensions": [ + { + "code": "Transporte y distribución de bienes aguas arriba_Transporte", + "name": "Transporte", + "position": 1, + "isRequired": true, + "values": [ + { + "name": "Tren de carga", + "parentValue": null + }, + { + "name": "Contenedores por barco", + "parentValue": null + }, + { + "name": "Granel por barco", + "parentValue": null + }, + { + "name": "Avión: Short haul (<2500km)", + "parentValue": null + }, + { + "name": "Avión: Medium haul (2500-5000km)", + "parentValue": null + }, + { + "name": "Avión: Long haul (<5000km)", + "parentValue": null + }, + { + "name": "Camión no refrigerado", + "parentValue": null + }, + { + "name": "Camión refrigerado", + "parentValue": null + }, + { + "name": "Van eléctrica", + "parentValue": null + }, + { + "name": "Van con motor a combustión", + "parentValue": null + } + ] + } + ], + "emissionFactors": [ + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Camión no refrigerado" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.2115 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Camión refrigerado" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.2482 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Van con motor a combustión" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.06183 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Van eléctrica" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.03758 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Avión: Long haul (<5000km)" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.1351 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Avión: Medium haul (2500-5000km)" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.1351 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Avión: Short haul (<2500km)" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.2051 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Contenedores por barco" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.01612 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Granel por barco" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.00353 + }, + { + "dimensionValue1": { + "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", + "valueName": "Tren de carga" + }, + "dimensionValue2": null, + "rateMeasurementUnitAbbreviation": "kg/km-ton", + "source": "DEFRA 2025", + "value": 0.02779 + } + ], + "allowedMeasurementUnitsAbbreviations": ["m", "km", "mi", "km-ton"], + "icon": "FACTORY" + }, { "name": "Disposición de residuos sólidos", - "description": "Emisiones generadas por el tratamiento y disposición final de residuos. Incluye emisiones enviadas a los rellenos sanitarios, reciclaje, compostaje, incineración u otros métodos de gestión de residuos generados por la empresa.", + "description": "GHG Protocol cat. 5 · ISO 14064-1 cat. 4 — Emisiones generadas por el tratamiento y disposición final de residuos. Incluye emisiones enviadas a los rellenos sanitarios, reciclaje, compostaje, incineración u otros métodos de gestión de residuos generados por la empresa.", + "position": 4, "emissionFactorDimensions": [ { "code": "Disposición de residuos sólidos_Material", @@ -2699,21 +2919,54 @@ "icon": "FACTORY" }, { - "name": "Consumo de agua y tratamiento de aguas residuales", - "description": "Emisiones asociadas al uso de agua y su tratamiento. Incluye las emisiones generadas por el consumo y desecho de las aguas utilizadas en tu empresa.", + "name": "Viajes de negocios - Traslado", + "description": "GHG Protocol cat. 6 · ISO 14064-1 cat. 3 — Emisiones producidas por el traslado en y hacia los viajes laborales. Incluye traslados en avión, bus, tren, taxi, vehículos arrendados u otros medios utilizados para viajes de negocios.", + "position": 5, "emissionFactorDimensions": [ { - "code": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", - "name": "Fuente de suministro", + "code": "Viajes de negocios - Traslado_Transporte", + "name": "Transporte", "position": 1, "isRequired": true, "values": [ { - "name": "Consumo de agua", + "name": "Transporte en auto", "parentValue": null }, { - "name": "Agua dispuesta en el alcantarillado", + "name": "Transporte en Taxi", + "parentValue": null + }, + { + "name": "Transporte en Bus", + "parentValue": null + }, + { + "name": "Transporte en Tren", + "parentValue": null + }, + { + "name": "Transporte en avión: Short haul (<3hrs) Economy", + "parentValue": null + }, + { + "name": "Transporte en avión: Short haul (<3hrs) Business", + "parentValue": null + }, + { + "name": "Transporte en avión: Medium haul (3-6hrs) Economy", + "parentValue": null + }, + { + "name": "Transporte en avión: Medium haul (3-6hrs) Business", + "parentValue": null + }, + { + "name": "Transporte en avión: Long haul (>6hrs) Economy", + "parentValue": null + }, + { + "name": "Transporte en avión: Long haul (>6hrs) Business", "parentValue": null } ] @@ -2722,331 +2975,112 @@ "emissionFactors": [ { "dimensionValue1": { - "dimensionCode": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", - "valueName": "Agua dispuesta en el alcantarillado" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en auto" }, "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/m3", + "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.17088 + "value": 0.173 }, { "dimensionValue1": { - "dimensionCode": "Consumo de agua y tratamiento de aguas residuales_Fuente de suministro", - "valueName": "Consumo de agua" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Long haul (>6hrs) Business" }, "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/m3", - "source": "DEFRA 2025", - "value": 0.1913 - } - ], - "allowedMeasurementUnitsAbbreviations": ["L", "m3", "gal"], - "icon": "FACTORY" - }, - { - "name": "Desplazamiento diario de empleados", - "description": "Emisiones asociadas al traslado cotidiano de los empleados entre su hogar y el lugar de trabajo (commuting).", - "emissionFactorDimensions": [ - { - "code": "Desplazamiento diario de empleados_Tipo", - "name": "Tipo", - "position": 1, - "isRequired": true, - "values": [ - { "name": "Auto", "parentValue": null }, - { "name": "Moto", "parentValue": null }, - { "name": "Bus urbano", "parentValue": null }, - { "name": "Bus interurbano", "parentValue": null }, - { "name": "Metro", "parentValue": null }, - { "name": "Tren cercanías", "parentValue": null }, - { "name": "Tren larga distancia", "parentValue": null }, - { "name": "Taxi/Ride-share", "parentValue": null }, - { "name": "Bici", "parentValue": null }, - { "name": "Caminata", "parentValue": null } - ] - }, - { - "code": "Desplazamiento diario de empleados_Combustible", - "name": "Combustible", - "position": 2, - "isRequired": true, - "values": [ - { "name": "Gasolina", "parentValue": null }, - { "name": "Diésel", "parentValue": null }, - { "name": "Eléctrico", "parentValue": null }, - { "name": "Híbrido", "parentValue": null }, - { "name": "No aplica", "parentValue": null } - ] - } - ], - "emissionFactors": [ - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Auto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Gasolina" - }, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.173 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Auto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Diésel" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.166 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Auto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Eléctrico" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.047 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Auto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Híbrido" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.11 + "value": 0.3165 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Moto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Gasolina" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Long haul (>6hrs) Economy" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.114 + "value": 0.1091 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Moto" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Eléctrico" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Medium haul (3-6hrs) Business" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.03 + "value": 0.3394 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Bus urbano" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Medium haul (3-6hrs) Economy" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", "value": 0.117 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Bus interurbano" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.027 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Metro" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.041 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Tren cercanías" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.035 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Tren larga distancia" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" - }, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.035 - }, - { - "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Taxi/Ride-share" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Gasolina" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Short haul (<3hrs) Business" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.149 + "value": 0.1886 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Taxi/Ride-share" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Eléctrico" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en avión: Short haul (<3hrs) Economy" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.06 + "value": 0.1257 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Taxi/Ride-share" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "Híbrido" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en Bus" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.11 + "value": 0.1038 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Bici" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en Tren" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.0 + "value": 0.0354 }, { "dimensionValue1": { - "dimensionCode": "Desplazamiento diario de empleados_Tipo", - "valueName": "Caminata" - }, - "dimensionValue2": { - "dimensionCode": "Desplazamiento diario de empleados_Combustible", - "valueName": "No aplica" + "dimensionCode": "Viajes de negocios - Traslado_Transporte", + "valueName": "Transporte en Taxi" }, + "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.0 - } - ], - "allowedMeasurementUnitsAbbreviations": ["km"], - "icon": "CAR" - }, - { - "name": "Trabajo remoto de empleados", - "description": "Emisiones asociadas al consumo energético del hogar atribuible a las horas de trabajo remoto (teletrabajo).", - "emissionFactorDimensions": [ - { - "code": "Trabajo remoto de empleados_Componente", - "name": "Componente", - "position": 1, - "isRequired": true, - "values": [ - { "name": "Equipo de oficina", "parentValue": null }, - { "name": "Calefacción", "parentValue": null }, - { "name": "Refrigeración", "parentValue": null } - ] - } - ], - "emissionFactors": [ - { - "dimensionValue1": { - "dimensionCode": "Trabajo remoto de empleados_Componente", - "valueName": "Equipo de oficina" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/h", - "source": "EcoAct 2020", - "value": 0.057 - }, - { - "dimensionValue1": { - "dimensionCode": "Trabajo remoto de empleados_Componente", - "valueName": "Calefacción" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/h", - "source": "EcoAct 2020", - "value": 0.3 - }, - { - "dimensionValue1": { - "dimensionCode": "Trabajo remoto de empleados_Componente", - "valueName": "Refrigeración" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/h", - "source": "EcoAct 2020", - "value": 0.122 + "value": 0.148 } ], - "allowedMeasurementUnitsAbbreviations": ["h"], - "icon": "BOLT" + "allowedMeasurementUnitsAbbreviations": ["m", "km", "mi"], + "icon": "FACTORY" }, { "name": "Viajes de negocios - Estadía", - "description": "Emisiones generadas durante la estadía en viajes laborales. Incluye emisiones asociadas a hoteles, alojamientos y servicios utilizados durante viajes de trabajo de acuerdo al país de residencia.", + "description": "GHG Protocol cat. 6 · ISO 14064-1 cat. 3 — Emisiones generadas durante la estadía en viajes laborales. Incluye emisiones asociadas a hoteles, alojamientos y servicios utilizados durante viajes de trabajo de acuerdo al país de residencia.", + "position": 6, "emissionFactorDimensions": [ { "code": "Viajes de negocios - Estadía_País", @@ -3357,53 +3391,82 @@ "icon": "FACTORY" }, { - "name": "Viajes de negocios - Traslado", - "description": "Emisiones producidas por el traslado en y hacia los viajes laborales. Incluye traslados en avión, bus, tren, taxi, vehículos arrendados u otros medios utilizados para viajes de negocios.", + "name": "Desplazamiento diario de empleados", + "description": "GHG Protocol cat. 7 · ISO 14064-1 cat. 3 — Emisiones asociadas al traslado cotidiano de los empleados entre su hogar y el lugar de trabajo (commuting).", + "position": 7, "emissionFactorDimensions": [ { - "code": "Viajes de negocios - Traslado_Transporte", - "name": "Transporte", + "code": "Desplazamiento diario de empleados_Tipo", + "name": "Tipo", "position": 1, "isRequired": true, "values": [ { - "name": "Transporte en auto", + "name": "Auto", "parentValue": null }, { - "name": "Transporte en Taxi", + "name": "Moto", "parentValue": null }, { - "name": "Transporte en Bus", + "name": "Bus urbano", "parentValue": null }, { - "name": "Transporte en Tren", + "name": "Bus interurbano", "parentValue": null }, { - "name": "Transporte en avión: Short haul (<3hrs) Economy", + "name": "Metro", "parentValue": null }, { - "name": "Transporte en avión: Short haul (<3hrs) Business", + "name": "Tren cercanías", "parentValue": null }, { - "name": "Transporte en avión: Medium haul (3-6hrs) Economy", + "name": "Tren larga distancia", "parentValue": null }, { - "name": "Transporte en avión: Medium haul (3-6hrs) Business", + "name": "Taxi/Ride-share", "parentValue": null }, { - "name": "Transporte en avión: Long haul (>6hrs) Economy", + "name": "Bici", "parentValue": null }, { - "name": "Transporte en avión: Long haul (>6hrs) Business", + "name": "Caminata", + "parentValue": null + } + ] + }, + { + "code": "Desplazamiento diario de empleados_Combustible", + "name": "Combustible", + "position": 2, + "isRequired": true, + "values": [ + { + "name": "Gasolina", + "parentValue": null + }, + { + "name": "Diésel", + "parentValue": null + }, + { + "name": "Eléctrico", + "parentValue": null + }, + { + "name": "Híbrido", + "parentValue": null + }, + { + "name": "No aplica", "parentValue": null } ] @@ -3412,156 +3475,237 @@ "emissionFactors": [ { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en auto" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Auto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Gasolina" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.173 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Auto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Diésel" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.166 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Auto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Eléctrico" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.047 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Auto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Híbrido" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.11 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Moto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Gasolina" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.114 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Moto" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Eléctrico" + }, + "rateMeasurementUnitAbbreviation": "kg/km", + "source": "DEFRA 2025", + "value": 0.03 + }, + { + "dimensionValue1": { + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Bus urbano" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.173 + "value": 0.117 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Long haul (>6hrs) Business" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Bus interurbano" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.3165 + "value": 0.027 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Long haul (>6hrs) Economy" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Metro" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.1091 + "value": 0.041 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Medium haul (3-6hrs) Business" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Tren cercanías" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.3394 + "value": 0.035 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Medium haul (3-6hrs) Economy" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Tren larga distancia" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.117 + "value": 0.035 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Short haul (<3hrs) Business" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Taxi/Ride-share" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Gasolina" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.1886 + "value": 0.149 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en avión: Short haul (<3hrs) Economy" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Taxi/Ride-share" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Eléctrico" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.1257 + "value": 0.06 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en Bus" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Taxi/Ride-share" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "Híbrido" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.1038 + "value": 0.11 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en Tren" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Bici" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.0354 + "value": 0.0 }, { "dimensionValue1": { - "dimensionCode": "Viajes de negocios - Traslado_Transporte", - "valueName": "Transporte en Taxi" + "dimensionCode": "Desplazamiento diario de empleados_Tipo", + "valueName": "Caminata" + }, + "dimensionValue2": { + "dimensionCode": "Desplazamiento diario de empleados_Combustible", + "valueName": "No aplica" }, - "dimensionValue2": null, "rateMeasurementUnitAbbreviation": "kg/km", "source": "DEFRA 2025", - "value": 0.148 + "value": 0.0 } ], - "allowedMeasurementUnitsAbbreviations": ["m", "km", "mi"], - "icon": "FACTORY" + "allowedMeasurementUnitsAbbreviations": ["km"], + "icon": "CAR" }, { - "name": "Transporte y distribución de bienes aguas arriba", - "description": "Emisiones asociadas al transporte de insumos hacia tu empresa. Incluye el traslado y distribución de materias primas, productos comprados e insumos desde proveedores hasta las instalaciones de la organización.", + "name": "Trabajo remoto de empleados", + "description": "GHG Protocol cat. 7 · ISO 14064-1 cat. 3 — Emisiones asociadas al consumo energético del hogar atribuible a las horas de trabajo remoto (teletrabajo).", + "position": 8, "emissionFactorDimensions": [ { - "code": "Transporte y distribución de bienes aguas arriba_Transporte", - "name": "Transporte", + "code": "Trabajo remoto de empleados_Componente", + "name": "Componente", "position": 1, "isRequired": true, "values": [ { - "name": "Tren de carga", - "parentValue": null - }, - { - "name": "Contenedores por barco", - "parentValue": null - }, - { - "name": "Granel por barco", - "parentValue": null - }, - { - "name": "Avión: Short haul (<2500km)", - "parentValue": null - }, - { - "name": "Avión: Medium haul (2500-5000km)", - "parentValue": null - }, - { - "name": "Avión: Long haul (<5000km)", - "parentValue": null - }, - { - "name": "Camión no refrigerado", - "parentValue": null - }, - { - "name": "Camión refrigerado", + "name": "Equipo de oficina", "parentValue": null }, { - "name": "Van eléctrica", + "name": "Calefacción", "parentValue": null }, { - "name": "Van con motor a combustión", + "name": "Refrigeración", "parentValue": null } ] @@ -3570,111 +3714,42 @@ "emissionFactors": [ { "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Camión no refrigerado" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.2115 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Camión refrigerado" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.2482 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Van con motor a combustión" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.06183 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Van eléctrica" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km", - "source": "DEFRA 2025", - "value": 0.03758 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Avión: Long haul (<5000km)" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.1351 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Avión: Medium haul (2500-5000km)" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.1351 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Avión: Short haul (<2500km)" - }, - "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.2051 - }, - { - "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Contenedores por barco" + "dimensionCode": "Trabajo remoto de empleados_Componente", + "valueName": "Equipo de oficina" }, "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.01612 + "rateMeasurementUnitAbbreviation": "kg/h", + "source": "EcoAct 2020", + "value": 0.057 }, { "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Granel por barco" + "dimensionCode": "Trabajo remoto de empleados_Componente", + "valueName": "Calefacción" }, "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.00353 + "rateMeasurementUnitAbbreviation": "kg/h", + "source": "EcoAct 2020", + "value": 0.3 }, { "dimensionValue1": { - "dimensionCode": "Transporte y distribución de bienes aguas arriba_Transporte", - "valueName": "Tren de carga" + "dimensionCode": "Trabajo remoto de empleados_Componente", + "valueName": "Refrigeración" }, "dimensionValue2": null, - "rateMeasurementUnitAbbreviation": "kg/km-ton", - "source": "DEFRA 2025", - "value": 0.02779 + "rateMeasurementUnitAbbreviation": "kg/h", + "source": "EcoAct 2020", + "value": 0.122 } ], - "allowedMeasurementUnitsAbbreviations": ["m", "km", "mi", "km-ton"], - "icon": "FACTORY" + "allowedMeasurementUnitsAbbreviations": ["h"], + "icon": "BOLT" }, { "name": "Transporte y distribución de bienes aguas abajo", - "description": "Emisiones asociadas al transporte de productos hacia clientes desde tu empresa. Incluye el traslado y distribución de productos terminados desde tu empresa hasta el cliente final.", + "description": "GHG Protocol cat. 9 · ISO 14064-1 cat. 3 — Emisiones asociadas al transporte de productos hacia clientes desde tu empresa. Incluye el traslado y distribución de productos terminados desde tu empresa hasta el cliente final.", + "position": 9, "emissionFactorDimensions": [ { "code": "Transporte y distribución de bienes aguas abajo_Transporte", @@ -3832,7 +3907,8 @@ }, { "name": "Uso de productos de la organización", - "description": "Emisiones generadas durante el uso de los productos vendidos. Incluye las emisiones que se producen cuando los clientes utilizan los productos que la empresa confecciona a lo largo de su vida útil.", + "description": "GHG Protocol cat. 11 · ISO 14064-1 cat. 5 — Emisiones generadas durante el uso de los productos vendidos. Incluye las emisiones que se producen cuando los clientes utilizan los productos que la empresa confecciona a lo largo de su vida útil.", + "position": 10, "emissionFactorDimensions": [], "emissionFactors": [], "allowedMeasurementUnitsAbbreviations": [], @@ -3840,7 +3916,8 @@ }, { "name": "Emisiones provenientes de otras fuentes", - "description": "Incluye cualquier otras fuentes de emisión que ocurra fuera de los límites físicos de la empresa y la operación no esté bajo el control de tu empresa.", + "description": "GHG Protocol: otras categorías · ISO 14064-1 cat. 6 — Incluye cualquier otras fuentes de emisión que ocurra fuera de los límites físicos de la empresa y la operación no esté bajo el control de tu empresa.", + "position": 11, "emissionFactorDimensions": [], "emissionFactors": [], "allowedMeasurementUnitsAbbreviations": [], diff --git a/tools/seed/src/scripts/seedMethodologyData/seedSubcategories.ts b/tools/seed/src/scripts/seedMethodologyData/seedSubcategories.ts index b8aa2184b6..98ae9d8ae1 100644 --- a/tools/seed/src/scripts/seedMethodologyData/seedSubcategories.ts +++ b/tools/seed/src/scripts/seedMethodologyData/seedSubcategories.ts @@ -20,6 +20,7 @@ export async function seedSubcategories( name: subcategory.name, icon: subcategory.icon, description: subcategory.description, + position: subcategory.position, allowedMeasurementUnitsAbbreviations: subcategory.allowedMeasurementUnitsAbbreviations ?? [], })) @@ -34,6 +35,16 @@ export async function seedSubcategories( "name", ]); + // Positions must also be unique per category (enforced by a partial unique + // index in the database), so catch a duplicated position here with a clear + // message instead of a raw constraint violation. + checkForDuplicates(subcategoriesData, [ + "countryIsoCode", + "methodologyVersionName", + "categoryName", + "position", + ]); + // Fetch categories with their methodology versions and countries to map by full path const categories = await prisma.category.findMany({ include: { @@ -70,6 +81,7 @@ export async function seedSubcategories( icon: subcategory.icon, status: SubcategoryStatus.ACTIVE, description: subcategory.description, + position: subcategory.position, }; }); diff --git a/tools/seed/src/scripts/shared.ts b/tools/seed/src/scripts/shared.ts index 9ec90da63a..33cb805326 100644 --- a/tools/seed/src/scripts/shared.ts +++ b/tools/seed/src/scripts/shared.ts @@ -34,6 +34,7 @@ export const FullMethodologyDataSchema = z.array( name: z.string().min(1), description: z.string().min(1), icon: z.string().min(1), + position: z.int().min(1), allowedMeasurementUnitsAbbreviations: z .array(z.string()) .optional(), From 2d392727358e15595bbad4600145c4f2be3e6987 Mon Sep 17 00:00:00 2001 From: Matias Rivas Date: Wed, 19 Aug 2026 11:01:43 -0400 Subject: [PATCH 4/5] feat(api): add endpoint to swap subcategory positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Countries edit their own methodology from the maintainer, so a subcategory added there landed last with no way to move it — the GHG Protocol order of the Scope 3 category degraded with the first insert. POST /subcategories/swap-positions mirrors the category endpoint: it swaps two positions atomically through a temporary position, and refuses subcategories from different categories, since positions are only unique (and only meaningful) within a category. Subcategory responses now carry `position` so the caller can order and optimistically reorder its rows. The swap response returns just the identifying fields plus the new positions instead of the full base schema — the full shape is already served by GET /subcategories, and building it here would mean a mapper with no other caller. --- .../createSubcategory/service.ts | 1 + apps/api/src/features/subcategories/errors.ts | 12 + .../getAllSubcategories/service.ts | 1 + .../swapSubcategoryPositions/handler.ts | 11 + .../swapSubcategoryPositions/route.ts | 29 ++ .../swapSubcategoryPositions/service.ts | 95 ++++++ .../updateSubcategory/service.ts | 2 + .../api/src/routes/api/subcategories/index.ts | 2 + .../integration.test.ts | 274 ++++++++++++++++++ packages/types/src/baseSchemas/subcategory.ts | 5 + .../createSubcategory/schemas.ts | 4 + .../getAllSubcategories/schemas.ts | 1 + packages/types/src/subcategories/index.ts | 2 + .../swapSubcategoryPositions/schemas.ts | 28 ++ .../swapSubcategoryPositions/types.ts | 12 + .../updateSubcategory/schemas.ts | 1 + 16 files changed, 480 insertions(+) create mode 100644 apps/api/src/features/subcategories/swapSubcategoryPositions/handler.ts create mode 100644 apps/api/src/features/subcategories/swapSubcategoryPositions/route.ts create mode 100644 apps/api/src/features/subcategories/swapSubcategoryPositions/service.ts create mode 100644 apps/api/test/features/subcategories/swapSubcategoryPositions/integration.test.ts create mode 100644 packages/types/src/subcategories/swapSubcategoryPositions/schemas.ts create mode 100644 packages/types/src/subcategories/swapSubcategoryPositions/types.ts diff --git a/apps/api/src/features/subcategories/createSubcategory/service.ts b/apps/api/src/features/subcategories/createSubcategory/service.ts index d6416aef86..eab26bf817 100644 --- a/apps/api/src/features/subcategories/createSubcategory/service.ts +++ b/apps/api/src/features/subcategories/createSubcategory/service.ts @@ -68,6 +68,7 @@ export const createSubcategoryService = async ( icon: true, description: true, explanation: true, + position: true, category: { select: { id: true, name: true, color: true }, }, diff --git a/apps/api/src/features/subcategories/errors.ts b/apps/api/src/features/subcategories/errors.ts index a3dc7db06f..9b22a3d7ed 100644 --- a/apps/api/src/features/subcategories/errors.ts +++ b/apps/api/src/features/subcategories/errors.ts @@ -29,3 +29,15 @@ export const CategoryFromDifferentMethodologyError = createError( "Target category must belong to the same methodology version", 422 ); + +export const SameSubcategoryError = createError( + "SAME_SUBCATEGORY", + "Both subcategory IDs must be different", + 422 +); + +export const SubcategoriesFromDifferentCategoriesError = createError( + "SUBCATEGORIES_FROM_DIFFERENT_CATEGORIES", + "Both subcategories must belong to the same category (Subcategory IDs: %s, %s)", + 422 +); diff --git a/apps/api/src/features/subcategories/getAllSubcategories/service.ts b/apps/api/src/features/subcategories/getAllSubcategories/service.ts index c95e9ae6c7..892603f462 100644 --- a/apps/api/src/features/subcategories/getAllSubcategories/service.ts +++ b/apps/api/src/features/subcategories/getAllSubcategories/service.ts @@ -45,6 +45,7 @@ export const getAllSubcategoriesService = async ( icon: IconNameSchema.parse(subcategory.icon), description: subcategory.description, explanation: subcategory.explanation, + position: subcategory.position, category: { id: category.id.toString(), name: category.name, diff --git a/apps/api/src/features/subcategories/swapSubcategoryPositions/handler.ts b/apps/api/src/features/subcategories/swapSubcategoryPositions/handler.ts new file mode 100644 index 0000000000..51fad3bd07 --- /dev/null +++ b/apps/api/src/features/subcategories/swapSubcategoryPositions/handler.ts @@ -0,0 +1,11 @@ +import { createPostHandler } from "@/handlerFactory/index.js"; +import { swapSubcategoryPositionsService } from "./service.js"; +import type { + SwapSubcategoryPositionsRequest, + SwapSubcategoryPositionsResponse, +} from "@repo/types"; + +export const swapSubcategoryPositionsHandler = createPostHandler< + SwapSubcategoryPositionsRequest, + SwapSubcategoryPositionsResponse +>("subcategories", swapSubcategoryPositionsService, "SubcategoryPositions"); diff --git a/apps/api/src/features/subcategories/swapSubcategoryPositions/route.ts b/apps/api/src/features/subcategories/swapSubcategoryPositions/route.ts new file mode 100644 index 0000000000..32924d8adf --- /dev/null +++ b/apps/api/src/features/subcategories/swapSubcategoryPositions/route.ts @@ -0,0 +1,29 @@ +import { defineRoute } from "@/routing/defineRoute.js"; +import { swapSubcategoryPositionsHandler } from "./handler.js"; +import { + SwapSubcategoryPositionsRequest, + SwapSubcategoryPositionsRequestSchema, + SwapSubcategoryPositionsResponseSchema, +} from "@repo/types"; +import { ApiErrorResponseSchema } from "@/commonSchemas/errors.js"; + +export const swapSubcategoryPositionsRoute = defineRoute<{ + Body: SwapSubcategoryPositionsRequest; +}>({ + method: "POST", + path: "/swap-positions", + schema: { + tags: ["subcategories"], + summary: "Swap positions of two subcategories", + description: + "Atomically swaps the position values of two subcategories within the same category", + body: SwapSubcategoryPositionsRequestSchema, + response: { + 201: SwapSubcategoryPositionsResponseSchema, + 404: ApiErrorResponseSchema, + 422: ApiErrorResponseSchema, + }, + }, + access: { mode: "private" }, + handler: swapSubcategoryPositionsHandler, +}); diff --git a/apps/api/src/features/subcategories/swapSubcategoryPositions/service.ts b/apps/api/src/features/subcategories/swapSubcategoryPositions/service.ts new file mode 100644 index 0000000000..f6d1e0c79c --- /dev/null +++ b/apps/api/src/features/subcategories/swapSubcategoryPositions/service.ts @@ -0,0 +1,95 @@ +import { type PrismaClient } from "@repo/database"; +import { + SubcategoryStatus, + User, + type SwapSubcategoryPositionsRequest, + type SwapSubcategoryPositionsResponse, +} from "@repo/types"; +import { + SubcategoryNotFoundError, + SubcategoriesFromDifferentCategoriesError, + SameSubcategoryError, +} from "../errors.js"; + +export const swapSubcategoryPositionsService = async ( + prismaClient: PrismaClient, + data: SwapSubcategoryPositionsRequest, + _user: User | null +): Promise => { + const idA = BigInt(data.subcategoryIdA); + const idB = BigInt(data.subcategoryIdB); + + if (idA === idB) { + throw new SameSubcategoryError(); + } + + const [updatedA, updatedB] = await prismaClient.$transaction(async (tx) => { + const [subA, subB] = await Promise.all([ + tx.subcategory.findFirst({ + where: { id: idA, status: { not: SubcategoryStatus.DELETED } }, + }), + tx.subcategory.findFirst({ + where: { id: idB, status: { not: SubcategoryStatus.DELETED } }, + }), + ]); + + if (!subA || !subB) { + const missingIds = []; + if (!subA) missingIds.push(idA); + if (!subB) missingIds.push(idB); + throw new SubcategoryNotFoundError(missingIds.join(", ")); + } + if (subA.categoryId !== subB.categoryId) { + throw new SubcategoriesFromDifferentCategoriesError(subA.id, subB.id); + } + + const positionA = subA.position; + const positionB = subB.position; + const categoryId = subA.categoryId; + + // Find a safe temp position to avoid the unique constraint during the swap + const aggregate = await tx.subcategory.aggregate({ + where: { + categoryId, + status: { not: SubcategoryStatus.DELETED }, + }, + _max: { position: true }, + }); + const tempPosition = (aggregate._max.position ?? 0) + 1; + + // Step 1: Move A out of the way + await tx.subcategory.update({ + where: { id: idA }, + data: { position: tempPosition }, + }); + // Step 2: Move B to A's original position + const bUpdated = await tx.subcategory.update({ + where: { id: idB }, + data: { position: positionA }, + }); + // Step 3: Move A to B's original position + const aUpdated = await tx.subcategory.update({ + where: { id: idA }, + data: { position: positionB }, + }); + + return [aUpdated, bUpdated] as const; + }); + + return { + subcategories: [ + { + id: updatedA.id.toString(), + categoryId: updatedA.categoryId.toString(), + name: updatedA.name, + position: updatedA.position, + }, + { + id: updatedB.id.toString(), + categoryId: updatedB.categoryId.toString(), + name: updatedB.name, + position: updatedB.position, + }, + ], + }; +}; diff --git a/apps/api/src/features/subcategories/updateSubcategory/service.ts b/apps/api/src/features/subcategories/updateSubcategory/service.ts index 7ffc3a6a75..6b451abdab 100644 --- a/apps/api/src/features/subcategories/updateSubcategory/service.ts +++ b/apps/api/src/features/subcategories/updateSubcategory/service.ts @@ -128,6 +128,7 @@ export const updateSubcategoryService = async ( icon: true, description: true, explanation: true, + position: true, category: { select: { id: true, name: true, color: true }, }, @@ -151,6 +152,7 @@ export const updateSubcategoryService = async ( icon: IconNameSchema.parse(subcategory.icon), description: subcategory.description, explanation: subcategory.explanation, + position: subcategory.position, category: { id: subcategory.category.id.toString(), name: subcategory.category.name, diff --git a/apps/api/src/routes/api/subcategories/index.ts b/apps/api/src/routes/api/subcategories/index.ts index 1fa5c5c64c..d1bb70266d 100644 --- a/apps/api/src/routes/api/subcategories/index.ts +++ b/apps/api/src/routes/api/subcategories/index.ts @@ -4,6 +4,7 @@ import { getAllSubcategoriesRoute } from "@/features/subcategories/getAllSubcate import { deleteSubcategoryRoute } from "@/features/subcategories/deleteSubcategory/route.js"; import { createSubcategoryRoute } from "@/features/subcategories/createSubcategory/route.js"; import { updateSubcategoryRoute } from "@/features/subcategories/updateSubcategory/route.js"; +import { swapSubcategoryPositionsRoute } from "@/features/subcategories/swapSubcategoryPositions/route.js"; import { SystemRole } from "@repo/types"; export default function subcategoriesRoutes(fastify: FastifyZodInstance) { @@ -14,6 +15,7 @@ export default function subcategoriesRoutes(fastify: FastifyZodInstance) { deleteSubcategoryRoute, createSubcategoryRoute, updateSubcategoryRoute, + swapSubcategoryPositionsRoute, ], { defaultSystemRoles: [SystemRole.SUPERADMIN, SystemRole.ADMIN] } ); diff --git a/apps/api/test/features/subcategories/swapSubcategoryPositions/integration.test.ts b/apps/api/test/features/subcategories/swapSubcategoryPositions/integration.test.ts new file mode 100644 index 0000000000..35939adc2d --- /dev/null +++ b/apps/api/test/features/subcategories/swapSubcategoryPositions/integration.test.ts @@ -0,0 +1,274 @@ +import { + describe, + it, + expect, + beforeAll, + afterAll, + afterEach, + inject, +} from "vitest"; +import { createTestApp } from "@test/factories/appFactory.js"; +import { createEmptyMethodologyVersion } from "@test/factories/methodologyFactory.js"; +import { createTestCategory } from "@test/factories/categoryFactory.js"; +import { createTestSubcategory } from "@test/factories/subcategoryFactory.js"; +import type { + GetAllSubcategoriesResponse, + SwapSubcategoryPositionsResponse, +} from "@repo/types"; +import { SubcategoryStatus } from "@repo/types"; +import type { FastifyInstance } from "fastify"; +import type { PrismaClient } from "@repo/database"; +import { MethodologyVersionStatus } from "@repo/database"; + +describe("POST /api/subcategories/swap-positions - Integration Tests", () => { + let app: FastifyInstance; + let prisma: PrismaClient; + + beforeAll(async () => { + const databaseUrl = inject("databaseUrl"); + app = await createTestApp(databaseUrl); + prisma = app.prisma; + }); + + afterAll(async () => { + await prisma.$disconnect(); + await app.close(); + }); + + afterEach(async () => { + await prisma.methodologyVersion.deleteMany({ + where: { name: { startsWith: "Test - " } }, + }); + }); + + /** Methodology with one category holding `count` subcategories at positions 1..count. */ + async function createCategoryWithSubcategories( + methodologyName: string, + count: number + ) { + const methodology = await createEmptyMethodologyVersion(prisma, { + name: methodologyName, + status: MethodologyVersionStatus.PUBLISHED, + }); + const category = await createTestCategory(prisma, methodology.id, { + name: `${methodologyName} Category`, + position: 1, + }); + const subcategories = []; + for (let i = 1; i <= count; i++) { + subcategories.push( + await createTestSubcategory(prisma, category.id, { + name: `${methodologyName} Sub ${i}`, + }) + ); + } + + return { methodology, category, subcategories }; + } + + describe("Successful swap", () => { + it("should swap positions of two subcategories and return 201", async () => { + const { subcategories } = await createCategoryWithSubcategories( + "Test - Swap Subcategories", + 2 + ); + const [subA, subB] = subcategories; + + const response = await app.inject({ + method: "POST", + url: "/api/subcategories/swap-positions", + payload: { + subcategoryIdA: subA.id.toString(), + subcategoryIdB: subB.id.toString(), + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse( + response.body + ) as SwapSubcategoryPositionsResponse; + + const returnedA = body.subcategories.find( + (s) => s.id === subA.id.toString() + ); + const returnedB = body.subcategories.find( + (s) => s.id === subB.id.toString() + ); + expect(returnedA!.position).toBe(2); + expect(returnedB!.position).toBe(1); + + const [dbSubA, dbSubB] = await Promise.all([ + prisma.subcategory.findUnique({ where: { id: subA.id } }), + prisma.subcategory.findUnique({ where: { id: subB.id } }), + ]); + expect(dbSubA!.position).toBe(2); + expect(dbSubB!.position).toBe(1); + }); + + it("should reorder the list returned by GET /subcategories", async () => { + const { methodology, subcategories } = + await createCategoryWithSubcategories("Test - Swap Reorders List", 3); + const [subA, subB] = subcategories; + + await app.inject({ + method: "POST", + url: "/api/subcategories/swap-positions", + payload: { + subcategoryIdA: subA.id.toString(), + subcategoryIdB: subB.id.toString(), + }, + }); + + const response = await app.inject({ + method: "GET", + url: `/api/subcategories/?methodologyVersionId=${methodology.id}`, + }); + + const body = JSON.parse(response.body) as GetAllSubcategoriesResponse; + expect(body.map((s) => s.id)).toEqual([ + subB.id.toString(), + subA.id.toString(), + subcategories[2].id.toString(), + ]); + }); + + it("should leave the other subcategories of the category untouched", async () => { + const { subcategories } = await createCategoryWithSubcategories( + "Test - Swap No Side Effects", + 3 + ); + const [subA, subB, subC] = subcategories; + + await app.inject({ + method: "POST", + url: "/api/subcategories/swap-positions", + payload: { + subcategoryIdA: subA.id.toString(), + subcategoryIdB: subB.id.toString(), + }, + }); + + const dbSubC = await prisma.subcategory.findUnique({ + where: { id: subC.id }, + }); + expect(dbSubC!.position).toBe(3); + }); + + it("should swap non-adjacent positions", async () => { + const { subcategories } = await createCategoryWithSubcategories( + "Test - Swap Non Adjacent", + 3 + ); + const [subA, , subC] = subcategories; + + const response = await app.inject({ + method: "POST", + url: "/api/subcategories/swap-positions", + payload: { + subcategoryIdA: subA.id.toString(), + subcategoryIdB: subC.id.toString(), + }, + }); + + expect(response.statusCode).toBe(201); + const [dbSubA, dbSubC] = await Promise.all([ + prisma.subcategory.findUnique({ where: { id: subA.id } }), + prisma.subcategory.findUnique({ where: { id: subC.id } }), + ]); + expect(dbSubA!.position).toBe(3); + expect(dbSubC!.position).toBe(1); + }); + }); + + describe("Error handling", () => { + it("should return 404 when a subcategory does not exist", async () => { + const { subcategories } = await createCategoryWithSubcategories( + "Test - Swap Missing", + 1 + ); + + const response = await app.inject({ + method: "POST", + url: "/api/subcategories/swap-positions", + payload: { + subcategoryIdA: subcategories[0].id.toString(), + subcategoryIdB: "999999999", + }, + }); + + expect(response.statusCode).toBe(404); + const body = JSON.parse(response.body) as { code: string }; + expect(body.code).toBe("SUBCATEGORY_NOT_FOUND"); + }); + + it("should return 404 when a subcategory is deleted", async () => { + const { subcategories } = await createCategoryWithSubcategories( + "Test - Swap Deleted", + 2 + ); + const [subA, subB] = subcategories; + await prisma.subcategory.update({ + where: { id: subB.id }, + data: { status: SubcategoryStatus.DELETED }, + }); + + const response = await app.inject({ + method: "POST", + url: "/api/subcategories/swap-positions", + payload: { + subcategoryIdA: subA.id.toString(), + subcategoryIdB: subB.id.toString(), + }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return 422 when both IDs are the same", async () => { + const { subcategories } = await createCategoryWithSubcategories( + "Test - Swap Same Id", + 1 + ); + + const response = await app.inject({ + method: "POST", + url: "/api/subcategories/swap-positions", + payload: { + subcategoryIdA: subcategories[0].id.toString(), + subcategoryIdB: subcategories[0].id.toString(), + }, + }); + + expect(response.statusCode).toBe(422); + const body = JSON.parse(response.body) as { code: string }; + expect(body.code).toBe("SAME_SUBCATEGORY"); + }); + + it("should return 422 when the subcategories belong to different categories", async () => { + const { methodology, subcategories } = + await createCategoryWithSubcategories("Test - Swap Cross Category", 1); + const otherCategory = await createTestCategory(prisma, methodology.id, { + name: "Test - Swap Cross Category Other", + position: 2, + }); + const otherSubcategory = await createTestSubcategory( + prisma, + otherCategory.id, + { name: "Test - Swap Cross Category Sub" } + ); + + const response = await app.inject({ + method: "POST", + url: "/api/subcategories/swap-positions", + payload: { + subcategoryIdA: subcategories[0].id.toString(), + subcategoryIdB: otherSubcategory.id.toString(), + }, + }); + + expect(response.statusCode).toBe(422); + const body = JSON.parse(response.body) as { code: string }; + expect(body.code).toBe("SUBCATEGORIES_FROM_DIFFERENT_CATEGORIES"); + }); + }); +}); diff --git a/packages/types/src/baseSchemas/subcategory.ts b/packages/types/src/baseSchemas/subcategory.ts index 7c82d97cf0..8266681ab8 100644 --- a/packages/types/src/baseSchemas/subcategory.ts +++ b/packages/types/src/baseSchemas/subcategory.ts @@ -20,6 +20,11 @@ export const SubcategoryBaseSchema = z.object({ .string() .nullable() .describe("Inline markdown explanation content, if any"), + position: z + .number() + .int() + .min(1) + .describe("The display position within the category"), status: z.enum(SubcategoryStatus).describe("The status of the subcategory"), createdAt: z.iso.datetime().describe("The creation date"), updatedAt: z.iso.datetime().nullable().describe("The update date"), diff --git a/packages/types/src/subcategories/createSubcategory/schemas.ts b/packages/types/src/subcategories/createSubcategory/schemas.ts index 8f2dd38aa7..657f4351ab 100644 --- a/packages/types/src/subcategories/createSubcategory/schemas.ts +++ b/packages/types/src/subcategories/createSubcategory/schemas.ts @@ -43,6 +43,9 @@ export const SubcategoryFormSchema = z.strictObject({ .nullable() .transform((v) => (v === "" ? null : v)), measurementUnitIds: z.array(MeasurementUnitBaseSchema.shape.id), + // Rows added in the grid carry 0 until the server assigns the real position + // on create, so this cannot reuse the base schema's `min(1)`. + position: z.number().int().min(0), }); // Response Schema @@ -52,6 +55,7 @@ export const CreateSubcategoryResponseSchema = SubcategoryBaseSchema.pick({ icon: true, description: true, explanation: true, + position: true, }).extend({ category: CategoryBaseSchema.pick({ id: true, name: true, color: true }), measurementUnits: z.array( diff --git a/packages/types/src/subcategories/getAllSubcategories/schemas.ts b/packages/types/src/subcategories/getAllSubcategories/schemas.ts index 3e6824c995..152ac9f533 100644 --- a/packages/types/src/subcategories/getAllSubcategories/schemas.ts +++ b/packages/types/src/subcategories/getAllSubcategories/schemas.ts @@ -20,6 +20,7 @@ export const GetAllSubcategoriesResponseSchema = z.array( icon: true, description: true, explanation: true, + position: true, }).extend({ category: CategoryBaseSchema.pick({ id: true, name: true, color: true }), measurementUnits: z.array( diff --git a/packages/types/src/subcategories/index.ts b/packages/types/src/subcategories/index.ts index b7825a3021..26497d225f 100644 --- a/packages/types/src/subcategories/index.ts +++ b/packages/types/src/subcategories/index.ts @@ -6,3 +6,5 @@ export * from "./createSubcategory/schemas.js"; export * from "./createSubcategory/types.js"; export * from "./updateSubcategory/schemas.js"; export * from "./updateSubcategory/types.js"; +export * from "./swapSubcategoryPositions/schemas.js"; +export * from "./swapSubcategoryPositions/types.js"; diff --git a/packages/types/src/subcategories/swapSubcategoryPositions/schemas.ts b/packages/types/src/subcategories/swapSubcategoryPositions/schemas.ts new file mode 100644 index 0000000000..5641511217 --- /dev/null +++ b/packages/types/src/subcategories/swapSubcategoryPositions/schemas.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { IdSchema } from "../../zod.js"; +import { SubcategoryBaseSchema } from "../../baseSchemas/index.js"; + +// Request Schema +export const SwapSubcategoryPositionsRequestSchema = z + .object({ + subcategoryIdA: IdSchema.describe("The ID of the first subcategory"), + subcategoryIdB: IdSchema.describe("The ID of the second subcategory"), + }) + .strict(); + +// Response Schema — only the fields the caller needs to re-order its rows; +// the full subcategory shape is served by GET /subcategories. +const SwappedSubcategorySchema = SubcategoryBaseSchema.pick({ + id: true, + categoryId: true, + name: true, + position: true, +}); + +export const SwapSubcategoryPositionsResponseSchema = z + .object({ + subcategories: z + .tuple([SwappedSubcategorySchema, SwappedSubcategorySchema]) + .describe("Both updated subcategories after the swap"), + }) + .strict(); diff --git a/packages/types/src/subcategories/swapSubcategoryPositions/types.ts b/packages/types/src/subcategories/swapSubcategoryPositions/types.ts new file mode 100644 index 0000000000..fe4686a8ce --- /dev/null +++ b/packages/types/src/subcategories/swapSubcategoryPositions/types.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +import type { + SwapSubcategoryPositionsRequestSchema, + SwapSubcategoryPositionsResponseSchema, +} from "./schemas.js"; + +export type SwapSubcategoryPositionsRequest = z.infer< + typeof SwapSubcategoryPositionsRequestSchema +>; +export type SwapSubcategoryPositionsResponse = z.infer< + typeof SwapSubcategoryPositionsResponseSchema +>; diff --git a/packages/types/src/subcategories/updateSubcategory/schemas.ts b/packages/types/src/subcategories/updateSubcategory/schemas.ts index 24fbfd2954..ae2df248ca 100644 --- a/packages/types/src/subcategories/updateSubcategory/schemas.ts +++ b/packages/types/src/subcategories/updateSubcategory/schemas.ts @@ -41,6 +41,7 @@ export const UpdateSubcategoryResponseSchema = SubcategoryBaseSchema.pick({ icon: true, description: true, explanation: true, + position: true, }).extend({ category: CategoryBaseSchema.pick({ id: true, name: true, color: true }), measurementUnits: z.array( From f35d752f1cc7bfdfad306bb9cbd0e30a341a8b8d Mon Sep 17 00:00:00 2001 From: Matias Rivas Date: Wed, 19 Aug 2026 11:01:55 -0400 Subject: [PATCH 5/5] feat(web): let maintainers reorder subcategories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Up/down arrows in the subcategories maintainer, same interaction as the categories screen. Moves are constrained to the row's own category — the arrows are disabled on the first and last row of each category group, not of the grid — because positions are unique per category and the grid lists every category at once. The optimistic update swaps the two rows in place rather than re-sorting the whole grid, so a pending new row keeps the top slot it was prepended to. --- apps/web/src/api/query/maintainer/index.ts | 1 + .../api/query/maintainer/useSubcategories.ts | 22 ++++++ .../Maintainer/hooks/useSubcategoriesForm.ts | 1 + .../hooks/useSubcategoryColumns.tsx | 36 ++++++++++ .../screens/SubcategoriesMaintainerScreen.tsx | 70 +++++++++++++++++++ 5 files changed, 130 insertions(+) diff --git a/apps/web/src/api/query/maintainer/index.ts b/apps/web/src/api/query/maintainer/index.ts index f9a0841141..46b7709246 100644 --- a/apps/web/src/api/query/maintainer/index.ts +++ b/apps/web/src/api/query/maintainer/index.ts @@ -19,6 +19,7 @@ export { useAddSubcategory, useUpdateSubcategory, useDeleteSubcategory, + useSwapSubcategoryPositions, } from "./useSubcategories"; export { useEmissionFactors, diff --git a/apps/web/src/api/query/maintainer/useSubcategories.ts b/apps/web/src/api/query/maintainer/useSubcategories.ts index b9afb22b82..3d4862c357 100644 --- a/apps/web/src/api/query/maintainer/useSubcategories.ts +++ b/apps/web/src/api/query/maintainer/useSubcategories.ts @@ -8,6 +8,8 @@ import type { CreateSubcategoryResponse, UpdateSubcategoryRequest, UpdateSubcategoryResponse, + SwapSubcategoryPositionsRequest, + SwapSubcategoryPositionsResponse, } from "@repo/types"; export const useSubcategories = (methodologyVersionId?: string) => @@ -84,3 +86,23 @@ export const useDeleteSubcategory = () => { }, }); }; + +export const useSwapSubcategoryPositions = () => { + const queryClient = useQueryClient(); + return useMutation< + SwapSubcategoryPositionsResponse, + Error, + SwapSubcategoryPositionsRequest + >({ + mutationFn: (data) => + apiClient.post("subcategories/swap-positions", { json: data }).json(), + onSuccess: () => { + void queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey.includes( + MaintainerQueryKey.SubcategoriesUpdateDependency + ), + }); + }, + }); +}; diff --git a/apps/web/src/screens/Maintainer/hooks/useSubcategoriesForm.ts b/apps/web/src/screens/Maintainer/hooks/useSubcategoriesForm.ts index 4d36e6b62c..110beb33bb 100644 --- a/apps/web/src/screens/Maintainer/hooks/useSubcategoriesForm.ts +++ b/apps/web/src/screens/Maintainer/hooks/useSubcategoriesForm.ts @@ -28,6 +28,7 @@ export function toFormSubcategory(s: Subcategory): SubcategoryForm { description: s.description, explanation: s.explanation, measurementUnitIds: s.measurementUnits.map((u) => u.id), + position: s.position, }; } diff --git a/apps/web/src/screens/Maintainer/hooks/useSubcategoryColumns.tsx b/apps/web/src/screens/Maintainer/hooks/useSubcategoryColumns.tsx index 6c67889b79..4d654c8070 100644 --- a/apps/web/src/screens/Maintainer/hooks/useSubcategoryColumns.tsx +++ b/apps/web/src/screens/Maintainer/hooks/useSubcategoryColumns.tsx @@ -27,6 +27,8 @@ interface UseSubcategoryColumnsParams { onDelete: (row: SubcategoryForm) => void; onOpenExplanation: (rowIndex: number) => void; onConfigureVariables?: (rowId: string) => void; + onMoveUp: (row: SubcategoryForm) => void; + onMoveDown: (row: SubcategoryForm) => void; rows: SubcategoryForm[]; categories: Array<{ id: string; name: string; color: string }>; allMeasurementUnits: MeasurementUnit[]; @@ -42,6 +44,8 @@ export const useSubcategoryColumns = ({ onDelete, onOpenExplanation, onConfigureVariables, + onMoveUp, + onMoveDown, rows, categories, allMeasurementUnits, @@ -55,6 +59,25 @@ export const useSubcategoryColumns = ({ [editingRowId] ); + // A subcategory only moves within its own category, so the arrows are + // disabled on the first / last row of each category group, not of the grid. + const categoryEdges = useMemo(() => { + const edges = new Map(); + for (const row of rows) { + if (row.id.startsWith("temp_")) continue; + const group = rows + .filter( + (r) => r.categoryId === row.categoryId && !r.id.startsWith("temp_") + ) + .sort((a, b) => a.position - b.position); + edges.set(row.categoryId, { + firstId: group[0].id, + lastId: group[group.length - 1].id, + }); + } + return edges; + }, [rows]); + return useMemo[]>( () => [ { @@ -267,6 +290,12 @@ export const useSubcategoryColumns = ({ ); } + const edges = formRow + ? categoryEdges.get(formRow.categoryId) + : undefined; + const isFirstInCategory = edges?.firstId === rowId; + const isLastInCategory = edges?.lastId === rowId; + return ( onDelete(formRow) : undefined} + onMoveUp={formRow ? () => onMoveUp(formRow) : undefined} + onMoveDown={formRow ? () => onMoveDown(formRow) : undefined} + moveUpDisabled={anyEditing || isNewRow || isFirstInCategory} + moveDownDisabled={anyEditing || isNewRow || isLastInCategory} onConfigureVariables={ !isNewRow && onConfigureVariables ? () => onConfigureVariables(params.row.id) @@ -291,6 +324,9 @@ export const useSubcategoryColumns = ({ isEditing, onCellChange, onStartEditRow, + onMoveUp, + onMoveDown, + categoryEdges, categories, allMeasurementUnits, onOpenExplanation, diff --git a/apps/web/src/screens/Maintainer/screens/SubcategoriesMaintainerScreen.tsx b/apps/web/src/screens/Maintainer/screens/SubcategoriesMaintainerScreen.tsx index 2e2b5187cc..d6860744b6 100644 --- a/apps/web/src/screens/Maintainer/screens/SubcategoriesMaintainerScreen.tsx +++ b/apps/web/src/screens/Maintainer/screens/SubcategoriesMaintainerScreen.tsx @@ -8,6 +8,7 @@ import { useAddSubcategory, useUpdateSubcategory, useDeleteSubcategory, + useSwapSubcategoryPositions, } from "@/api/query/maintainer"; import { useMeasurementUnits } from "@/api/query"; import { @@ -66,6 +67,7 @@ export const SubcategoriesMaintainerScreen: FC = () => { const addMutation = useAddSubcategory(); const updateMutation = useUpdateSubcategory(); const deleteMutation = useDeleteSubcategory(); + const swapMutation = useSwapSubcategoryPositions(); // --- Form --- const { form, fieldArray, handleCellChange } = useSubcategoriesForm(); @@ -232,6 +234,9 @@ export const SubcategoriesMaintainerScreen: FC = () => { description: "", explanation: null, measurementUnitIds: [], + // The category is picked later, so the real position is only known once + // the server appends the row to its category on create. + position: 0, }; fieldArray.prepend(newRow); setEditingRowId(tempId); @@ -274,6 +279,69 @@ export const SubcategoriesMaintainerScreen: FC = () => { ] ); + // --- Reordering --- + + const handleMove = useCallback( + async (row: SubcategoryForm, direction: "up" | "down") => { + if (isNewRow(row.id)) return; + + const rows = form.getValues("subcategories"); + // Positions are unique per category, so a move only ever swaps with the + // neighbour inside the same category — never across categories. + const siblings = rows + .filter((r) => r.categoryId === row.categoryId && !isNewRow(r.id)) + .sort((a, b) => a.position - b.position); + const siblingIdx = siblings.findIndex((r) => r.id === row.id); + + if (direction === "up" && siblingIdx <= 0) return; + if ( + direction === "down" && + (siblingIdx === -1 || siblingIdx >= siblings.length - 1) + ) + return; + + const neighbor = + siblings[direction === "up" ? siblingIdx - 1 : siblingIdx + 1]; + + try { + await swapMutation.mutateAsync({ + subcategoryIdA: row.id, + subcategoryIdB: neighbor.id, + }); + const updatedRows = rows.map((r) => { + if (r.id === row.id) return { ...r, position: neighbor.position }; + if (r.id === neighbor.id) return { ...r, position: row.position }; + return r; + }); + // Swap the two slots instead of re-sorting the whole grid, so a pending + // new row keeps the top spot it was prepended to. + const rowIdx = updatedRows.findIndex((r) => r.id === row.id); + const neighborIdx = updatedRows.findIndex((r) => r.id === neighbor.id); + [updatedRows[rowIdx], updatedRows[neighborIdx]] = [ + updatedRows[neighborIdx], + updatedRows[rowIdx], + ]; + form.reset({ subcategories: updatedRows }); + } catch (error) { + void enqueueSnackbar({ + message: getApiErrorMessage(error, "Error al mover sub-categoría"), + variant: "error", + }); + } + }, + [form, isNewRow, swapMutation, enqueueSnackbar] + ); + + const handleMoveUp = useCallback( + (row: SubcategoryForm) => handleMove(row, "up"), + [handleMove] + ); + + const handleMoveDown = useCallback( + (row: SubcategoryForm) => handleMove(row, "down"), + [handleMove] + ); + // --- Exit edit mode --- const { handleExitEditMode } = useMaintainerExitEditMode({ editingRowId, @@ -364,6 +432,8 @@ export const SubcategoriesMaintainerScreen: FC = () => { onCancelEditRow: handleCancelEditRow, onDelete: handleDelete, onOpenExplanation: handleOpenExplanation, + onMoveUp: handleMoveUp, + onMoveDown: handleMoveDown, rows: currentRows, categories: categoryOptions, allMeasurementUnits: measurementUnits ?? [],