DatabaseProgramming: blinkbuy_phase4.sql

File blinkbuy_phase4.sql, 33.8 KB (added by 231169, 6 days ago)
Line 
1-- ============================================================
2-- 1. fn_create_order
3-- Креира CART order и враќа order_id.
4CREATE OR REPLACE FUNCTION fn_create_order(
5 p_user_id BIGINT
6)
7RETURNS BIGINT
8LANGUAGE plpgsql
9AS $$
10DECLARE
11 v_order_id BIGINT;
12 v_shipping_method_id BIGINT;
13BEGIN
14 IF NOT EXISTS (
15 SELECT 1
16 FROM users u
17 JOIN user_roles ur ON ur.user_id = u.user_id
18 JOIN roles r ON r.role_id = ur.role_id
19 WHERE u.user_id = p_user_id
20 AND u.status = 'ACTIVE'
21 AND r.role_name = 'CUSTOMER'
22 ) THEN
23 RAISE EXCEPTION 'User % does not exist, is not ACTIVE, or is not CUSTOMER.', p_user_id;
24 END IF;
25
26 IF EXISTS (
27 SELECT 1
28 FROM orders o
29 WHERE o.user_id = p_user_id
30 AND o.status = 'CART'
31 ) THEN
32 RAISE EXCEPTION 'User % already has an active CART order.', p_user_id;
33 END IF;
34
35 SELECT sm.method_id
36 INTO v_shipping_method_id
37 FROM shipping_methods sm
38 ORDER BY sm.cost ASC, sm.method_id ASC
39 LIMIT 1;
40
41 IF v_shipping_method_id IS NULL THEN
42 RAISE EXCEPTION 'No shipping method exists. Cannot create order.';
43 END IF;
44
45 INSERT INTO orders (
46 user_id,
47 shipping_method_id,
48 order_date,
49 total_amount,
50 status
51 )
52 VALUES (
53 p_user_id,
54 v_shipping_method_id,
55 NULL,
56 NULL,
57 'CART'
58 )
59 RETURNING order_id INTO v_order_id;
60
61 RETURN v_order_id;
62END;
63$$;
64
65
66-- ============================================================
67-- 2. pr_add_order_item
68-- Додава или зголемува item во customer cart.
69CREATE OR REPLACE PROCEDURE pr_add_order_item(
70 p_user_id BIGINT,
71 p_variant_id BIGINT,
72 p_quantity INT
73)
74LANGUAGE plpgsql
75AS $$
76DECLARE
77 v_order_id BIGINT;
78 v_current_price NUMERIC(12, 2);
79 v_stock_total INT;
80 v_existing_cart_quantity INT;
81 v_price_history_id BIGINT;
82BEGIN
83 IF p_quantity IS NULL OR p_quantity <= 0 THEN
84 RAISE EXCEPTION 'Quantity must be greater than 0.';
85 END IF;
86
87 IF NOT EXISTS (
88 SELECT 1
89 FROM users u
90 JOIN user_roles ur ON ur.user_id = u.user_id
91 JOIN roles r ON r.role_id = ur.role_id
92 WHERE u.user_id = p_user_id
93 AND u.status = 'ACTIVE'
94 AND r.role_name = 'CUSTOMER'
95 ) THEN
96 RAISE EXCEPTION 'User % does not exist, is not ACTIVE, or is not CUSTOMER.', p_user_id;
97 END IF;
98
99 SELECT
100 pv.price,
101 pv.stock_total
102 INTO
103 v_current_price,
104 v_stock_total
105 FROM product_variants pv
106 WHERE pv.variant_id = p_variant_id;
107
108 IF v_current_price IS NULL THEN
109 RAISE EXCEPTION 'Product variant % does not exist.', p_variant_id;
110 END IF;
111
112 SELECT o.order_id
113 INTO v_order_id
114 FROM orders o
115 WHERE o.user_id = p_user_id
116 AND o.status = 'CART'
117 ORDER BY o.order_id DESC
118 LIMIT 1;
119
120 IF v_order_id IS NULL THEN
121 v_order_id := fn_create_order(p_user_id);
122 END IF;
123
124 SELECT COALESCE(SUM(oi.quantity), 0)
125 INTO v_existing_cart_quantity
126 FROM order_items oi
127 WHERE oi.order_id = v_order_id
128 AND oi.variant_id = p_variant_id;
129
130 IF v_existing_cart_quantity + p_quantity > v_stock_total THEN
131 RAISE EXCEPTION
132 'Not enough stock for variant %. Requested total %, available %.',
133 p_variant_id,
134 v_existing_cart_quantity + p_quantity,
135 v_stock_total;
136 END IF;
137
138 SELECT pph.history_id
139 INTO v_price_history_id
140 FROM product_price_history pph
141 WHERE pph.variant_id = p_variant_id
142 ORDER BY pph.history_id DESC
143 LIMIT 1;
144
145 IF v_price_history_id IS NULL THEN
146 INSERT INTO product_price_history (
147 variant_id,
148 old_price,
149 new_price,
150 change_date
151 )
152 VALUES (
153 p_variant_id,
154 v_current_price,
155 v_current_price,
156 NOW()
157 )
158 RETURNING history_id INTO v_price_history_id;
159 END IF;
160
161 IF EXISTS (
162 SELECT 1
163 FROM order_items oi
164 WHERE oi.order_id = v_order_id
165 AND oi.variant_id = p_variant_id
166 ) THEN
167 UPDATE order_items
168 SET quantity = quantity + p_quantity,
169 unit_price = v_current_price,
170 price_history_id = v_price_history_id
171 WHERE order_item_id = (
172 SELECT MIN(oi.order_item_id)
173 FROM order_items oi
174 WHERE oi.order_id = v_order_id
175 AND oi.variant_id = p_variant_id
176 );
177 ELSE
178 INSERT INTO order_items (
179 order_id,
180 price_history_id,
181 variant_id,
182 quantity,
183 unit_price
184 )
185 VALUES (
186 v_order_id,
187 v_price_history_id,
188 p_variant_id,
189 p_quantity,
190 v_current_price
191 );
192 END IF;
193END;
194$$;
195
196
197-- ============================================================
198-- 3. pr_record_loyalty_transaction
199-- Запишува loyalty трансакција и ажурира balance.
200CREATE OR REPLACE PROCEDURE pr_record_loyalty_transaction(
201 p_loyalty_account_id BIGINT,
202 p_order_id BIGINT,
203 p_payment_id BIGINT,
204 p_points_change INT,
205 p_transaction_type VARCHAR,
206 p_description TEXT
207)
208LANGUAGE plpgsql
209AS $$
210BEGIN
211 IF p_loyalty_account_id IS NULL OR p_points_change = 0 THEN
212 RETURN;
213 END IF;
214
215 UPDATE loyalty_accounts
216 SET current_balance = GREATEST(current_balance + p_points_change, 0),
217 last_updated = NOW()
218 WHERE loyalty_account_id = p_loyalty_account_id;
219
220 INSERT INTO loyalty_history (
221 loyalty_account_id,
222 order_id,
223 payment_id,
224 points_change,
225 transaction_type,
226 description,
227 created_at
228 )
229 VALUES (
230 p_loyalty_account_id,
231 p_order_id,
232 p_payment_id,
233 p_points_change,
234 p_transaction_type,
235 p_description,
236 NOW()
237 );
238END;
239$$;
240
241
242-- ============================================================
243-- 4. pr_recalculate_variant_stock
244-- Го усогласува stock-от според physical product_instances.
245CREATE OR REPLACE PROCEDURE pr_recalculate_variant_stock(
246 p_variant_ids BIGINT[]
247)
248LANGUAGE plpgsql
249AS $$
250BEGIN
251 IF p_variant_ids IS NULL OR cardinality(p_variant_ids) = 0 THEN
252 RETURN;
253 END IF;
254
255 WITH affected_inventory AS (
256 SELECT
257 ii.variant_id,
258 ii.warehouse_id
259 FROM inventory_items ii
260 WHERE ii.variant_id = ANY(p_variant_ids)
261 ),
262 counts AS (
263 SELECT
264 ai.variant_id,
265 ai.warehouse_id,
266 COALESCE(COUNT(pi.instance_id), 0)::int AS available_quantity
267 FROM affected_inventory ai
268 LEFT JOIN product_instances pi
269 ON pi.variant_id = ai.variant_id
270 AND pi.warehouse_id = ai.warehouse_id
271 AND pi.status = 'AVAILABLE'
272 GROUP BY ai.variant_id, ai.warehouse_id
273 )
274 UPDATE inventory_items ii
275 SET quantity = c.available_quantity
276 FROM counts c
277 WHERE ii.variant_id = c.variant_id
278 AND ii.warehouse_id = c.warehouse_id;
279
280 WITH totals AS (
281 SELECT
282 ii.variant_id,
283 COALESCE(SUM(ii.quantity), 0)::int AS total_stock
284 FROM inventory_items ii
285 WHERE ii.variant_id = ANY(p_variant_ids)
286 GROUP BY ii.variant_id
287 )
288 UPDATE product_variants pv
289 SET stock_total = t.total_stock
290 FROM totals t
291 WHERE pv.variant_id = t.variant_id;
292END;
293$$;
294
295
296-- ============================================================
297-- 5. pr_pay_order
298-- Извршува payment и loyalty пресметка за PLACED order.
299CREATE OR REPLACE PROCEDURE pr_pay_order(
300 p_order_id BIGINT,
301 p_payment_method VARCHAR
302)
303LANGUAGE plpgsql
304AS $$
305DECLARE
306 v_user_id BIGINT;
307 v_order_status VARCHAR;
308 v_total_amount NUMERIC(12,2);
309
310 v_coupon_discount NUMERIC(12,2);
311 v_amount_after_coupon NUMERIC(12,2);
312
313 v_loyalty_account_id BIGINT;
314 v_current_balance INT;
315 v_calculated_redeem_points INT;
316 v_actual_redeem_points INT := 0;
317
318 v_payment_amount NUMERIC(12,2);
319 v_payment_id BIGINT;
320 v_payment_status VARCHAR;
321 v_earn_points INT;
322BEGIN
323 SELECT
324 user_id,
325 status,
326 total_amount
327 INTO
328 v_user_id,
329 v_order_status,
330 v_total_amount
331 FROM orders
332 WHERE order_id = p_order_id
333 FOR UPDATE;
334
335 IF NOT FOUND THEN
336 RAISE EXCEPTION 'Order % does not exist.', p_order_id;
337 END IF;
338
339 IF v_order_status <> 'PLACED' THEN
340 RAISE EXCEPTION 'Order % cannot be paid from status %.', p_order_id, v_order_status;
341 END IF;
342
343 IF v_total_amount IS NULL OR v_total_amount <= 0 THEN
344 RAISE EXCEPTION 'Order % has invalid total_amount: %.', p_order_id, v_total_amount;
345 END IF;
346
347 SELECT COALESCE(SUM(amount_saved), 0)::numeric(12,2)
348 INTO v_coupon_discount
349 FROM order_discounts
350 WHERE order_id = p_order_id;
351
352 v_amount_after_coupon := GREATEST(v_total_amount - v_coupon_discount, 0)::numeric(12,2);
353
354 IF v_amount_after_coupon <= 0 THEN
355 RAISE EXCEPTION 'Order % has invalid amount after coupon discount: %.',
356 p_order_id, v_amount_after_coupon;
357 END IF;
358
359 SELECT
360 loyalty_account_id,
361 current_balance
362 INTO
363 v_loyalty_account_id,
364 v_current_balance
365 FROM loyalty_accounts
366 WHERE user_id = v_user_id
367 FOR UPDATE;
368
369 IF v_loyalty_account_id IS NOT NULL AND COALESCE(v_current_balance, 0) > 0 THEN
370 v_calculated_redeem_points := FLOOR(v_amount_after_coupon / 500);
371
372 IF v_calculated_redeem_points >= 1 THEN
373 v_actual_redeem_points := LEAST(v_current_balance, v_calculated_redeem_points);
374 END IF;
375 END IF;
376
377 v_payment_amount := GREATEST(v_amount_after_coupon - v_actual_redeem_points, 1)::numeric(12,2);
378
379 SELECT
380 payment_id,
381 payment_status
382 INTO
383 v_payment_id,
384 v_payment_status
385 FROM payments
386 WHERE transaction_id = 'TXN-' || p_order_id || '-MAIN'
387 LIMIT 1
388 FOR UPDATE;
389
390 IF v_payment_id IS NOT NULL AND v_payment_status = 'SUCCESS' THEN
391 RAISE EXCEPTION 'Order % already has a successful main payment.', p_order_id;
392 ELSIF v_payment_id IS NOT NULL THEN
393 UPDATE payments
394 SET payment_method = p_payment_method,
395 amount = v_payment_amount,
396 payment_status = 'SUCCESS',
397 payment_date = NOW()
398 WHERE payment_id = v_payment_id;
399 ELSE
400 INSERT INTO payments (
401 order_id,
402 payment_method,
403 amount,
404 payment_status,
405 transaction_id,
406 payment_date
407 )
408 VALUES (
409 p_order_id,
410 p_payment_method,
411 v_payment_amount,
412 'SUCCESS',
413 'TXN-' || p_order_id || '-MAIN',
414 NOW()
415 )
416 RETURNING payment_id INTO v_payment_id;
417 END IF;
418
419 UPDATE orders
420 SET status = 'PAID'
421 WHERE order_id = p_order_id;
422
423 IF v_loyalty_account_id IS NULL THEN
424 INSERT INTO loyalty_accounts (
425 user_id,
426 current_balance,
427 last_updated
428 )
429 VALUES (
430 v_user_id,
431 0,
432 NOW()
433 )
434 RETURNING loyalty_account_id INTO v_loyalty_account_id;
435 END IF;
436
437 IF v_actual_redeem_points > 0 THEN
438 CALL pr_record_loyalty_transaction(
439 v_loyalty_account_id,
440 p_order_id,
441 v_payment_id,
442 -v_actual_redeem_points,
443 'REDEEM',
444 'Loyalty points redeemed for payment of order #' || p_order_id
445 );
446 END IF;
447
448 v_earn_points := FLOOR(v_payment_amount / 100);
449
450 IF v_earn_points > 0 THEN
451 CALL pr_record_loyalty_transaction(
452 v_loyalty_account_id,
453 p_order_id,
454 v_payment_id,
455 v_earn_points,
456 'EARN',
457 'Loyalty points earned from payment for order #' || p_order_id
458 );
459 END IF;
460
461 RAISE NOTICE 'Order % paid successfully. Coupon discount: %, redeemed points: %, payment amount: %, earned points: %.',
462 p_order_id, v_coupon_discount, v_actual_redeem_points, v_payment_amount, v_earn_points;
463END;
464$$;
465
466
467-- ============================================================
468-- 6. pr_cancel_order
469-- Откажува order пред да заврши physical delivery flow.
470CREATE OR REPLACE PROCEDURE pr_cancel_order(
471 p_order_id BIGINT
472)
473LANGUAGE plpgsql
474AS $$
475DECLARE
476 v_user_id BIGINT;
477 v_order_status VARCHAR;
478
479 v_payment_id BIGINT;
480 v_payment_status VARCHAR;
481
482 v_loyalty_account_id BIGINT;
483 v_earned_points INT := 0;
484 v_redeemed_points INT := 0;
485
486 v_released_instances INT := 0;
487BEGIN
488 SELECT
489 user_id,
490 status
491 INTO
492 v_user_id,
493 v_order_status
494 FROM orders
495 WHERE order_id = p_order_id
496 FOR UPDATE;
497
498 IF NOT FOUND THEN
499 RAISE EXCEPTION 'Order % does not exist.', p_order_id;
500 END IF;
501
502 IF v_order_status NOT IN ('CART', 'PLACED', 'PAID') THEN
503 RAISE EXCEPTION 'Order % cannot be cancelled from status %.', p_order_id, v_order_status;
504 END IF;
505
506 IF EXISTS (
507 SELECT 1
508 FROM shipments
509 WHERE order_id = p_order_id
510 AND status IN ('SHIPPED', 'IN_TRANSIT', 'DELIVERED', 'RETURNED')
511 ) THEN
512 RAISE EXCEPTION 'Order % cannot be cancelled because shipment is already physically shipped or returned.',
513 p_order_id;
514 END IF;
515
516 DROP TABLE IF EXISTS tmp_cancel_instances;
517
518 CREATE TEMP TABLE tmp_cancel_instances ON COMMIT DROP AS
519 SELECT DISTINCT
520 pi.instance_id,
521 pi.variant_id,
522 pi.warehouse_id
523 FROM shipments s
524 JOIN shipment_items si ON si.shipment_id = s.shipment_id
525 JOIN product_instances pi ON pi.instance_id = si.instance_id
526 WHERE s.order_id = p_order_id;
527
528 SELECT COUNT(*)
529 INTO v_released_instances
530 FROM tmp_cancel_instances;
531
532 UPDATE product_instances pi
533 SET status = 'AVAILABLE'
534 FROM tmp_cancel_instances tci
535 WHERE pi.instance_id = tci.instance_id;
536
537 DELETE FROM shipment_items si
538 USING shipments s
539 WHERE si.shipment_id = s.shipment_id
540 AND s.order_id = p_order_id;
541
542 UPDATE shipments
543 SET
544 status = 'FAILED',
545 tracking_number = COALESCE(tracking_number, 'CANCEL-TRK-' || shipment_id),
546 shipped_date = COALESCE(shipped_date, NOW()),
547 estimated_arrival = COALESCE(estimated_arrival, CURRENT_DATE),
548 delivered_date = NULL
549 WHERE order_id = p_order_id
550 AND status IN ('PENDING', 'PROCESSING');
551
552 SELECT
553 payment_id,
554 payment_status
555 INTO
556 v_payment_id,
557 v_payment_status
558 FROM payments
559 WHERE transaction_id = 'TXN-' || p_order_id || '-MAIN'
560 LIMIT 1
561 FOR UPDATE;
562
563 IF v_order_status = 'PAID' THEN
564 IF v_payment_id IS NULL OR v_payment_status <> 'SUCCESS' THEN
565 RAISE EXCEPTION 'Paid order % does not have a SUCCESS main payment to refund.', p_order_id;
566 END IF;
567
568 UPDATE payments
569 SET payment_status = 'REFUNDED',
570 payment_date = NOW()
571 WHERE payment_id = v_payment_id;
572
573 SELECT loyalty_account_id
574 INTO v_loyalty_account_id
575 FROM loyalty_accounts
576 WHERE user_id = v_user_id
577 FOR UPDATE;
578
579 IF v_loyalty_account_id IS NOT NULL THEN
580 SELECT COALESCE(SUM(points_change), 0)::int
581 INTO v_earned_points
582 FROM loyalty_history
583 WHERE order_id = p_order_id
584 AND payment_id = v_payment_id
585 AND transaction_type = 'EARN';
586
587 SELECT COALESCE(SUM(ABS(points_change)), 0)::int
588 INTO v_redeemed_points
589 FROM loyalty_history
590 WHERE order_id = p_order_id
591 AND payment_id = v_payment_id
592 AND transaction_type = 'REDEEM';
593
594 IF v_earned_points > 0 THEN
595 CALL pr_record_loyalty_transaction(
596 v_loyalty_account_id,
597 p_order_id,
598 v_payment_id,
599 -v_earned_points,
600 'REFUND',
601 'Loyalty points removed because order #' || p_order_id || ' was cancelled and refunded.'
602 );
603 END IF;
604
605 IF v_redeemed_points > 0 THEN
606 CALL pr_record_loyalty_transaction(
607 v_loyalty_account_id,
608 p_order_id,
609 v_payment_id,
610 v_redeemed_points,
611 'ADJUSTMENT',
612 'Redeemed loyalty points returned because order #' || p_order_id || ' was cancelled.'
613 );
614 END IF;
615 END IF;
616 ELSIF v_order_status = 'PLACED'
617 AND v_payment_id IS NOT NULL
618 AND v_payment_status = 'PENDING' THEN
619
620 UPDATE payments
621 SET payment_status = 'FAILED',
622 payment_date = NOW()
623 WHERE payment_id = v_payment_id;
624 END IF;
625
626 UPDATE orders
627 SET status = 'CANCELLED'
628 WHERE order_id = p_order_id;
629
630 IF v_released_instances > 0 THEN
631 CALL pr_recalculate_variant_stock(
632 ARRAY(
633 SELECT DISTINCT variant_id
634 FROM tmp_cancel_instances
635 )
636 );
637 END IF;
638
639 RAISE NOTICE 'Order % cancelled successfully. Released instances: %, refunded payment: %, removed earned points: %, returned redeemed points: %.',
640 p_order_id,
641 v_released_instances,
642 CASE WHEN v_order_status = 'PAID' THEN 'YES' ELSE 'NO' END,
643 v_earned_points,
644 v_redeemed_points;
645END;
646$$;
647
648
649-- ============================================================
650-- 7. pr_courier_accept_shipment
651-- Го префрла accepted shipment од PENDING во PROCESSING.
652CREATE OR REPLACE PROCEDURE pr_courier_accept_shipment(
653 p_shipment_id BIGINT
654)
655LANGUAGE plpgsql
656AS $$
657DECLARE
658 v_order_id BIGINT;
659 v_order_status VARCHAR(255);
660 v_shipment_status VARCHAR(20);
661 v_initial_courier_id BIGINT;
662 v_accepted_courier_id BIGINT;
663BEGIN
664 SELECT
665 s.order_id,
666 s.status,
667 s.courier_id,
668 o.status
669 INTO
670 v_order_id,
671 v_shipment_status,
672 v_initial_courier_id,
673 v_order_status
674 FROM shipments s
675 JOIN orders o ON o.order_id = s.order_id
676 WHERE s.shipment_id = p_shipment_id
677 FOR UPDATE OF s;
678
679 IF v_order_id IS NULL THEN
680 RAISE EXCEPTION 'Shipment % does not exist.', p_shipment_id;
681 END IF;
682
683 IF v_shipment_status <> 'PENDING' THEN
684 RAISE EXCEPTION 'Shipment % must be PENDING. Current status: %.',
685 p_shipment_id, v_shipment_status;
686 END IF;
687
688 IF v_order_status <> 'PAID' THEN
689 RAISE EXCEPTION 'Order % must be PAID before courier acceptance. Current status: %.',
690 v_order_id, v_order_status;
691 END IF;
692
693 WITH courier_candidates AS (
694 SELECT
695 u.user_id AS courier_id,
696 random() AS acceptance_roll,
697 CASE
698 WHEN u.user_id = v_initial_courier_id THEN 0
699 WHEN u.user_id > v_initial_courier_id THEN 1
700 ELSE 2
701 END AS priority_group
702 FROM users u
703 JOIN user_roles ur ON ur.user_id = u.user_id
704 JOIN roles r ON r.role_id = ur.role_id
705 WHERE r.role_name = 'COURIER'
706 AND u.status = 'ACTIVE'
707 )
708 SELECT courier_id
709 INTO v_accepted_courier_id
710 FROM courier_candidates
711 WHERE acceptance_roll < 0.80
712 ORDER BY
713 priority_group,
714 courier_id
715 LIMIT 1;
716
717 IF v_accepted_courier_id IS NULL THEN
718 RAISE EXCEPTION 'No courier accepted shipment %.', p_shipment_id;
719 END IF;
720
721 UPDATE shipments
722 SET
723 courier_id = v_accepted_courier_id,
724 status = 'PROCESSING'
725 WHERE shipment_id = p_shipment_id;
726END;
727$$;
728
729
730-- ============================================================
731-- 8. pr_process_shipment
732-- Алоцира physical product_instances и го испраќа order-от.
733CREATE OR REPLACE PROCEDURE pr_process_shipment(
734 p_shipment_id BIGINT
735)
736LANGUAGE plpgsql
737AS $$
738DECLARE
739 v_order_id BIGINT;
740 v_order_status VARCHAR;
741 v_shipping_method_id BIGINT;
742 v_shipment_status VARCHAR;
743 v_tracking_number VARCHAR;
744 v_estimated_arrival DATE;
745 v_allocated_count INT := 0;
746BEGIN
747 SELECT
748 s.order_id,
749 s.status,
750 o.status,
751 o.shipping_method_id
752 INTO
753 v_order_id,
754 v_shipment_status,
755 v_order_status,
756 v_shipping_method_id
757 FROM shipments s
758 JOIN orders o ON o.order_id = s.order_id
759 WHERE s.shipment_id = p_shipment_id
760 FOR UPDATE OF s;
761
762 IF NOT FOUND THEN
763 RAISE EXCEPTION 'Shipment % does not exist.', p_shipment_id;
764 END IF;
765
766 IF v_shipment_status <> 'PROCESSING' THEN
767 RAISE EXCEPTION 'Shipment % cannot be processed from status %.',
768 p_shipment_id, v_shipment_status;
769 END IF;
770
771 IF v_order_status <> 'PAID' THEN
772 RAISE EXCEPTION 'Shipment % cannot be processed because order % has status %.',
773 p_shipment_id, v_order_id, v_order_status;
774 END IF;
775
776 IF v_shipping_method_id IS NULL THEN
777 RAISE EXCEPTION 'Order % has no shipping_method_id.', v_order_id;
778 END IF;
779
780 DROP TABLE IF EXISTS tmp_needed_units;
781 DROP TABLE IF EXISTS tmp_available_instances;
782 DROP TABLE IF EXISTS tmp_matched_instances;
783
784 CREATE TEMP TABLE tmp_needed_units ON COMMIT DROP AS
785 WITH shipped_counts AS (
786 SELECT
787 order_item_id,
788 COUNT(*) AS shipped_count
789 FROM shipment_items
790 GROUP BY order_item_id
791 ),
792 expanded_units AS (
793 SELECT
794 oi.order_item_id,
795 oi.variant_id,
796 gs.unit_no
797 FROM order_items oi
798 LEFT JOIN shipped_counts sc ON sc.order_item_id = oi.order_item_id
799 CROSS JOIN LATERAL generate_series(
800 1,
801 GREATEST(oi.quantity - COALESCE(sc.shipped_count, 0), 0)
802 ) AS gs(unit_no)
803 WHERE oi.order_id = v_order_id
804 )
805 SELECT
806 order_item_id,
807 variant_id,
808 ROW_NUMBER() OVER (
809 PARTITION BY variant_id
810 ORDER BY order_item_id, unit_no
811 ) AS variant_unit_rn
812 FROM expanded_units;
813
814 IF NOT EXISTS (SELECT 1 FROM tmp_needed_units) THEN
815 RAISE EXCEPTION 'Shipment % has no remaining order items to process.', p_shipment_id;
816 END IF;
817
818 CREATE TEMP TABLE tmp_available_instances ON COMMIT DROP AS
819 SELECT
820 pi.instance_id,
821 pi.variant_id,
822 pi.warehouse_id,
823 ROW_NUMBER() OVER (
824 PARTITION BY pi.variant_id
825 ORDER BY pi.instance_id
826 ) AS variant_unit_rn
827 FROM product_instances pi
828 WHERE pi.status = 'AVAILABLE'
829 AND EXISTS (
830 SELECT 1
831 FROM tmp_needed_units nu
832 WHERE nu.variant_id = pi.variant_id
833 )
834 AND NOT EXISTS (
835 SELECT 1
836 FROM shipment_items si
837 WHERE si.instance_id = pi.instance_id
838 );
839
840 CREATE TEMP TABLE tmp_matched_instances ON COMMIT DROP AS
841 SELECT
842 nu.order_item_id,
843 ai.instance_id,
844 ai.variant_id,
845 ai.warehouse_id
846 FROM tmp_needed_units nu
847 JOIN tmp_available_instances ai
848 ON ai.variant_id = nu.variant_id
849 AND ai.variant_unit_rn = nu.variant_unit_rn;
850
851 IF (SELECT COUNT(*) FROM tmp_matched_instances) < (SELECT COUNT(*) FROM tmp_needed_units) THEN
852 RAISE EXCEPTION 'Not enough available product instances to fully process shipment %.', p_shipment_id;
853 END IF;
854
855 INSERT INTO shipment_items (
856 shipment_id,
857 order_item_id,
858 instance_id
859 )
860 SELECT
861 p_shipment_id,
862 order_item_id,
863 instance_id
864 FROM tmp_matched_instances;
865
866 UPDATE product_instances pi
867 SET status = 'SHIPPED'
868 FROM tmp_matched_instances mi
869 WHERE pi.instance_id = mi.instance_id;
870
871 SELECT COUNT(*)
872 INTO v_allocated_count
873 FROM tmp_matched_instances;
874
875 v_tracking_number := 'TRK-' || p_shipment_id || '-' || EXTRACT(EPOCH FROM NOW())::BIGINT;
876
877 SELECT CURRENT_DATE + sm.estimated_days
878 INTO v_estimated_arrival
879 FROM shipping_methods sm
880 WHERE sm.method_id = v_shipping_method_id;
881
882 IF v_estimated_arrival IS NULL THEN
883 RAISE EXCEPTION 'Shipping method % does not exist.', v_shipping_method_id;
884 END IF;
885
886 UPDATE shipments
887 SET
888 tracking_number = v_tracking_number,
889 shipped_date = NOW(),
890 estimated_arrival = v_estimated_arrival,
891 delivered_date = NULL,
892 status = 'SHIPPED'
893 WHERE shipment_id = p_shipment_id;
894
895 UPDATE orders
896 SET status = 'SHIPPED'
897 WHERE order_id = v_order_id;
898
899 CALL pr_recalculate_variant_stock(
900 ARRAY(
901 SELECT DISTINCT variant_id
902 FROM tmp_matched_instances
903 )
904 );
905
906 RAISE NOTICE 'Shipment % processed successfully. Allocated % physical instances. Shipment and order are now SHIPPED.',
907 p_shipment_id, v_allocated_count;
908END;
909$$;
910
911
912-- ============================================================
913-- 9. fn_is_order_returnable
914-- Проверува дали order ги исполнува return условите.
915CREATE OR REPLACE FUNCTION fn_is_order_returnable(
916 p_order_id BIGINT
917)
918RETURNS BOOLEAN
919LANGUAGE plpgsql
920AS $$
921BEGIN
922 RETURN EXISTS (
923 SELECT 1
924 FROM orders o
925 WHERE o.order_id = p_order_id
926 AND o.status = 'COMPLETED'
927 AND EXISTS (
928 SELECT 1
929 FROM shipments s
930 WHERE s.order_id = o.order_id
931 AND s.status = 'DELIVERED'
932 )
933 AND EXISTS (
934 SELECT 1
935 FROM shipments s
936 JOIN shipment_items si ON si.shipment_id = s.shipment_id
937 WHERE s.order_id = o.order_id
938 )
939 AND EXISTS (
940 SELECT 1
941 FROM payments p
942 WHERE p.order_id = o.order_id
943 AND p.transaction_id = 'TXN-' || o.order_id || '-MAIN'
944 AND p.payment_status = 'SUCCESS'
945 )
946 );
947END;
948$$;
949
950
951-- ============================================================
952-- 10. pr_process_return
953-- Процесира return и refund за completed order.
954CREATE OR REPLACE PROCEDURE pr_process_return(
955 p_order_id BIGINT,
956 p_restock BOOLEAN DEFAULT TRUE
957)
958LANGUAGE plpgsql
959AS $$
960DECLARE
961 v_user_id BIGINT;
962 v_payment_id BIGINT;
963 v_loyalty_account_id BIGINT;
964 v_earned_points INT := 0;
965 v_redeemed_points INT := 0;
966 v_returned_instances INT := 0;
967BEGIN
968 IF fn_is_order_returnable(p_order_id) = FALSE THEN
969 RAISE EXCEPTION 'Order % is not returnable. It must be COMPLETED, delivered, physically shipped, and successfully paid.',
970 p_order_id;
971 END IF;
972
973 SELECT user_id
974 INTO v_user_id
975 FROM orders
976 WHERE order_id = p_order_id
977 FOR UPDATE;
978
979 SELECT payment_id
980 INTO v_payment_id
981 FROM payments
982 WHERE order_id = p_order_id
983 AND transaction_id = 'TXN-' || p_order_id || '-MAIN'
984 AND payment_status = 'SUCCESS'
985 FOR UPDATE;
986
987 DROP TABLE IF EXISTS tmp_return_instances;
988
989 CREATE TEMP TABLE tmp_return_instances ON COMMIT DROP AS
990 SELECT DISTINCT
991 pi.instance_id,
992 pi.variant_id,
993 pi.warehouse_id
994 FROM shipments s
995 JOIN shipment_items si ON si.shipment_id = s.shipment_id
996 JOIN product_instances pi ON pi.instance_id = si.instance_id
997 WHERE s.order_id = p_order_id
998 AND s.status = 'DELIVERED';
999
1000 SELECT COUNT(*)
1001 INTO v_returned_instances
1002 FROM tmp_return_instances;
1003
1004 IF v_returned_instances = 0 THEN
1005 RAISE EXCEPTION 'Order % has no physical product instances to return.', p_order_id;
1006 END IF;
1007
1008 UPDATE shipments
1009 SET status = 'RETURNED'
1010 WHERE order_id = p_order_id
1011 AND status = 'DELIVERED';
1012
1013 UPDATE payments
1014 SET payment_status = 'REFUNDED',
1015 payment_date = NOW()
1016 WHERE payment_id = v_payment_id;
1017
1018 SELECT loyalty_account_id
1019 INTO v_loyalty_account_id
1020 FROM loyalty_accounts
1021 WHERE user_id = v_user_id
1022 FOR UPDATE;
1023
1024 IF v_loyalty_account_id IS NOT NULL THEN
1025 SELECT COALESCE(SUM(points_change), 0)::int
1026 INTO v_earned_points
1027 FROM loyalty_history
1028 WHERE order_id = p_order_id
1029 AND payment_id = v_payment_id
1030 AND transaction_type = 'EARN';
1031
1032 SELECT COALESCE(SUM(ABS(points_change)), 0)::int
1033 INTO v_redeemed_points
1034 FROM loyalty_history
1035 WHERE order_id = p_order_id
1036 AND payment_id = v_payment_id
1037 AND transaction_type = 'REDEEM';
1038
1039 IF v_earned_points > 0 THEN
1040 CALL pr_record_loyalty_transaction(
1041 v_loyalty_account_id,
1042 p_order_id,
1043 v_payment_id,
1044 -v_earned_points,
1045 'REFUND',
1046 'Loyalty points removed because order #' || p_order_id || ' was returned and refunded.'
1047 );
1048 END IF;
1049
1050 IF v_redeemed_points > 0 THEN
1051 CALL pr_record_loyalty_transaction(
1052 v_loyalty_account_id,
1053 p_order_id,
1054 v_payment_id,
1055 v_redeemed_points,
1056 'ADJUSTMENT',
1057 'Redeemed loyalty points returned because order #' || p_order_id || ' was returned.'
1058 );
1059 END IF;
1060 END IF;
1061
1062 UPDATE product_instances pi
1063 SET status = CASE
1064 WHEN p_restock = TRUE THEN 'AVAILABLE'
1065 ELSE 'RETURNED'
1066 END
1067 FROM tmp_return_instances tri
1068 WHERE pi.instance_id = tri.instance_id;
1069
1070 IF p_restock = TRUE THEN
1071 CALL pr_recalculate_variant_stock(
1072 ARRAY(
1073 SELECT DISTINCT variant_id
1074 FROM tmp_return_instances
1075 )
1076 );
1077 END IF;
1078
1079 RAISE NOTICE 'Return processed for order %. Returned instances: %, restocked: %, refunded payment: %, removed earned points: %, returned redeemed points: %.',
1080 p_order_id,
1081 v_returned_instances,
1082 p_restock,
1083 v_payment_id,
1084 v_earned_points,
1085 v_redeemed_points;
1086END;
1087$$;
1088
1089
1090-- ============================================================
1091-- 11. trg_order_total_sync
1092-- Го синхронизира order total со order_items.
1093CREATE OR REPLACE FUNCTION fn_order_total_sync()
1094RETURNS TRIGGER
1095LANGUAGE plpgsql
1096AS $$
1097DECLARE
1098 v_old_order_id BIGINT;
1099 v_new_order_id BIGINT;
1100BEGIN
1101 IF TG_OP = 'DELETE' THEN
1102 v_old_order_id := OLD.order_id;
1103 v_new_order_id := NULL;
1104
1105 ELSIF TG_OP = 'INSERT' THEN
1106 v_old_order_id := NULL;
1107 v_new_order_id := NEW.order_id;
1108
1109 ELSE
1110 v_old_order_id := OLD.order_id;
1111 v_new_order_id := NEW.order_id;
1112 END IF;
1113
1114 IF v_old_order_id IS NOT NULL THEN
1115 UPDATE orders
1116 SET total_amount = CASE
1117 WHEN status = 'CART' THEN NULL
1118 ELSE (
1119 SELECT COALESCE(SUM(oi.quantity * oi.unit_price), 0)::numeric(12,2)
1120 FROM order_items oi
1121 WHERE oi.order_id = v_old_order_id
1122 )
1123 END
1124 WHERE order_id = v_old_order_id;
1125 END IF;
1126
1127 IF v_new_order_id IS NOT NULL
1128 AND (v_old_order_id IS NULL OR v_new_order_id <> v_old_order_id) THEN
1129
1130 UPDATE orders
1131 SET total_amount = CASE
1132 WHEN status = 'CART' THEN NULL
1133 ELSE (
1134 SELECT COALESCE(SUM(oi.quantity * oi.unit_price), 0)::numeric(12,2)
1135 FROM order_items oi
1136 WHERE oi.order_id = v_new_order_id
1137 )
1138 END
1139 WHERE order_id = v_new_order_id;
1140 END IF;
1141
1142 IF TG_OP = 'DELETE' THEN
1143 RETURN OLD;
1144 ELSE
1145 RETURN NEW;
1146 END IF;
1147END;
1148$$;
1149
1150DROP FUNCTION IF EXISTS fn_sync_single_order_total(BIGINT);
1151
1152DROP TRIGGER IF EXISTS trg_order_total_sync ON order_items;
1153
1154CREATE TRIGGER trg_order_total_sync
1155AFTER INSERT OR UPDATE OR DELETE ON order_items
1156FOR EACH ROW
1157EXECUTE FUNCTION fn_order_total_sync();
1158
1159
1160-- ============================================================
1161-- 12. trg_log_order_status_change
1162-- Автоматски запишува order status промени.
1163CREATE OR REPLACE FUNCTION fn_log_order_status_change()
1164RETURNS TRIGGER
1165LANGUAGE plpgsql
1166AS $$
1167BEGIN
1168 IF OLD.status IS DISTINCT FROM NEW.status THEN
1169
1170 INSERT INTO order_status_history (
1171 order_id,
1172 old_status,
1173 new_status,
1174 change_date
1175 )
1176 VALUES (
1177 OLD.order_id,
1178 OLD.status,
1179 NEW.status,
1180 NOW()
1181 );
1182
1183 END IF;
1184
1185 RETURN NEW;
1186END;
1187$$;
1188
1189DROP TRIGGER IF EXISTS trg_log_order_status_change ON orders;
1190
1191CREATE TRIGGER trg_log_order_status_change
1192AFTER UPDATE OF status ON orders
1193FOR EACH ROW
1194EXECUTE FUNCTION fn_log_order_status_change();
1195
1196
1197-- ============================================================
1198-- 13. trg_prevent_negative_inventory
1199-- Спречува негативна inventory quantity.
1200CREATE OR REPLACE FUNCTION fn_prevent_negative_inventory()
1201RETURNS TRIGGER
1202LANGUAGE plpgsql
1203AS $$
1204BEGIN
1205 IF NEW.quantity < 0 THEN
1206 RAISE EXCEPTION 'Inventory quantity cannot be negative. Variant %, warehouse %, attempted quantity %.',
1207 NEW.variant_id,
1208 NEW.warehouse_id,
1209 NEW.quantity;
1210 END IF;
1211
1212 RETURN NEW;
1213END;
1214$$;
1215
1216DROP TRIGGER IF EXISTS trg_prevent_negative_inventory ON inventory_items;
1217
1218CREATE TRIGGER trg_prevent_negative_inventory
1219BEFORE INSERT OR UPDATE OF quantity ON inventory_items
1220FOR EACH ROW
1221EXECUTE FUNCTION fn_prevent_negative_inventory();
1222
1223
1224-- ============================================================
1225-- 14. trg_audit_price_change
1226-- Запишува price changes во price history.
1227CREATE OR REPLACE FUNCTION fn_audit_price_change()
1228RETURNS TRIGGER
1229LANGUAGE plpgsql
1230AS $$
1231BEGIN
1232 IF OLD.price IS DISTINCT FROM NEW.price THEN
1233 INSERT INTO product_price_history (
1234 variant_id,
1235 old_price,
1236 new_price,
1237 change_date
1238 )
1239 VALUES (
1240 OLD.variant_id,
1241 OLD.price,
1242 NEW.price,
1243 NOW()
1244 );
1245 END IF;
1246
1247 RETURN NEW;
1248END;
1249$$;
1250
1251DROP TRIGGER IF EXISTS trg_audit_price_change ON product_variants;
1252
1253CREATE TRIGGER trg_audit_price_change
1254AFTER UPDATE OF price ON product_variants
1255FOR EACH ROW
1256EXECUTE FUNCTION fn_audit_price_change();