Product.java

package com.rbctcsworld.ecommerce.product;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import com.fasterxml.jackson.annotation.JsonInclude;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneOffset;

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true, length = 100)
    private String sku;

    @Column(length = 100)
    private String category;

    @Column(nullable = false, precision = 12, scale = 2)
    private BigDecimal price;

    /**
     * Total on hand in all warehouses. Never written by an entity update (only inserted): every change goes
     * through an atomic SQL statement (InventoryService), so a stale entity can never overwrite it.
     */
    @Column(nullable = false, updatable = false)
    private int stock;

    @Column(nullable = false)
    private boolean active = true;

    /** Average of PUBLISHED reviews, one decimal (0.0 when none). Maintained by ReviewService. */
    @Column(name = "rating_average", nullable = false, precision = 2, scale = 1)
    private BigDecimal ratingAverage = BigDecimal.ZERO.setScale(1);

    @Column(name = "rating_count", nullable = false)
    private int ratingCount;

    @Column(name = "image_url", length = 500)
    private String imageUrl;

    /** Products with the same variant group are options of one item (e.g. TEE-CLASSIC); null = a single product. */
    @Column(name = "variant_group", length = 40)
    private String variantGroup;

    /** What tells the variants of a group apart (either may be null, not both). */
    @Column(length = 20)
    private String size;

    @Column(length = 30)
    private String color;

    /** Flash deal (UTC): the price paid between start (inclusive) and end (exclusive). All three set, or none. */
    @Column(name = "deal_price", precision = 12, scale = 2)
    private BigDecimal dealPrice;

    @Column(name = "deal_starts_at")
    private LocalDateTime dealStartsAt;

    @Column(name = "deal_ends_at")
    private LocalDateTime dealEndsAt;

    protected Product() {
    }

    public Product(String name, String sku, String category, BigDecimal price, int stock) {
        this.name = name;
        this.sku = sku;
        this.category = category;
        this.price = price;
        this.stock = stock;
    }

    /** Applies every editable field (the old version only changed stock - a real defect). */
    public void update(String name, String sku, String category, BigDecimal price, int stock) {
        this.name = name;
        this.sku = sku;
        this.category = category;
        this.price = price;
        this.stock = stock;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public String getSku() { return sku; }
    public String getCategory() { return category; }
    public BigDecimal getPrice() { return price; }
    public int getStock() { return stock; }
    public boolean isActive() { return active; }
    public BigDecimal getRatingAverage() { return ratingAverage; }
    public int getRatingCount() { return ratingCount; }
    public String getImageUrl() { return imageUrl; }
    public String getVariantGroup() { return variantGroup; }
    public String getSize() { return size; }
    public String getColor() { return color; }

    /** Already validated by ProductService; null price = no deal. */
    public void changeDeal(BigDecimal dealPrice, LocalDateTime startsAt, LocalDateTime endsAt) {
        boolean none = dealPrice == null;
        this.dealPrice = none ? null : dealPrice;
        this.dealStartsAt = none ? null : startsAt;
        this.dealEndsAt = none ? null : endsAt;
    }

    /** The deal fields are only in the JSON of a product that has a deal, so ordinary products look as before. */
    @JsonInclude(JsonInclude.Include.NON_NULL) public BigDecimal getDealPrice() { return dealPrice; }
    @JsonInclude(JsonInclude.Include.NON_NULL) public LocalDateTime getDealStartsAt() { return dealStartsAt; }
    @JsonInclude(JsonInclude.Include.NON_NULL) public LocalDateTime getDealEndsAt() { return dealEndsAt; }

    /** True while the deal runs, false before it starts or after it ended, absent without a deal. */
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public Boolean getDealLive() {
        return dealPrice == null ? null : isDealLiveAt(LocalDateTime.now(ZoneOffset.UTC));
    }

    public boolean isDealLiveAt(LocalDateTime nowUtc) {
        return dealPrice != null && !nowUtc.isBefore(dealStartsAt) && nowUtc.isBefore(dealEndsAt);
    }

    /** What a customer pays at this moment: the deal price while the deal runs, the price otherwise. */
    public BigDecimal priceAt(LocalDateTime nowUtc) {
        return isDealLiveAt(nowUtc) ? dealPrice : price;
    }

    /** Not a JSON property (no "get" prefix); cart, orders and the wishlist use it. */
    public BigDecimal currentPrice() {
        return priceAt(LocalDateTime.now(ZoneOffset.UTC));
    }

    /** Already normalised by ProductService (group upper case; blank = null). */
    public void changeVariant(String variantGroup, String size, String color) {
        this.variantGroup = variantGroup;
        this.size = variantGroup == null ? null : size;
        this.color = variantGroup == null ? null : color;
    }

    /**
     * The name on cart lines, orders, invoices and e-mails: "Classic Cotton T-Shirt (Navy, M)" for a variant, the
     * plain name otherwise. Not a JSON property (no "get" prefix): clients build their own label from size/color.
     */
    public String displayName() {
        String options = java.util.stream.Stream.of(color, size).filter(v -> v != null && !v.isBlank())
                .collect(java.util.stream.Collectors.joining(", "));
        return options.isEmpty() ? name : name + " (" + options + ")";
    }

    /** null or blank = no own picture (the storefront shows the category picture). */
    public void changeImage(String imageUrl) {
        this.imageUrl = imageUrl == null || imageUrl.isBlank() ? null : imageUrl.trim();
    }

    public void setActive(boolean active) { this.active = active; }
    public void setStock(int stock) { this.stock = stock; }

    public void applyRating(BigDecimal average, int count) {
        this.ratingAverage = average;
        this.ratingCount = count;
    }
}