feat(receipt-import): add refresh categories endpoint and UI integration
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Schemalagd Uppdatering av Kategorier",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "echo \"0 0 * * 1 cd /opt/containers/recipe-app && node -e \\\"require('./dist/src/receipt-import/receipt-import.service').loadCategories()\\\"\" | crontab -",
|
||||||
|
"isBackground": false,
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import { ReceiptImportService } from './receipt-import.service';
|
import { ReceiptImportService } from './receipt-import.service';
|
||||||
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
|
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
const ALLOWED_MIMES = [
|
const ALLOWED_MIMES = [
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
@@ -52,4 +53,11 @@ export class ReceiptImportController {
|
|||||||
const userId = typeof req?.user?.id === 'number' ? req.user.id : undefined;
|
const userId = typeof req?.user?.id === 'number' ? req.user.id : undefined;
|
||||||
return this.receiptImportService.parseReceipt(file, isPremium, userId);
|
return this.receiptImportService.parseReceipt(file, isPremium, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('refresh-categories')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
async refreshCategories() {
|
||||||
|
await this.receiptImportService.loadCategories();
|
||||||
|
return { message: 'Kategorier har uppdaterats.' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,12 +102,21 @@ function inferPackageDebugFromRawName(rawName: string): {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReceiptImportService {
|
export class ReceiptImportService {
|
||||||
private readonly logger = new Logger(ReceiptImportService.name);
|
private readonly logger = new Logger(ReceiptImportService.name);
|
||||||
|
private cachedCategories: any[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly aiService: AiService,
|
private readonly aiService: AiService,
|
||||||
private readonly categoriesService: CategoriesService,
|
private readonly categoriesService: CategoriesService,
|
||||||
) {}
|
) {
|
||||||
|
this.loadCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCategories() {
|
||||||
|
this.cachedCategories = await this.prisma.category.findMany({
|
||||||
|
include: { children: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
||||||
// Steg 1: Delegera AI-parsning till microservice-importer
|
// Steg 1: Delegera AI-parsning till microservice-importer
|
||||||
@@ -149,12 +158,8 @@ export class ReceiptImportService {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignorera parse-fel
|
// ignorera parse-fel
|
||||||
}
|
}
|
||||||
this.logger.error(`Importer-api kvittoparsfel: ${message}`);
|
|
||||||
if (response.status >= 400 && response.status < 500) {
|
|
||||||
throw new BadRequestException(message);
|
throw new BadRequestException(message);
|
||||||
}
|
}
|
||||||
throw new ServiceUnavailableException(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<ParsedReceiptItem[]>;
|
return response.json() as Promise<ParsedReceiptItem[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,12 +360,6 @@ INSERT INTO `Category` (`name`, `parentId`)
|
|||||||
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri'
|
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri'
|
||||||
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
|
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
|
||||||
|
|
||||||
-- ── NIVÅ 3: under Mejeri, ost & ägg > Mjölk (standard) ───────────────
|
|
||||||
INSERT INTO `Category` (`name`, `parentId`)
|
|
||||||
SELECT 'Standardmjölk', c2.id FROM `Category` c1
|
|
||||||
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Mjölk'
|
|
||||||
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
|
|
||||||
|
|
||||||
-- ── NIVÅ 3: under Mejeri, ost & ägg > Matlagning ──────────────────
|
-- ── NIVÅ 3: under Mejeri, ost & ägg > Matlagning ──────────────────
|
||||||
INSERT INTO `Category` (`name`, `parentId`)
|
INSERT INTO `Category` (`name`, `parentId`)
|
||||||
SELECT 'Gräddfil & creme fraiche', c2.id FROM `Category` c1
|
SELECT 'Gräddfil & creme fraiche', c2.id FROM `Category` c1
|
||||||
@@ -491,10 +485,6 @@ INSERT INTO `Category` (`name`, `parentId`)
|
|||||||
SELECT 'Färdigmat enportioner', c2.id FROM `Category` c1
|
SELECT 'Färdigmat enportioner', c2.id FROM `Category` c1
|
||||||
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Färdigmat'
|
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Färdigmat'
|
||||||
WHERE c1.name = 'Fryst' AND c1.parentId IS NULL;
|
WHERE c1.name = 'Fryst' AND c1.parentId IS NULL;
|
||||||
INSERT INTO `Category` (`name`, `parentId`)
|
|
||||||
SELECT 'Pizza, paj & piroger', c2.id FROM `Category` c1
|
|
||||||
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Färdigmat'
|
|
||||||
WHERE c1.name = 'Fryst' AND c1.parentId IS NULL;
|
|
||||||
|
|
||||||
-- ── NIVÅ 3: under Skafferi > Bakning ────────────────────────
|
-- ── NIVÅ 3: under Skafferi > Bakning ────────────────────────
|
||||||
INSERT INTO `Category` (`name`, `parentId`)
|
INSERT INTO `Category` (`name`, `parentId`)
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ class CategoryApiPaths {
|
|||||||
static const tree = '/categories/tree';
|
static const tree = '/categories/tree';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ReceiptImportApiPaths {
|
||||||
|
static const refreshCategories = '/receipt-import/refresh-categories';
|
||||||
|
}
|
||||||
|
|
||||||
class RecipeApiPaths {
|
class RecipeApiPaths {
|
||||||
static const list = '/recipes';
|
static const list = '/recipes';
|
||||||
static String detail(int id) => '/recipes/$id';
|
static String detail(int id) => '/recipes/$id';
|
||||||
|
|||||||
@@ -41,4 +41,12 @@ class ProfileRepository {
|
|||||||
);
|
);
|
||||||
return UserProfile.fromJson(data);
|
return UserProfile.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshCategories() async {
|
||||||
|
final token = await _ref.read(authStateProvider.future);
|
||||||
|
await guardedApiCall(
|
||||||
|
_ref,
|
||||||
|
() => _apiClient.postJson(ReceiptImportApiPaths.refreshCategories, body: {}, token: token),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
|
bool _isRefreshingCategories = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
UserProfile? _profile;
|
UserProfile? _profile;
|
||||||
_ProfileTab _activeTab = _ProfileTab.profile;
|
_ProfileTab _activeTab = _ProfileTab.profile;
|
||||||
@@ -105,6 +106,24 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
context.go('/login');
|
context.go('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshCategories() async {
|
||||||
|
setState(() => _isRefreshingCategories = true);
|
||||||
|
try {
|
||||||
|
await ref.read(profileRepositoryProvider).refreshCategories();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Kategorier har uppdaterats.')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isRefreshingCategories = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<_ProfileTab> _visibleTabs(bool isAdmin) {
|
List<_ProfileTab> _visibleTabs(bool isAdmin) {
|
||||||
return [
|
return [
|
||||||
_ProfileTab.profile,
|
_ProfileTab.profile,
|
||||||
@@ -212,6 +231,23 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
: Text(context.l10n.profileSaveAction),
|
: Text(context.l10n.profileSaveAction),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isRefreshingCategories ? null : _refreshCategories,
|
||||||
|
icon: _isRefreshingCategories
|
||||||
|
? const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
width: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Uppdatera kategorier'),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user