WarehouseService.java

package com.rbctcsworld.ecommerce.inventory;

import com.rbctcsworld.ecommerce.audit.AuditChange;
import com.rbctcsworld.ecommerce.audit.AuditEvent;
import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.common.exception.ConflictException;
import com.rbctcsworld.ecommerce.common.exception.NotFoundException;
import com.rbctcsworld.ecommerce.inventory.WarehouseDtos.Adjust;
import com.rbctcsworld.ecommerce.inventory.WarehouseDtos.CreateWarehouse;
import com.rbctcsworld.ecommerce.inventory.WarehouseDtos.ProductStock;
import com.rbctcsworld.ecommerce.inventory.WarehouseDtos.StockLine;
import com.rbctcsworld.ecommerce.inventory.WarehouseDtos.Transfer;
import com.rbctcsworld.ecommerce.inventory.WarehouseDtos.UpdateWarehouse;
import com.rbctcsworld.ecommerce.inventory.WarehouseDtos.WarehouseView;
import com.rbctcsworld.ecommerce.product.Product;
import com.rbctcsworld.ecommerce.product.ProductRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Locale;

/**
 * Store admin: warehouses, and stock per warehouse (receive goods, correct a count, write off damage,
 * move stock between warehouses). Every change keeps products.stock = SUM(warehouse_stock) and writes
 * stock movements; warehouse changes go to the audit trail.
 *
 * Lock order is the same as for orders: the product row first, then its warehouse rows.
 */
@Service
@Transactional
public class WarehouseService {

    private final WarehouseStore store;
    private final ProductRepository products;
    private final StockMovementRepository movements;
    private final ApplicationEventPublisher events;

    public WarehouseService(WarehouseStore store, ProductRepository products, StockMovementRepository movements,
                            ApplicationEventPublisher events) {
        this.store = store;
        this.products = products;
        this.movements = movements;
        this.events = events;
    }

    // ---------------------------------------------------------------- warehouses

    @Transactional(readOnly = true)
    public List<WarehouseView> all() {
        return store.all().stream().map(this::view).toList();
    }

    public WarehouseView create(CreateWarehouse r) {
        String code = r.code().trim().toUpperCase(Locale.ROOT);
        if (store.codeExists(code)) throw new ConflictException("Warehouse code already exists: " + code);
        long id = store.insert(code, r.name().trim(), r.city().trim(), r.state().trim().toUpperCase(Locale.ROOT), r.priority());
        Warehouse w = store.find(id).orElseThrow();
        events.publishEvent(AuditEvent.of(AuditChange.diff(AuditChange.WAREHOUSE, id, "WAREHOUSE_CREATED", null, audited(w))));
        return view(w);
    }

    /**
     * Edits a warehouse. Switching one off is only allowed when it is empty (move its stock first) and
     * another warehouse stays active (new stock needs a home).
     */
    public WarehouseView update(long id, UpdateWarehouse r) {
        Warehouse w = store.find(id).orElseThrow(() -> new NotFoundException("Warehouse not found: " + id));
        if (w.active() && !r.active()) {
            if (store.unitsIn(id) > 0) {
                throw new ConflictException("Warehouse " + w.code() + " still holds stock: move it to another warehouse first");
            }
            if (store.activeCount() <= 1) throw new ConflictException("At least one warehouse must stay active");
        }
        store.update(id, r.name().trim(), r.city().trim(), r.state().trim().toUpperCase(Locale.ROOT), r.priority(), r.active());
        Warehouse after = store.find(id).orElseThrow();
        events.publishEvent(AuditEvent.of(AuditChange.diff(AuditChange.WAREHOUSE, id, "WAREHOUSE_UPDATED", audited(w),
                audited(after))));
        return view(after);
    }

    // ---------------------------------------------------------------- stock per warehouse

    /** The product's stock in every warehouse (also the empty ones), total first. */
    @Transactional(readOnly = true)
    public ProductStock stockOf(long productId) {
        Product p = products.findById(productId).orElseThrow(() -> new NotFoundException("Product not found: " + productId));
        List<StockLine> lines = store.all().stream().map(w -> new StockLine(w.id(), w.code(), w.city(), w.state(),
                w.active(), store.quantity(w.id(), productId))).toList();
        return new ProductStock(productId, p.getSku(), p.getName(), products.currentStock(productId), lines);
    }

    /** Received goods (+), a count correction (+/-) or damaged units written off (-), in one warehouse. */
    public ProductStock adjust(Adjust a) {
        if (a.delta() == 0) throw new BusinessRuleException("delta must not be 0");
        if (a.reason().equals(StockMovement.RECEIVED) && a.delta() < 0) throw new BusinessRuleException("RECEIVED adds units");
        if (a.reason().equals(StockMovement.DAMAGED) && a.delta() > 0) throw new BusinessRuleException("DAMAGED removes units");
        Warehouse w = activeWarehouse(a.warehouseId(), a.delta() > 0);
        lockProduct(a.productId());
        store.lockStock(a.productId());
        if (a.delta() > 0) {
            store.put(w.id(), a.productId(), a.delta());
        } else if (!store.take(w.id(), a.productId(), -a.delta())) {
            throw new ConflictException(w.code() + " holds only " + store.quantity(w.id(), a.productId()) + " of this product");
        }
        products.incrementStock(a.productId(), a.delta());
        movements.save(new StockMovement(a.productId(), null, w.id(), a.delta(), a.reason(),
                products.currentStock(a.productId())));
        return stockOf(a.productId());
    }

    /** Moves units from one warehouse to another; the total does not change. */
    public ProductStock transfer(Transfer t) {
        if (t.fromWarehouseId().equals(t.toWarehouseId())) throw new BusinessRuleException("from and to must be different warehouses");
        Warehouse from = store.find(t.fromWarehouseId())
                .orElseThrow(() -> new NotFoundException("Warehouse not found: " + t.fromWarehouseId()));
        Warehouse to = activeWarehouse(t.toWarehouseId(), true);
        lockProduct(t.productId());
        store.lockStock(t.productId());
        if (!store.take(from.id(), t.productId(), t.quantity())) {
            throw new ConflictException(from.code() + " holds only " + store.quantity(from.id(), t.productId()) + " of this product");
        }
        store.put(to.id(), t.productId(), t.quantity());
        int total = products.currentStock(t.productId());
        movements.save(new StockMovement(t.productId(), null, from.id(), -t.quantity(), StockMovement.TRANSFER_OUT, total - t.quantity()));
        movements.save(new StockMovement(t.productId(), null, to.id(), t.quantity(), StockMovement.TRANSFER_IN, total));
        return stockOf(t.productId());
    }

    @Transactional(readOnly = true)
    public List<WarehouseStore.OrderAllocation> allocations(long orderId) {
        return store.allocations(orderId);
    }

    // ---------------------------------------------------------------- helpers

    /** Units may only go INTO an active warehouse; taking out of a switched-off one (emptying it) is fine. */
    private Warehouse activeWarehouse(long id, boolean receiving) {
        Warehouse w = store.find(id).orElseThrow(() -> new NotFoundException("Warehouse not found: " + id));
        if (receiving && !w.active()) throw new ConflictException("Warehouse " + w.code() + " is switched off");
        return w;
    }

    private void lockProduct(long productId) {
        products.lockById(productId).orElseThrow(() -> new NotFoundException("Product not found: " + productId));
    }

    private WarehouseView view(Warehouse w) {
        return new WarehouseView(w.id(), w.code(), w.name(), w.city(), w.state(), w.priority(), w.active(), store.unitsIn(w.id()));
    }

    static java.util.Map<String, Object> audited(Warehouse w) {
        return AuditChange.fields("code", w.code(), "name", w.name(), "city", w.city(), "state", w.state(),
                "priority", w.priority(), "active", w.active());
    }
}