feat(sync): synchronize Hiboutik data through POST /sync

POST /sync starts the full synchronization in the background (202, or 409 when one is already running). Entities are synced in dependency order: brands, categories, suppliers, products (with versions and stocks), customers. Each row is synced independently so that a failure is logged without stopping the rest. Replaces the default AppService.
This commit is contained in:
MastersGame
2026-11-05 23:00:49 +01:00
parent 27d0e0149d
commit c5f65dcb62
17 changed files with 878 additions and 34 deletions

View File

@@ -0,0 +1,51 @@
import { Injectable, Logger } from '@nestjs/common';
import { CategoryHiboutikRepository } from '../../hiboutik/category.hiboutik.repository';
import { CategoryDatabaseRepository } from '../../database/category.database.repository';
import { CategoryConverter } from '../../models/converter/category.converter';
import { forEachSafely } from '../../utils/sync';
@Injectable()
export class CategoryHiboutikService {
private readonly logger = new Logger('CategoryHiboutikService');
constructor(
private readonly categoryHiboutikRepository: CategoryHiboutikRepository,
private readonly categoryDatabaseRepository: CategoryDatabaseRepository,
private readonly categoryConverter: CategoryConverter,
) {}
/**
* Mirrors Hiboutik categories and their tree: upserts each of them, links them to their parent,
* then deletes the categories missing from Hiboutik.
*/
async syncCategories() {
const hiboutikCategories = await this.categoryHiboutikRepository.getCategories();
// Pass 1: categories themselves. One upsert per category: an invalid category must not prevent the others from being saved
const nbFailed = await forEachSafely(
this.categoryConverter.fromHiboutikList(hiboutikCategories),
(category) => this.categoryDatabaseRepository.upsert(category),
this.logger,
(category) => `Hiboutik category ${category.hiboutikId}`,
);
// Pass 2: parent links, once every category exists (a child can come before its parent in Hiboutik's list)
const nbParentsFailed = await forEachSafely(
hiboutikCategories,
(dto) => this.categoryDatabaseRepository.setParent(Number(dto.category_id), Number(dto.category_id_parent) || null),
this.logger,
(dto) => `parent of Hiboutik category ${dto.category_id}`,
);
// Safety net: an empty answer from Hiboutik most likely means an API issue
if (hiboutikCategories.length === 0) {
this.logger.warn('Hiboutik returned no categories: deletion skipped.');
return;
}
const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => Number(dto.category_id)));
this.logger.log(
`Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` +
`${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`,
);
}
}