| | 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 |
| | 326 | ALTER TABLE payment |
| | 327 | DROP CONSTRAINT IF EXISTS payment_target_required; |
| | 328 | |
| | 329 | ALTER TABLE payment |
| | 330 | ADD CONSTRAINT payment_target_required |
| | 331 | CHECK |
| | 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 |
| | 346 | CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_paid_unique |
| | 347 | ON payment (appointment_id) |
| | 348 | WHERE status = 'PAID'; |
| | 349 | |
| | 350 | CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_package_purchase_paid_unique |
| | 351 | ON payment (package_purchase_id) |
| | 352 | WHERE 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 |
| | 366 | CREATE OR REPLACE FUNCTION trg_payment_validate() |
| | 367 | RETURNS trigger |
| | 368 | LANGUAGE plpgsql |
| | 369 | AS $$ |
| | 370 | DECLARE |
| | 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; |
| | 376 | BEGIN |
| | 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 | |
| | 469 | END; |
| | 470 | $$; |
| | 471 | |
| | 472 | DROP TRIGGER IF EXISTS payment_validate |
| | 473 | ON payment; |
| | 474 | |
| | 475 | CREATE TRIGGER payment_validate |
| | 476 | BEFORE INSERT OR UPDATE |
| | 477 | ON payment |
| | 478 | FOR EACH ROW |
| | 479 | EXECUTE 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 |
| | 490 | CREATE OR REPLACE FUNCTION trg_appointment_prevent_price_change() |
| | 491 | RETURNS trigger |
| | 492 | LANGUAGE plpgsql |
| | 493 | AS $$ |
| | 494 | DECLARE |
| | 495 | v_has_paid int; |
| | 496 | BEGIN |
| | 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 | |
| | 516 | END; |
| | 517 | $$; |
| | 518 | |
| | 519 | DROP TRIGGER IF EXISTS appointment_prevent_price_change |
| | 520 | ON appointment; |
| | 521 | |
| | 522 | CREATE TRIGGER appointment_prevent_price_change |
| | 523 | BEFORE UPDATE |
| | 524 | ON appointment |
| | 525 | FOR EACH ROW |
| | 526 | EXECUTE 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 |
| | 537 | CREATE 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 | ) |
| | 543 | RETURNS int |
| | 544 | LANGUAGE plpgsql |
| | 545 | AS $$ |
| | 546 | DECLARE |
| | 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; |
| | 552 | BEGIN |
| | 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 | |
| | 611 | END; |
| | 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 |
| | 634 | CREATE UNIQUE INDEX IF NOT EXISTS idx_loyaltycard_user_unique |
| | 635 | ON loyaltycard (user_id); |
| | 636 | |
| | 637 | ALTER TABLE loyaltycard |
| | 638 | ADD CONSTRAINT loyaltycard_points_nonnegative |
| | 639 | CHECK (points >= 0); |
| | 640 | |
| | 641 | ALTER TABLE payment |
| | 642 | ADD CONSTRAINT payment_points_used_nonnegative |
| | 643 | CHECK (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 |
| | 654 | CREATE OR REPLACE FUNCTION sp_ensure_loyalty_card(p_user_id int) |
| | 655 | RETURNS int |
| | 656 | LANGUAGE plpgsql |
| | 657 | AS $$ |
| | 658 | DECLARE |
| | 659 | v_card_id int; |
| | 660 | BEGIN |
| | 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; |
| | 671 | END; |
| | 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 |
| | 683 | CREATE OR REPLACE FUNCTION sp_award_loyalty_points(p_appointment_id int) |
| | 684 | RETURNS int |
| | 685 | LANGUAGE plpgsql |
| | 686 | AS $$ |
| | 687 | DECLARE |
| | 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; |
| | 696 | BEGIN |
| | 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; |
| | 736 | END; |
| | 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 |
| | 750 | CREATE OR REPLACE FUNCTION sp_mark_payment_paid( |
| | 751 | p_payment_id int |
| | 752 | ) |
| | 753 | RETURNS int |
| | 754 | LANGUAGE plpgsql |
| | 755 | AS $$ |
| | 756 | DECLARE |
| | 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; |
| | 768 | BEGIN |
| | 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; |
| | 834 | END; |
| | 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 |
| | 846 | CREATE OR REPLACE FUNCTION sp_refund_payment_for_appointment(p_appointment_id int) |
| | 847 | RETURNS int |
| | 848 | LANGUAGE plpgsql |
| | 849 | AS $$ |
| | 850 | DECLARE |
| | 851 | v_payment_id int; |
| | 852 | v_user_id int; |
| | 853 | v_total numeric; |
| | 854 | v_points_used int; |
| | 855 | v_points_earned int; |
| | 856 | BEGIN |
| | 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; |
| | 908 | END; |
| | 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 |
| | 932 | CREATE 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 |
| | 969 | CREATE OR REPLACE FUNCTION sp_apply_package_to_appointment( |
| | 970 | p_appointment_id INT, |
| | 971 | p_purchase_id INT |
| | 972 | ) |
| | 973 | RETURNS VOID |
| | 974 | AS $$ |
| | 975 | DECLARE |
| | 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; |
| | 983 | BEGIN |
| | 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; |
| | 1044 | END; |
| | 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 |
| | 1056 | CREATE OR REPLACE FUNCTION sp_finalize_package_usage_on_appointment( |
| | 1057 | p_appointment_id INT |
| | 1058 | ) |
| | 1059 | RETURNS VOID |
| | 1060 | AS $$ |
| | 1061 | DECLARE |
| | 1062 | v_purchase_id INT; |
| | 1063 | v_used_units INT; |
| | 1064 | v_finalized_at TIMESTAMP; |
| | 1065 | v_remaining INT; |
| | 1066 | BEGIN |
| | 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; |
| | 1100 | END; |
| | 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 |
| | 1122 | CREATE UNIQUE INDEX IF NOT EXISTS idx_availability_unique_window |
| | 1123 | ON 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 |
| | 1134 | CREATE OR REPLACE FUNCTION fn_available_slots(p_date date, p_service_ids int[]) |
| | 1135 | RETURNS TABLE (start_time timestamp, end_time timestamp) |
| | 1136 | LANGUAGE sql |
| | 1137 | AS $$ |
| | 1138 | WITH svc AS ( |
| | 1139 | SELECT fn_service_total_minutes(p_service_ids) AS total_minutes |
| | 1140 | ), |
| | 1141 | windows 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 | ), |
| | 1152 | candidates 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 | ) |
| | 1164 | SELECT c.start_time, c.end_time |
| | 1165 | FROM candidates c |
| | 1166 | WHERE 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 | ) |
| | 1175 | ORDER 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 |
| | 1187 | CREATE 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 | ) |
| | 1193 | RETURNS int |
| | 1194 | LANGUAGE plpgsql |
| | 1195 | AS $$ |
| | 1196 | DECLARE |
| | 1197 | v_availability_id int; |
| | 1198 | v_exists int; |
| | 1199 | BEGIN |
| | 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; |
| | 1235 | END; |
| | 1236 | $$; |
| | 1237 | }}} |
| | 1238 | |
| | 1239 | ==== `sp_admin_update_availability` ==== |
| | 1240 | |
| | 1241 | * '''Име:''' `sp_admin_update_availability` |
| | 1242 | * '''Работи над:''' `availability` |
| | 1243 | * '''Бизнис правило:''' update само на постоечки запис, без временски конфликт. |
| | 1244 | |
| | 1245 | {{{ |
| | 1246 | #!sql |
| | 1247 | CREATE 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 | ) |
| | 1254 | RETURNS void |
| | 1255 | LANGUAGE plpgsql |
| | 1256 | AS $$ |
| | 1257 | DECLARE |
| | 1258 | v_exists int; |
| | 1259 | v_duplicate int; |
| | 1260 | BEGIN |
| | 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; |
| | 1296 | END; |
| | 1297 | $$; |
| | 1298 | }}} |
| | 1299 | |
| | 1300 | ==== `sp_admin_delete_availability` ==== |
| | 1301 | |
| | 1302 | * '''Име:''' `sp_admin_delete_availability` |
| | 1303 | * '''Работи над:''' `availability` |
| | 1304 | * '''Бизнис правило:''' delete само ако записот постои. |
| | 1305 | |
| | 1306 | {{{ |
| | 1307 | #!sql |
| | 1308 | CREATE OR REPLACE FUNCTION sp_admin_delete_availability( |
| | 1309 | p_availability_id int |
| | 1310 | ) |
| | 1311 | RETURNS void |
| | 1312 | LANGUAGE plpgsql |
| | 1313 | AS $$ |
| | 1314 | DECLARE |
| | 1315 | v_deleted int; |
| | 1316 | BEGIN |
| | 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; |
| | 1325 | END; |
| | 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 |
| | 1339 | CREATE OR REPLACE VIEW v_services_grouped_by_category AS |
| | 1340 | SELECT |
| | 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 |
| | 1358 | FROM category c |
| | 1359 | LEFT JOIN service s |
| | 1360 | ON s.category_id = c.category_id |
| | 1361 | LEFT 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 |
| | 1390 | ON true |
| | 1391 | GROUP 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 |
| | 1402 | CREATE OR REPLACE VIEW v_user_appointments AS |
| | 1403 | SELECT |
| | 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 |
| | 1425 | FROM appointment a |
| | 1426 | LEFT JOIN status st |
| | 1427 | ON st.status_id = a.status_id |
| | 1428 | LEFT JOIN appointmentservice aps |
| | 1429 | ON aps.appointment_id = a.appointment_id |
| | 1430 | LEFT JOIN service s |
| | 1431 | ON s.service_id = aps.service_id |
| | 1432 | GROUP 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 |
| | 1453 | CREATE VIEW v_user_appointments_payment_state AS |
| | 1454 | SELECT |
| | 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 |
| | 1480 | FROM appointment a |
| | 1481 | JOIN status st |
| | 1482 | ON st.status_id = a.status_id |
| | 1483 | LEFT 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 |
| | 1502 | ON true |
| | 1503 | LEFT 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 |
| | 1514 | ON true |
| | 1515 | LEFT 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 |
| | 1525 | ON true |
| | 1526 | LEFT 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 |
| | 1538 | CREATE VIEW v_admin_appointments_payment_state AS |
| | 1539 | SELECT |
| | 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 |
| | 1559 | FROM appointment a |
| | 1560 | JOIN "User" u |
| | 1561 | ON u.user_id = a.user_id |
| | 1562 | JOIN status st |
| | 1563 | ON st.status_id = a.status_id |
| | 1564 | LEFT 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 |
| | 1583 | ON true |
| | 1584 | LEFT 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 |
| | 1595 | ON true |
| | 1596 | LEFT 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 |
| | 1606 | ON true |
| | 1607 | LEFT 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 |
| | 1619 | CREATE OR REPLACE VIEW v_admin_availability_by_date AS |
| | 1620 | SELECT |
| | 1621 | date, |
| | 1622 | availability_id, |
| | 1623 | start_time, |
| | 1624 | end_time, |
| | 1625 | is_closed |
| | 1626 | FROM availability |
| | 1627 | ORDER 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 |
| | 1638 | CREATE OR REPLACE VIEW v_user_active_packages AS |
| | 1639 | SELECT |
| | 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 |
| | 1647 | FROM UserPackagePurchase upp |
| | 1648 | JOIN Package p |
| | 1649 | ON p.package_id = upp.package_id |
| | 1650 | JOIN PackageService ps |
| | 1651 | ON ps.package_id = p.package_id |
| | 1652 | JOIN Service s |
| | 1653 | ON s.service_id = ps.service_id |
| | 1654 | WHERE upp.status = 'ACTIVE' |
| | 1655 | GROUP 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 |
| | 1674 | CREATE OR REPLACE FUNCTION sp_auto_mark_no_show( |
| | 1675 | p_grace_minutes int DEFAULT 30 |
| | 1676 | ) |
| | 1677 | RETURNS int |
| | 1678 | LANGUAGE plpgsql |
| | 1679 | AS $$ |
| | 1680 | DECLARE |
| | 1681 | v_no_show_id int; |
| | 1682 | v_count int; |
| | 1683 | BEGIN |
| | 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; |
| | 1709 | END; |
| | 1710 | $$; |
| | 1711 | }}} |
| | 1712 | |
| | 1713 | === Апликациски scheduler повик === |
| | 1714 | |
| | 1715 | * '''Објект:''' периодичен повик од апликациски слој |
| | 1716 | * '''Бизнис правило:''' автоматизација на lifecycle без рачна интервенција. |
| | 1717 | |
| | 1718 | {{{ |
| | 1719 | #!sql |
| | 1720 | SELECT sp_auto_mark_no_show($1::int); |
| | 1721 | }}} |