--
-- This file is part of TALER
-- Copyright (C) 2025 Taler Systems SA
--
-- TALER is free software; you can redistribute it and/or modify it under the
-- terms of the GNU General Public License as published by the Free Software
-- Foundation; either version 3, or (at your option) any later version.
--
-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-- A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License along with
-- TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>

-- @file merchant-0027.sql
-- @brief Add fractional stock support to merchant_inventory
-- @author Bohdan Potuzhnyi

BEGIN;

-- Check patch versioning is in place.
SELECT _v.register_patch('merchant-0027', NULL, NULL);

SET search_path TO merchant;

ALTER TABLE merchant_inventory
    ADD COLUMN price_array taler_amount_currency[]
        NOT NULL
        DEFAULT ARRAY[]::taler_amount_currency[];
COMMENT ON COLUMN merchant_inventory.price_array
    IS 'List of unit prices available for the product (multiple tiers supported).';

UPDATE merchant_inventory
SET price_array = ARRAY[price]::taler_amount_currency[]
WHERE price IS NOT NULL; -- theoretically all objects, but just to be sure

-- I assume we want to make drop price column at some point of time

ALTER TABLE merchant_inventory
    ADD COLUMN total_stock_frac INT4 NOT NULL DEFAULT 0;
COMMENT ON COLUMN merchant_inventory.total_stock_frac
    IS 'Fractional part of stock in units of 1/1000000 of the base value';

ALTER TABLE merchant_inventory
    ADD COLUMN total_sold_frac INT4 NOT NULL DEFAULT 0;
COMMENT ON COLUMN merchant_inventory.total_sold_frac
    IS 'Fractional part of units sold in units of 1/1000000 of the base value';

ALTER TABLE merchant_inventory
    ADD COLUMN total_lost_frac INT4 NOT NULL DEFAULT 0;
COMMENT ON COLUMN merchant_inventory.total_lost_frac
    IS 'Fractional part of units lost in units of 1/1000000 of the base value';

ALTER TABLE merchant_inventory
    ADD COLUMN allow_fractional_quantity BOOL NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN merchant_inventory.allow_fractional_quantity
    IS 'Whether fractional stock (total_stock_frac) should be honored for this product';

ALTER TABLE merchant_inventory
    ADD COLUMN fractional_precision_level INT4 NOT NULL DEFAULT 0;
COMMENT ON COLUMN merchant_inventory.fractional_precision_level
    IS 'Preset number of decimal places for fractional quantities';

ALTER TABLE merchant_inventory_locks
    ADD COLUMN total_locked_frac INT4 NOT NULL DEFAULT 0;
COMMENT ON COLUMN merchant_inventory_locks.total_locked_frac
    IS 'Fractional part of locked stock in units of 1/1000000 of the base value';

ALTER TABLE merchant_order_locks
    ADD COLUMN total_locked_frac INT4 NOT NULL DEFAULT 0;
COMMENT ON COLUMN merchant_order_locks.total_locked_frac
    IS 'Fractional part of locked stock associated with orders in units of 1/1000000 of the base value';

CREATE TABLE merchant_builtin_units
(
    unit_serial BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    unit TEXT NOT NULL UNIQUE,
    unit_name_long TEXT NOT NULL,
    unit_name_short TEXT NOT NULL,
    unit_name_long_i18n BYTEA NOT NULL DEFAULT convert_to('{}','UTF8'),
    unit_name_short_i18n BYTEA NOT NULL DEFAULT convert_to('{}','UTF8'),
    unit_allow_fraction BOOLEAN NOT NULL DEFAULT FALSE,
    unit_precision_level INT4 NOT NULL DEFAULT 0 CHECK (unit_precision_level BETWEEN 0 AND 6),
    unit_active BOOLEAN NOT NULL DEFAULT TRUE
);
COMMENT ON TABLE merchant_builtin_units
    IS 'Global catalogue of builtin measurement units.';
COMMENT ON COLUMN merchant_builtin_units.unit_active
    IS 'Default visibility for the builtin unit; instances may override.';

CREATE TABLE merchant_custom_units
(
    unit_serial BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    merchant_serial BIGINT NOT NULL REFERENCES merchant_instances (merchant_serial) ON DELETE CASCADE,
    unit TEXT NOT NULL,
    unit_name_long TEXT NOT NULL,
    unit_name_short TEXT NOT NULL,
    unit_name_long_i18n BYTEA NOT NULL DEFAULT convert_to('{}','UTF8'),
    unit_name_short_i18n BYTEA NOT NULL DEFAULT convert_to('{}','UTF8'),
    unit_allow_fraction BOOLEAN NOT NULL DEFAULT FALSE,
    unit_precision_level INT4 NOT NULL DEFAULT 0 CHECK (unit_precision_level BETWEEN 0 AND 6),
    unit_active BOOLEAN NOT NULL DEFAULT TRUE,
    UNIQUE (merchant_serial, unit)
);
COMMENT ON TABLE merchant_custom_units
    IS 'Per-instance custom measurement units.';

CREATE TABLE merchant_builtin_unit_overrides
(
    merchant_serial BIGINT NOT NULL REFERENCES merchant_instances (merchant_serial) ON DELETE CASCADE,
    builtin_unit_serial BIGINT NOT NULL REFERENCES merchant_builtin_units (unit_serial) ON DELETE CASCADE,
    override_allow_fraction BOOLEAN,
    override_precision_level INT4 CHECK (override_precision_level BETWEEN 0 AND 6),
    override_active BOOLEAN,
    PRIMARY KEY (merchant_serial, builtin_unit_serial)
);
COMMENT ON TABLE merchant_builtin_unit_overrides
    IS 'Per-instance overrides for builtin units (fraction policy and visibility).';

INSERT INTO merchant_builtin_units (unit, unit_name_long, unit_name_short, unit_allow_fraction, unit_precision_level, unit_active)
VALUES
    ('Piece', 'piece', 'pc', FALSE, 0, TRUE),
    ('Set', 'set', 'set', FALSE, 0, TRUE),
    ('SizeUnitCm', 'centimetre', 'cm', TRUE, 1, TRUE),
    ('SizeUnitDm', 'decimetre', 'dm', TRUE, 3, TRUE),
    ('SizeUnitFoot', 'foot', 'ft', TRUE, 3, TRUE),
    ('SizeUnitInch', 'inch', 'in', TRUE, 2, TRUE),
    ('SizeUnitM', 'metre', 'm', TRUE, 3, TRUE),
    ('SizeUnitMm', 'millimetre', 'mm', FALSE, 0, TRUE),
    ('SurfaceUnitCm2', 'square centimetre', 'cm²', TRUE, 2, TRUE),
    ('SurfaceUnitDm2', 'square decimetre', 'dm²', TRUE, 3, TRUE),
    ('SurfaceUnitFoot2', 'square foot', 'ft²', TRUE, 3, TRUE),
    ('SurfaceUnitInch2', 'square inch', 'in²', TRUE, 4, TRUE),
    ('SurfaceUnitM2', 'square metre', 'm²', TRUE, 4, TRUE),
    ('SurfaceUnitMm2', 'square millimetre', 'mm²', TRUE, 1, TRUE),
    ('TimeUnitDay', 'day', 'd', TRUE, 3, TRUE),
    ('TimeUnitHour', 'hour', 'h', TRUE, 2, TRUE),
    ('TimeUnitMinute', 'minute', 'min', TRUE, 3, TRUE),
    ('TimeUnitMonth', 'month', 'mo', TRUE, 2, TRUE),
    ('TimeUnitSecond', 'second', 's', TRUE, 3, TRUE),
    ('TimeUnitWeek', 'week', 'wk', TRUE, 3, TRUE),
    ('TimeUnitYear', 'year', 'yr', TRUE, 4, TRUE),
    ('VolumeUnitCm3', 'cubic centimetre', 'cm³', TRUE, 3, TRUE),
    ('VolumeUnitDm3', 'cubic decimetre', 'dm³', TRUE, 5, TRUE),
    ('VolumeUnitFoot3', 'cubic foot', 'ft³', TRUE, 5, TRUE),
    ('VolumeUnitGallon', 'gallon', 'gal', TRUE, 3, TRUE),
    ('VolumeUnitInch3', 'cubic inch', 'in³', TRUE, 2, TRUE),
    ('VolumeUnitLitre', 'litre', 'L', TRUE, 3, TRUE),
    ('VolumeUnitM3', 'cubic metre', 'm³', TRUE, 6, TRUE),
    ('VolumeUnitMm3', 'cubic millimetre', 'mm³', TRUE, 1, TRUE),
    ('VolumeUnitOunce', 'fluid ounce', 'fl oz', TRUE, 2, TRUE),
    ('WeightUnitG', 'gram', 'g', TRUE, 1, TRUE),
    ('WeightUnitKg', 'kilogram', 'kg', TRUE, 3, TRUE),
    ('WeightUnitMg', 'milligram', 'mg', FALSE, 0, TRUE),
    ('WeightUnitOunce', 'ounce', 'oz', TRUE, 2, TRUE),
    ('WeightUnitPound', 'pound', 'lb', TRUE, 3, TRUE),
    ('WeightUnitTon', 'metric tonne', 't', TRUE, 3, TRUE);

DROP FUNCTION IF EXISTS merchant_do_insert_unit;
CREATE FUNCTION merchant_do_insert_unit (
    IN in_instance_id TEXT,
    IN in_unit TEXT,
    IN in_unit_name_long TEXT,
    IN in_unit_name_short TEXT,
    IN in_unit_name_long_i18n BYTEA,
    IN in_unit_name_short_i18n BYTEA,
    IN in_unit_allow_fraction BOOL,
    IN in_unit_precision_level INT4,
    IN in_unit_active BOOL,
    OUT out_no_instance BOOL,
    OUT out_conflict BOOL,
    OUT out_unit_serial INT8)
    LANGUAGE plpgsql
AS $$
DECLARE
    my_merchant_id INT8;
BEGIN
    SELECT merchant_serial
    INTO my_merchant_id
    FROM merchant_instances
    WHERE merchant_id = in_instance_id;

    IF NOT FOUND THEN
        out_no_instance := TRUE;
        out_conflict := FALSE;
        out_unit_serial := NULL;
        RETURN;
    END IF;

    out_no_instance := FALSE;

    -- Reject attempts to shadow builtin identifiers.
    IF EXISTS (
        SELECT 1 FROM merchant_builtin_units bu WHERE bu.unit = in_unit
    ) THEN
        out_conflict := TRUE;
        out_unit_serial := NULL;
        RETURN;
    END IF;

    INSERT INTO merchant_custom_units (
        merchant_serial,
        unit,
        unit_name_long,
        unit_name_short,
        unit_name_long_i18n,
        unit_name_short_i18n,
        unit_allow_fraction,
        unit_precision_level,
        unit_active)
    VALUES (
               my_merchant_id,
               in_unit,
               in_unit_name_long,
               in_unit_name_short,
               in_unit_name_long_i18n,
               in_unit_name_short_i18n,
               in_unit_allow_fraction,
               in_unit_precision_level,
               in_unit_active)
    ON CONFLICT (merchant_serial, unit) DO NOTHING
    RETURNING unit_serial
        INTO out_unit_serial;

    IF FOUND THEN
        out_conflict := FALSE;
        RETURN;
    END IF;

    -- Conflict: custom unit already exists.
    SELECT unit_serial
    INTO out_unit_serial
    FROM merchant_custom_units
    WHERE merchant_serial = my_merchant_id
      AND unit = in_unit;

    out_conflict := TRUE;
END $$;

DROP FUNCTION IF EXISTS merchant_do_update_unit;
CREATE FUNCTION merchant_do_update_unit (
    IN in_instance_id TEXT,
    IN in_unit_id TEXT,
    IN in_unit_name_long TEXT,
    IN in_unit_name_long_i18n BYTEA,
    IN in_unit_name_short TEXT,
    IN in_unit_name_short_i18n BYTEA,
    IN in_unit_allow_fraction BOOL,
    IN in_unit_precision_level INT4,
    IN in_unit_active BOOL,
    OUT out_no_instance BOOL,
    OUT out_no_unit BOOL,
    OUT out_builtin_conflict BOOL)
    LANGUAGE plpgsql
AS $$
DECLARE
    my_merchant_id INT8;
    my_custom merchant_custom_units%ROWTYPE;
    my_builtin merchant_builtin_units%ROWTYPE;
    my_override merchant_builtin_unit_overrides%ROWTYPE;
    new_unit_name_long TEXT;
    new_unit_name_short TEXT;
    new_unit_name_long_i18n BYTEA;
    new_unit_name_short_i18n BYTEA;
    new_unit_allow_fraction BOOL;
    new_unit_precision_level INT4;
    new_unit_active BOOL;
    old_unit_allow_fraction BOOL;
    old_unit_precision_level INT4;
    old_unit_active BOOL;
BEGIN
    out_no_instance := FALSE;
    out_no_unit := FALSE;
    out_builtin_conflict := FALSE;

    SELECT merchant_serial
    INTO my_merchant_id
    FROM merchant_instances
    WHERE merchant_id = in_instance_id;

    IF NOT FOUND THEN
        out_no_instance := TRUE;
        RETURN;
    END IF;

    SELECT *
    INTO my_custom
    FROM merchant_custom_units
    WHERE merchant_serial = my_merchant_id
      AND unit = in_unit_id
        FOR UPDATE;

    IF FOUND THEN
        old_unit_allow_fraction := my_custom.unit_allow_fraction;
        old_unit_precision_level := my_custom.unit_precision_level;
        old_unit_active := my_custom.unit_active;

        new_unit_name_long := COALESCE (in_unit_name_long, my_custom.unit_name_long);
        new_unit_name_short := COALESCE (in_unit_name_short, my_custom.unit_name_short);
        new_unit_name_long_i18n := COALESCE (in_unit_name_long_i18n,
                                             my_custom.unit_name_long_i18n);
        new_unit_name_short_i18n := COALESCE (in_unit_name_short_i18n,
                                              my_custom.unit_name_short_i18n);
        new_unit_allow_fraction := COALESCE (in_unit_allow_fraction,
                                             my_custom.unit_allow_fraction);
        new_unit_precision_level := COALESCE (in_unit_precision_level,
                                              my_custom.unit_precision_level);
        IF NOT new_unit_allow_fraction THEN
            new_unit_precision_level := 0;
        END IF;

        new_unit_active := COALESCE (in_unit_active, my_custom.unit_active);

        UPDATE merchant_custom_units SET
            unit_name_long = new_unit_name_long
           ,unit_name_long_i18n = new_unit_name_long_i18n
           ,unit_name_short = new_unit_name_short
           ,unit_name_short_i18n = new_unit_name_short_i18n
           ,unit_allow_fraction = new_unit_allow_fraction
           ,unit_precision_level = new_unit_precision_level
           ,unit_active = new_unit_active
        WHERE unit_serial = my_custom.unit_serial;

        ASSERT FOUND,'SELECTED it earlier, should UPDATE it now';

        IF old_unit_allow_fraction IS DISTINCT FROM new_unit_allow_fraction
            OR old_unit_precision_level IS DISTINCT FROM new_unit_precision_level
        THEN
            UPDATE merchant_inventory SET
                allow_fractional_quantity = new_unit_allow_fraction
              , fractional_precision_level = new_unit_precision_level
            WHERE merchant_serial = my_merchant_id
              AND unit = in_unit_id
              AND allow_fractional_quantity = old_unit_allow_fraction
              AND fractional_precision_level = old_unit_precision_level;
        END IF;
        RETURN;
    END IF;

    -- Try builtin with overrides.
    SELECT *
    INTO my_builtin
    FROM merchant_builtin_units
    WHERE unit = in_unit_id;

    IF NOT FOUND THEN
        out_no_unit := TRUE;
        RETURN;
    END IF;

    SELECT *
    INTO my_override
    FROM merchant_builtin_unit_overrides
    WHERE merchant_serial = my_merchant_id
      AND builtin_unit_serial = my_builtin.unit_serial
        FOR UPDATE;

    old_unit_allow_fraction := COALESCE (my_override.override_allow_fraction,
                                         my_builtin.unit_allow_fraction);
    old_unit_precision_level := COALESCE (my_override.override_precision_level,
                                          my_builtin.unit_precision_level);
    old_unit_active := COALESCE (my_override.override_active,
                                 my_builtin.unit_active);

    -- Only policy flags can change for builtin units.
    IF in_unit_name_long IS NOT NULL
        OR in_unit_name_short IS NOT NULL
        OR in_unit_name_long_i18n IS NOT NULL
        OR in_unit_name_short_i18n IS NOT NULL THEN
        out_builtin_conflict := TRUE;
        RETURN;
    END IF;

    new_unit_allow_fraction := COALESCE (in_unit_allow_fraction,
                                         old_unit_allow_fraction);
    new_unit_precision_level := COALESCE (in_unit_precision_level,
                                          old_unit_precision_level);
    IF NOT new_unit_allow_fraction THEN
        new_unit_precision_level := 0;
    END IF;
    new_unit_active := COALESCE (in_unit_active, old_unit_active);

    INSERT INTO merchant_builtin_unit_overrides (
        merchant_serial,
        builtin_unit_serial,
        override_allow_fraction,
        override_precision_level,
        override_active)
    VALUES (my_merchant_id,
            my_builtin.unit_serial,
            new_unit_allow_fraction,
            new_unit_precision_level,
            new_unit_active)
    ON CONFLICT (merchant_serial, builtin_unit_serial)
        DO UPDATE SET override_allow_fraction = EXCLUDED.override_allow_fraction
                   , override_precision_level = EXCLUDED.override_precision_level
                   , override_active = EXCLUDED.override_active;

    IF old_unit_allow_fraction IS DISTINCT FROM new_unit_allow_fraction
        OR old_unit_precision_level IS DISTINCT FROM new_unit_precision_level
    THEN
        UPDATE merchant_inventory SET
            allow_fractional_quantity = new_unit_allow_fraction
          , fractional_precision_level = new_unit_precision_level
        WHERE merchant_serial = my_merchant_id
          AND unit = in_unit_id
          AND allow_fractional_quantity = old_unit_allow_fraction
          AND fractional_precision_level = old_unit_precision_level;
    END IF;

    RETURN;
END $$;

DROP FUNCTION IF EXISTS merchant_do_delete_unit;
CREATE FUNCTION merchant_do_delete_unit (
    IN in_instance_id TEXT,
    IN in_unit_id TEXT,
    OUT out_no_instance BOOL,
    OUT out_no_unit BOOL,
    OUT out_builtin_conflict BOOL)
    LANGUAGE plpgsql
AS $$
DECLARE
    my_merchant_id INT8;
    my_unit merchant_custom_units%ROWTYPE;
BEGIN
    out_no_instance := FALSE;
    out_no_unit := FALSE;
    out_builtin_conflict := FALSE;

    SELECT merchant_serial
    INTO my_merchant_id
    FROM merchant_instances
    WHERE merchant_id = in_instance_id;

    IF NOT FOUND THEN
        out_no_instance := TRUE;
        RETURN;
    END IF;

    SELECT *
    INTO my_unit
    FROM merchant_custom_units
    WHERE merchant_serial = my_merchant_id
      AND unit = in_unit_id
        FOR UPDATE;

    IF NOT FOUND THEN
        IF EXISTS (SELECT 1 FROM merchant_builtin_units bu WHERE bu.unit = in_unit_id) THEN
            out_builtin_conflict := TRUE;
        ELSE
            out_no_unit := TRUE;
        END IF;
        RETURN;
    END IF;

    DELETE FROM merchant_custom_units
    WHERE unit_serial = my_unit.unit_serial;

    RETURN;
END $$;

COMMIT;
