@mostajs/stocks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +22 -0
  2. package/llms.txt +38 -0
  3. package/package.json +30 -0
  4. package/src/index.js +123 -0
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @mostajs/stocks
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later
4
+
5
+ Gestion des **stocks / inventaire** (couche logique) — articles, mouvements
6
+ (entrées/sorties/ajustements), **niveaux** calculés, **lots & péremption**, **seuils & alertes**,
7
+ **valorisation PMP**. **Compose** des repositories injectés (DEVRULES §10) ; DB-agnostique.
8
+
9
+ ```js
10
+ import { createStocks, ArticleSchema, StockMovementSchema } from '@mostajs/stocks';
11
+ const s = createStocks({ repositories: { articles, movements } }); // repos ORM/data-plug
12
+ const a = await s.articles.create({ name: 'Lait', unit: 'L', minThreshold: 10 });
13
+ await s.movements.in(a.id, { qty: 50, unitCost: 90, lot: 'L1', expiry: '2026-08-01' });
14
+ await s.movements.out(a.id, { qty: 45, beneficiaryId: 'ben-1' }); // refuse le stock négatif
15
+ await s.levels.of(a.id); // { qty: 5, byLot }
16
+ await s.alerts.lowStock(); // articles ≤ seuil
17
+ await s.alerts.expiringSoon({ days: 30 });
18
+ await s.valorize(); // { total, method: 'pmp' }
19
+ ```
20
+
21
+ Tests & exemple (§11.1 / §12) : `npm install && npm test`. Conception : `DESIGN.md`. Proposition &
22
+ livrables : `docs/`. Fiche LLM : `llms.txt`.
package/llms.txt ADDED
@@ -0,0 +1,38 @@
1
+ # @mostajs/stocks — fiche LLM
2
+ > Gestion des stocks / inventaire : articles, mouvements, niveaux, lots & péremption, seuils & alertes, valorisation PMP.
3
+
4
+ - Version: 0.1.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI <drmdh@msn.com>
5
+ - Stack: mosta-gestion-stack
6
+
7
+ ## RÔLE
8
+ Couche LOGIQUE d'inventaire, DB-agnostique (repositories injectés, §10). Suit des ARTICLES et leurs
9
+ MOUVEMENTS (entrées/sorties/ajustements), calcule les NIVEAUX, alerte sur seuils & péremption, valorise
10
+ en PMP. Ne réimplémente ni persistance ni numérotation.
11
+
12
+ ## EXPORTS
13
+ - createStocks({ repositories, now? }) -> { articles, movements, levels, alerts, valorize }
14
+ - ArticleSchema, StockMovementSchema (EntitySchema @mostajs/orm)
15
+
16
+ ## API
17
+ - articles: create(data), update(id,patch), get(id), list(), deactivate(id)
18
+ - movements:
19
+ - in(articleId, { qty, lot?, expiry?, unitCost?, ref?, by? }) entrée (don/achat)
20
+ - out(articleId, { qty, ref?, beneficiaryId?, by? }) sortie (refuse stock négatif)
21
+ - adjust(articleId, { qty, reason?, by? }) ajustement signé (casse/inventaire)
22
+ - list({ articleId? })
23
+ - levels: of(articleId) -> { articleId, qty, byLot }, all() -> { article, qty, byLot }[]
24
+ - alerts: lowStock() -> articles ≤ seuil, expiringSoon({ days }) -> lots proches péremption
25
+ - valorize() -> { total, method:'pmp' } (prix moyen pondéré des entrées)
26
+
27
+ ## REPOSITORIES (contrat injecté)
28
+ - articles, movements : chacun { create, findById, update, find(predicate) }
29
+ (l'app registre ArticleSchema / StockMovementSchema sur son ORM/data-plug)
30
+
31
+ ## ROADMAP
32
+ FEFO strict par lot (dépletion), multi-emplacements & transferts, valorisation FIFO, sortie = aide en
33
+ nature (lien @mostajs/aid-grants) + pièce @mostajs/ged, inventaires tournants.
34
+
35
+ ## PIÈGES
36
+ - `out` refuse de passer le stock négatif (contrôle de disponibilité).
37
+ - `adjust` prend une qté SIGNÉE (négative = casse/perte).
38
+ - valorize = PMP simple sur les entrées (FIFO = roadmap).
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@mostajs/stocks",
3
+ "version": "0.1.0",
4
+ "description": "Gestion des stocks / inventaire — articles, mouvements (entrées/sorties/ajustements), niveaux, lots & péremption, seuils & alertes, valorisation PMP. DB-agnostique (repositories injectés).",
5
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
+ "license": "AGPL-3.0-or-later",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "llms.txt"
16
+ ],
17
+ "keywords": [
18
+ "stock",
19
+ "inventaire",
20
+ "inventory",
21
+ "warehouse",
22
+ "mostajs"
23
+ ],
24
+ "devDependencies": {
25
+ "@mostajs/mjs-unit": "^0.3.0"
26
+ },
27
+ "scripts": {
28
+ "test": "node test-scripts/unit/stocks.test.mjs && node examples/run.mjs"
29
+ }
30
+ }
package/src/index.js ADDED
@@ -0,0 +1,123 @@
1
+ // @mostajs/stocks — gestion des stocks / inventaire (couche logique). MVP.
2
+ // Articles + mouvements (entrées/sorties/ajustements), niveaux calculés, lots & péremption,
3
+ // seuils & alertes, valorisation PMP. COMPOSE par INJECTION (DEVRULES §10) : repositories
4
+ // injectés (DB-agnostique) ; ne réimplémente ni la persistance ni la numérotation.
5
+ // @author Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
6
+
7
+ /** Article de stock (EntitySchema @mostajs/orm). */
8
+ export const ArticleSchema = {
9
+ name: 'Article', collection: 'stock_articles', timestamps: true,
10
+ fields: {
11
+ name: { type: 'string', required: true }, sku: { type: 'string', default: null },
12
+ unit: { type: 'string', default: 'u' }, category: { type: 'string', default: null },
13
+ minThreshold: { type: 'number', default: 0 }, perishable: { type: 'boolean', default: false },
14
+ active: { type: 'boolean', default: true },
15
+ },
16
+ indexes: [{ fields: { category: 'asc' } }],
17
+ };
18
+ /** Mouvement de stock (EntitySchema @mostajs/orm). */
19
+ export const StockMovementSchema = {
20
+ name: 'StockMovement', collection: 'stock_movements', timestamps: true,
21
+ fields: {
22
+ articleId: { type: 'string', required: true }, kind: { type: 'string', required: true }, // in|out|adjust
23
+ qty: { type: 'number', default: 0 }, lot: { type: 'string', default: null }, expiry: { type: 'date', default: null },
24
+ unitCost: { type: 'number', default: 0 }, ref: { type: 'string', default: null },
25
+ beneficiaryId: { type: 'string', default: null }, reason: { type: 'string', default: null },
26
+ by: { type: 'string', default: null }, at: { type: 'date' },
27
+ },
28
+ indexes: [{ fields: { articleId: 'asc' } }, { fields: { kind: 'asc' } }],
29
+ };
30
+
31
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
32
+
33
+ /**
34
+ * @param {object} opts
35
+ * @param {object} opts.repositories { articles, movements } — chacun { create, findById, update, find(predicate) }
36
+ * @param {() => Date} [opts.now]
37
+ */
38
+ export function createStocks({ repositories = {}, now = () => new Date() } = {}) {
39
+ const articlesRepo = repositories.articles;
40
+ const movementsRepo = repositories.movements;
41
+ if (!articlesRepo || !movementsRepo) throw new Error('createStocks: repositories.articles et .movements requis');
42
+
43
+ const articles = {
44
+ create: (data) => articlesRepo.create({ unit: 'u', minThreshold: 0, perishable: false, active: true, ...data }),
45
+ update: (id, patch) => articlesRepo.update(id, patch),
46
+ get: (id) => articlesRepo.findById(id),
47
+ list: () => articlesRepo.find((a) => a.active !== false),
48
+ deactivate: (id) => articlesRepo.update(id, { active: false }),
49
+ };
50
+
51
+ const movementsOf = (articleId) => movementsRepo.find((m) => m.articleId === articleId);
52
+
53
+ const levels = {
54
+ /** Niveau courant d'un article : qté, valeur (cumul des entrées), lots. */
55
+ async of(articleId) {
56
+ const ms = await movementsOf(articleId);
57
+ let qty = 0; const lots = {};
58
+ for (const m of ms) {
59
+ if (m.kind === 'in') { qty += num(m.qty); if (m.lot) lots[m.lot] = (lots[m.lot] || 0) + num(m.qty); }
60
+ else if (m.kind === 'out') qty -= num(m.qty);
61
+ else if (m.kind === 'adjust') qty += num(m.qty); // signé
62
+ }
63
+ return { articleId, qty, byLot: lots };
64
+ },
65
+ async all() {
66
+ const arts = await articles.list();
67
+ return Promise.all(arts.map(async (a) => ({ article: a, ...(await levels.of(a.id)) })));
68
+ },
69
+ };
70
+
71
+ const movements = {
72
+ /** Entrée (don/achat). */
73
+ async in(articleId, { qty, lot = null, expiry = null, unitCost = 0, ref = null, by = null } = {}) {
74
+ if (!(num(qty) > 0)) throw new Error('stocks.in: quantité invalide');
75
+ return movementsRepo.create({ articleId, kind: 'in', qty: num(qty), lot, expiry, unitCost: num(unitCost), ref, beneficiaryId: null, reason: null, by, at: now() });
76
+ },
77
+ /** Sortie (distribution) — refuse de passer le stock négatif. */
78
+ async out(articleId, { qty, ref = null, beneficiaryId = null, by = null } = {}) {
79
+ if (!(num(qty) > 0)) throw new Error('stocks.out: quantité invalide');
80
+ const lvl = await levels.of(articleId);
81
+ if (num(qty) > lvl.qty) throw new Error(`stocks.out: stock insuffisant (disponible ${lvl.qty}, demandé ${num(qty)})`);
82
+ return movementsRepo.create({ articleId, kind: 'out', qty: num(qty), lot: null, expiry: null, unitCost: 0, ref, beneficiaryId, reason: null, by, at: now() });
83
+ },
84
+ /** Ajustement d'inventaire (qté signée : casse, péremption, recomptage). */
85
+ async adjust(articleId, { qty, reason = 'inventaire', by = null } = {}) {
86
+ return movementsRepo.create({ articleId, kind: 'adjust', qty: num(qty), lot: null, expiry: null, unitCost: 0, ref: null, beneficiaryId: null, reason, by, at: now() });
87
+ },
88
+ list: ({ articleId } = {}) => (articleId ? movementsOf(articleId) : movementsRepo.find(() => true)),
89
+ };
90
+
91
+ const alerts = {
92
+ /** Articles dont le stock est ≤ seuil minimal. */
93
+ async lowStock() {
94
+ const all = await levels.all();
95
+ return all.filter((x) => x.qty <= num(x.article.minThreshold)).map((x) => ({ article: x.article, qty: x.qty, minThreshold: num(x.article.minThreshold) }));
96
+ },
97
+ /** Lots dont la péremption tombe dans `days` jours (entrées avec date). */
98
+ async expiringSoon({ days = 30 } = {}) {
99
+ const ms = (await movementsRepo.find((m) => m.kind === 'in' && m.expiry));
100
+ const limit = new Date(now().getTime() + days * 86400000).getTime();
101
+ return ms.filter((m) => new Date(m.expiry).getTime() <= limit)
102
+ .map((m) => ({ articleId: m.articleId, lot: m.lot, expiry: m.expiry, qty: num(m.qty) }));
103
+ },
104
+ };
105
+
106
+ /** Valorisation du stock (PMP — prix moyen pondéré des entrées). */
107
+ async function valorize() {
108
+ const all = await levels.all();
109
+ let total = 0;
110
+ for (const x of all) {
111
+ const ins = (await movementsOf(x.article.id)).filter((m) => m.kind === 'in');
112
+ const inQty = ins.reduce((s, m) => s + num(m.qty), 0);
113
+ const inVal = ins.reduce((s, m) => s + num(m.qty) * num(m.unitCost), 0);
114
+ const pmp = inQty ? inVal / inQty : 0;
115
+ total += x.qty * pmp;
116
+ }
117
+ return { total: Math.round(total * 100) / 100, method: 'pmp' };
118
+ }
119
+
120
+ return { now, articles, movements, levels, alerts, valorize };
121
+ }
122
+
123
+ export default createStocks;