ADR-020 — Warehouse автоматически обновляет stock при completion/refund заказа

Context

Warehouse-service был полностью изолирован от событий заказа: ни одного @KafkaListener, никакой реакции на закрытие/возврат. Stock balances обновлялись только вручную через Receipt Acts (приход) и Write Off Acts (черновое списание).

Это означало:

  • E2e flow «заказ → списание ингредиентов» не работал — current_quantity не падало после продажи
  • Refund возвращал деньги, но не возвращал ингредиенты на склад
  • Любая отчётность по себестоимости/потерям была невалидной

Симптомы поймали в e2e-тестах сквозных функциональностей 2026-05-05 (см. план e2e тесты сквозных функциональностей).

Decision

Warehouse-service подписывается на 2 события из order-service:

  1. order.completed → авто-списание (AUTO-WRITEOFF-{order_number}-{ts})

    • По каждому order_items ищем TechCard.findByProductIdAndModifierOptionIdIsNull(product_id)
    • Суммируем recipe_item.net_weight × order_item.quantity по ingredient_id
    • Создаём WriteOffAct со status="posted", в одной транзакции с StockBalance.current_quantity -= delta и recalculateAverageCost
  2. order.refunded с is_full_refund=true → restock (AUTO-RESTOCK-{order_number}-{ts})

    • Используем тот же calculator, инверсию знака (positive delta)
    • Партиальные возвраты пока не поддерживаются (требуют маппинг refund_cart на recipe — отдельная задача)

Order-service обогащён: Completed/Refunded payloads теперь содержат items: [{product_id, product_name, quantity}]. Это минимальный snapshot без ссылок на storage-сервис.

Implementation

ComponentPathChanges
Order DTOerp-order-service/.../event/OrderEventPayloads.java+ nested Item, поле items в Completed и Refunded
Order Publishererp-order-service/.../event/OrderEventPublisher.javahelper toEventItems(order); publishCompleted всегда заполняет, publishRefunded — только если isFullRefund
Warehouse Kafka error handlererp-warehouse-service/.../config/KafkaConfig.javaDLT + safeRecoverer (копия order-service.KafkaConfig)
Warehouse consumererp-warehouse-service/.../event/OrderEventConsumer.java2 @KafkaListener методы (consumer-groups: warehouse-service-order-completed, warehouse-service-order-refunded); парсит JsonNode (StringDeserializer на consumer level)
Warehouse сервисerp-warehouse-service/.../service/OrderInventoryService.javadeductForCompletedOrder/restockForRefundedOrder — atomically создаёт WriteOffAct со status=“posted” + lines + обновляет StockBalance
Warehouse YAMLerp-warehouse-service/src/main/resources/application.ymlspring.kafka.consumer + spring.kafka.listener.missing-topics-fatal=false

Consequences

Положительные

  • Stock_balances реально отражают остатки после продаж
  • WriteOffAct записи дают аудит всех auto-операций (видны в админке)
  • Refund восстанавливает склад симметрично
  • Average cost пересчитывается после каждой операции

Известные ограничения (отложено)

  1. Modifier-tech-cards игнорируются. OrderItem.modifiers (jsonb) хранит только group_name+option_name+price, без modifier_option_id. Чтобы списывать модификаторы, нужно расширить ModifierEntry UUID-ссылкой на modifier_options.id. Затронет: order-service entity + pos-bff body validation + frontend (POS) order submit.

  2. Partial refund. Pos-bff передаёт refund_cart со списком возвращаемых items. Сейчас warehouse возвращает stock только для is_full_refund=true. Для partial — нужен маппинг refund_cart.product_id → order_items.product_id и пропорциональный delta (например в одной шаурме вернули половину → 1/2 ingredient_qty).

  3. No insufficient-stock guard. Если current_quantity < delta, мы делаем отрицательный balance. По хорошему — лог warning + не падать (продажа уже произошла, склад просто не сошёлся). Для production — добавить алерт.

Отрицательные

  • Дополнительная зависимость warehouse → order через kafka (но direction OK по слоистой архитектуре)
  • Eventual consistency: между POST /complete и обновлением stock проходит несколько мс (kafka lag); если кто-то делает GET /stock-balances сразу — увидит стейл

Verification

E2e прогон 2026-05-05 на Арбате:

  • order 001 (Шаурма Классическая M + Кола): после complete — Курица филе -0.150, Лепёшка -1, Лук -0.030, Помидор -0.050, Капуста -0.040, Соус чесночный -30мл (тех.карта применена точно)
  • order 003 (2× Шаурма Говядина + 2× Кола): complete → -0.300 кг говядины, refund → +0.300 кг (восстановлено)
  • WriteOffActs созданы со статусами posted, reason содержит order_number → трассировка с заказами

Ссылки