Напреден развој на базата
1. Валидација на термини (Appointment Validation)
Опис на барањата за податочни ограничувања
Имплементираните правила се:
- термин не смее да биде во минато,
end_timeмора да е послеappointment_time,- терминот мора да е во availability прозорец (
is_closed = false), - не е дозволено преклопување со друг SCHEDULED термин,
- при креирање мора да има барем една услуга,
end_timeиtotal_priceавтоматски се пресметуваат од услуги,- валидацијата се извршува на INSERT/UPDATE.
Имплементација
fn_validate_appointment
- Име:
fn_validate_appointment - Работи над:
appointment,availability,status - Бизнис правило: временска валидност, availability валидација, anti-overlap.
CREATE OR REPLACE FUNCTION fn_validate_appointment(p_appointment_id int)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
v_start timestamp;
v_end timestamp;
v_date date;
v_overlap_count int;
v_avail_count int;
BEGIN
SELECT appointment_time, end_time
INTO v_start, v_end
FROM appointment
WHERE appointment_id = p_appointment_id;
IF v_start IS NULL OR v_end IS NULL THEN
RAISE EXCEPTION 'Appointment must have a start and end time';
END IF;
IF v_end <= v_start THEN
RAISE EXCEPTION 'Appointment end time must be after start time';
END IF;
IF v_start < now() THEN
RAISE EXCEPTION 'Appointment time must be in the future';
END IF;
v_date := v_start::date;
SELECT COUNT(*)
INTO v_avail_count
FROM availability a
WHERE a.date = v_date
AND a.is_closed = false
AND v_start >= (a.date + a.start_time)
AND v_end <= (a.date + a.end_time);
IF v_avail_count = 0 THEN
RAISE EXCEPTION 'Appointment is outside availability';
END IF;
SELECT COUNT(*)
INTO v_overlap_count
FROM appointment ap
JOIN status st
ON st.status_id = ap.status_id
WHERE ap.appointment_id <> p_appointment_id
AND UPPER(st.name) = 'SCHEDULED'
AND v_start < ap.end_time
AND v_end > ap.appointment_time;
IF v_overlap_count > 0 THEN
RAISE EXCEPTION 'Appointment overlaps an existing booking';
END IF;
END;
$$;
fn_recalculate_appointment
- Име:
fn_recalculate_appointment - Работи над:
appointment,appointmentservice,service - Бизнис правило: автоматско пресметување на
total_priceиend_time.
CREATE OR REPLACE FUNCTION fn_recalculate_appointment(p_appointment_id int)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
v_total_minutes int;
v_total_price numeric;
v_start timestamp;
BEGIN
SELECT appointment_time
INTO v_start
FROM appointment
WHERE appointment_id = p_appointment_id
FOR UPDATE;
IF v_start IS NULL THEN
RETURN;
END IF;
SELECT
COALESCE(SUM(s.duration_minutes), 0)::int,
COALESCE(SUM(s.price), 0)::numeric
INTO
v_total_minutes,
v_total_price
FROM appointmentservice aps
JOIN service s
ON s.service_id = aps.service_id
WHERE aps.appointment_id = p_appointment_id;
UPDATE appointment
SET
total_price = v_total_price,
end_time = v_start + make_interval(mins => v_total_minutes)
WHERE appointment_id = p_appointment_id;
END;
$$;
sp_create_appointment
- Име:
sp_create_appointment - Работи над:
service,status,appointment,appointmentservice - Бизнис правило: креирање термин со целосна DB валидација и пресметка.
извадок од функцијата
CREATE OR REPLACE FUNCTION sp_create_appointment(
p_user_id int,
p_appointment_time timestamp,
p_service_ids int[],
p_notes text
)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
v_appointment_id int;
v_status_id int;
v_service_count int;
v_total_minutes int;
v_total_price numeric;
v_end_time timestamp;
v_enum_type text;
v_enum_label text;
BEGIN
IF p_service_ids IS NULL
OR array_length(p_service_ids, 1) IS NULL THEN
RAISE EXCEPTION 'At least one service is required';
END IF;
SELECT COUNT(*)
INTO v_service_count
FROM service
WHERE service_id = ANY(p_service_ids);
IF v_service_count <> array_length(p_service_ids, 1) THEN
RAISE EXCEPTION 'One or more services not found';
END IF;
v_total_minutes := fn_service_total_minutes(p_service_ids);
v_total_price := fn_service_total_price(p_service_ids);
v_end_time :=
p_appointment_time
+ make_interval(mins => v_total_minutes);
SELECT status_id
INTO v_status_id
FROM status
WHERE UPPER(name) = 'SCHEDULED'
LIMIT 1;
EXECUTE format(
'INSERT INTO appointment
(
appointment_time,
end_time,
total_price,
notes,
status_id,
user_id,
"type"
)
VALUES ($1,$2,$3,$4,$5,$6,$7::%I)
RETURNING appointment_id',
v_enum_type
)
INTO v_appointment_id
USING
p_appointment_time,
v_end_time,
v_total_price,
p_notes,
v_status_id,
p_user_id,
v_enum_label;
INSERT INTO appointmentservice
(
appointment_id,
service_id
)
SELECT
v_appointment_id,
unnest(p_service_ids);
PERFORM fn_validate_appointment(v_appointment_id);
RETURN v_appointment_id;
END;
$$;
trg_validate_appointment + appointment_validate
- Имиња:
trg_validate_appointment,appointment_validate - Работи над:
appointment - Бизнис правило: задолжителна валидација и на insert и на update.
CREATE OR REPLACE FUNCTION trg_validate_appointment()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM fn_validate_appointment(NEW.appointment_id);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS appointment_validate
ON appointment;
CREATE CONSTRAINT TRIGGER appointment_validate
AFTER INSERT OR UPDATE
ON appointment
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION trg_validate_appointment();
trg_appointmentservice_recalculate + appointmentservice_recalculate
- Имиња:
trg_appointmentservice_recalculate,appointmentservice_recalculate - Работи над:
appointmentservice,appointment - Бизнис правило: секоја промена на услуги во термин автоматски ги рефрешира цена/траење.
CREATE OR REPLACE FUNCTION trg_appointmentservice_recalculate()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP IN ('INSERT', 'UPDATE') THEN
PERFORM fn_recalculate_appointment(NEW.appointment_id);
END IF;
IF TG_OP IN ('DELETE', 'UPDATE') THEN
PERFORM fn_recalculate_appointment(OLD.appointment_id);
END IF;
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS appointmentservice_recalculate
ON appointmentservice;
CREATE TRIGGER appointmentservice_recalculate
AFTER INSERT OR UPDATE OR DELETE
ON appointmentservice
FOR EACH ROW
EXECUTE FUNCTION trg_appointmentservice_recalculate();
2. Валидација на плаќања (Payment Validation)
Опис на барањата
Имплементирани правила:
- payment мора да има target (
appointment_idилиpackage_purchase_id), - amount мора да е валиден во контекст на target-от,
- дупли PAID не се дозволени (appointment/package purchase),
- review е дозволен само за валиден PAID payment.
Имплементација
payment_target_required constraint
- Име:
payment_target_required - Работи над:
payment - Бизнис правило: payment без target е невалиден.
ALTER TABLE payment
DROP CONSTRAINT IF EXISTS payment_target_required;
ALTER TABLE payment
ADD CONSTRAINT payment_target_required
CHECK
(
appointment_id IS NOT NULL
OR package_purchase_id IS NOT NULL
);
Partial unique indexes за PAID
- Имиња:
idx_payment_paid_unique,idx_payment_package_purchase_paid_unique - Работи над:
payment - Бизнис правило: максимум едно PAID по appointment и едно upfront PAID по package purchase.
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_paid_unique ON payment (appointment_id) WHERE status = 'PAID'; CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_package_purchase_paid_unique ON payment (package_purchase_id) WHERE status = 'PAID' AND appointment_id IS NULL;
trg_payment_validate + payment_validate
- Имиња:
trg_payment_validate,payment_validate - Работи над:
payment,appointment,status,userpackagepurchase,package,packageservice,appointmentpackageusage - Бизнис правило: контекстуална валидација на amount/method/points и согласност со package usage.
извадок од функцијата
CREATE OR REPLACE FUNCTION trg_payment_validate()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_total_price numeric;
v_status_name text;
v_expected numeric;
v_package_total numeric;
v_usage_purchase_id int;
BEGIN
IF NEW.appointment_id IS NULL
AND NEW.package_purchase_id IS NULL THEN
RAISE EXCEPTION
'Payment must reference appointment_id or package_purchase_id';
END IF;
IF NEW.appointment_id IS NULL
AND NEW.package_purchase_id IS NOT NULL THEN
SELECT
COALESCE(
p.total_price,
package_price.calc_total_price
)
INTO v_package_total
FROM userpackagepurchase upp
JOIN package p
ON p.package_id = upp.package_id
LEFT JOIN LATERAL
(
SELECT
COALESCE(
SUM(ps.discounted_price),
0
)::numeric AS calc_total_price
FROM packageservice ps
WHERE ps.package_id = p.package_id
) package_price
ON true
WHERE upp.purchase_id = NEW.package_purchase_id;
IF COALESCE(NEW.points_used, 0) <> 0 THEN
RAISE EXCEPTION
'Package purchase payment cannot redeem loyalty points';
END IF;
IF NEW.amount IS DISTINCT FROM v_package_total THEN
RAISE EXCEPTION
'Package purchase payment amount must equal package total';
END IF;
RETURN NEW;
END IF;
IF UPPER(COALESCE(NEW.method, '')) = 'PACKAGE' THEN
IF NEW.amount IS DISTINCT FROM 0::numeric THEN
RAISE EXCEPTION
'PACKAGE appointment payment amount must be 0';
END IF;
SELECT apu.purchase_id
INTO v_usage_purchase_id
FROM appointmentpackageusage apu
WHERE apu.appointment_id = NEW.appointment_id
LIMIT 1;
IF NEW.package_purchase_id IS NOT NULL
AND NEW.package_purchase_id <> v_usage_purchase_id THEN
RAISE EXCEPTION
'payment.package_purchase_id does not match appointment package usage';
END IF;
RETURN NEW;
END IF;
SELECT a.total_price, s.name
INTO v_total_price, v_status_name
FROM appointment a
JOIN status s
ON s.status_id = a.status_id
WHERE a.appointment_id = NEW.appointment_id;
IF UPPER(v_status_name) <> 'SCHEDULED' THEN
RAISE EXCEPTION
'Payment allowed only for SCHEDULED appointments';
END IF;
v_expected :=
v_total_price
- COALESCE(NEW.points_used, 0);
IF NEW.amount IS DISTINCT FROM v_expected THEN
RAISE EXCEPTION
'Payment amount must equal appointment total minus points';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS payment_validate
ON payment;
CREATE TRIGGER payment_validate
BEFORE INSERT OR UPDATE
ON payment
FOR EACH ROW
EXECUTE FUNCTION trg_payment_validate();
trg_appointment_prevent_price_change + appointment_prevent_price_change
- Имиња:
trg_appointment_prevent_price_change,appointment_prevent_price_change - Работи над:
appointment,payment - Бизнис правило: после PAID payment не смее да се менува
appointment.total_price.
CREATE OR REPLACE FUNCTION trg_appointment_prevent_price_change()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_has_paid int;
BEGIN
IF NEW.total_price IS DISTINCT FROM OLD.total_price THEN
SELECT 1
INTO v_has_paid
FROM payment
WHERE appointment_id = OLD.appointment_id
AND status = 'PAID'
LIMIT 1;
IF v_has_paid IS NOT NULL THEN
RAISE EXCEPTION
'Cannot change total_price after a PAID payment';
END IF;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS appointment_prevent_price_change
ON appointment;
CREATE TRIGGER appointment_prevent_price_change
BEFORE UPDATE
ON appointment
FOR EACH ROW
EXECUTE FUNCTION trg_appointment_prevent_price_change();
sp_create_review
- Име:
sp_create_review - Работи над:
payment,appointment,review - Бизнис правило: review само за PAID, завршен, сопствен payment, и една review по payment.
CREATE OR REPLACE FUNCTION sp_create_review(
p_user_id int,
p_payment_id int,
p_rating int,
p_comment text
)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
v_review_id int;
v_payment_status text;
v_appointment_user int;
v_appointment_time timestamp;
v_end_time timestamp;
BEGIN
SELECT
p.status,
a.user_id,
a.appointment_time,
a.end_time
INTO
v_payment_status,
v_appointment_user,
v_appointment_time,
v_end_time
FROM payment p
JOIN appointment a
ON a.appointment_id = p.appointment_id
WHERE p.payment_id = p_payment_id;
IF v_payment_status <> 'PAID' THEN
RAISE EXCEPTION
'Review allowed only for PAID payments';
END IF;
IF COALESCE(v_end_time, v_appointment_time) > now() THEN
RAISE EXCEPTION
'Review allowed only after appointment is completed';
END IF;
IF v_appointment_user <> p_user_id THEN
RAISE EXCEPTION
'Unauthorized access to payment';
END IF;
IF EXISTS
(
SELECT 1
FROM review
WHERE payment_id = p_payment_id
) THEN
RAISE EXCEPTION
'Review already exists for this payment';
END IF;
INSERT INTO review
(
rating,
comment,
payment_id
)
VALUES
(
p_rating,
p_comment,
p_payment_id
)
RETURNING review_id
INTO v_review_id;
RETURN v_review_id;
END;
$$;
3. Loyalty System
Опис на барањата за податочни ограничувања
- еден корисник -> една loyalty картичка,
- points не смеат да се негативни,
- points се доделуваат само кога условите се исполнети (
COMPLETED+PAID), - refund ја враќа состојбата на поени.
Имплементација
UNIQUE/CHECK constraints
- Објекти:
idx_loyaltycard_user_unique,loyaltycard_points_nonnegative,payment_points_used_nonnegative - Работи над:
loyaltycard,payment - Бизнис правило: единствена картичка и не-негативни поени.
CREATE UNIQUE INDEX IF NOT EXISTS idx_loyaltycard_user_unique ON loyaltycard (user_id); ALTER TABLE loyaltycard ADD CONSTRAINT loyaltycard_points_nonnegative CHECK (points >= 0); ALTER TABLE payment ADD CONSTRAINT payment_points_used_nonnegative CHECK (points_used >= 0);
sp_ensure_loyalty_card
- Име:
sp_ensure_loyalty_card - Работи над:
loyaltycard - Бизнис правило: автоматско креирање loyalty card ако недостига.
CREATE OR REPLACE FUNCTION sp_ensure_loyalty_card(p_user_id int)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
v_card_id int;
BEGIN
INSERT INTO loyaltycard (user_id, points)
VALUES (p_user_id, 0)
ON CONFLICT (user_id) DO NOTHING;
SELECT card_id
INTO v_card_id
FROM loyaltycard
WHERE user_id = p_user_id;
RETURN v_card_id;
END;
$$;
sp_award_loyalty_points
- Име:
sp_award_loyalty_points - Работи над:
appointment,status,payment,loyaltycard - Бизнис правило: award само ако терминот е COMPLETED и има PAID payment, еднаш по термин.
CREATE OR REPLACE FUNCTION sp_award_loyalty_points(p_appointment_id int)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
v_user_id int;
v_total numeric;
v_status text;
v_points_awarded boolean;
v_paid_status text;
v_completed boolean;
v_paid boolean;
v_earned int;
BEGIN
SELECT a.user_id, a.total_price, s.name, a.points_awarded
INTO v_user_id, v_total, v_status, v_points_awarded
FROM appointment a
JOIN status s ON s.status_id = a.status_id
WHERE a.appointment_id = p_appointment_id
FOR UPDATE;
v_completed := (UPPER(v_status) = 'COMPLETED');
SELECT p.status
INTO v_paid_status
FROM payment p
WHERE p.appointment_id = p_appointment_id
AND UPPER(p.status) = 'PAID'
LIMIT 1;
v_paid := (v_paid_status IS NOT NULL);
IF NOT v_completed OR NOT v_paid THEN
RETURN 0;
END IF;
IF v_points_awarded THEN
RETURN 0;
END IF;
PERFORM sp_ensure_loyalty_card(v_user_id);
v_earned := FLOOR(COALESCE(v_total, 0) * 0.05);
UPDATE loyaltycard
SET points = points + v_earned
WHERE user_id = v_user_id;
UPDATE appointment
SET points_awarded = true
WHERE appointment_id = p_appointment_id;
RETURN v_earned;
END;
$$;
sp_mark_payment_paid
- Име:
sp_mark_payment_paid - Работи над:
payment,appointment,userpackagepurchase,package,loyaltycard - Бизнис правило: централен PAID flow за appointment payment и package purchase payment.
извадок од функцијата
CREATE OR REPLACE FUNCTION sp_mark_payment_paid(
p_payment_id int
)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
v_appointment_id int;
v_package_purchase_id int;
v_package_id int;
v_user_id int;
v_total_price numeric;
v_requested_points int;
v_has_paid int;
v_used int := 0;
v_payment_status text;
v_payment_method text;
v_points_earned int;
BEGIN
SELECT appointment_id, package_purchase_id, points_used, status, method
INTO v_appointment_id, v_package_purchase_id, v_requested_points, v_payment_status, v_payment_method
FROM payment
WHERE payment_id = p_payment_id
FOR UPDATE;
IF UPPER(COALESCE(v_payment_status, '')) = 'PAID' THEN
RETURN COALESCE(v_requested_points, 0);
END IF;
IF v_appointment_id IS NOT NULL THEN
IF UPPER(COALESCE(v_payment_method, '')) = 'PACKAGE' THEN
UPDATE payment
SET status = 'PAID',
points_used = 0,
amount = 0
WHERE payment_id = p_payment_id;
RETURN 0;
END IF;
SELECT a.user_id, a.total_price
INTO v_user_id, v_total_price
FROM appointment a
WHERE a.appointment_id = v_appointment_id;
v_used := sp_redeem_loyalty_points(
v_user_id,
v_total_price,
v_requested_points
);
UPDATE payment
SET status = 'PAID',
points_used = v_used,
amount = v_total_price - v_used
WHERE payment_id = p_payment_id;
RETURN v_used;
END IF;
SELECT upp.user_id, upp.package_id
INTO v_user_id, v_package_id
FROM userpackagepurchase upp
WHERE upp.purchase_id = v_package_purchase_id
FOR UPDATE;
UPDATE payment
SET status = 'PAID',
points_used = 0,
amount = v_total_price
WHERE payment_id = p_payment_id;
PERFORM sp_ensure_loyalty_card(v_user_id);
v_points_earned := FLOOR(COALESCE(v_total_price, 0) * 0.05);
IF v_points_earned > 0 THEN
UPDATE loyaltycard
SET points = points + v_points_earned
WHERE user_id = v_user_id;
END IF;
RETURN 0;
END;
$$;
sp_refund_payment_for_appointment
- Име:
sp_refund_payment_for_appointment - Работи над:
payment,appointment,loyaltycard - Бизнис правило: refund + враќање на
points_used+ rollback наpoints_awarded.
CREATE OR REPLACE FUNCTION sp_refund_payment_for_appointment(p_appointment_id int)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
v_payment_id int;
v_user_id int;
v_total numeric;
v_points_used int;
v_points_earned int;
BEGIN
SELECT p.payment_id, p.points_used
INTO v_payment_id, v_points_used
FROM payment p
WHERE p.appointment_id = p_appointment_id
AND p.status = 'PAID'
ORDER BY p."timestamp" DESC
LIMIT 1
FOR UPDATE;
IF v_payment_id IS NULL THEN
RETURN 0;
END IF;
SELECT a.user_id, a.total_price
INTO v_user_id, v_total
FROM appointment a
WHERE a.appointment_id = p_appointment_id
FOR UPDATE;
UPDATE payment
SET status = 'REFUNDED'
WHERE payment_id = v_payment_id;
IF v_points_used > 0 THEN
PERFORM sp_ensure_loyalty_card(v_user_id);
UPDATE loyaltycard
SET points = points + v_points_used
WHERE user_id = v_user_id;
END IF;
IF EXISTS (
SELECT 1
FROM appointment
WHERE appointment_id = p_appointment_id
AND points_awarded = true
) THEN
v_points_earned := FLOOR(COALESCE(v_total, 0) * 0.05);
PERFORM sp_ensure_loyalty_card(v_user_id);
UPDATE loyaltycard
SET points = GREATEST(points - v_points_earned, 0)
WHERE user_id = v_user_id;
UPDATE appointment
SET points_awarded = false
WHERE appointment_id = p_appointment_id;
END IF;
RETURN v_payment_id;
END;
$$;
Валидација на користење пакети (Package Usage Validation)
Опис на барањата
- package purchase мора да е
ACTIVE, remaining_usesмора да е позитивен,- услуги во appointment мора да се покриени од
packageservice, - финализација намалува
remaining_uses, - двојна финализација се пресекува со
finalized_at.
Имплементација
appointmentpackageusage ограничувања
- Објект:
appointmentpackageusage - Работи над:
appointmentpackageusage - Бизнис правило: валидна usage релација и позитивни units.
CREATE TABLE appointmentpackageusage (
appointment_id integer PRIMARY KEY,
purchase_id integer NOT NULL,
service_id integer NOT NULL,
used_units integer NOT NULL DEFAULT 1,
finalized_at timestamp NULL,
CONSTRAINT appointmentpackageusage_appointment_id_fkey
FOREIGN KEY (appointment_id)
REFERENCES appointment(appointment_id)
ON DELETE CASCADE,
CONSTRAINT appointmentpackageusage_purchase_id_fkey
FOREIGN KEY (purchase_id)
REFERENCES userpackagepurchase(purchase_id)
ON DELETE RESTRICT,
CONSTRAINT appointmentpackageusage_service_id_fkey
FOREIGN KEY (service_id)
REFERENCES service(service_id)
ON DELETE RESTRICT,
CONSTRAINT appointmentpackageusage_used_units_check
CHECK (used_units > 0)
);
sp_apply_package_to_appointment
- Име:
sp_apply_package_to_appointment - Работи над:
appointment,appointmentservice,userpackagepurchase,packageservice,appointmentpackageusage,payment - Бизнис правило: само валиден пакет да покрие термин; терминот станува prepaid (
total_price = 0).
извадок од функцијата
CREATE OR REPLACE FUNCTION sp_apply_package_to_appointment(
p_appointment_id INT,
p_purchase_id INT
)
RETURNS VOID
AS $$
DECLARE
v_appt_user_id INT;
v_purchase_user_id INT;
v_purchase_status TEXT;
v_purchase_remaining INT;
v_purchase_package_id INT;
v_first_service_id INT;
v_missing_service_id INT;
BEGIN
SELECT a.user_id
INTO v_appt_user_id
FROM appointment a
WHERE a.appointment_id = p_appointment_id
FOR UPDATE;
SELECT upp.user_id, upp.status, upp.remaining_uses, upp.package_id
INTO v_purchase_user_id, v_purchase_status, v_purchase_remaining, v_purchase_package_id
FROM userpackagepurchase upp
WHERE upp.purchase_id = p_purchase_id
FOR UPDATE;
IF v_purchase_status <> 'ACTIVE' THEN
RAISE EXCEPTION 'Purchase % is not ACTIVE', p_purchase_id;
END IF;
IF v_purchase_remaining IS NULL OR v_purchase_remaining <= 0 THEN
RAISE EXCEPTION 'Purchase % has no remaining uses', p_purchase_id;
END IF;
SELECT aps.service_id
INTO v_missing_service_id
FROM appointmentservice aps
LEFT JOIN packageservice ps
ON ps.package_id = v_purchase_package_id
AND ps.service_id = aps.service_id
WHERE aps.appointment_id = p_appointment_id
AND ps.service_id IS NULL
LIMIT 1;
IF v_missing_service_id IS NOT NULL THEN
RAISE EXCEPTION
'Appointment service % is not included in selected package',
v_missing_service_id;
END IF;
INSERT INTO appointmentpackageusage (
appointment_id,
purchase_id,
service_id,
used_units,
finalized_at
)
VALUES (
p_appointment_id,
p_purchase_id,
v_first_service_id,
1,
NULL
)
ON CONFLICT (appointment_id)
DO UPDATE SET
purchase_id = EXCLUDED.purchase_id,
service_id = EXCLUDED.service_id,
used_units = EXCLUDED.used_units,
finalized_at = NULL;
UPDATE appointment
SET total_price = 0
WHERE appointment_id = p_appointment_id;
END;
$$ LANGUAGE plpgsql;
sp_finalize_package_usage_on_appointment
- Име:
sp_finalize_package_usage_on_appointment - Работи над:
appointmentpackageusage,userpackagepurchase - Бизнис правило: еднократно трошење на package units при финализација.
CREATE OR REPLACE FUNCTION sp_finalize_package_usage_on_appointment(
p_appointment_id INT
)
RETURNS VOID
AS $$
DECLARE
v_purchase_id INT;
v_used_units INT;
v_finalized_at TIMESTAMP;
v_remaining INT;
BEGIN
SELECT apu.purchase_id, apu.used_units, apu.finalized_at
INTO v_purchase_id, v_used_units, v_finalized_at
FROM appointmentpackageusage apu
WHERE apu.appointment_id = p_appointment_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN;
END IF;
IF v_finalized_at IS NOT NULL THEN
RETURN;
END IF;
SELECT upp.remaining_uses
INTO v_remaining
FROM userpackagepurchase upp
WHERE upp.purchase_id = v_purchase_id
FOR UPDATE;
IF v_remaining < v_used_units THEN
RAISE EXCEPTION
'Insufficient remaining uses on purchase %',
v_purchase_id;
END IF;
UPDATE userpackagepurchase
SET remaining_uses = remaining_uses - v_used_units
WHERE purchase_id = v_purchase_id;
UPDATE appointmentpackageusage
SET finalized_at = now()
WHERE appointment_id = p_appointment_id;
END;
$$ LANGUAGE plpgsql;
5. Валидација на достапност (Availability Validation)
Опис на барањата
- дупликат прозорци за ист датум/време не се дозволени,
end_time > start_timeе задолжително,- availability се користи за runtime генерација на слотови.
Имплементација
Unique index на availability прозорец
- Име:
idx_availability_unique_window - Работи над:
availability - Бизнис правило: нема duplicate window за ист датум/опсег.
CREATE UNIQUE INDEX IF NOT EXISTS idx_availability_unique_window ON availability (date, start_time, end_time);
fn_available_slots
- Име:
fn_available_slots - Работи над:
availability,appointment,status,service - Бизнис правило: слотови само во отворен прозорец и без преклоп со SCHEDULED.
CREATE OR REPLACE FUNCTION fn_available_slots(p_date date, p_service_ids int[])
RETURNS TABLE (start_time timestamp, end_time timestamp)
LANGUAGE sql
AS $$
WITH svc AS (
SELECT fn_service_total_minutes(p_service_ids) AS total_minutes
),
windows AS (
SELECT
(p_date + a.start_time) AS window_start,
(p_date + a.end_time) AS window_end,
s.total_minutes
FROM availability a
CROSS JOIN svc s
WHERE a.date = p_date
AND a.is_closed = false
AND s.total_minutes > 0
),
candidates AS (
SELECT
gs AS start_time,
gs + make_interval(mins => w.total_minutes) AS end_time
FROM windows w
JOIN LATERAL generate_series(
w.window_start,
w.window_end - make_interval(mins => w.total_minutes),
interval '15 minutes'
) AS gs
ON true
)
SELECT c.start_time, c.end_time
FROM candidates c
WHERE NOT EXISTS (
SELECT 1
FROM appointment ap
JOIN status st
ON st.status_id = ap.status_id
WHERE UPPER(st.name) = 'SCHEDULED'
AND c.start_time < ap.end_time
AND c.end_time > ap.appointment_time
)
ORDER BY c.start_time;
$$;
sp_admin_add_availability
- Име:
sp_admin_add_availability - Работи над:
availability - Бизнис правило: валидна временска рамка + no duplicate.
CREATE OR REPLACE FUNCTION sp_admin_add_availability(
p_date date,
p_start time,
p_end time,
p_is_closed boolean DEFAULT false
)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
v_availability_id int;
v_exists int;
BEGIN
IF p_end <= p_start THEN
RAISE EXCEPTION 'End time must be after start time';
END IF;
SELECT 1
INTO v_exists
FROM availability
WHERE date = p_date
AND start_time = p_start
AND end_time = p_end
LIMIT 1;
IF v_exists IS NOT NULL THEN
RAISE EXCEPTION
'Availability window already exists for this date and time';
END IF;
INSERT INTO availability
(
date,
start_time,
end_time,
is_closed
)
VALUES
(
p_date,
p_start,
p_end,
p_is_closed
)
RETURNING availability_id
INTO v_availability_id;
RETURN v_availability_id;
END;
$$;
sp_admin_update_availability
- Име:
sp_admin_update_availability - Работи над:
availability - Бизнис правило: update само на постоечки запис, без временски конфликт.
CREATE OR REPLACE FUNCTION sp_admin_update_availability(
p_availability_id int,
p_date date,
p_start time,
p_end time,
p_is_closed boolean
)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
v_exists int;
v_duplicate int;
BEGIN
IF p_end <= p_start THEN
RAISE EXCEPTION 'End time must be after start time';
END IF;
SELECT 1
INTO v_exists
FROM availability
WHERE availability_id = p_availability_id
LIMIT 1;
IF v_exists IS NULL THEN
RAISE EXCEPTION 'Availability window not found';
END IF;
SELECT 1
INTO v_duplicate
FROM availability
WHERE date = p_date
AND start_time = p_start
AND end_time = p_end
AND availability_id <> p_availability_id
LIMIT 1;
IF v_duplicate IS NOT NULL THEN
RAISE EXCEPTION
'Availability window already exists for this date and time';
END IF;
UPDATE availability
SET
date = p_date,
start_time = p_start,
end_time = p_end,
is_closed = p_is_closed
WHERE availability_id = p_availability_id;
END;
$$;
sp_admin_delete_availability
- Име:
sp_admin_delete_availability - Работи над:
availability - Бизнис правило: delete само ако записот постои.
CREATE OR REPLACE FUNCTION sp_admin_delete_availability(
p_availability_id int
)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
v_deleted int;
BEGIN
DELETE FROM availability
WHERE availability_id = p_availability_id;
GET DIAGNOSTICS v_deleted = ROW_COUNT;
IF v_deleted = 0 THEN
RAISE EXCEPTION 'Availability window not found';
END IF;
END;
$$;
6. Напредни погледи (Advanced Views)
v_services_grouped_by_category
- Име:
v_services_grouped_by_category - Работи над:
category,service,appointmentservice,appointment,payment,review,"User" - Бизнис правило/сценарио: агрегиран services каталог со ratings/reviews за
/servicesи/book.
CREATE OR REPLACE VIEW v_services_grouped_by_category AS
SELECT
c.category_id,
c.name AS category_name,
COALESCE(
jsonb_agg(
jsonb_build_object(
'service_id', s.service_id,
'name', s.name,
'price', s.price,
'duration_minutes', s.duration_minutes,
'avg_rating', COALESCE(svc.avg_rating, 0),
'review_count', COALESCE(svc.review_count, 0),
'reviews', COALESCE(svc.reviews, '[]'::jsonb)
)
ORDER BY s.service_id
) FILTER (WHERE s.service_id IS NOT NULL),
'[]'::jsonb
) AS services
FROM category c
LEFT JOIN service s
ON s.category_id = c.category_id
LEFT JOIN LATERAL (
SELECT
ROUND(AVG(r.rating)::numeric, 1) AS avg_rating,
COUNT(r.review_id) AS review_count,
COALESCE(
jsonb_agg(
jsonb_build_object(
'review_id', r.review_id,
'rating', r.rating,
'comment', r.comment,
'created_at', r.created_at,
'reviewer', u.full_name
)
ORDER BY r.created_at DESC
) FILTER (WHERE r.review_id IS NOT NULL),
'[]'::jsonb
) AS reviews
FROM appointmentservice aps
JOIN appointment a
ON a.appointment_id = aps.appointment_id
JOIN payment p
ON p.appointment_id = a.appointment_id
JOIN review r
ON r.payment_id = p.payment_id
JOIN "User" u
ON u.user_id = a.user_id
WHERE aps.service_id = s.service_id
AND p.status = 'PAID'
) svc
ON true
GROUP BY c.category_id, c.name;
v_user_appointments
- Име:
v_user_appointments - Работи над:
appointment,status,appointmentservice,service - Бизнис правило/сценарио: read-model за кориснички термини.
CREATE OR REPLACE VIEW v_user_appointments AS
SELECT
a.appointment_id,
a.user_id,
a.appointment_time,
a.end_time,
a.total_price,
a.notes,
a.status_id,
st.name AS status_name,
a."type"::text AS appointment_type,
COALESCE(
jsonb_agg(
jsonb_build_object(
'service_id', s.service_id,
'name', s.name,
'price', s.price,
'duration_minutes', s.duration_minutes
)
ORDER BY s.service_id
) FILTER (WHERE s.service_id IS NOT NULL),
'[]'::jsonb
) AS services
FROM appointment a
LEFT JOIN status st
ON st.status_id = a.status_id
LEFT JOIN appointmentservice aps
ON aps.appointment_id = a.appointment_id
LEFT JOIN service s
ON s.service_id = aps.service_id
GROUP BY
a.appointment_id,
a.user_id,
a.appointment_time,
a.end_time,
a.total_price,
a.notes,
a.status_id,
st.name,
a."type";
v_user_appointments_payment_state
- Име:
v_user_appointments_payment_state - Работи над:
appointment,status,appointmentservice,service,payment,review - Бизнис правило/сценарио: кориснички payment/review state +
can_pay,can_review.
CREATE VIEW v_user_appointments_payment_state AS
SELECT
a.appointment_id,
a.user_id,
a.appointment_time,
a.end_time,
a.total_price,
st.name AS status_name,
svc.services AS services,
lp.payment_id AS payment_id,
lp.status AS payment_status,
lp.method AS payment_method,
lp."timestamp" AS payment_timestamp,
pp.payment_id AS paid_payment_id,
rv.review_id AS review_id,
rv.rating AS review_rating,
rv.comment AS review_comment,
rv.created_at AS review_created_at,
(
UPPER(st.name) = 'SCHEDULED'
AND pp.payment_id IS NULL
) AS can_pay,
(
pp.payment_id IS NOT NULL
AND rv.review_id IS NULL
AND COALESCE(a.end_time, a.appointment_time) <= now()
) AS can_review
FROM appointment a
JOIN status st
ON st.status_id = a.status_id
LEFT JOIN LATERAL (
SELECT
COALESCE(
jsonb_agg(
jsonb_build_object(
'service_id', s.service_id,
'name', s.name,
'price', s.price,
'duration_minutes', s.duration_minutes
)
ORDER BY s.service_id
) FILTER (WHERE s.service_id IS NOT NULL),
'[]'::jsonb
) AS services
FROM appointmentservice aps
JOIN service s
ON s.service_id = aps.service_id
WHERE aps.appointment_id = a.appointment_id
) svc
ON true
LEFT JOIN LATERAL (
SELECT
p.payment_id,
p.status,
p.method,
p."timestamp"
FROM payment p
WHERE p.appointment_id = a.appointment_id
ORDER BY p."timestamp" DESC
LIMIT 1
) lp
ON true
LEFT JOIN LATERAL (
SELECT
p.payment_id,
p."timestamp"
FROM payment p
WHERE p.appointment_id = a.appointment_id
AND p.status = 'PAID'
ORDER BY p."timestamp" DESC
LIMIT 1
) pp
ON true
LEFT JOIN review rv
ON rv.payment_id = pp.payment_id;
v_admin_appointments_payment_state
- Име:
v_admin_appointments_payment_state - Работи над:
appointment,"User",status,appointmentservice,service,payment,review - Бизнис правило/сценарио: админ преглед на термини со payment/review контекст.
CREATE VIEW v_admin_appointments_payment_state AS
SELECT
a.appointment_id,
a.user_id,
u.full_name,
u.email,
u.phone,
a.appointment_time,
a.end_time,
a.total_price,
st.name AS status_name,
svc.services AS services,
lp.payment_id AS payment_id,
lp.status AS payment_status,
lp.method AS payment_method,
lp."timestamp" AS payment_timestamp,
pp.payment_id AS paid_payment_id,
rv.review_id AS review_id,
rv.rating AS review_rating,
rv.comment AS review_comment,
rv.created_at AS review_created_at
FROM appointment a
JOIN "User" u
ON u.user_id = a.user_id
JOIN status st
ON st.status_id = a.status_id
LEFT JOIN LATERAL (
SELECT
COALESCE(
jsonb_agg(
jsonb_build_object(
'service_id', s.service_id,
'name', s.name,
'price', s.price,
'duration_minutes', s.duration_minutes
)
ORDER BY s.service_id
) FILTER (WHERE s.service_id IS NOT NULL),
'[]'::jsonb
) AS services
FROM appointmentservice aps
JOIN service s
ON s.service_id = aps.service_id
WHERE aps.appointment_id = a.appointment_id
) svc
ON true
LEFT JOIN LATERAL (
SELECT
p.payment_id,
p.status,
p.method,
p."timestamp"
FROM payment p
WHERE p.appointment_id = a.appointment_id
ORDER BY p."timestamp" DESC
LIMIT 1
) lp
ON true
LEFT JOIN LATERAL (
SELECT
p.payment_id,
p."timestamp"
FROM payment p
WHERE p.appointment_id = a.appointment_id
AND p.status = 'PAID'
ORDER BY p."timestamp" DESC
LIMIT 1
) pp
ON true
LEFT JOIN review rv
ON rv.payment_id = pp.payment_id;
v_admin_availability_by_date
- Име:
v_admin_availability_by_date - Работи над:
availability - Бизнис правило/сценарио: read-model за админ schedule екран.
CREATE OR REPLACE VIEW v_admin_availability_by_date AS
SELECT
date,
availability_id,
start_time,
end_time,
is_closed
FROM availability
ORDER BY date ASC, start_time ASC;
v_user_active_packages
- Име:
v_user_active_packages - Работи над:
userpackagepurchase,package,packageservice,service - Бизнис правило/сценарио: активни пакети и вклучени услуги.
CREATE OR REPLACE VIEW v_user_active_packages AS
SELECT
upp.user_id,
upp.purchase_id,
upp.package_id,
p.name AS package_name,
upp.remaining_uses,
upp.purchased_at,
array_agg(s.name ORDER BY s.name) AS services
FROM UserPackagePurchase upp
JOIN Package p
ON p.package_id = upp.package_id
JOIN PackageService ps
ON ps.package_id = p.package_id
JOIN Service s
ON s.service_id = ps.service_id
WHERE upp.status = 'ACTIVE'
GROUP BY
upp.user_id,
upp.purchase_id,
upp.package_id,
p.name,
upp.remaining_uses,
upp.purchased_at;
7. Автоматизација и background jobs
sp_auto_mark_no_show
- Име:
sp_auto_mark_no_show - Работи над:
appointment,status - Бизнис правило: автоматско означување NO_SHOW за задоцнети SCHEDULED термини.
CREATE OR REPLACE FUNCTION sp_auto_mark_no_show(
p_grace_minutes int DEFAULT 30
)
RETURNS int
LANGUAGE plpgsql
AS $$
DECLARE
v_no_show_id int;
v_count int;
BEGIN
v_no_show_id := fn_status_id(ARRAY['no_show']);
IF v_no_show_id IS NULL THEN
RETURN 0;
END IF;
WITH candidates AS (
SELECT a.appointment_id
FROM appointment a
JOIN status s
ON s.status_id = a.status_id
WHERE UPPER(s.name) = 'SCHEDULED'
AND a.end_time IS NOT NULL
AND a.end_time <= now() - make_interval(mins => p_grace_minutes)
)
UPDATE appointment
SET status_id = v_no_show_id
WHERE appointment_id IN (
SELECT appointment_id
FROM candidates
);
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$;
Апликациски scheduler повик
- Објект: периодичен повик од апликациски слој
- Бизнис правило: автоматизација на lifecycle без рачна интервенција.
SELECT sp_auto_mark_no_show($1::int);
Last modified
30 hours ago
Last modified on 05/24/26 17:49:21
Note:
See TracWiki
for help on using the wiki.
