feat: add unit mapping functionality
Test Suite / test (24.15.0) (push) Has been cancelled

- Added new API path for unit mappings in `api_paths.dart`.
- Implemented `upsertUnitMapping` method in `ImportRepository` to handle unit mapping creation.
- Updated `ReceiptImportTab` to learn and save unit mappings during receipt import.
- Created DTO for unit mapping with validation in `create-unit-mapping.dto.ts`.
- Added SQL migration for `UnitMapping` table creation with necessary constraints.
This commit is contained in:
Nils-Johan Gynther
2026-05-07 10:00:42 +02:00
parent 26823fbf35
commit a68a0ca86f
35 changed files with 558 additions and 24 deletions
+72
View File
@@ -7,6 +7,24 @@ export declare class InventoryController {
constructor(inventoryService: InventoryService);
consume(id: number, body: ConsumeInventoryDto): Promise<{
product: {
categoryRef: ({
parent: ({
parent: {
name: string;
id: number;
parentId: number | null;
} | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
category: string | null;
status: string;
@@ -50,6 +68,24 @@ export declare class InventoryController {
}[]>;
findAll(location?: string, sort?: string): Promise<({
product: {
categoryRef: ({
parent: ({
parent: {
name: string;
id: number;
parentId: number | null;
} | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
category: string | null;
status: string;
@@ -116,6 +152,24 @@ export declare class InventoryController {
})[]>;
create(body: CreateInventoryDto): Promise<{
product: {
categoryRef: ({
parent: ({
parent: {
name: string;
id: number;
parentId: number | null;
} | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
category: string | null;
status: string;
@@ -149,6 +203,24 @@ export declare class InventoryController {
}>;
update(id: number, body: UpdateInventoryDto): Promise<{
product: {
categoryRef: ({
parent: ({
parent: {
name: string;
id: number;
parentId: number | null;
} | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
category: string | null;
status: string;
+73
View File
@@ -10,11 +10,30 @@ type InventoryQuery = {
export declare class InventoryService {
private prisma;
constructor(prisma: PrismaService);
private readonly productWithCategoryInclude;
private throwInventoryItemNotFound;
private findInventoryItemByIdOrThrow;
private ensureProductExists;
findAll(query?: InventoryQuery): Promise<({
product: {
categoryRef: ({
parent: ({
parent: {
name: string;
id: number;
parentId: number | null;
} | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
category: string | null;
status: string;
@@ -48,6 +67,24 @@ export declare class InventoryService {
})[]>;
consume(id: number, data: ConsumeInventoryDto): Promise<{
product: {
categoryRef: ({
parent: ({
parent: {
name: string;
id: number;
parentId: number | null;
} | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
category: string | null;
status: string;
@@ -124,6 +161,24 @@ export declare class InventoryService {
})[]>;
create(data: CreateInventoryDto): Promise<{
product: {
categoryRef: ({
parent: ({
parent: {
name: string;
id: number;
parentId: number | null;
} | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
category: string | null;
status: string;
@@ -157,6 +212,24 @@ export declare class InventoryService {
}>;
update(id: number, data: UpdateInventoryDto): Promise<{
product: {
categoryRef: ({
parent: ({
parent: {
name: string;
id: number;
parentId: number | null;
} | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
id: number;
parentId: number | null;
}) | null;
} & {
name: string;
category: string | null;
status: string;
+17 -4
View File
@@ -16,6 +16,19 @@ const prisma_service_1 = require("../prisma/prisma.service");
let InventoryService = class InventoryService {
constructor(prisma) {
this.prisma = prisma;
this.productWithCategoryInclude = {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
};
}
throwInventoryItemNotFound(id) {
throw new common_1.NotFoundException(`Inventory item with id ${id} not found`);
@@ -61,7 +74,7 @@ let InventoryService = class InventoryService {
return this.prisma.inventoryItem.findMany({
where,
include: {
product: true,
product: this.productWithCategoryInclude,
},
orderBy,
});
@@ -77,7 +90,7 @@ let InventoryService = class InventoryService {
quantity: new client_1.Prisma.Decimal(newQuantity),
},
include: {
product: true,
product: this.productWithCategoryInclude,
},
});
await tx.inventoryConsumption.create({
@@ -146,7 +159,7 @@ let InventoryService = class InventoryService {
: undefined,
},
include: {
product: true,
product: this.productWithCategoryInclude,
},
});
}
@@ -199,7 +212,7 @@ let InventoryService = class InventoryService {
where: { id },
data: updateData,
include: {
product: true,
product: this.productWithCategoryInclude,
},
});
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -1,3 +1,4 @@
export declare class CreatePantryItemDto {
productId: number;
location?: string;
}
+6
View File
@@ -19,4 +19,10 @@ __decorate([
(0, class_validator_1.IsPositive)(),
__metadata("design:type", Number)
], CreatePantryItemDto.prototype, "productId", void 0);
__decorate([
(0, class_validator_1.IsOptional)(),
(0, class_validator_1.IsString)(),
(0, class_validator_1.MaxLength)(50),
__metadata("design:type", String)
], CreatePantryItemDto.prototype, "location", void 0);
//# sourceMappingURL=create-pantry-item.dto.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"create-pantry-item.dto.js","sourceRoot":"","sources":["../../../src/pantry/dto/create-pantry-item.dto.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,qDAAoD;AAEpD,MAAa,mBAAmB;CAI/B;AAJD,kDAIC;AADC;IAFC,IAAA,uBAAK,GAAE;IACP,IAAA,4BAAU,GAAE;;sDACK"}
{"version":3,"file":"create-pantry-item.dto.js","sourceRoot":"","sources":["../../../src/pantry/dto/create-pantry-item.dto.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,qDAAqF;AAErF,MAAa,mBAAmB;CAS/B;AATD,kDASC;AANC;IAFC,IAAA,uBAAK,GAAE;IACP,IAAA,4BAAU,GAAE;;sDACK;AAKlB;IAHC,IAAA,4BAAU,GAAE;IACZ,IAAA,0BAAQ,GAAE;IACV,IAAA,2BAAS,EAAC,EAAE,CAAC;;qDACI"}
+3
View File
@@ -26,6 +26,7 @@ export declare class PantryController {
createdAt: Date;
updatedAt: Date;
productId: number;
location: string | null;
userId: number;
})[]>;
create(user: {
@@ -51,6 +52,7 @@ export declare class PantryController {
createdAt: Date;
updatedAt: Date;
productId: number;
location: string | null;
userId: number;
}>;
remove(user: {
@@ -60,6 +62,7 @@ export declare class PantryController {
createdAt: Date;
updatedAt: Date;
productId: number;
location: string | null;
userId: number;
}>;
}
+3
View File
@@ -24,6 +24,7 @@ export declare class PantryService {
createdAt: Date;
updatedAt: Date;
productId: number;
location: string | null;
userId: number;
})[]>;
create(userId: number, data: CreatePantryItemDto): Promise<{
@@ -47,6 +48,7 @@ export declare class PantryService {
createdAt: Date;
updatedAt: Date;
productId: number;
location: string | null;
userId: number;
}>;
remove(userId: number, id: number): Promise<{
@@ -54,6 +56,7 @@ export declare class PantryService {
createdAt: Date;
updatedAt: Date;
productId: number;
location: string | null;
userId: number;
}>;
}
+5 -1
View File
@@ -40,7 +40,11 @@ let PantryService = class PantryService {
throw new common_1.ConflictException('Produkten finns redan i baslagret');
}
return this.prisma.pantryItem.create({
data: { userId, productId: data.productId },
data: {
userId,
productId: data.productId,
location: data.location?.trim() || null,
},
include: { product: true },
});
}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"pantry.service.js","sourceRoot":"","sources":["../../src/pantry/pantry.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAkF;AAClF,6DAAyD;AAIlD,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAA6B,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAEtD,OAAO,CAAC,MAAc;QACpB,OAAO,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC;YACrC,KAAK,EAAE,EAAE,MAAM,EAAE;YACjB,OAAO,EAAE;gBACP,OAAO,EAAE,IAAI;aACd;YACD,OAAO,EAAE;gBACP,OAAO,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE;aACzB;SACF,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,MAAc,EAAE,IAAyB;QACpD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,UAAU,CAAC;YACvD,KAAK,EAAE;gBACL,gBAAgB,EAAE;oBAChB,MAAM;oBACN,SAAS,EAAE,IAAI,CAAC,SAAS;iBAC1B;aACF;SACF,CAAC,CAAC;QAEH,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,IAAI,0BAAiB,CAAC,mCAAmC,CAAC,CAAC;QACnE,CAAC;QAED,OAAO,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC;YACnC,IAAI,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE;YAC3C,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;SAC3B,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,MAAc,EAAE,EAAU;QACrC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC;YAClD,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;SACtB,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,0BAAiB,CAAC,qBAAqB,EAAE,gBAAgB,CAAC,CAAC;QACvE,CAAC;QAED,OAAO,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;IAC1D,CAAC;CACF,CAAA;AA9CY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAE0B,8BAAa;GADvC,aAAa,CA8CzB"}
{"version":3,"file":"pantry.service.js","sourceRoot":"","sources":["../../src/pantry/pantry.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAkF;AAClF,6DAAyD;AAIlD,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAA6B,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAEtD,OAAO,CAAC,MAAc;QACpB,OAAO,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC;YACrC,KAAK,EAAE,EAAE,MAAM,EAAE;YACjB,OAAO,EAAE;gBACP,OAAO,EAAE,IAAI;aACd;YACD,OAAO,EAAE;gBACP,OAAO,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE;aACzB;SACF,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,MAAc,EAAE,IAAyB;QACpD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,UAAU,CAAC;YACvD,KAAK,EAAE;gBACL,gBAAgB,EAAE;oBAChB,MAAM;oBACN,SAAS,EAAE,IAAI,CAAC,SAAS;iBAC1B;aACF;SACF,CAAC,CAAC;QAEH,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,IAAI,0BAAiB,CAAC,mCAAmC,CAAC,CAAC;QACnE,CAAC;QAED,OAAO,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC;YACnC,IAAI,EAAE;gBACJ,MAAM;gBACN,SAAS,EAAE,IAAI,CAAC,SAAS;gBACzB,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,EAAE,IAAI,IAAI;aACxC;YACD,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;SAC3B,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,MAAc,EAAE,EAAU;QACrC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC;YAClD,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;SACtB,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,0BAAiB,CAAC,qBAAqB,EAAE,gBAAgB,CAAC,CAAC;QACvE,CAAC;QAED,OAAO,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;IAC1D,CAAC;CACF,CAAA;AAlDY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAE0B,8BAAa;GADvC,aAAa,CAkDzB"}
+1 -1
View File
@@ -40,7 +40,7 @@ let ProductsService = class ProductsService {
}
async findByOwner(userId) {
return this.prisma.product.findMany({
where: { ownerId: userId, isPrivate: true, isActive: true },
where: { ownerId: userId, isActive: true },
select: { id: true, name: true, canonicalName: true, categoryId: true },
orderBy: { name: 'asc' },
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
export declare class CreateUnitMappingDto {
productId: number;
originalUnit: string;
preferredUnit: string;
}
@@ -0,0 +1,31 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CreateUnitMappingDto = void 0;
const class_validator_1 = require("class-validator");
class CreateUnitMappingDto {
}
exports.CreateUnitMappingDto = CreateUnitMappingDto;
__decorate([
(0, class_validator_1.IsInt)(),
__metadata("design:type", Number)
], CreateUnitMappingDto.prototype, "productId", void 0);
__decorate([
(0, class_validator_1.IsString)(),
(0, class_validator_1.MinLength)(1),
__metadata("design:type", String)
], CreateUnitMappingDto.prototype, "originalUnit", void 0);
__decorate([
(0, class_validator_1.IsString)(),
(0, class_validator_1.MinLength)(1),
__metadata("design:type", String)
], CreateUnitMappingDto.prototype, "preferredUnit", void 0);
//# sourceMappingURL=create-unit-mapping.dto.js.map
@@ -0,0 +1 @@
{"version":3,"file":"create-unit-mapping.dto.js","sourceRoot":"","sources":["../../../src/receipt-import/dto/create-unit-mapping.dto.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,qDAA6D;AAE7D,MAAa,oBAAoB;CAWhC;AAXD,oDAWC;AATC;IADC,IAAA,uBAAK,GAAE;;uDACW;AAInB;IAFC,IAAA,0BAAQ,GAAE;IACV,IAAA,2BAAS,EAAC,CAAC,CAAC;;0DACS;AAItB;IAFC,IAAA,0BAAQ,GAAE;IACV,IAAA,2BAAS,EAAC,CAAC,CAAC;;2DACU"}
@@ -1,5 +1,6 @@
import { ReceiptImportService } from './receipt-import.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
import { CreateUnitMappingDto } from './dto/create-unit-mapping.dto';
export declare class ReceiptImportController {
private readonly receiptImportService;
constructor(receiptImportService: ReceiptImportService);
@@ -7,4 +8,5 @@ export declare class ReceiptImportController {
refreshCategories(): Promise<{
message: string;
}>;
upsertUnitMapping(dto: CreateUnitMappingDto, req?: any): Promise<any>;
}
@@ -18,6 +18,7 @@ const throttler_1 = require("@nestjs/throttler");
const platform_express_1 = require("@nestjs/platform-express");
const multer_1 = require("multer");
const receipt_import_service_1 = require("./receipt-import.service");
const create_unit_mapping_dto_1 = require("./dto/create-unit-mapping.dto");
const passport_1 = require("@nestjs/passport");
const ALLOWED_MIMES = [
'image/jpeg',
@@ -47,6 +48,17 @@ let ReceiptImportController = class ReceiptImportController {
await this.receiptImportService.loadCategories();
return { message: 'Kategorier har uppdaterats.' };
}
async upsertUnitMapping(dto, req) {
const userId = typeof req?.user?.id === 'number'
? req.user.id
: typeof req?.user?.userId === 'number'
? req.user.userId
: undefined;
if (!userId) {
throw new common_1.BadRequestException('Kunde inte identifiera användaren.');
}
return this.receiptImportService.upsertUnitMapping(userId, dto.productId, dto.originalUnit, dto.preferredUnit);
}
};
exports.ReceiptImportController = ReceiptImportController;
__decorate([
@@ -70,6 +82,15 @@ __decorate([
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], ReceiptImportController.prototype, "refreshCategories", null);
__decorate([
(0, common_1.Post)('unit-mappings'),
(0, common_1.UseGuards)((0, passport_1.AuthGuard)('jwt')),
__param(0, (0, common_1.Body)()),
__param(1, (0, common_1.Request)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [create_unit_mapping_dto_1.CreateUnitMappingDto, Object]),
__metadata("design:returntype", Promise)
], ReceiptImportController.prototype, "upsertUnitMapping", null);
exports.ReceiptImportController = ReceiptImportController = __decorate([
(0, common_1.Controller)('receipt-import'),
__metadata("design:paramtypes", [receipt_import_service_1.ReceiptImportService])
@@ -1 +1 @@
{"version":3,"file":"receipt-import.controller.js","sourceRoot":"","sources":["../../src/receipt-import/receipt-import.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CASwB;AACxB,iDAA6C;AAC7C,+DAA2D;AAC3D,mCAAuC;AACvC,qEAAgE;AAEhE,+CAA6C;AAE7C,MAAM,aAAa,GAAG;IACpB,YAAY;IACZ,WAAW;IACX,YAAY;IACZ,YAAY;IACZ,YAAY;IACZ,iBAAiB;IACjB,0BAA0B;CAC3B,CAAC;AAGK,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAClC,YAA6B,oBAA0C;QAA1C,yBAAoB,GAApB,oBAAoB,CAAsB;IAAG,CAAC;IAWrE,AAAN,KAAK,CAAC,YAAY,CACA,IAA0B,EAC/B,GAAS;QAEpB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;YAClB,MAAM,IAAI,4BAAmB,CAAC,0BAA0B,CAAC,CAAC;QAC5D,CAAC;QACD,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC3C,MAAM,IAAI,4BAAmB,CAC3B,qDAAqD,CACtD,CAAC;QACJ,CAAC;QACD,MAAM,SAAS,GAAG,GAAG,EAAE,IAAI,EAAE,SAAS,KAAK,IAAI,IAAI,GAAG,EAAE,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;QAC/E,MAAM,MAAM,GAAG,OAAO,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;QAC3E,OAAO,IAAI,CAAC,oBAAoB,CAAC,YAAY,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;IACzE,CAAC;IAIK,AAAN,KAAK,CAAC,iBAAiB;QACrB,MAAM,IAAI,CAAC,oBAAoB,CAAC,cAAc,EAAE,CAAC;QACjD,OAAO,EAAE,OAAO,EAAE,6BAA6B,EAAE,CAAC;IACpD,CAAC;CACF,CAAA;AAnCY,0DAAuB;AAY5B;IATL,IAAA,iBAAQ,EAAC,GAAG,CAAC;IACb,IAAA,aAAI,GAAE;IACN,IAAA,oBAAQ,EAAC,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,CAAC;IACjD,IAAA,wBAAe,EACd,IAAA,kCAAe,EAAC,MAAM,EAAE;QACtB,OAAO,EAAE,IAAA,sBAAa,GAAE;QACxB,MAAM,EAAE,EAAE,QAAQ,EAAE,EAAE,GAAG,IAAI,GAAG,IAAI,EAAE;KACvC,CAAC,CACH;IAEE,WAAA,IAAA,qBAAY,GAAE,CAAA;IACd,WAAA,IAAA,gBAAO,GAAE,CAAA;;;;2DAaX;AAIK;IAFL,IAAA,aAAI,EAAC,oBAAoB,CAAC;IAC1B,IAAA,kBAAS,EAAC,IAAA,oBAAS,EAAC,KAAK,CAAC,CAAC;;;;gEAI3B;kCAlCU,uBAAuB;IADnC,IAAA,mBAAU,EAAC,gBAAgB,CAAC;qCAEwB,6CAAoB;GAD5D,uBAAuB,CAmCnC"}
{"version":3,"file":"receipt-import.controller.js","sourceRoot":"","sources":["../../src/receipt-import/receipt-import.controller.ts"],"names":[],"mappings":"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"}
@@ -12,6 +12,7 @@ export declare class ReceiptImportService {
constructor(prisma: PrismaService, aiService: AiService, categoriesService: CategoriesService);
loadCategories(): Promise<void>;
parseReceipt(file: Express.Multer.File, _isPremium?: boolean, userId?: number): Promise<ParsedReceiptItem[]>;
upsertUnitMapping(userId: number, productId: number, originalUnit: string, preferredUnit: string): Promise<any>;
private parseReceiptViaImporter;
private matchProducts;
private findWordMatch;
+45 -1
View File
@@ -143,6 +143,35 @@ let ReceiptImportService = ReceiptImportService_1 = class ReceiptImportService {
const matched = await this.matchProducts(rawItems, userId);
return this.enrichWithAiCategories(matched, userId);
}
async upsertUnitMapping(userId, productId, originalUnit, preferredUnit) {
const prismaAny = this.prisma;
const normalizedOriginalUnit = originalUnit.trim().toLowerCase();
const normalizedPreferredUnit = preferredUnit.trim().toLowerCase();
if (!normalizedOriginalUnit || !normalizedPreferredUnit) {
throw new common_1.BadRequestException('Enheter måste vara ifyllda.');
}
if (normalizedOriginalUnit === normalizedPreferredUnit) {
return { skipped: true };
}
return prismaAny.unitMapping.upsert({
where: {
productId_originalUnit_userId: {
productId,
originalUnit: normalizedOriginalUnit,
userId,
},
},
update: {
preferredUnit: normalizedPreferredUnit,
},
create: {
productId,
userId,
originalUnit: normalizedOriginalUnit,
preferredUnit: normalizedPreferredUnit,
},
});
}
async parseReceiptViaImporter(file) {
const form = new FormData();
form.append('file', new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }), file.originalname);
@@ -175,6 +204,7 @@ let ReceiptImportService = ReceiptImportService_1 = class ReceiptImportService {
return items.filter((item) => !isIgnoredReceiptName(item.rawName));
}
async matchProducts(items, userId) {
const prismaAny = this.prisma;
const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true };
const aliasFilter = userId
? {
@@ -184,7 +214,13 @@ let ReceiptImportService = ReceiptImportService_1 = class ReceiptImportService {
],
}
: { isGlobal: true };
const [aliases, products] = await Promise.all([
const unitMappingsPromise = userId && prismaAny.unitMapping?.findMany
? prismaAny.unitMapping.findMany({
where: { userId },
select: { productId: true, originalUnit: true, preferredUnit: true },
})
: Promise.resolve([]);
const [aliases, products, unitMappings] = await Promise.all([
this.prisma.receiptAlias.findMany({
where: aliasFilter,
orderBy: [
@@ -197,6 +233,7 @@ let ReceiptImportService = ReceiptImportService_1 = class ReceiptImportService {
where: productFilter,
select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } },
}),
unitMappingsPromise,
]);
return items.map((item) => {
const raw = (item.rawName ?? '').toLowerCase().trim();
@@ -204,11 +241,14 @@ let ReceiptImportService = ReceiptImportService_1 = class ReceiptImportService {
return item;
const alias = aliases.find((a) => a.receiptName === raw);
if (alias) {
const mappedUnit = unitMappings.find((um) => um.productId === alias.product.id &&
um.originalUnit === (item.unit ?? '').trim().toLowerCase())?.preferredUnit;
const cat = alias.product.categoryRef;
return {
...item,
matchedProductId: alias.product.id,
matchedProductName: alias.product.canonicalName ?? alias.product.name,
unit: mappedUnit ?? item.unit,
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'high', usedFallback: false } } : {}),
};
}
@@ -216,11 +256,15 @@ let ReceiptImportService = ReceiptImportService_1 = class ReceiptImportService {
if (!suggestion) {
return { ...item };
}
const unitMapping = unitMappings.find((um) => um.productId === suggestion.id &&
um.originalUnit === (item.unit ?? '').trim().toLowerCase());
const preferredUnit = unitMapping ? unitMapping.preferredUnit : item.unit;
const cat = suggestion.categoryRef;
return {
...item,
suggestedProductId: suggestion.id,
suggestedProductName: suggestion.canonicalName ?? suggestion.name,
unit: preferredUnit,
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'medium', usedFallback: false } } : {}),
};
});
File diff suppressed because one or more lines are too long
@@ -32,6 +32,7 @@ describe('ReceiptImportService test matrix', () => {
category: { findMany: jest.fn().mockResolvedValue([]) },
receiptAlias: { findMany: jest.fn().mockResolvedValue([]) },
product: { findMany: jest.fn().mockResolvedValue([]) },
unitMapping: { findMany: jest.fn().mockResolvedValue([]) },
};
const aiServiceMock = {
suggestCategory: jest.fn(),
@@ -44,6 +45,7 @@ describe('ReceiptImportService test matrix', () => {
jest.clearAllMocks();
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
prismaMock.product.findMany.mockResolvedValue([]);
prismaMock.unitMapping.findMany.mockResolvedValue([]);
});
describe('ignore patterns', () => {
it.each([
@@ -171,6 +173,31 @@ describe('ReceiptImportService test matrix', () => {
expect(second[0].matchedProductName).toBe('Mjolk');
expect(second[0].suggestedProductId).toBeUndefined();
});
it('använder inlärd enhetsmappning vid aliasträff', async () => {
prismaMock.receiptAlias.findMany.mockResolvedValue([
{
receiptName: 'mjolk 1l',
productId: 501,
product: {
id: 501,
name: 'Mjolk user',
canonicalName: 'Mjolk user',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
},
]);
prismaMock.unitMapping.findMany.mockResolvedValue([
{
productId: 501,
originalUnit: 'l',
preferredUnit: 'st',
},
]);
const result = await service.matchProducts([{ rawName: 'MJOLK 1L', unit: 'L' }], 77);
expect(result[0].matchedProductId).toBe(501);
expect(result[0].unit).toBe('st');
});
});
});
//# sourceMappingURL=receipt-import.service.spec.js.map
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -77,6 +77,7 @@ let RecipeAnalysisService = class RecipeAnalysisService {
});
const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
const userInventory = await this.prisma.inventoryItem.findMany({
where: { product: { ownerId: userId } },
select: { productId: true },
});
const availableProductIds = new Set([
@@ -142,7 +143,7 @@ let RecipeAnalysisService = class RecipeAnalysisService {
};
}
const inventoryItems = await this.prisma.inventoryItem.findMany({
where: { productId: ingredient.productId },
where: { productId: ingredient.productId, product: { ownerId: userId } },
select: { quantity: true, unit: true },
});
const availableQuantity = this.calculateAvailableQuantity(inventoryItems, requiredUnit);
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
CREATE TABLE `UnitMapping` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`productId` INTEGER NOT NULL,
`originalUnit` VARCHAR(191) NOT NULL,
`preferredUnit` VARCHAR(191) NOT NULL,
`userId` INTEGER NOT NULL,
UNIQUE INDEX `UnitMapping_productId_originalUnit_userId_key`(`productId`, `originalUnit`, `userId`),
INDEX `UnitMapping_productId_idx`(`productId`),
INDEX `UnitMapping_userId_idx`(`userId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `UnitMapping`
ADD CONSTRAINT `UnitMapping_productId_fkey`
FOREIGN KEY (`productId`) REFERENCES `Product`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `UnitMapping`
ADD CONSTRAINT `UnitMapping_userId_fkey`
FOREIGN KEY (`userId`) REFERENCES `User`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
import { IsInt, IsString, MinLength } from 'class-validator';
export class CreateUnitMappingDto {
@IsInt()
productId!: number;
@IsString()
@MinLength(1)
originalUnit!: string;
@IsString()
@MinLength(1)
preferredUnit!: string;
}
@@ -1,4 +1,5 @@
import {
Body,
Controller,
HttpCode,
Post,
@@ -13,6 +14,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { ReceiptImportService } from './receipt-import.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
import { CreateUnitMappingDto } from './dto/create-unit-mapping.dto';
import { AuthGuard } from '@nestjs/passport';
const ALLOWED_MIMES = [
@@ -61,4 +63,28 @@ export class ReceiptImportController {
await this.receiptImportService.loadCategories();
return { message: 'Kategorier har uppdaterats.' };
}
@Post('unit-mappings')
@UseGuards(AuthGuard('jwt'))
async upsertUnitMapping(
@Body() dto: CreateUnitMappingDto,
@Request() req?: any,
) {
const userId =
typeof req?.user?.id === 'number'
? req.user.id
: typeof req?.user?.userId === 'number'
? req.user.userId
: undefined;
if (!userId) {
throw new BadRequestException('Kunde inte identifiera användaren.');
}
return this.receiptImportService.upsertUnitMapping(
userId,
dto.productId,
dto.originalUnit,
dto.preferredUnit,
);
}
}
@@ -40,6 +40,7 @@ describe('ReceiptImportService test matrix', () => {
category: { findMany: jest.fn().mockResolvedValue([]) },
receiptAlias: { findMany: jest.fn().mockResolvedValue([]) },
product: { findMany: jest.fn().mockResolvedValue([]) },
unitMapping: { findMany: jest.fn().mockResolvedValue([]) },
};
const aiServiceMock = {
@@ -60,6 +61,7 @@ describe('ReceiptImportService test matrix', () => {
jest.clearAllMocks();
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
prismaMock.product.findMany.mockResolvedValue([]);
prismaMock.unitMapping.findMany.mockResolvedValue([]);
});
describe('ignore patterns', () => {
@@ -222,5 +224,37 @@ describe('ReceiptImportService test matrix', () => {
expect(second[0].matchedProductName).toBe('Mjolk');
expect(second[0].suggestedProductId).toBeUndefined();
});
it('använder inlärd enhetsmappning vid aliasträff', async () => {
prismaMock.receiptAlias.findMany.mockResolvedValue([
{
receiptName: 'mjolk 1l',
productId: 501,
product: {
id: 501,
name: 'Mjolk user',
canonicalName: 'Mjolk user',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
},
]);
prismaMock.unitMapping.findMany.mockResolvedValue([
{
productId: 501,
originalUnit: 'l',
preferredUnit: 'st',
},
]);
const result = await (service as any).matchProducts(
[{ rawName: 'MJOLK 1L', unit: 'L' }],
77,
);
expect(result[0].matchedProductId).toBe(501);
expect(result[0].unit).toBe('st');
});
});
});
@@ -162,6 +162,45 @@ export class ReceiptImportService {
return this.enrichWithAiCategories(matched, userId);
}
async upsertUnitMapping(
userId: number,
productId: number,
originalUnit: string,
preferredUnit: string,
) {
const prismaAny = this.prisma as any;
const normalizedOriginalUnit = originalUnit.trim().toLowerCase();
const normalizedPreferredUnit = preferredUnit.trim().toLowerCase();
if (!normalizedOriginalUnit || !normalizedPreferredUnit) {
throw new BadRequestException('Enheter måste vara ifyllda.');
}
// Ingen inlärning behövs om enheten redan är samma.
if (normalizedOriginalUnit === normalizedPreferredUnit) {
return { skipped: true };
}
return prismaAny.unitMapping.upsert({
where: {
productId_originalUnit_userId: {
productId,
originalUnit: normalizedOriginalUnit,
userId,
},
},
update: {
preferredUnit: normalizedPreferredUnit,
},
create: {
productId,
userId,
originalUnit: normalizedOriginalUnit,
preferredUnit: normalizedPreferredUnit,
},
});
}
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
const form = new FormData();
form.append(
@@ -205,6 +244,19 @@ export class ReceiptImportService {
items: ParsedReceiptItem[],
userId?: number,
): Promise<ParsedReceiptItem[]> {
type UnitMappingLite = { productId: number; originalUnit: string; preferredUnit: string };
type AliasLite = {
receiptName: string;
product: {
id: number;
name: string;
canonicalName: string | null;
categoryRef: { id: number; name: string } | null;
};
};
const prismaAny = this.prisma as any;
// Hämta alias och produkter parallellt — filtrera på userId om angivet
const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true };
const aliasFilter = userId
@@ -215,6 +267,14 @@ export class ReceiptImportService {
],
}
: { isGlobal: true };
const unitMappingsPromise =
userId && prismaAny.unitMapping?.findMany
? (prismaAny.unitMapping.findMany({
where: { userId },
select: { productId: true, originalUnit: true, preferredUnit: true },
}) as Promise<UnitMappingLite[]>)
: Promise.resolve([] as UnitMappingLite[]);
const [aliases, products, unitMappings] = await Promise.all([
this.prisma.receiptAlias.findMany({
where: aliasFilter,
@@ -228,24 +288,27 @@ export class ReceiptImportService {
where: productFilter,
select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } },
}),
this.prisma.unitMapping.findMany({
where: { userId: userId },
select: { productId: true, originalUnit: true, preferredUnit: true },
}),
]);
unitMappingsPromise,
]) as [AliasLite[], { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null }[], UnitMappingLite[]];
return items.map((item) => {
const raw = (item.rawName ?? '').toLowerCase().trim();
if (!raw) return item;
// 1. Alias-match (säker, användaren behöver inte bekräfta)
const alias = aliases.find((a) => a.receiptName === raw);
const alias = aliases.find((a: AliasLite) => a.receiptName === raw);
if (alias) {
const mappedUnit = unitMappings.find(
(um) =>
um.productId === alias.product.id &&
um.originalUnit === (item.unit ?? '').trim().toLowerCase(),
)?.preferredUnit;
const cat = alias.product.categoryRef;
return {
...item,
matchedProductId: alias.product.id,
matchedProductName: alias.product.canonicalName ?? alias.product.name,
unit: mappedUnit ?? item.unit,
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'high' as const, usedFallback: false } } : {}),
};
}
@@ -257,7 +320,11 @@ export class ReceiptImportService {
}
// Kontrollera om det finns en enhetsmappning för produkten och användaren
const unitMapping = unitMappings.find((um) => um.productId === suggestion.id && um.originalUnit === item.unit);
const unitMapping = unitMappings.find(
(um) =>
um.productId === suggestion.id &&
um.originalUnit === (item.unit ?? '').trim().toLowerCase(),
);
const preferredUnit = unitMapping ? unitMapping.preferredUnit : item.unit;
const cat = suggestion.categoryRef;
File diff suppressed because one or more lines are too long