Real Stock Manager for WooCommerce
Un plugin woocommerce qui permet une gestion des "Stock réel" pour les gens qui travail avec des fournisseurs en "flux tendu" et qui ont besoin de savoir ce qui est en stock parceque affiché disponible aux clients et ce qui est réellement en stock sur site
Install
Upload it under Plugins → Add New Plugin → Upload Plugin, then activate.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/benoitbonavia/real-stock-manager-for-woocommerce/releases/download/v3.9.0/real-stock-manager-for-woocommerce-3.9.0.zipPlugin maison regroupant les règles et automatismes de gestion des stocks réels d'une boutique WooCommerce, à la place de snippets dispersés dans functions.php ou Code Snippets.
- Version : 0.1.0
- Prérequis : WordPress 6.8+, PHP 7.4+, WooCommerce 9.9+ (testé jusqu'à 11.0)
- Dépendance optionnelle : pour valoriser le stock à son coût d'achat sur « Gestion du stock », le plugin lit le coût déclaré par « Cost of Goods Sold » (natif WooCommerce 10.3+, à activer dans Réglages → Avancé → Fonctionnalités) ou par le plugin gratuit « Cost of Goods for WooCommerce » (WPFactory). Sans l'un des deux, seule la valeur marchande est calculée.
- Préfixe :
rsmw_(options, hooks) /RSMW\(namespace PHP) - Text domain :
real-stock-manager-for-woocommerce(doit rester identique au slug du dossier)
Arborescence
real-stock-manager-for-woocommerce/
├── real-stock-manager-for-woocommerce.php Fichier principal : en-tête, constantes, hooks d'amorçage
├── readme.txt Métadonnées au format WordPress.org (lues pour la fiche de mise à jour)
├── uninstall.php Purge des options rsmw_* à la suppression
├── .gitattributes export-ignore : fichiers exclus des archives
├── .github/workflows/release.yml Construit et publie l'archive sur push d'un tag v*
├── bin/build-plugin-zip.sh Construction reproductible de l'archive d'installation
├── lib/plugin-update-checker/ Bibliothèque tierce embarquée (Plugin Update Checker 5.7)
├── composer.json Autoload PSR-4 + outillage de dev (PHPCS, PHPStan)
├── phpcs.xml.dist Règles WordPress Coding Standards
├── phpstan.neon.dist Analyse statique avec stubs WordPress/WooCommerce
├── assets/
│ ├── css/admin.css
│ └── js/admin.js
├── languages/ Fichiers .pot / .po / .mo
└── src/
├── Autoloader.php Autoload PSR-4 sans Composer
├── functions.php Helpers globaux : rsmw(), rsmw_log()
├── Plugin.php Conteneur : amorçage + registre des modules
├── Updater.php Mises à jour depuis les releases GitHub
├── Requirements.php Vérification WooCommerce
├── Installer.php Activation / désactivation / migrations
├── Admin/
│ ├── Admin.php Hooks admin, assets, lien « Réglages »
│ └── SettingsTab.php WooCommerce → Réglages → Stocks réels
├── Modules/
│ ├── ModuleInterface.php Contrat d'un module
│ └── AbstractModule.php Base : activation pilotée par option
└── Support/
├── Settings.php Lecture/écriture des options rsmw_*
└── Logger.php Journaux WooCommerce (source real-stock-manager)
Convertir un snippet en module
- Créer
src/Modules/MonModule.php:
<?php
declare( strict_types=1 );
namespace RSMW\Modules;
defined( 'ABSPATH' ) || exit;
final class MonModule extends AbstractModule {
protected $id = 'mon_module';
protected $title = 'Titre affiché dans les réglages';
public function register(): void {
add_filter( 'woocommerce_product_get_stock_quantity', array( $this, 'filter_stock' ), 10, 2 );
}
public function filter_stock( $quantity, $product ) {
// Le contenu du snippet vient ici.
return $quantity;
}
}
- Le déclarer dans
Plugin::get_module_classes():
return (array) apply_filters(
'rsmw_module_classes',
array(
\RSMW\Modules\MonModule::class,
)
);
Une case à cocher rsmw_module_mon_module_enabled apparaît automatiquement dans
WooCommerce → Réglages → Stocks réels → Modules. Le module n'est chargé que si elle est cochée.
Points d'extension
| Hook | Type | Description |
|---|---|---|
rsmw_loaded |
action | Le plugin est amorcé, tous les modules sont enregistrés. |
rsmw_module_classes |
filtre | Ajouter ou retirer des classes de modules. |
rsmw_module_is_enabled |
filtre | Forcer l'état d'un module (contourne les réglages). |
rsmw_setting |
filtre | Filtrer la valeur d'un réglage. |
rsmw_upgrade |
action | Migrations de données à l'activation après changement de version. |
rsmw_activated / rsmw_deactivated |
actions | Activation / désactivation. |
rsmw_product_cost |
filtre | Ajuster le coût d'achat unitaire résolu pour un produit (RSMW\Preparation\Cost::for_product()). |
Développement
composer install # outillage de dev uniquement, l'autoload de prod ne dépend pas de vendor/
composer lint # PHPCS (WordPress Coding Standards 3.4)
composer lint:fix # PHPCBF
composer analyse # PHPStan niveau 6 + stubs WordPress/WooCommerce
L'autoloader maison (src/Autoloader.php) suffit en production : le plugin fonctionne sans exécuter composer install.
Compatibilité déclarée
- HPOS /
custom_order_tables - Blocs Cart & Checkout
Ces déclarations sont faites sur before_woocommerce_init dans le fichier principal ; à réévaluer si un module venait à manipuler directement les tables de commandes historiques.
Module « Préparation des commandes & stock physique »
Remplace le snippet WPCode « Maison Hespérides — Préparation ». Gère un stock physique réel, distinct du stock WooCommerce : statut de commande « À empaqueter », métabox de pointage, pages Besoins pour commande et Gestion stock, attribution FIFO, champs de stock sur les produits.
Les trois états du stock
| État | Couleur | Clé produit | Clé ligne de commande |
|---|---|---|---|
| Préparé — en main, pointé | bleu | _mh_stock_reel (libre) |
_mh_prep_qty |
| Commandé au fournisseur — pas encore reçu | orange | _rsmw_stock_ordered (libre) |
_rsmw_prep_ordered |
| Manquant — ni l'un ni l'autre | vide | — | — |
Invariant par ligne : préparé + commandé ≤ quantité. Il est tenu en un seul endroit,
Items::set_quantity(), par où passe toute variation du préparé : à la hausse, la part commandée
fond d'autant, ce qui évite de compter deux fois une unité qui vient d'arriver ; à la baisse elle
reste intacte, un dépointage ne ressuscitant pas une commande fournisseur.
Depuis la v3.6.0, une hausse ne peut jamais dépasser le stock physique libre. Le même point
unique l'écrête : au-delà, le retour (delta/qty) porte ce qui a réellement été appliqué, pas ce
qui a été demandé, et la métabox comme l'AJAX en rendent compte au lieu d'enregistrer une valeur
qui n'a pas de contrepartie physique.
Corollaire dans Allocator::receive() : seul le résidu (reçu − converti) est retiré du
compteur libre, ce que les lignes ont déjà absorbé ne devant pas l'être une seconde fois.
Une commande fournisseur ne synchronise jamais le statut : la marchandise n'est pas là, la commande ne peut donc pas devenir « À empaqueter ».
« À empaqueter » implique « intégralement pointée »
C'est l'invariant le plus porteur du module, et il était tenu par personne.
Demand::active_order_ids() définit le périmètre « commandes à servir » par le statut, en
excluant volontairement mh-empaqueter. L'exclusion n'est pas gratuite : elle repose entièrement
sur l'idée qu'une commande portant ce statut n'a plus rien à préparer, donc rien à recevoir.
Trois chemins le démentaient : l'action groupée native « Marquer À empaqueter » — que le plugin ajoute lui-même, et que WooCommerce traite seul, en posant le statut sans rien pointer — le menu de statut de la fiche commande, et toute automatisation externe.
Une commande ainsi placée tombait dans un cul-de-sac complet. Les trois fonctions qui distribuent
le stock itèrent toutes sur active_order_ids() : distribute_stock() (cœur de receive() et de
credit_found_stock()), reallocate_all() et order_from_supplier(). L'attribution automatique,
elle, teste Config::statuses(), d'où mh-empaqueter est exclu par construction
(Config::normalize_statuses()). Plus rien ne pouvait la servir, son stock n'était jamais
décompté, et aucune trace n'était laissée — l'alerte flag_completed_without_prep() ne réagit
qu'à « Terminée ».
L'effet le plus coûteux était indirect : son besoin non couvert manquait aussi à la clé restant
de Demand::map(), scopée au même périmètre. Or l'onglet Inventaire court-circuite l'attribution
quand restant vaut zéro (if ( $delta > 0 && 0 === $pending ), écriture directe pour éviter un
parcours inutile des commandes). Une seule commande gelée suffisait donc à désactiver
l'attribution de l'onglet Inventaire pour la référence entière, pénalisant les autres commandes
qui, elles, attendaient légitimement.
Allocator::reconcile_pack_status() tient désormais l'invariant à la source, plutôt que
d'apprendre à six mécanismes à vivre avec sa violation. À l'arrivée dans le statut : il sert la
commande avec le stock disponible, et si elle reste incomplète la renvoie dans le circuit de
préparation via StatusSync::sync(), avec note de commande et Log::error().
Deux points de conception :
Le chemin normal ne coûte rien. StatusSync::apply_status() enregistre la commande pour poser
le statut, ce qui rejoue tout le hook. À cet instant la commande est complète par définition —
c'est ce qui a déclenché la promotion. Le gestionnaire sort donc sur Items::order_is_ready(),
une comparaison de métas, sans la moindre lecture de stock.
La redescente n'est pas conditionnée par le réglage « Attribution automatique ». Seule
l'attribution l'est : un marchand qui l'a désactivée pointe à la main. Mais le statut resterait un
mensonge et la commande gelée hors de tout périmètre — redescendre n'est pas négociable. Ce n'est
d'ailleurs pas un comportement nouveau : run_withdraw() fait déjà exactement cela, et l'onglet
Inventaire a déjà son canal de compte rendu pour ça (rendues → dropped).
StalePacks résorbe l'arriéré déjà en base, qu'aucun événement ne viendrait rattraper : balayage
unique par lots de dix sur admin_init, sur le patron de FrozenHolds, curseur sur l'identifiant
de commande — jamais un offset, qu'une commande quittant le statut décalerait. Chaque commande
passe par la même Allocator::serve_or_demote_pack() : une seule implémentation de la règle, deux
déclencheurs. Contrairement à FrozenHolds, ce balayage écrit du stock : c'est l'écriture qui
aurait dû avoir lieu à l'arrivée de la commande. Compte rendu en carte sur « Besoins pour
commande », et Demand::incomplete_packs() alimente le panneau Diagnostic.
Reste une ergonomie en suspens : l'action groupée « Marquer À empaqueter » n'est plus destructrice, mais elle demeure trompeuse — le marchand demande « marquer à empaqueter » et voit une partie des commandes revenir en « En cours ». La note de commande l'explique ; la retirer ou la renommer est un choix à part.
Continuité des données
Le module lit et écrit exactement les mêmes clés que le snippet — aucune migration.
Elles sont figées dans src/Preparation/Legacy.php et ne doivent jamais changer :
_mh_stock_reel · _mh_prep_qty · _mh_prep_from_stock · _mh_prep_date · _mh_prep_user ·
_mh_prep_prev_status · statut wc-mh-empaqueter · option mh_prep_receptions ·
pages mh-prep-stock et mh-prep-reception.
Bascule sans coupure
Tant que le snippet est chargé, SnippetGuard met le module et l'enregistrement du statut en
veille, et affiche un avertissement. Sans cela les deux accrocheraient les mêmes hooks : double
attribution automatique, double métabox, entrées de menu en double.
Cette garde repose sur un point de calendrier : WPCode exécute les snippets « Exécuter partout »
sur plugins_loaded priorité 5, le plugin s'amorce sur ce même hook en priorité 20.
Abaisser cette priorité sous 5 casserait la détection sans aucun signal.
Procédure :
- Sauvegarder la base de données.
- Mettre à jour le plugin en laissant le snippet actif — la configuration est reprise depuis
les constantes
MH_PREP_*tant qu'elles existent. - Vérifier les statuts suivis dans WooCommerce → Réglages → Stocks réels → Préparation.
- Désactiver le snippet WPCode.
- Contrôler le panneau Diagnostic du même onglet : il affiche la volumétrie des données reprises (références avec stock, lignes pointées, mouvements) et l'état du statut.
Retour arrière : réactiver le snippet. Le module se remet en veille au chargement suivant, les données n'ayant bougé dans aucun sens.
Interface
La page Gestion du stock a trois onglets. Réception d'un colis liste ce qui est attendu et propose deux champs par référence, conforme et défectueux, avec vérification avant écriture. Mouvement à l'unité est la console : un formulaire unique à quatre sens, et un panneau qui se renseigne en AJAX dès qu'une référence est choisie. Inventaire liste le catalogue entier — voir plus bas. Le journal, commun aux deux premiers onglets, est filtrable par sens ; l'Inventaire n'en a pas (voir la raison ci-dessous).
Les formulaires sont traités sur load-{écran}, avant l'envoi de l'en-tête d'administration —
seule fenêtre où une redirection reste possible. Sans elle, un rafraîchissement rejouerait
l'écriture, et sur une réception en lot c'est un colis entier qui serait enregistré deux fois.
Inventaire : un mouvement en lot, sur des totaux
Les deux autres onglets sont demand-driven : ils ne listent que des références déjà engagées
dans une commande client ou fournisseur. L'onglet Inventaire (RSMW\Preparation\Inventory) est le
premier écran du plugin à parcourir le catalogue entier — un produit simple par ligne, une
ligne par déclinaison pour un produit à variations, jamais le produit variable parent lui-même.
Aucune fonction WooCommerce ne permet de mélanger product et product_variation dans un seul
appel : la liste vient d'une requête SQL directe sur wp_posts, dans l'esprit de
BackInStock\Demand.
Les deux champs saisissables portent des TOTAUX, pas des soldes : « Stock physique (total) » et
« Commandé au fournisseur (total) » sont chacun la somme du libre (Stock::get() / Supply::get())
et de la part déjà attribuée à des commandes clients (detenu / commande_detenu de
Demand::map()). Le marchand saisit ce qu'il compte réellement — rayon compris, articles déjà mis
de côté pour des commandes inclus — sans avoir à en soustraire mentalement ce qui est engagé.
C'est désormais un vrai mouvement, pas une correction directe : l'écart entre le total saisi et
le total courant est routé vers Allocator (credit_found_stock() / withdraw() pour le stock,
order_from_supplier() / cancel_supplier_order() pour le commandé), qui l'attribue automatiquement
en FIFO (hausse) ou reprend en LIFO (baisse) aux commandes clients en attente — sans aucun réglage
manuel possible depuis cet écran. Une hausse peut donc faire basculer une commande vers
« À empaqueter », une baisse peut l'en faire redescendre ; le compte rendu affiché après
enregistrement le signale. Une correction qui n'a besoin de toucher aucune commande (aucun reste à
préparer, ou retrait tenant entièrement dans le libre) reste une écriture directe, sans le coût d'un
parcours des commandes actives. Pas d'entrée au journal ligne par ligne pour autant — un inventoire
complet écraserait les 200 entrées de Journal::MAX_ENTRIES — la traçabilité passe par les notes de
commande qu'Allocator pose déjà et par un Log::info()/Log::error() récapitulatif.
Un verrou optimiste protège l'enregistrement : chaque valeur affichée au rendu du formulaire est
transportée dans un champ caché compact, et sert à détecter un état qui a changé depuis (rejeu de
POST, double-clic, deux onglets ouverts) — la ligne concernée est alors sautée plutôt que d'appliquer
un delta calculé sur une base périmée. Le delta lui-même n'est jamais calculé contre ce champ caché,
toujours contre Stock::get()/Supply::get() relus en base.
Catalogue de quelques centaines de références ou moins : recherche, filtre par catégorie
(product_cat) et tri se font entièrement côté navigateur (assets/js/inventory-table.js), sans
pagination — le même principe que needs-table.js pour « Besoins pour commande ». Les catégories
sont résolues en une seule requête groupée (wp_get_object_terms()), jamais get_the_terms()
par ligne : Labels::prime() n'amorce aucune taxonomie, seulement les posts et leurs métadonnées.
Une troisième colonne modifiable porte le stock WooCommerce — celui qui gouverne « en stock » /
« rupture » côté client, distinct des deux compteurs internes du plugin. Une variation peut suivre
sa propre quantité, ou l'hériter de son produit parent (stock mutualisé entre déclinaisons,
réglage « Gérer le stock ? » laissé sur « Parent » côté variation) : Inventory::woo_stock_for()
résout cette indirection avec WC_Product::get_stock_managed_by_id(), la même méthode
qu'utilise wc_update_product_stock() en écriture — lecture et écriture visent donc toujours le
même compteur. Une référence qui ne suit aucun stock affiche un tiret, jamais un champ : ce
tableau ne pose jamais manage_stock à la volée. La cellule passe en rouge dès que la quantité
tombe à zéro ou en dessous, y compris pendant la saisie (assets/js/inventory-table.js), puisque
c'est ce chiffre qui décide de ce que le client voit.
Chaque ligne s'ouvre sur une vignette carrée, la même que sur la fiche produit :
WC_Product::get_image( 'woocommerce_thumbnail' ) — jamais la taille thumbnail de WordPress,
qui dépend des réglages Médias et n'est pas garantie carrée. Cette méthode gère elle-même le
repli sur l'image du produit parent pour une variation qui n'en a pas, puis sur le placeholder
WooCommerce : aucune logique de repli à écrire côté plugin.
Une dernière colonne, « Déjà attribué », est en LECTURE SEULE — volontairement, contrairement
aux deux totaux. Elle se lit comme une barre à deux segments plutôt qu'un simple nombre, base 100 %
= le total attribué (jamais le total de la référence) : bleu pour la part prélevée sur le stock
physique (detenu de Demand::map()), orange pour la part couverte par une commande fournisseur
(commande_detenu, scopée au même périmètre détenteur — commandes actives et « À empaqueter »). Une
référence entièrement couverte par du stock physique affiche une barre bleue et un seul chiffre ;
dès qu'une part vient du commandé, la barre se partage et affiche les deux chiffres. La rendre
éditable n'aurait pas de sens : ce qu'elle montre se déduit des commandes, il ne se corrige pas à la
main — seuls les deux totaux à sa gauche se corrigent, et c'est leur correction qui la fait bouger.
Divergence assumée avec la fiche produit (ProductFields) : ses champs « Stock physique libre »
/ « Commandé au fournisseur » restent, eux, une correction directe du compteur LIBRE, sans passage
par Allocator. Les deux écrans affichent donc des nombres différents pour la même référence dès
qu'elle détient quelque chose — seuls les libellés (« libre » contre « (total) ») les distinguent.
Réception : ce qu'un défectueux ne doit pas faire
Un article reçu défectueux n'entre jamais en stock pour en ressortir aussitôt. L'aller-retour
paraît neutre, il ne l'est pas : Allocator::receive() sert les commandes de la plus ancienne à
la plus récente, Allocator::withdraw() reprend de la plus récente à la plus ancienne. Le couple
transfère donc du stock réel d'une commande vers une autre, avec deux changements de statut et deux
notes sur des commandes étrangères au colis.
Un défectueux passe par Allocator::cancel_supplier_order() : il cesse d'être attendu, sans toucher
au stock physique ni aux statuts. La référence remonte alors dans « Reste à commander », où elle est
visible. Le cumul par référence vit dans Defects — le journal, borné et destiné à l'exploitation
courante, ne peut pas porter une réclamation fournisseur.
Le socle visuel n'introduit aucune chaîne de build. Il repose sur trois choses vérifiées dans les sources de WordPress 7.1 et WooCommerce 11 :
- les *jetons `--wpds-
** de la feuillewp-theme, déclarée en dépendance et systématiquement écrite avec une valeur de repli —var(--wpds-border-radius-lg, 8px)` ; - les boutons du cœur (
.button,.button-primary), qui suivent déjà la refonte WordPress 7.0 (40 px de haut, rayon 2 px, anneaux de focus) ; - une carte maison reprenant les valeurs exactes de la Card de Gutenberg.
Attention : .components-card ne produit aucun style depuis PHP — Card, Flex et Text sont passés
en CSS-in-JS. Seuls components-button, components-notice, components-panel, components-badge
et components-spinner sont du CSS statique réellement utilisable sans React.
Le conteneur des deux pages doit porter la classe woocommerce : tout le style de formulaire de
WooCommerce y est scopé.
Réglages
Config résout chaque réglage dans cet ordre : constante MH_PREP_* → option rsmw_* → défaut.
Une constante encore définie est signalée dans l'écran de réglages, et le champ correspondant reste
volontairement modifiable — le désactiver ferait enregistrer une valeur vide par WooCommerce.
Module « Précommandes »
Quand le fournisseur n'a pas de stock mais peut fabriquer à la demande, la boutique ouvre une
précommande. Le module (src/PreOrder/) remplace cinq snippets : statut de commande, date
d'expédition estimée, texte de disponibilité, libellé du bouton, badge promo, vue « À traiter ».
La trace n'est pas le statut
C'est le défaut que ce module corrige. Les snippets faisaient porter l'information « c'est une précommande » par le statut de commande, or un statut est une valeur unique : dès que la commande avance vers la préparation puis l'expédition, la trace disparaît. Impossible de savoir a posteriori ce qui avait été précommandé.
Le module sépare donc les deux :
| Où | Écrit quand | Réécrit ? | |
|---|---|---|---|
| La trace | métas de ligne de commande | à l'achat | jamais |
| L'état | statut de commande | au fil du flux | librement, par WooCommerce ou à la main |
Conséquence directe : le statut n'est plus jamais nécessaire. Il redevient un simple état de flux — ce qui, paradoxalement, permet de l'automatiser sans danger.
La bascule automatique, rétablie en 0.7.0
PreOrder\StatusFlip replace la commande dans le statut « Précommande ». Optionnel, décoché par
défaut : Réglages → Précommandes → Statut automatique.
Trois différences avec le snippet remplacé, chacune corrigeant un défaut constaté :
| Snippet | Module | |
|---|---|---|
| Décision | is_on_backorder() rejoué après l'achat |
le marqueur de la commande |
| Répétition | à chaque passage en « En cours » | une seule fois |
| Sortie | jamais — statut manuel | StatusSync → « À empaqueter » au pointage |
Le premier point est le plus important : is_on_backorder() lit l'état courant du produit, sans
contexte de commande. Rejoué après l'achat, il répond « non » dès que la marchandise est revenue — le
snippet ratait donc la bascule. Le marqueur, lui, dit ce qui a réellement été vendu.
La bascule est suspendue tant que precommande ne figure pas dans les « Statuts à préparer ».
Ce n'est pas de la prudence : sans cette garde, chaque précommande sortirait du circuit — absente de
Demand, ignorée par Allocator, jamais ramenée en « À empaqueter » par StatusSync. Elle resterait
bloquée. Config::auto_status_is_operative() porte cette règle, et le diagnostic affiche les trois
états séparément : demandée / statut suivi / effective.
Priorité 30 sur woocommerce_order_status_changed, et ce n'est pas cosmétique.
Preparation\Allocator est sur le même hook en priorité 20 : il sert la commande dans le stock libre
puis, si elle devient complète, appelle StatusSync qui la passe en « À empaqueter ». À égalité de
priorité nous étions départagés par l'ordre d'enregistrement des modules, et nous écrasions un
« À empaqueter » tout juste posé. Pour la même raison, maybe_schedule() ignore les arguments $to
et $order du hook — figés avant que les autres n'agissent — et relit la commande.
Différée via Action Scheduler depuis la 3.8.0. maybe_schedule() ne fait plus que décider s'il
faut basculer ; la bascule elle-même (run(), qui pose le statut et donc rejoue tous les écouteurs de
woocommerce_order_status_changed, Allocator compris) s'exécute hors de la requête de paiement,
quelques instants plus tard. En cause : sur une commande précommandée, cette requête est déjà celle où
la passerelle de paiement (Stripe) débite la carte et attend la réponse JSON pour débloquer le client ;
y rejouer tout Allocator une seconde fois — le seul traitement qui distingue une précommande d'une
commande normale à cet instant précis — a été identifié comme le déclencheur le plus crédible d'un
paiement Stripe pourtant réussi, qui se voyait resoumis puis marqué « Échoué » à tort par le plugin
Stripe (qui n'écarte pas ce cas). eligible_order() revérifie tout à neuf au moment de l'exécution,
plutôt que de reprendre l'état lu à la planification — devenu possiblement périmé entre-temps.
La sémantique du statut vit hors du module
Trois filtres décrivent ce que « Précommande » signifie pour WooCommerce, et sont donc enregistrés avec le statut, pas avec le module :
woocommerce_order_is_paid_statuses— sans quoi le chiffre d'affaires d'une précommande sort des rapports pendant toute l'attente du fournisseur, alors que l'argent est encaissé ;woocommerce_valid_order_statuses_for_payment_complete— sans quoipayment_complete()devient un no-op silencieux : une boutique encaissant par virement verrait ses précommandes bloquées là définitivement ;woocommerce_order_is_download_permitted—WC_Order::is_download_permitted()teste « Terminée » ou « En cours » en dur, la bascule retirerait donc l'accès aux fichiers.
Les laisser dans le module ferait qu'en le désactivant, des commandes qui n'ont pas bougé sortiraient des rapports.
Ce que la bascule faisait bien, et qu'il a fallu rendre
La bascule automatique avait une vertu : elle colorait la colonne Statut, donc le marchand repérait ses précommandes en balayant la liste. La supprimer a laissé un trou — plus aucun marqueur visuel, seulement un lien de vue à cliquer.
D'où la colonne « Précommande », insérée après « Préparation ». Elle ne lit que le drapeau de
commande _rsmw_has_preorder, jamais un statut : c'est la garantie mécanique qu'une commande expédiée
ou terminée affiche encore sa puce, là précisément où la colonne « Préparation » se tait.
☐ Commande Date Statut Préparation Précommande Total
─────────────────────────────────────────────────────────────────────────────────────────────
☐ #14827 C. Dubois 2 sept. [En cours] ▰▰▰▱▱ 2/4 +1 ● Précommande 12 oct. 184,50 €
☐ #14826 M. Ferrand 2 sept. [Expédiée] · ● Précommande 30 sept. 62,00 €
☐ #14825 A. Roy 1 sept. [En cours] ▰▰▰▰▰ 3/3 · 118,90 €
Trois contraintes ont dicté la forme :
Abscisse fixe. Un marqueur greffé sur le numéro de commande ou sur la pastille de statut flotte horizontalement selon la longueur du nom de l'acheteur. Une colonne crée une bande où l'œil ne distingue que deux formes — la puce, ou le point médian.
Un mot, pas une couleur. « Précommande » est écrit en clair ; le bordeaux et la puce ronde ne sont
que des accélérateurs de balayage. C'est aussi ce qui a écarté la classe CSS sur le <tr> : signal
purement chromatique, et disponible sous HPOS seulement.
Coût nul par ligne. On lit deux métas de commande, jamais les lignes. Sous HPOS elles sont déjà
en mémoire — CustomMetaDataStore::get_meta_data_for_object_ids() charge celles des vingt commandes
de l'écran en une requête. Passer par Marker::order_has_marked_line() aurait coûté une soixantaine
d'objets de ligne sous HPOS, et de l'ordre de cent cinquante requêtes en stockage historique.
Le corollaire est assumé : la couverture du marqueur vaut exactement celle du marquage. Une commande que la reprise d'historique n'a pas pu marquer n'affiche rien — et n'apparaît pas non plus dans la vue « Précommandes ». C'est le test de diagnostic : si une commande manque dans la vue, le problème est le marquage, pas l'affichage.
L'ordre « Statut | Préparation | Précommande » tient sur les deux modes de stockage sans jamais nommer
la clé de la colonne Préparation, qui est une constante privée d'un autre module — voir le commentaire
de PreOrder\Admin\OrdersColumn::add_column(), les deux mécanismes diffèrent.
Métas lisibles sur la fiche de commande
L'écran de modification d'une commande et la modale d'aperçu appellent get_all_formatted_meta_data( '' )
— préfixe vide. Contrairement au front, aux emails et à l'espace client, ils n'écartent donc pas
les clés soulignées. Sans traitement, le marchand y lit _rsmw_preorder_qty: 2.
PreOrder\Admin\ItemMeta masque ce qui fait doublon et renomme le reste : « Quantité précommandée »,
« Précommande levée le » avec l'horodatage mis en forme. Aucun risque de fuite côté client — en front
la boucle écarte les clés soulignées avant d'appliquer le filtre de libellé.
Métas posées
| Donnée | Clé | Emplacement |
|---|---|---|
| Date d'expédition estimée | _mh_preorder_date |
produit et variation |
| Date figée à l'achat | _mh_preorder_date |
ligne de commande |
| Date lisible par le client | Expédition estimée |
ligne, visible |
| Quantité précommandée | _rsmw_preorder_qty |
ligne |
| Levée de la précommande | _rsmw_preorder_filled_at |
ligne |
| Commande concernée | _rsmw_has_preorder |
commande (index) |
| Date promise la plus lointaine | _rsmw_preorder_date_max |
commande (tri) |
Les quatre premières clés sont gelées : elles sont écrites à l'identique par les snippets
remplacés. Expédition estimée est délibérément une chaîne littérale, pas un __() — sur une
méta de ligne, le libellé est la clé de stockage : le passer par la traduction rendrait orphelines
toutes les métas déjà en base au premier changement de locale.
Le drapeau au niveau commande n'est qu'un index. La vérité est sur la ligne ; sans lui, retrouver les précommandes imposerait de parcourir toutes les lignes de toutes les commandes.
Pose des marqueurs
Sur woocommerce_checkout_create_order_line_item, qui couvre aussi le tunnel en blocs — le Store API
délègue à WC_Checkout::create_order_line_items(). La quantité est lue en priorité sur la méta native
Backordered que WooCommerce vient d'écrire, sinon recalculée : cette méta n'existe que si le réglage
de rupture vaut « Autoriser, mais informer le client ».
Le callback est idempotent et purement déclaratif : il n'écrit que des métas, aucun journal, aucun compteur, aucun mouvement de stock.
Deux limites assumées. woocommerce_new_order_item est mort — il n'est plus émis que par des
fonctions dépréciées : le back-office est rattrapé par woocommerce_ajax_add_order_item_meta, mais
l'API REST et le Point de vente ne sont pas couverts. Et is_on_backorder() lit l'état courant
du produit, sans contexte de commande : la condition n'est donc jamais rejouée après l'achat.
Levée
Preparation\Items::set_quantity() émet rsmw_line_prepared( $item, $prepared, $quantity ) — le seul
point par où passe toute variation du préparé. Le module s'y abonne pour horodater la levée, sans que
les deux modules aient à se connaître.
Reprise de l'historique
Par lots, à chaque chargement de l'administration, en deux phases : les commandes portant
wc-precommande (sans toucher au statut), puis celles dont une ligne porte une date d'expédition
ou la méta native de rupture.
Une commande déjà repassée en « En cours » et dont aucune ligne ne porte de méta est définitivement perdue — c'est exactement le trou que ce modèle vient boucher. Le volume repris s'affiche dans l'écran de réglages.
Bascule
PreOrder\SnippetGuard a sa propre liste de sentinelles, séparée de celle de Preparation. Ce
n'est pas de la duplication : snippet_is_active() est un OU plat sans notion de portée, et
Plugin::boot() s'en sert pour conditionner à la fois le module et l'enregistrement du statut.
Mutualiser les listes mettrait tout le module Préparation en veille dès qu'un seul snippet de
précommande resterait actif.
Ce qui fait une sentinelle valable — seulement un snippet qui écrit ou déclare quelque chose : un statut, une méta, une vue, un automatisme. Un simple filtre d'affichage n'en est pas un.
La leçon a été apprise à ses dépens : ds_change_sale_text avait été mise dans la liste. Elle ne
surcharge pourtant que le libellé du badge promotionnel — aucun doublon de données possible. Or c'est
un nom générique qu'un marchand peut légitimement réutiliser pour une règle sans rapport (une
catégorie « outlet », par exemple). La sentinelle mettait alors tout le module en veille, jusqu'à
désenregistrer le statut « Précommande » — et les commandes concernées disparaissaient des écrans
d'administration. Elle a été retirée en 0.6.2.
Corollaire : quand un filtre d'affichage du marchand coexiste avec le module, les deux s'appliquent.
Le nôtre est enregistré plus tard — WPCode exécute ses snippets sur plugins_loaded en priorité 5, le
plugin démarre en 20 — donc il passe en second et l'emporte sur les articles précommandés.
L'avertissement d'administration et le panneau de diagnostic nomment les fonctions détectées. Sans cela, le marchand doit deviner lequel de ses snippets bloque le module.
Ordre à respecter : mettre à jour le plugin d'abord, vérifier le diagnostic dans les réglages, puis désactiver les snippets. Retour arrière : les réactiver, le module se remet en veille au chargement suivant.
Fournisseurs
Le marchand travaille en flux tendu avec plusieurs fournisseurs, et son geste réel est fournisseur par fournisseur. La page « Besoins pour commande » a donc un onglet « Général », un onglet par fournisseur, et un onglet « Sans fournisseur ».
Une taxonomie, rsmw_supplier
Non hiérarchique, attachée à product seulement, show_ui => true mais show_in_menu => false :
l'écran natif edit-tags.php fournit tout le CRUD, sans apparaître sous « Produits ». Un
add_submenu_page( 'woocommerce', … ) le place sous le menu de l'extension, et les filtres
parent_file / submenu_file empêchent WordPress d'ouvrir le menu Produits à la place.
Deux raisons l'emportent sur un type de contenu :
- le CRUD est déjà écrit par WordPress. Le contre-exemple chiffre l'alternative : la classe de
livraison de WooCommerce a
show_ui => false, et il a fallu lui réécrire tout un écran en Backbone ; wp_delete_term()détache proprement chaque produit. Un type de contenu laisserait des identifiants pointant dans le vide sur des centaines de produits.
product_brand a été écarté malgré la tentation : un distributeur livre plusieurs marques, et une
marque arrive parfois par deux distributeurs.
Variations : remontée au parent
Une « référence » de la table des besoins est l'ID de la variation quand la ligne en porte une
(Items::key()). Or une variation ne porte pas de terme. Suppliers\Resolver remonte donc au parent —
patron de WC_Product_Variation::get_shipping_class_id(). Conséquence assumée : toutes les
déclinaisons d'un produit partagent son fournisseur.
Une requête pour toute la page. L'ID parent était déjà sélectionné par la requête de demande sous
l'alias pid, puis jeté ; Demand::map() le reporte maintenant sous la clé parent, et le résolveur
fait un unique wp_get_object_terms( …, 'all_with_object_id' ).
Ne jamais passer par
wc_get_product_terms()ici : il retombe surwp_get_post_terms()et coûte une requête par produit — invisible sur une fiche, ruineux sur un tableau de centaines de lignes.
L'onglet « Sans fournisseur » n'est pas décoratif
Au démarrage, 100 % des références y sont. Et si le marchand travaille fournisseur par fournisseur,
une référence sans terme ne serait dans aucun onglet : il ne la commanderait jamais. Il est donc en
dernier, compteur en rouge, et masqué dès qu'il est vide — sa disparition signale que le catalogue
est cartographié. L'onglet « Général » porte en renfort une colonne Fournisseur, et le nom du
fournisseur entre dans data-search, ce qui permet de filtrer par fournisseur à la recherche.
Les compteurs comptent les références à manque > 0, et non toutes celles du fournisseur : la case
« Manquants uniquement » étant cochée d'entrée, c'est exactement ce que le marchand verra en ouvrant
l'onglet. Invariant, et test de recette : somme des onglets fournisseurs + « Sans fournisseur » =
compteur de « Général ».
Passer la commande depuis l'onglet
C'est le vrai besoin derrière « pouvoir passer les commandes plus facilement ». Enregistrer une
commande fournisseur imposait de passer par Gestion stock → Mouvement à l'unité, une référence à la
fois : quinze allers-retours pour quinze lignes. En pratique le marchand ne le faisait pas — et comme
il ne le faisait pas, manque ne baissait jamais et la page lui redemandait chaque semaine de
commander ce qu'il avait déjà commandé.
Preparation\Purchase est l'image miroir de Reception : saisie par ligne préremplie avec le manque,
vérification avant écriture, puis un seul enregistrement qui délègue à
Allocator::order_from_supplier(). Une commande fournisseur ne fait jamais basculer une commande
client en « À empaqueter » : la marchandise n'est pas arrivée.
Prérequis : Pages::register() accroche désormais NeedsPage::handle_post sur load-{écran}.
La page traitait ses POST dans render(), donc après l'envoi de l'en-tête — aucune redirection
possible. Tolérable pour une réaffectation, inacceptable pour « j'ai commandé douze articles », qu'un
F5 aurait enregistré deux fois.
Filtrer la réception par fournisseur
L'onglet « Réception d'un colis » porte un sélecteur « Colis reçu de ». Trois décisions :
- le filtre survit à la redirection (
StockPage::redirect()). Sans cela, le marchand qui vient de pointer un colis retomberait sur la liste complète, et la réapparition des autres références lui ferait croire que rien n'a été enregistré ; - le sélecteur ne liste que les fournisseurs qui attendent quelque chose, avec le compteur. Il répond à « de qui ce colis peut-il venir ? », pas à « qui est-ce que je connais ? ». « Sans fournisseur » y figure dès qu'une référence non affectée est attendue — sinon elle ne serait réceptionnable par aucun filtre ;
- c'est un formulaire GET autonome, posé hors du formulaire de réception : deux formulaires ne peuvent pas s'imbriquer, et celui de saisie enveloppe déjà tout le tableau. Il marche sans JavaScript ; le script ne fait que le soumettre au changement.
Deux slugs réservés
general et sans-fournisseur désignent les filtres du plugin dans l'URL. Un fournisseur nommé
« Général » ou « Sans fournisseur » produirait exactement le même slug — et ses références
deviendraient invisibles dans son propre onglet, derrière un compteur qui les annonce pourtant.
Suppliers\Taxonomy::reserve_slugs() règle le conflit là où il naît, sur le filtre
wp_unique_term_slug, plutôt que de l'arbitrer à la lecture dans chaque écran. Toute nouvelle
constante de filtre adossée à un slug de fournisseur doit être ajoutée à RESERVED_SLUGS.
Contrat du JavaScript
assets/js/needs-table.js filtre, trie et exporte côté client, sur les data-* du <tr> — jamais
sur les cellules. Deux règles à respecter en touchant au gaba
This README is longer than the copy stored here. Read the rest on GitHub →
Releases
Newest 25 of 27 recorded releases. Each count is every asset in that release; expand a row for the breakdown.