Changes between Version 1 and Version 2 of AdvancedDatabaseDevelopment


Ignore:
Timestamp:
05/24/26 17:49:21 (34 hours ago)
Author:
202033
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • AdvancedDatabaseDevelopment

    v1 v2  
    1 == Валидација на термини (Appointment Validation) ==
     1= Напреден развој на базата
     2
     3== 1. Валидација на термини (Appointment Validation) ==
    24
    35=== Опис на барањата за податочни ограничувања ===
     
    300302EXECUTE FUNCTION trg_appointmentservice_recalculate();
    301303}}}
     304
     305== 2.  Валидација на плаќања (Payment Validation) ==
     306
     307=== Опис на барањата ===
     308
     309Имплементирани правила:
     310
     311 * payment мора да има target (`appointment_id` или `package_purchase_id`),
     312 * amount мора да е валиден во контекст на target-от,
     313 * дупли ''PAID'' не се дозволени (appointment/package purchase),
     314 * review е дозволен само за валиден ''PAID'' payment.
     315
     316=== Имплементација ===
     317
     318==== `payment_target_required` constraint ====
     319
     320 * '''Име:''' `payment_target_required`
     321 * '''Работи над:''' `payment`
     322 * '''Бизнис правило:''' payment без target е невалиден.
     323
     324{{{
     325#!sql
     326ALTER TABLE payment
     327DROP CONSTRAINT IF EXISTS payment_target_required;
     328
     329ALTER TABLE payment
     330ADD CONSTRAINT payment_target_required
     331CHECK
     332(
     333    appointment_id IS NOT NULL
     334    OR package_purchase_id IS NOT NULL
     335);
     336}}}
     337
     338==== Partial unique indexes за PAID ====
     339
     340 * '''Имиња:''' `idx_payment_paid_unique`, `idx_payment_package_purchase_paid_unique`
     341 * '''Работи над:''' `payment`
     342 * '''Бизнис правило:''' максимум едно ''PAID'' по appointment и едно upfront ''PAID'' по package purchase.
     343
     344{{{
     345#!sql
     346CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_paid_unique
     347ON payment (appointment_id)
     348WHERE status = 'PAID';
     349
     350CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_package_purchase_paid_unique
     351ON payment (package_purchase_id)
     352WHERE status = 'PAID'
     353  AND appointment_id IS NULL;
     354}}}
     355
     356==== `trg_payment_validate` + `payment_validate` ====
     357
     358 * '''Имиња:''' `trg_payment_validate`, `payment_validate`
     359 * '''Работи над:''' `payment`, `appointment`, `status`, `userpackagepurchase`, `package`, `packageservice`, `appointmentpackageusage`
     360 * '''Бизнис правило:''' контекстуална валидација на amount/method/points и согласност со package usage.
     361
     362''извадок од функцијата''
     363
     364{{{
     365#!sql
     366CREATE OR REPLACE FUNCTION trg_payment_validate()
     367RETURNS trigger
     368LANGUAGE plpgsql
     369AS $$
     370DECLARE
     371    v_total_price numeric;
     372    v_status_name text;
     373    v_expected numeric;
     374    v_package_total numeric;
     375    v_usage_purchase_id int;
     376BEGIN
     377
     378    IF NEW.appointment_id IS NULL
     379       AND NEW.package_purchase_id IS NULL THEN
     380        RAISE EXCEPTION
     381            'Payment must reference appointment_id or package_purchase_id';
     382    END IF;
     383
     384    IF NEW.appointment_id IS NULL
     385       AND NEW.package_purchase_id IS NOT NULL THEN
     386
     387        SELECT
     388            COALESCE(
     389                p.total_price,
     390                package_price.calc_total_price
     391            )
     392        INTO v_package_total
     393        FROM userpackagepurchase upp
     394        JOIN package p
     395          ON p.package_id = upp.package_id
     396        LEFT JOIN LATERAL
     397        (
     398            SELECT
     399                COALESCE(
     400                    SUM(ps.discounted_price),
     401                    0
     402                )::numeric AS calc_total_price
     403            FROM packageservice ps
     404            WHERE ps.package_id = p.package_id
     405        ) package_price
     406          ON true
     407        WHERE upp.purchase_id = NEW.package_purchase_id;
     408
     409        IF COALESCE(NEW.points_used, 0) <> 0 THEN
     410            RAISE EXCEPTION
     411                'Package purchase payment cannot redeem loyalty points';
     412        END IF;
     413
     414        IF NEW.amount IS DISTINCT FROM v_package_total THEN
     415            RAISE EXCEPTION
     416                'Package purchase payment amount must equal package total';
     417        END IF;
     418
     419        RETURN NEW;
     420
     421    END IF;
     422
     423    IF UPPER(COALESCE(NEW.method, '')) = 'PACKAGE' THEN
     424
     425        IF NEW.amount IS DISTINCT FROM 0::numeric THEN
     426            RAISE EXCEPTION
     427                'PACKAGE appointment payment amount must be 0';
     428        END IF;
     429
     430        SELECT apu.purchase_id
     431        INTO v_usage_purchase_id
     432        FROM appointmentpackageusage apu
     433        WHERE apu.appointment_id = NEW.appointment_id
     434        LIMIT 1;
     435
     436        IF NEW.package_purchase_id IS NOT NULL
     437           AND NEW.package_purchase_id <> v_usage_purchase_id THEN
     438            RAISE EXCEPTION
     439                'payment.package_purchase_id does not match appointment package usage';
     440        END IF;
     441
     442        RETURN NEW;
     443
     444    END IF;
     445
     446    SELECT a.total_price, s.name
     447    INTO v_total_price, v_status_name
     448    FROM appointment a
     449    JOIN status s
     450      ON s.status_id = a.status_id
     451    WHERE a.appointment_id = NEW.appointment_id;
     452
     453    IF UPPER(v_status_name) <> 'SCHEDULED' THEN
     454        RAISE EXCEPTION
     455            'Payment allowed only for SCHEDULED appointments';
     456    END IF;
     457
     458    v_expected :=
     459        v_total_price
     460        - COALESCE(NEW.points_used, 0);
     461
     462    IF NEW.amount IS DISTINCT FROM v_expected THEN
     463        RAISE EXCEPTION
     464            'Payment amount must equal appointment total minus points';
     465    END IF;
     466
     467    RETURN NEW;
     468
     469END;
     470$$;
     471
     472DROP TRIGGER IF EXISTS payment_validate
     473ON payment;
     474
     475CREATE TRIGGER payment_validate
     476BEFORE INSERT OR UPDATE
     477ON payment
     478FOR EACH ROW
     479EXECUTE FUNCTION trg_payment_validate();
     480}}}
     481
     482==== `trg_appointment_prevent_price_change` + `appointment_prevent_price_change` ====
     483
     484 * '''Имиња:''' `trg_appointment_prevent_price_change`, `appointment_prevent_price_change`
     485 * '''Работи над:''' `appointment`, `payment`
     486 * '''Бизнис правило:''' после ''PAID'' payment не смее да се менува `appointment.total_price`.
     487
     488{{{
     489#!sql
     490CREATE OR REPLACE FUNCTION trg_appointment_prevent_price_change()
     491RETURNS trigger
     492LANGUAGE plpgsql
     493AS $$
     494DECLARE
     495    v_has_paid int;
     496BEGIN
     497
     498    IF NEW.total_price IS DISTINCT FROM OLD.total_price THEN
     499
     500        SELECT 1
     501        INTO v_has_paid
     502        FROM payment
     503        WHERE appointment_id = OLD.appointment_id
     504          AND status = 'PAID'
     505        LIMIT 1;
     506
     507        IF v_has_paid IS NOT NULL THEN
     508            RAISE EXCEPTION
     509                'Cannot change total_price after a PAID payment';
     510        END IF;
     511
     512    END IF;
     513
     514    RETURN NEW;
     515
     516END;
     517$$;
     518
     519DROP TRIGGER IF EXISTS appointment_prevent_price_change
     520ON appointment;
     521
     522CREATE TRIGGER appointment_prevent_price_change
     523BEFORE UPDATE
     524ON appointment
     525FOR EACH ROW
     526EXECUTE FUNCTION trg_appointment_prevent_price_change();
     527}}}
     528
     529==== `sp_create_review` ====
     530
     531 * '''Име:''' `sp_create_review`
     532 * '''Работи над:''' `payment`, `appointment`, `review`
     533 * '''Бизнис правило:''' review само за ''PAID'', завршен, сопствен payment, и една review по payment.
     534
     535{{{
     536#!sql
     537CREATE OR REPLACE FUNCTION sp_create_review(
     538    p_user_id int,
     539    p_payment_id int,
     540    p_rating int,
     541    p_comment text
     542)
     543RETURNS int
     544LANGUAGE plpgsql
     545AS $$
     546DECLARE
     547    v_review_id int;
     548    v_payment_status text;
     549    v_appointment_user int;
     550    v_appointment_time timestamp;
     551    v_end_time timestamp;
     552BEGIN
     553
     554    SELECT
     555        p.status,
     556        a.user_id,
     557        a.appointment_time,
     558        a.end_time
     559    INTO
     560        v_payment_status,
     561        v_appointment_user,
     562        v_appointment_time,
     563        v_end_time
     564    FROM payment p
     565    JOIN appointment a
     566      ON a.appointment_id = p.appointment_id
     567    WHERE p.payment_id = p_payment_id;
     568
     569    IF v_payment_status <> 'PAID' THEN
     570        RAISE EXCEPTION
     571            'Review allowed only for PAID payments';
     572    END IF;
     573
     574    IF COALESCE(v_end_time, v_appointment_time) > now() THEN
     575        RAISE EXCEPTION
     576            'Review allowed only after appointment is completed';
     577    END IF;
     578
     579    IF v_appointment_user <> p_user_id THEN
     580        RAISE EXCEPTION
     581            'Unauthorized access to payment';
     582    END IF;
     583
     584    IF EXISTS
     585    (
     586        SELECT 1
     587        FROM review
     588        WHERE payment_id = p_payment_id
     589    ) THEN
     590        RAISE EXCEPTION
     591            'Review already exists for this payment';
     592    END IF;
     593
     594    INSERT INTO review
     595    (
     596        rating,
     597        comment,
     598        payment_id
     599    )
     600    VALUES
     601    (
     602        p_rating,
     603        p_comment,
     604        p_payment_id
     605    )
     606    RETURNING review_id
     607    INTO v_review_id;
     608
     609    RETURN v_review_id;
     610
     611END;
     612$$;
     613}}}
     614
     615== 3.  Loyalty System ==
     616
     617=== Опис на барањата за податочни ограничувања ===
     618
     619 * еден корисник -> една loyalty картичка,
     620 * points не смеат да се негативни,
     621 * points се доделуваат само кога условите се исполнети (`COMPLETED` + `PAID`),
     622 * refund ја враќа состојбата на поени.
     623
     624=== Имплементација ===
     625
     626==== UNIQUE/CHECK constraints ====
     627
     628 * '''Објекти:''' `idx_loyaltycard_user_unique`, `loyaltycard_points_nonnegative`, `payment_points_used_nonnegative`
     629 * '''Работи над:''' `loyaltycard`, `payment`
     630 * '''Бизнис правило:''' единствена картичка и не-негативни поени.
     631
     632{{{
     633#!sql
     634CREATE UNIQUE INDEX IF NOT EXISTS idx_loyaltycard_user_unique
     635ON loyaltycard (user_id);
     636
     637ALTER TABLE loyaltycard
     638ADD CONSTRAINT loyaltycard_points_nonnegative
     639CHECK (points >= 0);
     640
     641ALTER TABLE payment
     642ADD CONSTRAINT payment_points_used_nonnegative
     643CHECK (points_used >= 0);
     644}}}
     645
     646==== `sp_ensure_loyalty_card` ====
     647
     648 * '''Име:''' `sp_ensure_loyalty_card`
     649 * '''Работи над:''' `loyaltycard`
     650 * '''Бизнис правило:''' автоматско креирање loyalty card ако недостига.
     651
     652{{{
     653#!sql
     654CREATE OR REPLACE FUNCTION sp_ensure_loyalty_card(p_user_id int)
     655RETURNS int
     656LANGUAGE plpgsql
     657AS $$
     658DECLARE
     659    v_card_id int;
     660BEGIN
     661    INSERT INTO loyaltycard (user_id, points)
     662    VALUES (p_user_id, 0)
     663    ON CONFLICT (user_id) DO NOTHING;
     664
     665    SELECT card_id
     666    INTO v_card_id
     667    FROM loyaltycard
     668    WHERE user_id = p_user_id;
     669
     670    RETURN v_card_id;
     671END;
     672$$;
     673}}}
     674
     675==== `sp_award_loyalty_points` ====
     676
     677 * '''Име:''' `sp_award_loyalty_points`
     678 * '''Работи над:''' `appointment`, `status`, `payment`, `loyaltycard`
     679 * '''Бизнис правило:''' award само ако терминот е ''COMPLETED'' и има ''PAID'' payment, еднаш по термин.
     680
     681{{{
     682#!sql
     683CREATE OR REPLACE FUNCTION sp_award_loyalty_points(p_appointment_id int)
     684RETURNS int
     685LANGUAGE plpgsql
     686AS $$
     687DECLARE
     688    v_user_id int;
     689    v_total numeric;
     690    v_status text;
     691    v_points_awarded boolean;
     692    v_paid_status text;
     693    v_completed boolean;
     694    v_paid boolean;
     695    v_earned int;
     696BEGIN
     697    SELECT a.user_id, a.total_price, s.name, a.points_awarded
     698    INTO v_user_id, v_total, v_status, v_points_awarded
     699    FROM appointment a
     700    JOIN status s ON s.status_id = a.status_id
     701    WHERE a.appointment_id = p_appointment_id
     702    FOR UPDATE;
     703
     704    v_completed := (UPPER(v_status) = 'COMPLETED');
     705
     706    SELECT p.status
     707    INTO v_paid_status
     708    FROM payment p
     709    WHERE p.appointment_id = p_appointment_id
     710      AND UPPER(p.status) = 'PAID'
     711    LIMIT 1;
     712
     713    v_paid := (v_paid_status IS NOT NULL);
     714
     715    IF NOT v_completed OR NOT v_paid THEN
     716        RETURN 0;
     717    END IF;
     718
     719    IF v_points_awarded THEN
     720        RETURN 0;
     721    END IF;
     722
     723    PERFORM sp_ensure_loyalty_card(v_user_id);
     724
     725    v_earned := FLOOR(COALESCE(v_total, 0) * 0.05);
     726
     727    UPDATE loyaltycard
     728    SET points = points + v_earned
     729    WHERE user_id = v_user_id;
     730
     731    UPDATE appointment
     732    SET points_awarded = true
     733    WHERE appointment_id = p_appointment_id;
     734
     735    RETURN v_earned;
     736END;
     737$$;
     738}}}
     739
     740==== `sp_mark_payment_paid` ====
     741
     742 * '''Име:''' `sp_mark_payment_paid`
     743 * '''Работи над:''' `payment`, `appointment`, `userpackagepurchase`, `package`, `loyaltycard`
     744 * '''Бизнис правило:''' централен PAID flow за appointment payment и package purchase payment.
     745
     746''извадок од функцијата''
     747
     748{{{
     749#!sql
     750CREATE OR REPLACE FUNCTION sp_mark_payment_paid(
     751    p_payment_id int
     752)
     753RETURNS int
     754LANGUAGE plpgsql
     755AS $$
     756DECLARE
     757    v_appointment_id int;
     758    v_package_purchase_id int;
     759    v_package_id int;
     760    v_user_id int;
     761    v_total_price numeric;
     762    v_requested_points int;
     763    v_has_paid int;
     764    v_used int := 0;
     765    v_payment_status text;
     766    v_payment_method text;
     767    v_points_earned int;
     768BEGIN
     769    SELECT appointment_id, package_purchase_id, points_used, status, method
     770    INTO v_appointment_id, v_package_purchase_id, v_requested_points, v_payment_status, v_payment_method
     771    FROM payment
     772    WHERE payment_id = p_payment_id
     773    FOR UPDATE;
     774
     775    IF UPPER(COALESCE(v_payment_status, '')) = 'PAID' THEN
     776        RETURN COALESCE(v_requested_points, 0);
     777    END IF;
     778
     779    IF v_appointment_id IS NOT NULL THEN
     780
     781        IF UPPER(COALESCE(v_payment_method, '')) = 'PACKAGE' THEN
     782            UPDATE payment
     783            SET status = 'PAID',
     784                points_used = 0,
     785                amount = 0
     786            WHERE payment_id = p_payment_id;
     787
     788            RETURN 0;
     789        END IF;
     790
     791        SELECT a.user_id, a.total_price
     792        INTO v_user_id, v_total_price
     793        FROM appointment a
     794        WHERE a.appointment_id = v_appointment_id;
     795
     796        v_used := sp_redeem_loyalty_points(
     797            v_user_id,
     798            v_total_price,
     799            v_requested_points
     800        );
     801
     802        UPDATE payment
     803        SET status = 'PAID',
     804            points_used = v_used,
     805            amount = v_total_price - v_used
     806        WHERE payment_id = p_payment_id;
     807
     808        RETURN v_used;
     809    END IF;
     810
     811    SELECT upp.user_id, upp.package_id
     812    INTO v_user_id, v_package_id
     813    FROM userpackagepurchase upp
     814    WHERE upp.purchase_id = v_package_purchase_id
     815    FOR UPDATE;
     816
     817    UPDATE payment
     818    SET status = 'PAID',
     819        points_used = 0,
     820        amount = v_total_price
     821    WHERE payment_id = p_payment_id;
     822
     823    PERFORM sp_ensure_loyalty_card(v_user_id);
     824
     825    v_points_earned := FLOOR(COALESCE(v_total_price, 0) * 0.05);
     826
     827    IF v_points_earned > 0 THEN
     828        UPDATE loyaltycard
     829        SET points = points + v_points_earned
     830        WHERE user_id = v_user_id;
     831    END IF;
     832
     833    RETURN 0;
     834END;
     835$$;
     836}}}
     837
     838==== `sp_refund_payment_for_appointment` ====
     839
     840 * '''Име:''' `sp_refund_payment_for_appointment`
     841 * '''Работи над:''' `payment`, `appointment`, `loyaltycard`
     842 * '''Бизнис правило:''' refund + враќање на `points_used` + rollback на `points_awarded`.
     843
     844{{{
     845#!sql
     846CREATE OR REPLACE FUNCTION sp_refund_payment_for_appointment(p_appointment_id int)
     847RETURNS int
     848LANGUAGE plpgsql
     849AS $$
     850DECLARE
     851    v_payment_id int;
     852    v_user_id int;
     853    v_total numeric;
     854    v_points_used int;
     855    v_points_earned int;
     856BEGIN
     857    SELECT p.payment_id, p.points_used
     858    INTO v_payment_id, v_points_used
     859    FROM payment p
     860    WHERE p.appointment_id = p_appointment_id
     861      AND p.status = 'PAID'
     862    ORDER BY p."timestamp" DESC
     863    LIMIT 1
     864    FOR UPDATE;
     865
     866    IF v_payment_id IS NULL THEN
     867        RETURN 0;
     868    END IF;
     869
     870    SELECT a.user_id, a.total_price
     871    INTO v_user_id, v_total
     872    FROM appointment a
     873    WHERE a.appointment_id = p_appointment_id
     874    FOR UPDATE;
     875
     876    UPDATE payment
     877    SET status = 'REFUNDED'
     878    WHERE payment_id = v_payment_id;
     879
     880    IF v_points_used > 0 THEN
     881        PERFORM sp_ensure_loyalty_card(v_user_id);
     882
     883        UPDATE loyaltycard
     884        SET points = points + v_points_used
     885        WHERE user_id = v_user_id;
     886    END IF;
     887
     888    IF EXISTS (
     889        SELECT 1
     890        FROM appointment
     891        WHERE appointment_id = p_appointment_id
     892          AND points_awarded = true
     893    ) THEN
     894        v_points_earned := FLOOR(COALESCE(v_total, 0) * 0.05);
     895
     896        PERFORM sp_ensure_loyalty_card(v_user_id);
     897
     898        UPDATE loyaltycard
     899        SET points = GREATEST(points - v_points_earned, 0)
     900        WHERE user_id = v_user_id;
     901
     902        UPDATE appointment
     903        SET points_awarded = false
     904        WHERE appointment_id = p_appointment_id;
     905    END IF;
     906
     907    RETURN v_payment_id;
     908END;
     909$$;
     910}}}
     911
     912== Валидација на користење пакети (Package Usage Validation) ==
     913
     914=== Опис на барањата ===
     915
     916 * package purchase мора да е `ACTIVE`,
     917 * `remaining_uses` мора да е позитивен,
     918 * услуги во appointment мора да се покриени од `packageservice`,
     919 * финализација намалува `remaining_uses`,
     920 * двојна финализација се пресекува со `finalized_at`.
     921
     922=== Имплементација ===
     923
     924==== `appointmentpackageusage` ограничувања ====
     925
     926 * '''Објект:''' `appointmentpackageusage`
     927 * '''Работи над:''' `appointmentpackageusage`
     928 * '''Бизнис правило:''' валидна usage релација и позитивни units.
     929
     930{{{
     931#!sql
     932CREATE TABLE appointmentpackageusage (
     933    appointment_id integer PRIMARY KEY,
     934    purchase_id integer NOT NULL,
     935    service_id integer NOT NULL,
     936    used_units integer NOT NULL DEFAULT 1,
     937    finalized_at timestamp NULL,
     938
     939    CONSTRAINT appointmentpackageusage_appointment_id_fkey
     940        FOREIGN KEY (appointment_id)
     941        REFERENCES appointment(appointment_id)
     942        ON DELETE CASCADE,
     943
     944    CONSTRAINT appointmentpackageusage_purchase_id_fkey
     945        FOREIGN KEY (purchase_id)
     946        REFERENCES userpackagepurchase(purchase_id)
     947        ON DELETE RESTRICT,
     948
     949    CONSTRAINT appointmentpackageusage_service_id_fkey
     950        FOREIGN KEY (service_id)
     951        REFERENCES service(service_id)
     952        ON DELETE RESTRICT,
     953
     954    CONSTRAINT appointmentpackageusage_used_units_check
     955        CHECK (used_units > 0)
     956);
     957}}}
     958
     959==== `sp_apply_package_to_appointment` ====
     960
     961 * '''Име:''' `sp_apply_package_to_appointment`
     962 * '''Работи над:''' `appointment`, `appointmentservice`, `userpackagepurchase`, `packageservice`, `appointmentpackageusage`, `payment`
     963 * '''Бизнис правило:''' само валиден пакет да покрие термин; терминот станува prepaid (`total_price = 0`).
     964
     965''извадок од функцијата''
     966
     967{{{
     968#!sql
     969CREATE OR REPLACE FUNCTION sp_apply_package_to_appointment(
     970    p_appointment_id INT,
     971    p_purchase_id INT
     972)
     973RETURNS VOID
     974AS $$
     975DECLARE
     976    v_appt_user_id INT;
     977    v_purchase_user_id INT;
     978    v_purchase_status TEXT;
     979    v_purchase_remaining INT;
     980    v_purchase_package_id INT;
     981    v_first_service_id INT;
     982    v_missing_service_id INT;
     983BEGIN
     984    SELECT a.user_id
     985    INTO v_appt_user_id
     986    FROM appointment a
     987    WHERE a.appointment_id = p_appointment_id
     988    FOR UPDATE;
     989
     990    SELECT upp.user_id, upp.status, upp.remaining_uses, upp.package_id
     991    INTO v_purchase_user_id, v_purchase_status, v_purchase_remaining, v_purchase_package_id
     992    FROM userpackagepurchase upp
     993    WHERE upp.purchase_id = p_purchase_id
     994    FOR UPDATE;
     995
     996    IF v_purchase_status <> 'ACTIVE' THEN
     997        RAISE EXCEPTION 'Purchase % is not ACTIVE', p_purchase_id;
     998    END IF;
     999
     1000    IF v_purchase_remaining IS NULL OR v_purchase_remaining <= 0 THEN
     1001        RAISE EXCEPTION 'Purchase % has no remaining uses', p_purchase_id;
     1002    END IF;
     1003
     1004    SELECT aps.service_id
     1005    INTO v_missing_service_id
     1006    FROM appointmentservice aps
     1007    LEFT JOIN packageservice ps
     1008      ON ps.package_id = v_purchase_package_id
     1009     AND ps.service_id = aps.service_id
     1010    WHERE aps.appointment_id = p_appointment_id
     1011      AND ps.service_id IS NULL
     1012    LIMIT 1;
     1013
     1014    IF v_missing_service_id IS NOT NULL THEN
     1015        RAISE EXCEPTION
     1016            'Appointment service % is not included in selected package',
     1017            v_missing_service_id;
     1018    END IF;
     1019
     1020    INSERT INTO appointmentpackageusage (
     1021        appointment_id,
     1022        purchase_id,
     1023        service_id,
     1024        used_units,
     1025        finalized_at
     1026    )
     1027    VALUES (
     1028        p_appointment_id,
     1029        p_purchase_id,
     1030        v_first_service_id,
     1031        1,
     1032        NULL
     1033    )
     1034    ON CONFLICT (appointment_id)
     1035    DO UPDATE SET
     1036        purchase_id = EXCLUDED.purchase_id,
     1037        service_id = EXCLUDED.service_id,
     1038        used_units = EXCLUDED.used_units,
     1039        finalized_at = NULL;
     1040
     1041    UPDATE appointment
     1042    SET total_price = 0
     1043    WHERE appointment_id = p_appointment_id;
     1044END;
     1045$$ LANGUAGE plpgsql;
     1046}}}
     1047
     1048==== `sp_finalize_package_usage_on_appointment` ====
     1049
     1050 * '''Име:''' `sp_finalize_package_usage_on_appointment`
     1051 * '''Работи над:''' `appointmentpackageusage`, `userpackagepurchase`
     1052 * '''Бизнис правило:''' еднократно трошење на package units при финализација.
     1053
     1054{{{
     1055#!sql
     1056CREATE OR REPLACE FUNCTION sp_finalize_package_usage_on_appointment(
     1057    p_appointment_id INT
     1058)
     1059RETURNS VOID
     1060AS $$
     1061DECLARE
     1062    v_purchase_id INT;
     1063    v_used_units INT;
     1064    v_finalized_at TIMESTAMP;
     1065    v_remaining INT;
     1066BEGIN
     1067    SELECT apu.purchase_id, apu.used_units, apu.finalized_at
     1068    INTO v_purchase_id, v_used_units, v_finalized_at
     1069    FROM appointmentpackageusage apu
     1070    WHERE apu.appointment_id = p_appointment_id
     1071    FOR UPDATE;
     1072
     1073    IF NOT FOUND THEN
     1074        RETURN;
     1075    END IF;
     1076
     1077    IF v_finalized_at IS NOT NULL THEN
     1078        RETURN;
     1079    END IF;
     1080
     1081    SELECT upp.remaining_uses
     1082    INTO v_remaining
     1083    FROM userpackagepurchase upp
     1084    WHERE upp.purchase_id = v_purchase_id
     1085    FOR UPDATE;
     1086
     1087    IF v_remaining < v_used_units THEN
     1088        RAISE EXCEPTION
     1089            'Insufficient remaining uses on purchase %',
     1090            v_purchase_id;
     1091    END IF;
     1092
     1093    UPDATE userpackagepurchase
     1094    SET remaining_uses = remaining_uses - v_used_units
     1095    WHERE purchase_id = v_purchase_id;
     1096
     1097    UPDATE appointmentpackageusage
     1098    SET finalized_at = now()
     1099    WHERE appointment_id = p_appointment_id;
     1100END;
     1101$$ LANGUAGE plpgsql;
     1102}}}
     1103
     1104== 5. Валидација на достапност (Availability Validation) ==
     1105
     1106=== Опис на барањата ===
     1107
     1108 * дупликат прозорци за ист датум/време не се дозволени,
     1109 * `end_time > start_time` е задолжително,
     1110 * availability се користи за runtime генерација на слотови.
     1111
     1112=== Имплементација ===
     1113
     1114==== Unique index на availability прозорец ====
     1115
     1116 * '''Име:''' `idx_availability_unique_window`
     1117 * '''Работи над:''' `availability`
     1118 * '''Бизнис правило:''' нема duplicate window за ист датум/опсег.
     1119
     1120{{{
     1121#!sql
     1122CREATE UNIQUE INDEX IF NOT EXISTS idx_availability_unique_window
     1123ON availability (date, start_time, end_time);
     1124}}}
     1125
     1126==== `fn_available_slots` ====
     1127
     1128 * '''Име:''' `fn_available_slots`
     1129 * '''Работи над:''' `availability`, `appointment`, `status`, `service`
     1130 * '''Бизнис правило:''' слотови само во отворен прозорец и без преклоп со ''SCHEDULED''.
     1131
     1132{{{
     1133#!sql
     1134CREATE OR REPLACE FUNCTION fn_available_slots(p_date date, p_service_ids int[])
     1135RETURNS TABLE (start_time timestamp, end_time timestamp)
     1136LANGUAGE sql
     1137AS $$
     1138WITH svc AS (
     1139    SELECT fn_service_total_minutes(p_service_ids) AS total_minutes
     1140),
     1141windows AS (
     1142    SELECT
     1143        (p_date + a.start_time) AS window_start,
     1144        (p_date + a.end_time) AS window_end,
     1145        s.total_minutes
     1146    FROM availability a
     1147    CROSS JOIN svc s
     1148    WHERE a.date = p_date
     1149      AND a.is_closed = false
     1150      AND s.total_minutes > 0
     1151),
     1152candidates AS (
     1153    SELECT
     1154        gs AS start_time,
     1155        gs + make_interval(mins => w.total_minutes) AS end_time
     1156    FROM windows w
     1157    JOIN LATERAL generate_series(
     1158        w.window_start,
     1159        w.window_end - make_interval(mins => w.total_minutes),
     1160        interval '15 minutes'
     1161    ) AS gs
     1162    ON true
     1163)
     1164SELECT c.start_time, c.end_time
     1165FROM candidates c
     1166WHERE NOT EXISTS (
     1167    SELECT 1
     1168    FROM appointment ap
     1169    JOIN status st
     1170      ON st.status_id = ap.status_id
     1171    WHERE UPPER(st.name) = 'SCHEDULED'
     1172      AND c.start_time < ap.end_time
     1173      AND c.end_time > ap.appointment_time
     1174)
     1175ORDER BY c.start_time;
     1176$$;
     1177}}}
     1178
     1179==== `sp_admin_add_availability` ====
     1180
     1181 * '''Име:''' `sp_admin_add_availability`
     1182 * '''Работи над:''' `availability`
     1183 * '''Бизнис правило:''' валидна временска рамка + no duplicate.
     1184
     1185{{{
     1186#!sql
     1187CREATE OR REPLACE FUNCTION sp_admin_add_availability(
     1188    p_date date,
     1189    p_start time,
     1190    p_end time,
     1191    p_is_closed boolean DEFAULT false
     1192)
     1193RETURNS int
     1194LANGUAGE plpgsql
     1195AS $$
     1196DECLARE
     1197    v_availability_id int;
     1198    v_exists int;
     1199BEGIN
     1200    IF p_end <= p_start THEN
     1201        RAISE EXCEPTION 'End time must be after start time';
     1202    END IF;
     1203
     1204    SELECT 1
     1205    INTO v_exists
     1206    FROM availability
     1207    WHERE date = p_date
     1208      AND start_time = p_start
     1209      AND end_time = p_end
     1210    LIMIT 1;
     1211
     1212    IF v_exists IS NOT NULL THEN
     1213        RAISE EXCEPTION
     1214            'Availability window already exists for this date and time';
     1215    END IF;
     1216
     1217    INSERT INTO availability
     1218    (
     1219        date,
     1220        start_time,
     1221        end_time,
     1222        is_closed
     1223    )
     1224    VALUES
     1225    (
     1226        p_date,
     1227        p_start,
     1228        p_end,
     1229        p_is_closed
     1230    )
     1231    RETURNING availability_id
     1232    INTO v_availability_id;
     1233
     1234    RETURN v_availability_id;
     1235END;
     1236$$;
     1237}}}
     1238
     1239==== `sp_admin_update_availability` ====
     1240
     1241 * '''Име:''' `sp_admin_update_availability`
     1242 * '''Работи над:''' `availability`
     1243 * '''Бизнис правило:''' update само на постоечки запис, без временски конфликт.
     1244
     1245{{{
     1246#!sql
     1247CREATE OR REPLACE FUNCTION sp_admin_update_availability(
     1248    p_availability_id int,
     1249    p_date date,
     1250    p_start time,
     1251    p_end time,
     1252    p_is_closed boolean
     1253)
     1254RETURNS void
     1255LANGUAGE plpgsql
     1256AS $$
     1257DECLARE
     1258    v_exists int;
     1259    v_duplicate int;
     1260BEGIN
     1261    IF p_end <= p_start THEN
     1262        RAISE EXCEPTION 'End time must be after start time';
     1263    END IF;
     1264
     1265    SELECT 1
     1266    INTO v_exists
     1267    FROM availability
     1268    WHERE availability_id = p_availability_id
     1269    LIMIT 1;
     1270
     1271    IF v_exists IS NULL THEN
     1272        RAISE EXCEPTION 'Availability window not found';
     1273    END IF;
     1274
     1275    SELECT 1
     1276    INTO v_duplicate
     1277    FROM availability
     1278    WHERE date = p_date
     1279      AND start_time = p_start
     1280      AND end_time = p_end
     1281      AND availability_id <> p_availability_id
     1282    LIMIT 1;
     1283
     1284    IF v_duplicate IS NOT NULL THEN
     1285        RAISE EXCEPTION
     1286            'Availability window already exists for this date and time';
     1287    END IF;
     1288
     1289    UPDATE availability
     1290    SET
     1291        date = p_date,
     1292        start_time = p_start,
     1293        end_time = p_end,
     1294        is_closed = p_is_closed
     1295    WHERE availability_id = p_availability_id;
     1296END;
     1297$$;
     1298}}}
     1299
     1300==== `sp_admin_delete_availability` ====
     1301
     1302 * '''Име:''' `sp_admin_delete_availability`
     1303 * '''Работи над:''' `availability`
     1304 * '''Бизнис правило:''' delete само ако записот постои.
     1305
     1306{{{
     1307#!sql
     1308CREATE OR REPLACE FUNCTION sp_admin_delete_availability(
     1309    p_availability_id int
     1310)
     1311RETURNS void
     1312LANGUAGE plpgsql
     1313AS $$
     1314DECLARE
     1315    v_deleted int;
     1316BEGIN
     1317    DELETE FROM availability
     1318    WHERE availability_id = p_availability_id;
     1319
     1320    GET DIAGNOSTICS v_deleted = ROW_COUNT;
     1321
     1322    IF v_deleted = 0 THEN
     1323        RAISE EXCEPTION 'Availability window not found';
     1324    END IF;
     1325END;
     1326$$;
     1327}}}
     1328
     1329== 6. Напредни погледи (Advanced Views) ==
     1330
     1331=== `v_services_grouped_by_category` ===
     1332
     1333 * '''Име:''' `v_services_grouped_by_category`
     1334 * '''Работи над:''' `category`, `service`, `appointmentservice`, `appointment`, `payment`, `review`, `"User"`
     1335 * '''Бизнис правило/сценарио:''' агрегиран services каталог со ratings/reviews за `/services` и `/book`.
     1336
     1337{{{
     1338#!sql
     1339CREATE OR REPLACE VIEW v_services_grouped_by_category AS
     1340SELECT
     1341    c.category_id,
     1342    c.name AS category_name,
     1343    COALESCE(
     1344        jsonb_agg(
     1345            jsonb_build_object(
     1346                'service_id', s.service_id,
     1347                'name', s.name,
     1348                'price', s.price,
     1349                'duration_minutes', s.duration_minutes,
     1350                'avg_rating', COALESCE(svc.avg_rating, 0),
     1351                'review_count', COALESCE(svc.review_count, 0),
     1352                'reviews', COALESCE(svc.reviews, '[]'::jsonb)
     1353            )
     1354            ORDER BY s.service_id
     1355        ) FILTER (WHERE s.service_id IS NOT NULL),
     1356        '[]'::jsonb
     1357    ) AS services
     1358FROM category c
     1359LEFT JOIN service s
     1360  ON s.category_id = c.category_id
     1361LEFT JOIN LATERAL (
     1362    SELECT
     1363        ROUND(AVG(r.rating)::numeric, 1) AS avg_rating,
     1364        COUNT(r.review_id) AS review_count,
     1365        COALESCE(
     1366            jsonb_agg(
     1367                jsonb_build_object(
     1368                    'review_id', r.review_id,
     1369                    'rating', r.rating,
     1370                    'comment', r.comment,
     1371                    'created_at', r.created_at,
     1372                    'reviewer', u.full_name
     1373                )
     1374                ORDER BY r.created_at DESC
     1375            ) FILTER (WHERE r.review_id IS NOT NULL),
     1376            '[]'::jsonb
     1377        ) AS reviews
     1378    FROM appointmentservice aps
     1379    JOIN appointment a
     1380      ON a.appointment_id = aps.appointment_id
     1381    JOIN payment p
     1382      ON p.appointment_id = a.appointment_id
     1383    JOIN review r
     1384      ON r.payment_id = p.payment_id
     1385    JOIN "User" u
     1386      ON u.user_id = a.user_id
     1387    WHERE aps.service_id = s.service_id
     1388      AND p.status = 'PAID'
     1389) svc
     1390ON true
     1391GROUP BY c.category_id, c.name;
     1392}}}
     1393
     1394=== `v_user_appointments` ===
     1395
     1396 * '''Име:''' `v_user_appointments`
     1397 * '''Работи над:''' `appointment`, `status`, `appointmentservice`, `service`
     1398 * '''Бизнис правило/сценарио:''' read-model за кориснички термини.
     1399
     1400{{{
     1401#!sql
     1402CREATE OR REPLACE VIEW v_user_appointments AS
     1403SELECT
     1404    a.appointment_id,
     1405    a.user_id,
     1406    a.appointment_time,
     1407    a.end_time,
     1408    a.total_price,
     1409    a.notes,
     1410    a.status_id,
     1411    st.name AS status_name,
     1412    a."type"::text AS appointment_type,
     1413    COALESCE(
     1414        jsonb_agg(
     1415            jsonb_build_object(
     1416                'service_id', s.service_id,
     1417                'name', s.name,
     1418                'price', s.price,
     1419                'duration_minutes', s.duration_minutes
     1420            )
     1421            ORDER BY s.service_id
     1422        ) FILTER (WHERE s.service_id IS NOT NULL),
     1423        '[]'::jsonb
     1424    ) AS services
     1425FROM appointment a
     1426LEFT JOIN status st
     1427  ON st.status_id = a.status_id
     1428LEFT JOIN appointmentservice aps
     1429  ON aps.appointment_id = a.appointment_id
     1430LEFT JOIN service s
     1431  ON s.service_id = aps.service_id
     1432GROUP BY
     1433    a.appointment_id,
     1434    a.user_id,
     1435    a.appointment_time,
     1436    a.end_time,
     1437    a.total_price,
     1438    a.notes,
     1439    a.status_id,
     1440    st.name,
     1441    a."type";
     1442}}}
     1443
     1444
     1445=== `v_user_appointments_payment_state` ===
     1446
     1447 * '''Име:''' `v_user_appointments_payment_state`
     1448 * '''Работи над:''' `appointment`, `status`, `appointmentservice`, `service`, `payment`, `review`
     1449 * '''Бизнис правило/сценарио:''' кориснички payment/review state + `can_pay`, `can_review`.
     1450
     1451{{{
     1452#!sql
     1453CREATE VIEW v_user_appointments_payment_state AS
     1454SELECT
     1455    a.appointment_id,
     1456    a.user_id,
     1457    a.appointment_time,
     1458    a.end_time,
     1459    a.total_price,
     1460    st.name AS status_name,
     1461    svc.services AS services,
     1462    lp.payment_id AS payment_id,
     1463    lp.status AS payment_status,
     1464    lp.method AS payment_method,
     1465    lp."timestamp" AS payment_timestamp,
     1466    pp.payment_id AS paid_payment_id,
     1467    rv.review_id AS review_id,
     1468    rv.rating AS review_rating,
     1469    rv.comment AS review_comment,
     1470    rv.created_at AS review_created_at,
     1471    (
     1472        UPPER(st.name) = 'SCHEDULED'
     1473        AND pp.payment_id IS NULL
     1474    ) AS can_pay,
     1475    (
     1476        pp.payment_id IS NOT NULL
     1477        AND rv.review_id IS NULL
     1478        AND COALESCE(a.end_time, a.appointment_time) <= now()
     1479    ) AS can_review
     1480FROM appointment a
     1481JOIN status st
     1482  ON st.status_id = a.status_id
     1483LEFT JOIN LATERAL (
     1484    SELECT
     1485        COALESCE(
     1486            jsonb_agg(
     1487                jsonb_build_object(
     1488                    'service_id', s.service_id,
     1489                    'name', s.name,
     1490                    'price', s.price,
     1491                    'duration_minutes', s.duration_minutes
     1492                )
     1493                ORDER BY s.service_id
     1494            ) FILTER (WHERE s.service_id IS NOT NULL),
     1495            '[]'::jsonb
     1496        ) AS services
     1497    FROM appointmentservice aps
     1498    JOIN service s
     1499      ON s.service_id = aps.service_id
     1500    WHERE aps.appointment_id = a.appointment_id
     1501) svc
     1502ON true
     1503LEFT JOIN LATERAL (
     1504    SELECT
     1505        p.payment_id,
     1506        p.status,
     1507        p.method,
     1508        p."timestamp"
     1509    FROM payment p
     1510    WHERE p.appointment_id = a.appointment_id
     1511    ORDER BY p."timestamp" DESC
     1512    LIMIT 1
     1513) lp
     1514ON true
     1515LEFT JOIN LATERAL (
     1516    SELECT
     1517        p.payment_id,
     1518        p."timestamp"
     1519    FROM payment p
     1520    WHERE p.appointment_id = a.appointment_id
     1521      AND p.status = 'PAID'
     1522    ORDER BY p."timestamp" DESC
     1523    LIMIT 1
     1524) pp
     1525ON true
     1526LEFT JOIN review rv
     1527  ON rv.payment_id = pp.payment_id;
     1528}}}
     1529
     1530=== `v_admin_appointments_payment_state` ===
     1531
     1532 * '''Име:''' `v_admin_appointments_payment_state`
     1533 * '''Работи над:''' `appointment`, `"User"`, `status`, `appointmentservice`, `service`, `payment`, `review`
     1534 * '''Бизнис правило/сценарио:''' админ преглед на термини со payment/review контекст.
     1535
     1536{{{
     1537#!sql
     1538CREATE VIEW v_admin_appointments_payment_state AS
     1539SELECT
     1540    a.appointment_id,
     1541    a.user_id,
     1542    u.full_name,
     1543    u.email,
     1544    u.phone,
     1545    a.appointment_time,
     1546    a.end_time,
     1547    a.total_price,
     1548    st.name AS status_name,
     1549    svc.services AS services,
     1550    lp.payment_id AS payment_id,
     1551    lp.status AS payment_status,
     1552    lp.method AS payment_method,
     1553    lp."timestamp" AS payment_timestamp,
     1554    pp.payment_id AS paid_payment_id,
     1555    rv.review_id AS review_id,
     1556    rv.rating AS review_rating,
     1557    rv.comment AS review_comment,
     1558    rv.created_at AS review_created_at
     1559FROM appointment a
     1560JOIN "User" u
     1561  ON u.user_id = a.user_id
     1562JOIN status st
     1563  ON st.status_id = a.status_id
     1564LEFT JOIN LATERAL (
     1565    SELECT
     1566        COALESCE(
     1567            jsonb_agg(
     1568                jsonb_build_object(
     1569                    'service_id', s.service_id,
     1570                    'name', s.name,
     1571                    'price', s.price,
     1572                    'duration_minutes', s.duration_minutes
     1573                )
     1574                ORDER BY s.service_id
     1575            ) FILTER (WHERE s.service_id IS NOT NULL),
     1576            '[]'::jsonb
     1577        ) AS services
     1578    FROM appointmentservice aps
     1579    JOIN service s
     1580      ON s.service_id = aps.service_id
     1581    WHERE aps.appointment_id = a.appointment_id
     1582) svc
     1583ON true
     1584LEFT JOIN LATERAL (
     1585    SELECT
     1586        p.payment_id,
     1587        p.status,
     1588        p.method,
     1589        p."timestamp"
     1590    FROM payment p
     1591    WHERE p.appointment_id = a.appointment_id
     1592    ORDER BY p."timestamp" DESC
     1593    LIMIT 1
     1594) lp
     1595ON true
     1596LEFT JOIN LATERAL (
     1597    SELECT
     1598        p.payment_id,
     1599        p."timestamp"
     1600    FROM payment p
     1601    WHERE p.appointment_id = a.appointment_id
     1602      AND p.status = 'PAID'
     1603    ORDER BY p."timestamp" DESC
     1604    LIMIT 1
     1605) pp
     1606ON true
     1607LEFT JOIN review rv
     1608  ON rv.payment_id = pp.payment_id;
     1609}}}
     1610
     1611=== `v_admin_availability_by_date` ===
     1612
     1613 * '''Име:''' `v_admin_availability_by_date`
     1614 * '''Работи над:''' `availability`
     1615 * '''Бизнис правило/сценарио:''' read-model за админ schedule екран.
     1616
     1617{{{
     1618#!sql
     1619CREATE OR REPLACE VIEW v_admin_availability_by_date AS
     1620SELECT
     1621    date,
     1622    availability_id,
     1623    start_time,
     1624    end_time,
     1625    is_closed
     1626FROM availability
     1627ORDER BY date ASC, start_time ASC;
     1628}}}
     1629
     1630=== `v_user_active_packages` ===
     1631
     1632 * '''Име:''' `v_user_active_packages`
     1633 * '''Работи над:''' `userpackagepurchase`, `package`, `packageservice`, `service`
     1634 * '''Бизнис правило/сценарио:''' активни пакети и вклучени услуги.
     1635
     1636{{{
     1637#!sql
     1638CREATE OR REPLACE VIEW v_user_active_packages AS
     1639SELECT
     1640    upp.user_id,
     1641    upp.purchase_id,
     1642    upp.package_id,
     1643    p.name AS package_name,
     1644    upp.remaining_uses,
     1645    upp.purchased_at,
     1646    array_agg(s.name ORDER BY s.name) AS services
     1647FROM UserPackagePurchase upp
     1648JOIN Package p
     1649  ON p.package_id = upp.package_id
     1650JOIN PackageService ps
     1651  ON ps.package_id = p.package_id
     1652JOIN Service s
     1653  ON s.service_id = ps.service_id
     1654WHERE upp.status = 'ACTIVE'
     1655GROUP BY
     1656    upp.user_id,
     1657    upp.purchase_id,
     1658    upp.package_id,
     1659    p.name,
     1660    upp.remaining_uses,
     1661    upp.purchased_at;
     1662}}}
     1663
     1664== 7. Автоматизација и background jobs ==
     1665
     1666=== `sp_auto_mark_no_show` ===
     1667
     1668 * '''Име:''' `sp_auto_mark_no_show`
     1669 * '''Работи над:''' `appointment`, `status`
     1670 * '''Бизнис правило:''' автоматско означување ''NO_SHOW'' за задоцнети ''SCHEDULED'' термини.
     1671
     1672{{{
     1673#!sql
     1674CREATE OR REPLACE FUNCTION sp_auto_mark_no_show(
     1675    p_grace_minutes int DEFAULT 30
     1676)
     1677RETURNS int
     1678LANGUAGE plpgsql
     1679AS $$
     1680DECLARE
     1681    v_no_show_id int;
     1682    v_count int;
     1683BEGIN
     1684    v_no_show_id := fn_status_id(ARRAY['no_show']);
     1685
     1686    IF v_no_show_id IS NULL THEN
     1687        RETURN 0;
     1688    END IF;
     1689
     1690    WITH candidates AS (
     1691        SELECT a.appointment_id
     1692        FROM appointment a
     1693        JOIN status s
     1694          ON s.status_id = a.status_id
     1695        WHERE UPPER(s.name) = 'SCHEDULED'
     1696          AND a.end_time IS NOT NULL
     1697          AND a.end_time <= now() - make_interval(mins => p_grace_minutes)
     1698    )
     1699    UPDATE appointment
     1700    SET status_id = v_no_show_id
     1701    WHERE appointment_id IN (
     1702        SELECT appointment_id
     1703        FROM candidates
     1704    );
     1705
     1706    GET DIAGNOSTICS v_count = ROW_COUNT;
     1707
     1708    RETURN v_count;
     1709END;
     1710$$;
     1711}}}
     1712
     1713=== Апликациски scheduler повик ===
     1714
     1715 * '''Објект:''' периодичен повик од апликациски слој
     1716 * '''Бизнис правило:''' автоматизација на lifecycle без рачна интервенција.
     1717
     1718{{{
     1719#!sql
     1720SELECT sp_auto_mark_no_show($1::int);
     1721}}}