Changeset 6c7cfa6


Ignore:
Timestamp:
09/21/26 00:41:03 (9 days ago)
Author:
Klimentina Efremova <klimentina08642@…>
Branches:
main
Children:
2d1ec46
Parents:
6149556
git-author:
Klimentina Efremova <klimentina08642@…> (09/21/26 00:24:09)
git-committer:
Klimentina Efremova <klimentina08642@…> (09/21/26 00:41:03)
Message:

Implemented Advaced database reports

Files:
1 added
11 edited

Legend:

Unmodified
Added
Removed
  • database.js

    r6149556 r6c7cfa6  
    13831383
    13841384    query(
    1385         `SELECT *
    1386          FROM report
    1387          WHERE store_id = $1
    1388          ORDER BY generated_at DESC`,
     1385        `SELECT
     1386            r.date,
     1387            r.store_id,
     1388            r.overall_profit,
     1389            r.sales_trend,
     1390            r.marketing_growth,
     1391            r.owner_signature
     1392         FROM report r
     1393         WHERE r.store_id = $1
     1394         ORDER BY r.date DESC`,
    13891395        [storeId],
    13901396        (err, result) => {
    1391 
    13921397            callback(
    13931398                err,
    … …  
    14041409
    14051410    query(
    1406         `SELECT COUNT(*) AS total_products
    1407          FROM product
    1408          WHERE store_id = $1`,
     1411        `SELECT COUNT(DISTINCT se.product_code) AS total_products
     1412         FROM sells se
     1413         WHERE se.store_id = $1`,
    14091414        [storeId],
    14101415        (err, result) => {
    … …  
    14151420            }
    14161421
    1417             stats.total_products =
    1418                 result.rows[0]
    1419                     ? Number(result.rows[0].total_products)
    1420                     : 0;
     1422            stats.total_products = Number(
     1423                result.rows[0]?.total_products || 0
     1424            );
    14211425
    14221426            query(
    1423                 `SELECT COUNT(*) AS total_orders
    1424                  FROM "order"
    1425                  WHERE store_id = $1`,
     1427                `SELECT COUNT(DISTINCT o.order_num) AS total_orders
     1428                 FROM sells se
     1429                 JOIN includes i
     1430                   ON i.product_code = se.product_code
     1431                 JOIN "order" o
     1432                   ON o.order_num = i.order_num
     1433                 WHERE se.store_id = $1`,
    14261434                [storeId],
    14271435                (err, result) => {
    … …  
    14321440                    }
    14331441
    1434                     stats.total_orders =
    1435                         result.rows[0]
    1436                             ? Number(result.rows[0].total_orders)
    1437                             : 0;
     1442                    stats.total_orders = Number(
     1443                        result.rows[0]?.total_orders || 0
     1444                    );
    14381445
    14391446                    query(
    … …  
    14411448                            COALESCE(
    14421449                                SUM(
    1443                                     oi.price * oi.quantity
     1450                                    p.price * i.quantity
     1451                                    * (1 - COALESCE(o.discount, 0) / 100.0)
    14441452                                ),
    14451453                                0
    14461454                            ) AS total_revenue
    1447                          FROM order_items oi
     1455                         FROM sells se
     1456                         JOIN product p
     1457                           ON p.code = se.product_code
     1458                         JOIN includes i
     1459                           ON i.product_code = se.product_code
    14481460                         JOIN "order" o
    1449                            ON oi.order_num =
    1450                               o.order_num
    1451                          WHERE o.store_id = $1`,
     1461                           ON o.order_num = i.order_num
     1462                         WHERE se.store_id = $1`,
    14521463                        [storeId],
    14531464                        (err, result) => {
    … …  
    14581469                            }
    14591470
    1460                             stats.total_revenue =
    1461                                 Number(
    1462                                     result.rows[0]
    1463                                         .total_revenue || 0
    1464                                 );
     1471                            stats.total_revenue = Number(
     1472                                result.rows[0]?.total_revenue || 0
     1473                            );
    14651474
    14661475                            query(
    1467                                 `SELECT
    1468                                     COALESCE(
    1469                                         AVG(r.rating),
    1470                                         0
    1471                                     ) AS avg_rating
    1472                                  FROM review r
    1473                                  JOIN product p
    1474                                    ON r.product_code =
    1475                                       p.code
    1476                                  WHERE p.store_id = $1`,
     1476                                `WITH store_reviews AS (
     1477                                    SELECT DISTINCT
     1478                                        r.order_num,
     1479                                        r.rating
     1480                                    FROM review r
     1481                                    JOIN includes i
     1482                                      ON i.order_num = r.order_num
     1483                                    JOIN sells se
     1484                                      ON se.product_code = i.product_code
     1485                                    WHERE se.store_id = $1
     1486                                )
     1487                                SELECT COALESCE(AVG(rating), 0) AS avg_rating
     1488                                FROM store_reviews`,
    14771489                                [storeId],
    14781490                                (err, result) => {
    … …  
    14831495                                    }
    14841496
    1485                                     stats.avg_rating =
    1486                                         Number(
    1487                                             result.rows[0]
    1488                                                 .avg_rating || 0
    1489                                         );
    1490 
    1491                                     callback(
    1492                                         null,
    1493                                         stats
     1497                                    stats.avg_rating = Number(
     1498                                        result.rows[0]?.avg_rating || 0
    14941499                                    );
     1500
     1501                                    callback(null, stats);
    14951502                                }
    14961503                            );
    … …  
    22932300
    22942301
     2302
     2303
     2304/*
     2305 * ============================================================
     2306 * ADVANCED REPORTS
     2307 * ============================================================
     2308 */
     2309
     2310
     2311const REPORT_FUNCTIONS_SQL = String.raw`
     2312-- ============================================================
     2313-- HANDCRAFT MARKETPLACE REPORT FUNCTIONS
     2314-- PostgreSQL / exact project schema
     2315-- ============================================================
     2316
     2317CREATE OR REPLACE FUNCTION get_orders_by_total()
     2318RETURNS TABLE (
     2319    order_num VARCHAR(11),
     2320    client_id INTEGER,
     2321    client_name TEXT,
     2322    order_quantity BIGINT,
     2323    order_status VARCHAR(20),
     2324    payment_method VARCHAR(250),
     2325    discount NUMERIC,
     2326    order_total NUMERIC
     2327)
     2328LANGUAGE sql
     2329AS $$
     2330    SELECT
     2331        o.order_num,
     2332        o.client_id,
     2333        CONCAT_WS(' ', c.first_name, c.last_name) AS client_name,
     2334        COALESCE(SUM(i.quantity), 0)::BIGINT AS order_quantity,
     2335        o.status,
     2336        o.payment_method,
     2337        COALESCE(o.discount, 0)::NUMERIC AS discount,
     2338        ROUND(
     2339            COALESCE(SUM(p.price * i.quantity), 0)
     2340            * (1 - COALESCE(o.discount, 0) / 100.0),
     2341            2
     2342        ) AS order_total
     2343    FROM "order" o
     2344    LEFT JOIN client c ON c.client_id = o.client_id
     2345    LEFT JOIN includes i ON i.order_num = o.order_num
     2346    LEFT JOIN product p ON p.code = i.product_code
     2347    GROUP BY
     2348        o.order_num, o.client_id, c.first_name, c.last_name,
     2349        o.status, o.payment_method, o.discount
     2350    ORDER BY order_total DESC, o.order_num;
     2351$$;
     2352
     2353CREATE OR REPLACE FUNCTION get_products_by_total_sales()
     2354RETURNS TABLE (
     2355    product_code VARCHAR(8),
     2356    product_description VARCHAR(500),
     2357    product_price NUMERIC,
     2358    number_of_orders BIGINT,
     2359    total_quantity_sold BIGINT,
     2360    total_revenue NUMERIC
     2361)
     2362LANGUAGE sql
     2363AS $$
     2364    SELECT
     2365        p.code,
     2366        p.description,
     2367        p.price::NUMERIC,
     2368        COUNT(DISTINCT i.order_num) AS number_of_orders,
     2369        COALESCE(SUM(i.quantity), 0)::BIGINT AS total_quantity_sold,
     2370        ROUND(
     2371            COALESCE(
     2372                SUM(
     2373                    p.price * i.quantity
     2374                    * (1 - COALESCE(o.discount, 0) / 100.0)
     2375                ),
     2376                0
     2377            ),
     2378            2
     2379        ) AS total_revenue
     2380    FROM product p
     2381    LEFT JOIN includes i ON i.product_code = p.code
     2382    LEFT JOIN "order" o ON o.order_num = i.order_num
     2383    GROUP BY p.code, p.description, p.price
     2384    ORDER BY number_of_orders DESC, total_quantity_sold DESC, total_revenue DESC;
     2385$$;
     2386
     2387CREATE OR REPLACE FUNCTION get_low_stock_high_demand_products(
     2388    p_stock_threshold INTEGER,
     2389    p_demand_threshold INTEGER
     2390)
     2391RETURNS TABLE (
     2392    product_code VARCHAR(8),
     2393    product_description VARCHAR(500),
     2394    current_stock INTEGER,
     2395    number_of_orders BIGINT,
     2396    total_quantity_sold BIGINT
     2397)
     2398LANGUAGE sql
     2399AS $$
     2400    SELECT
     2401        p.code,
     2402        p.description,
     2403        p.availability,
     2404        COUNT(DISTINCT i.order_num),
     2405        COALESCE(SUM(i.quantity), 0)::BIGINT
     2406    FROM product p
     2407    JOIN includes i ON i.product_code = p.code
     2408    GROUP BY p.code, p.description, p.availability
     2409    HAVING
     2410        p.availability < p_stock_threshold
     2411        AND COUNT(DISTINCT i.order_num) >= p_demand_threshold
     2412    ORDER BY number_of_orders DESC, total_quantity_sold DESC, current_stock ASC;
     2413$$;
     2414
     2415CREATE OR REPLACE FUNCTION get_products_monthly_sales()
     2416RETURNS TABLE (
     2417    product_code VARCHAR(8),
     2418    product_description VARCHAR(500),
     2419    year INTEGER,
     2420    month INTEGER,
     2421    number_of_orders BIGINT,
     2422    total_quantity_sold BIGINT,
     2423    total_revenue NUMERIC
     2424)
     2425LANGUAGE sql
     2426AS $$
     2427    SELECT
     2428        p.code,
     2429        p.description,
     2430        EXTRACT(YEAR FROM o.last_date_mod)::INTEGER,
     2431        EXTRACT(MONTH FROM o.last_date_mod)::INTEGER,
     2432        COUNT(DISTINCT o.order_num),
     2433        COALESCE(SUM(i.quantity), 0)::BIGINT,
     2434        ROUND(
     2435            COALESCE(
     2436                SUM(
     2437                    p.price * i.quantity
     2438                    * (1 - COALESCE(o.discount, 0) / 100.0)
     2439                ),
     2440                0
     2441            ),
     2442            2
     2443        )
     2444    FROM product p
     2445    JOIN includes i ON i.product_code = p.code
     2446    JOIN "order" o ON o.order_num = i.order_num
     2447    GROUP BY
     2448        p.code, p.description,
     2449        EXTRACT(YEAR FROM o.last_date_mod),
     2450        EXTRACT(MONTH FROM o.last_date_mod)
     2451    ORDER BY year DESC, month DESC, total_revenue DESC;
     2452$$;
     2453
     2454CREATE OR REPLACE FUNCTION get_stores_by_last_calendar_year_revenue()
     2455RETURNS TABLE (
     2456    store_id VARCHAR(3),
     2457    store_name VARCHAR(50),
     2458    number_of_orders BIGINT,
     2459    total_quantity_sold BIGINT,
     2460    total_revenue NUMERIC
     2461)
     2462LANGUAGE sql
     2463AS $$
     2464    SELECT
     2465        s.store_id,
     2466        s.name,
     2467        COUNT(DISTINCT o.order_num),
     2468        COALESCE(SUM(i.quantity), 0)::BIGINT,
     2469        ROUND(
     2470            COALESCE(
     2471                SUM(
     2472                    p.price * i.quantity
     2473                    * (1 - COALESCE(o.discount, 0) / 100.0)
     2474                ),
     2475                0
     2476            ),
     2477            2
     2478        )
     2479    FROM store s
     2480    LEFT JOIN sells se ON se.store_id = s.store_id
     2481    LEFT JOIN product p ON p.code = se.product_code
     2482    LEFT JOIN includes i ON i.product_code = p.code
     2483    LEFT JOIN "order" o
     2484        ON o.order_num = i.order_num
     2485       AND o.last_date_mod >= DATE_TRUNC('year', CURRENT_DATE) - INTERVAL '1 year'
     2486       AND o.last_date_mod < DATE_TRUNC('year', CURRENT_DATE)
     2487    GROUP BY s.store_id, s.name
     2488    ORDER BY total_revenue DESC, s.store_id;
     2489$$;
     2490
     2491CREATE OR REPLACE FUNCTION get_products_never_ordered()
     2492RETURNS TABLE (
     2493    product_code VARCHAR(8),
     2494    product_description VARCHAR(500),
     2495    product_price NUMERIC,
     2496    current_stock INTEGER
     2497)
     2498LANGUAGE sql
     2499AS $$
     2500    SELECT p.code, p.description, p.price::NUMERIC, p.availability
     2501    FROM product p
     2502    WHERE NOT EXISTS (
     2503        SELECT 1
     2504        FROM includes i
     2505        WHERE i.product_code = p.code
     2506    )
     2507    ORDER BY p.code;
     2508$$;
     2509
     2510CREATE OR REPLACE FUNCTION get_products_by_number_of_orders()
     2511RETURNS TABLE (
     2512    product_code VARCHAR(8),
     2513    product_description VARCHAR(500),
     2514    product_price NUMERIC,
     2515    number_of_orders BIGINT
     2516)
     2517LANGUAGE sql
     2518AS $$
     2519    SELECT
     2520        p.code,
     2521        p.description,
     2522        p.price::NUMERIC,
     2523        COUNT(DISTINCT i.order_num)
     2524    FROM product p
     2525    JOIN includes i ON i.product_code = p.code
     2526    GROUP BY p.code, p.description, p.price
     2527    ORDER BY number_of_orders DESC, p.code;
     2528$$;
     2529
     2530CREATE OR REPLACE FUNCTION get_stores_by_average_review()
     2531RETURNS TABLE (
     2532    store_id VARCHAR(3),
     2533    store_name VARCHAR(50),
     2534    average_review NUMERIC,
     2535    number_of_reviews BIGINT
     2536)
     2537LANGUAGE sql
     2538AS $$
     2539    WITH store_reviews AS (
     2540        SELECT DISTINCT
     2541            s.store_id,
     2542            s.name AS store_name,
     2543            r.order_num,
     2544            r.rating
     2545        FROM store s
     2546        JOIN sells se ON se.store_id = s.store_id
     2547        JOIN includes i ON i.product_code = se.product_code
     2548        JOIN review r ON r.order_num = i.order_num
     2549    )
     2550    SELECT
     2551        s.store_id,
     2552        s.name,
     2553        COALESCE(ROUND(AVG(sr.rating), 2), 0)::NUMERIC,
     2554        COUNT(sr.order_num)
     2555    FROM store s
     2556    LEFT JOIN store_reviews sr ON sr.store_id = s.store_id
     2557    GROUP BY s.store_id, s.name
     2558    ORDER BY average_review DESC, s.store_id;
     2559$$;
     2560
     2561CREATE OR REPLACE FUNCTION get_store_with_highest_revenue_growth()
     2562RETURNS TABLE (
     2563    store_id VARCHAR(3),
     2564    store_name VARCHAR(50),
     2565    previous_year_revenue NUMERIC,
     2566    last_year_revenue NUMERIC,
     2567    revenue_growth NUMERIC
     2568)
     2569LANGUAGE sql
     2570AS $$
     2571    WITH store_years AS (
     2572        SELECT
     2573            s.store_id,
     2574            s.name AS store_name,
     2575            EXTRACT(YEAR FROM o.last_date_mod)::INTEGER AS sales_year,
     2576            SUM(
     2577                p.price * i.quantity
     2578                * (1 - COALESCE(o.discount, 0) / 100.0)
     2579            ) AS revenue
     2580        FROM store s
     2581        JOIN sells se ON se.store_id = s.store_id
     2582        JOIN includes i ON i.product_code = se.product_code
     2583        JOIN product p ON p.code = i.product_code
     2584        JOIN "order" o ON o.order_num = i.order_num
     2585        WHERE o.last_date_mod >= DATE_TRUNC('year', CURRENT_DATE) - INTERVAL '2 years'
     2586          AND o.last_date_mod < DATE_TRUNC('year', CURRENT_DATE)
     2587        GROUP BY s.store_id, s.name, EXTRACT(YEAR FROM o.last_date_mod)
     2588    ),
     2589    comparison AS (
     2590        SELECT
     2591            s.store_id,
     2592            s.name AS store_name,
     2593            COALESCE(MAX(CASE
     2594                WHEN sy.sales_year = EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER - 2
     2595                THEN sy.revenue ELSE 0 END), 0) AS previous_year_revenue,
     2596            COALESCE(MAX(CASE
     2597                WHEN sy.sales_year = EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER - 1
     2598                THEN sy.revenue ELSE 0 END), 0) AS last_year_revenue
     2599        FROM store s
     2600        LEFT JOIN store_years sy ON sy.store_id = s.store_id
     2601        GROUP BY s.store_id, s.name
     2602    )
     2603    SELECT
     2604        store_id,
     2605        store_name,
     2606        ROUND(previous_year_revenue, 2),
     2607        ROUND(last_year_revenue, 2),
     2608        ROUND(last_year_revenue - previous_year_revenue, 2)
     2609    FROM comparison
     2610    ORDER BY revenue_growth DESC, store_id
     2611    LIMIT 1;
     2612$$;
     2613
     2614CREATE OR REPLACE FUNCTION get_clients_by_number_of_orders()
     2615RETURNS TABLE (
     2616    client_id INTEGER,
     2617    client_name TEXT,
     2618    number_of_orders BIGINT
     2619)
     2620LANGUAGE sql
     2621AS $$
     2622    SELECT
     2623        c.client_id,
     2624        CONCAT_WS(' ', c.first_name, c.last_name),
     2625        COUNT(o.order_num)
     2626    FROM client c
     2627    JOIN "order" o ON o.client_id = c.client_id
     2628    GROUP BY c.client_id, c.first_name, c.last_name
     2629    ORDER BY number_of_orders DESC, c.client_id;
     2630$$;
     2631
     2632CREATE OR REPLACE FUNCTION get_approximate_orders_per_client()
     2633RETURNS TABLE (
     2634    total_clients BIGINT,
     2635    total_orders BIGINT,
     2636    approximate_orders_per_client NUMERIC
     2637)
     2638LANGUAGE sql
     2639AS $$
     2640    SELECT
     2641        (SELECT COUNT(*) FROM client),
     2642        (SELECT COUNT(*) FROM "order"),
     2643        ROUND(
     2644            (SELECT COUNT(*)::NUMERIC FROM "order")
     2645            / NULLIF((SELECT COUNT(*) FROM client), 0),
     2646            2
     2647        );
     2648$$;
     2649
     2650CREATE OR REPLACE FUNCTION get_clients_without_orders()
     2651RETURNS TABLE (
     2652    client_id INTEGER,
     2653    client_name TEXT,
     2654    email VARCHAR(50)
     2655)
     2656LANGUAGE sql
     2657AS $$
     2658    SELECT
     2659        c.client_id,
     2660        CONCAT_WS(' ', c.first_name, c.last_name),
     2661        c.email
     2662    FROM client c
     2663    WHERE NOT EXISTS (
     2664        SELECT 1 FROM "order" o WHERE o.client_id = c.client_id
     2665    )
     2666    ORDER BY c.client_id;
     2667$$;
     2668
     2669CREATE OR REPLACE FUNCTION get_store_request_statistics()
     2670RETURNS TABLE (
     2671    store_id VARCHAR(3),
     2672    store_name VARCHAR(50),
     2673    total_requests BIGINT,
     2674    solved_requests BIGINT,
     2675    requests_in_progress BIGINT
     2676)
     2677LANGUAGE sql
     2678AS $$
     2679    SELECT
     2680        s.store_id,
     2681        s.name,
     2682        COUNT(r.request_num),
     2683        COUNT(r.request_num) FILTER (WHERE r.customer_satisfaction > 0),
     2684        COUNT(r.request_num) FILTER (WHERE r.customer_satisfaction <= 0)
     2685    FROM store s
     2686    LEFT JOIN for_store fs ON fs.store_id = s.store_id
     2687    LEFT JOIN request r ON r.request_num = fs.request_num
     2688    GROUP BY s.store_id, s.name
     2689    ORDER BY total_requests DESC, s.store_id;
     2690$$;
     2691
     2692CREATE OR REPLACE FUNCTION get_top_10_employees_by_requests_last_month()
     2693RETURNS TABLE (
     2694    employee_id VARCHAR(10),
     2695    employee_name TEXT,
     2696    number_of_requests BIGINT
     2697)
     2698LANGUAGE sql
     2699AS $$
     2700    SELECT
     2701        e.employee_id,
     2702        CONCAT_WS(' ', p.first_name, p.last_name),
     2703        COUNT(DISTINCT a.request_num)
     2704    FROM employees e
     2705    JOIN personal p ON p.id = e.employee_id
     2706    JOIN answers a ON a.personal_id = e.employee_id
     2707    JOIN request r ON r.request_num = a.request_num
     2708    WHERE r.date_and_time >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
     2709      AND r.date_and_time < DATE_TRUNC('month', CURRENT_DATE)
     2710    GROUP BY e.employee_id, p.first_name, p.last_name
     2711    ORDER BY number_of_requests DESC, e.employee_id
     2712    LIMIT 10;
     2713$$;
     2714
     2715CREATE OR REPLACE FUNCTION get_employees_by_hours_and_pay()
     2716RETURNS TABLE (
     2717    employee_id VARCHAR(10),
     2718    employee_name TEXT,
     2719    total_hours_worked NUMERIC,
     2720    total_pay NUMERIC
     2721)
     2722LANGUAGE sql
     2723AS $$
     2724    SELECT
     2725        e.employee_id,
     2726        CONCAT_WS(' ', p.first_name, p.last_name),
     2727        COALESCE(SUM(w.total_hours), 0),
     2728        COALESCE(SUM(w.wage * w.total_hours), 0)
     2729    FROM employees e
     2730    JOIN personal p ON p.id = e.employee_id
     2731    LEFT JOIN worked w ON w.personal_id = e.employee_id
     2732    GROUP BY e.employee_id, p.first_name, p.last_name
     2733    ORDER BY total_hours_worked DESC, total_pay DESC, e.employee_id;
     2734$$;
     2735
     2736CREATE OR REPLACE FUNCTION get_stores_average_pay()
     2737RETURNS TABLE (
     2738    store_id VARCHAR(3),
     2739    store_name VARCHAR(50),
     2740    average_pay NUMERIC
     2741)
     2742LANGUAGE sql
     2743AS $$
     2744    SELECT
     2745        s.store_id,
     2746        s.name,
     2747        COALESCE(ROUND(AVG(w.wage), 2), 0)
     2748    FROM store s
     2749    LEFT JOIN worked w ON w.store_id = s.store_id
     2750    GROUP BY s.store_id, s.name
     2751    ORDER BY average_pay DESC, s.store_id;
     2752$$;
     2753
     2754CREATE OR REPLACE FUNCTION get_employee_product_changes_last_month()
     2755RETURNS TABLE (
     2756    employee_id VARCHAR(10),
     2757    employee_name TEXT,
     2758    number_of_product_changes BIGINT
     2759)
     2760LANGUAGE sql
     2761AS $$
     2762    SELECT
     2763        e.employee_id,
     2764        CONCAT_WS(' ', p.first_name, p.last_name),
     2765        COUNT(mc.change_date_time)
     2766    FROM employees e
     2767    JOIN personal p ON p.id = e.employee_id
     2768    LEFT JOIN makes_change mc
     2769        ON mc.personal_id = e.employee_id
     2770       AND mc.change_date_time >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
     2771       AND mc.change_date_time < DATE_TRUNC('month', CURRENT_DATE)
     2772    GROUP BY e.employee_id, p.first_name, p.last_name
     2773    ORDER BY number_of_product_changes DESC, e.employee_id;
     2774$$;
     2775
     2776CREATE OR REPLACE FUNCTION get_stores_by_monthly_profit_and_revenue_growth()
     2777RETURNS TABLE (
     2778    store_id VARCHAR(3),
     2779    store_name VARCHAR(50),
     2780    month_and_year TEXT,
     2781    monthly_profit NUMERIC,
     2782    previous_month_revenue NUMERIC,
     2783    current_month_revenue NUMERIC,
     2784    revenue_growth NUMERIC
     2785)
     2786LANGUAGE sql
     2787AS $$
     2788    WITH monthly_revenue AS (
     2789        SELECT
     2790            s.store_id,
     2791            s.name AS store_name,
     2792            DATE_TRUNC('month', o.last_date_mod)::DATE AS month_date,
     2793            SUM(
     2794                p.price * i.quantity
     2795                * (1 - COALESCE(o.discount, 0) / 100.0)
     2796            ) AS revenue
     2797        FROM store s
     2798        JOIN sells se ON se.store_id = s.store_id
     2799        JOIN includes i ON i.product_code = se.product_code
     2800        JOIN product p ON p.code = i.product_code
     2801        JOIN "order" o ON o.order_num = i.order_num
     2802        GROUP BY s.store_id, s.name, DATE_TRUNC('month', o.last_date_mod)
     2803    ),
     2804    with_previous AS (
     2805        SELECT
     2806            store_id,
     2807            store_name,
     2808            month_date,
     2809            revenue,
     2810            LAG(revenue) OVER (
     2811                PARTITION BY store_id
     2812                ORDER BY month_date
     2813            ) AS previous_revenue
     2814        FROM monthly_revenue
     2815    )
     2816    SELECT
     2817        store_id,
     2818        store_name,
     2819        TO_CHAR(month_date, 'YYYY-MM'),
     2820        ROUND(revenue, 2),
     2821        ROUND(COALESCE(previous_revenue, 0), 2),
     2822        ROUND(revenue, 2),
     2823        ROUND(revenue - COALESCE(previous_revenue, 0), 2)
     2824    FROM with_previous
     2825    ORDER BY month_date DESC, monthly_profit DESC, store_id;
     2826$$;
     2827
     2828CREATE OR REPLACE FUNCTION get_unapproved_reports()
     2829RETURNS TABLE (
     2830    report_date TIMESTAMP,
     2831    store_id VARCHAR(3),
     2832    overall_profit NUMERIC,
     2833    sales_trend VARCHAR(100),
     2834    marketing_growth VARCHAR(100),
     2835    owner_signature VARCHAR(50)
     2836)
     2837LANGUAGE sql
     2838AS $$
     2839    SELECT
     2840        r.date,
     2841        r.store_id,
     2842        r.overall_profit,
     2843        r.sales_trend,
     2844        r.marketing_growth,
     2845        r.owner_signature
     2846    FROM report r
     2847    LEFT JOIN approves a
     2848        ON a.report_date = r.date
     2849       AND a.store_id = r.store_id
     2850    WHERE a.report_date IS NULL
     2851    ORDER BY r.date DESC, r.store_id;
     2852$$;
     2853`;
     2854
     2855function installReportFunctions(callback) {
     2856
     2857    pool.query(REPORT_FUNCTIONS_SQL)
     2858        .then(() => {
     2859            console.log('✅ PostgreSQL report functions installed');
     2860            if (callback) callback(null);
     2861        })
     2862        .catch(err => {
     2863            console.error('❌ Failed to install PostgreSQL report functions:', err);
     2864            if (callback) callback(err);
     2865        });
     2866}
     2867
     2868const REPORT_FUNCTION_NAMES = new Set([
     2869    'get_orders_by_total',
     2870    'get_products_by_total_sales',
     2871    'get_low_stock_high_demand_products',
     2872    'get_products_monthly_sales',
     2873    'get_stores_by_last_calendar_year_revenue',
     2874    'get_products_never_ordered',
     2875    'get_products_by_number_of_orders',
     2876    'get_stores_by_average_review',
     2877    'get_store_with_highest_revenue_growth',
     2878    'get_clients_by_number_of_orders',
     2879    'get_approximate_orders_per_client',
     2880    'get_clients_without_orders',
     2881    'get_store_request_statistics',
     2882    'get_top_10_employees_by_requests_last_month',
     2883    'get_employees_by_hours_and_pay',
     2884    'get_stores_average_pay',
     2885    'get_employee_product_changes_last_month',
     2886    'get_stores_by_monthly_profit_and_revenue_growth',
     2887    'get_unapproved_reports'
     2888]);
     2889
     2890function runReport(reportName, params, callback) {
     2891
     2892    if (!REPORT_FUNCTION_NAMES.has(reportName)) {
     2893        callback(new Error('Unknown report: ' + reportName), null);
     2894        return;
     2895    }
     2896
     2897    const values = Array.isArray(params) ? params : [];
     2898
     2899    const placeholders = values.map(
     2900        (_, index) => '$' + (index + 1)
     2901    ).join(', ');
     2902
     2903    query(
     2904        `SELECT * FROM ${reportName}(${placeholders})`,
     2905        values,
     2906        (err, result) => {
     2907            callback(
     2908                err,
     2909                result ? result.rows : []
     2910            );
     2911        }
     2912    );
     2913}
     2914
     2915function generateStoreReport(
     2916    storeId,
     2917    startDate,
     2918    endDate,
     2919    type,
     2920    period,
     2921    ownerSignature,
     2922    callback
     2923) {
     2924
     2925    query(
     2926        `WITH sales AS (
     2927            SELECT
     2928                COALESCE(
     2929                    SUM(
     2930                        p.price * i.quantity
     2931                        * (1 - COALESCE(o.discount, 0) / 100.0)
     2932                    ),
     2933                    0
     2934                ) AS revenue
     2935            FROM sells se
     2936            JOIN product p
     2937              ON p.code = se.product_code
     2938            JOIN includes i
     2939              ON i.product_code = se.product_code
     2940            JOIN "order" o
     2941              ON o.order_num = i.order_num
     2942            WHERE se.store_id = $1
     2943              AND o.last_date_mod >= $2::timestamp
     2944              AND o.last_date_mod < ($3::timestamp + INTERVAL '1 day')
     2945        ),
     2946        refunds AS (
     2947            SELECT
     2948                COALESCE(SUM(rf.amount), 0) AS refund_total
     2949            FROM refund rf
     2950            JOIN "order" o
     2951              ON o.order_num = rf.order_num
     2952            WHERE LEFT(o.order_num, 3) = $1
     2953              AND o.last_date_mod >= $2::timestamp
     2954              AND o.last_date_mod < ($3::timestamp + INTERVAL '1 day')
     2955              AND rf.status IN ('approved', 'processed')
     2956        )
     2957        SELECT
     2958            sales.revenue,
     2959            refunds.refund_total,
     2960            sales.revenue - refunds.refund_total AS net_profit
     2961        FROM sales CROSS JOIN refunds`,
     2962        [storeId, startDate, endDate],
     2963        (err, result) => {
     2964
     2965            if (err) {
     2966                callback(err, null);
     2967                return;
     2968            }
     2969
     2970            const row = result.rows[0] || {};
     2971            const revenue = Number(row.revenue || 0);
     2972            const refundTotal = Number(row.refund_total || 0);
     2973            const netProfit = Number(row.net_profit || 0);
     2974
     2975            const salesTrend =
     2976                `Revenue ${revenue.toFixed(2)}; Refunds ${refundTotal.toFixed(2)}`;
     2977
     2978            query(
     2979                `SELECT
     2980                    COALESCE(SUM(
     2981                        p.price * i.quantity
     2982                        * (1 - COALESCE(o.discount, 0) / 100.0)
     2983                    ), 0) AS revenue
     2984                 FROM sells se
     2985                 JOIN product p
     2986                   ON p.code = se.product_code
     2987                 JOIN includes i
     2988                   ON i.product_code = se.product_code
     2989                 JOIN "order" o
     2990                   ON o.order_num = i.order_num
     2991                 WHERE se.store_id = $1
     2992                   AND o.last_date_mod >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
     2993                   AND o.last_date_mod < DATE_TRUNC('month', CURRENT_DATE)`,
     2994                [storeId],
     2995                (previousErr, previousResult) => {
     2996
     2997                    if (previousErr) {
     2998                        callback(previousErr, null);
     2999                        return;
     3000                    }
     3001
     3002                    const previousRevenue =
     3003                        Number(previousResult.rows[0]?.revenue || 0);
     3004
     3005                    const growth =
     3006                        previousRevenue === 0
     3007                            ? (revenue > 0 ? 100 : 0)
     3008                            : ((revenue - previousRevenue) / previousRevenue) * 100;
     3009
     3010                    const marketingGrowth =
     3011                        `${growth.toFixed(2)}%`;
     3012
     3013                    query(
     3014                        `INSERT INTO report
     3015                            (
     3016                                date,
     3017                                store_id,
     3018                                overall_profit,
     3019                                sales_trend,
     3020                                marketing_growth,
     3021                                owner_signature
     3022                            )
     3023                         VALUES
     3024                            (
     3025                                CURRENT_TIMESTAMP,
     3026                                $1,
     3027                                $2,
     3028                                $3,
     3029                                $4,
     3030                                $5
     3031                            )
     3032                         RETURNING
     3033                            date,
     3034                            store_id,
     3035                            overall_profit,
     3036                            sales_trend,
     3037                            marketing_growth,
     3038                            owner_signature`,
     3039                        [
     3040                            storeId,
     3041                            Math.max(0, netProfit),
     3042                            salesTrend.slice(0, 100),
     3043                            marketingGrowth.slice(0, 100),
     3044                            ownerSignature || 'Not signed yet'
     3045                        ],
     3046                        (insertErr, insertResult) => {
     3047
     3048                            if (insertErr) {
     3049                                callback(insertErr, null);
     3050                                return;
     3051                            }
     3052
     3053                            const report = insertResult.rows[0];
     3054
     3055                            /*
     3056                             * monthly_profit has a composite primary key of
     3057                             * (report_date, store_id), so it can contain one
     3058                             * summary row per generated report without changing
     3059                             * the project database structure.
     3060                             */
     3061                            query(
     3062                                `INSERT INTO monthly_profit
     3063                                    (
     3064                                        report_date,
     3065                                        store_id,
     3066                                        month_and_year,
     3067                                        profit
     3068                                    )
     3069                                 VALUES
     3070                                    (
     3071                                        $1,
     3072                                        $2,
     3073                                        DATE_TRUNC('month', $3::timestamp)::DATE,
     3074                                        $4
     3075                                    )
     3076                                 ON CONFLICT (report_date, store_id)
     3077                                 DO UPDATE SET
     3078                                    month_and_year = EXCLUDED.month_and_year,
     3079                                    profit = EXCLUDED.profit`,
     3080                                [
     3081                                    report.date,
     3082                                    storeId,
     3083                                    endDate,
     3084                                    Math.max(0, netProfit)
     3085                                ],
     3086                                (monthlyErr) => {
     3087
     3088                                    if (monthlyErr) {
     3089                                        console.error(
     3090                                            'Warning inserting monthly profit:',
     3091                                            monthlyErr
     3092                                        );
     3093                                    }
     3094
     3095                                    query(
     3096                                        `INSERT INTO exchanges_data
     3097                                            (
     3098                                                report_date,
     3099                                                store_id,
     3100                                                monthly_profit,
     3101                                                date,
     3102                                                sales,
     3103                                                damages
     3104                                            )
     3105                                         VALUES
     3106                                            (
     3107                                                $1,
     3108                                                $2,
     3109                                                $3,
     3110                                                CURRENT_TIMESTAMP,
     3111                                                $4,
     3112                                                $5
     3113                                            )
     3114                                         ON CONFLICT (report_date, store_id)
     3115                                         DO UPDATE SET
     3116                                            monthly_profit = EXCLUDED.monthly_profit,
     3117                                            date = EXCLUDED.date,
     3118                                            sales = EXCLUDED.sales,
     3119                                            damages = EXCLUDED.damages`,
     3120                                        [
     3121                                            report.date,
     3122                                            storeId,
     3123                                            Math.max(0, netProfit),
     3124                                            revenue,
     3125                                            -refundTotal
     3126                                        ],
     3127                                        (exchangeErr) => {
     3128
     3129                                            if (exchangeErr) {
     3130                                                console.error(
     3131                                                    'Warning inserting exchange data:',
     3132                                                    exchangeErr
     3133                                                );
     3134                                            }
     3135
     3136                                            callback(
     3137                                                null,
     3138                                                report
     3139                                            );
     3140                                        }
     3141                                    );
     3142                                }
     3143                            );
     3144                        }
     3145                    );
     3146                }
     3147            );
     3148        }
     3149    );
     3150}
    22953151/*
    22963152 * ============================================================
    … …  
    23403196    getStoreStats,
    23413197
     3198    installReportFunctions,
     3199    runReport,
     3200    generateStoreReport,
     3201
    23423202    createOrderNew,
    23433203    getOrdersByClient,
  • node_modules/.package-lock.json

    r6149556 r6c7cfa6  
    468468    },
    469469    "node_modules/semver": {
    470       "version": "7.7.4",
    471       "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
    472       "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
     470      "version": "7.8.5",
     471      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
     472      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
    473473      "dev": true,
    474474      "license": "ISC",
  • node_modules/semver/README.md

    r6149556 r6c7cfa6  
    5757const semverSort = require('semver/functions/sort')
    5858const semverRsort = require('semver/functions/rsort')
     59const semverTruncate = require('semver/functions/truncate')
    5960
    6061// low-level comparators between versions
    … …  
    400401caret      ::= '^' partial
    401402qualifier  ::= ( '-' pre )? ( '+' build )?
    402 pre        ::= parts
    403 build      ::= parts
    404 parts      ::= part ( '.' part ) *
    405 part       ::= nr | [-0-9A-Za-z]+
    406 ```
     403pre        ::= prepart ( '.' prepart ) *
     404prepart    ::= nr | alphanumid
     405build      ::= buildid ( '.' buildid ) *
     406alphanumid ::= ( ['0'-'9'] ) * [-A-Za-z] [-0-9A-Za-z] *
     407buildid    ::= [-0-9A-Za-z]+
     408```
     409
     410Note: Prerelease identifiers (`pre`) use `nr` for numeric parts, which
     411disallows leading zeros (e.g., `1.2.3-00` is invalid). Build metadata
     412identifiers (`build`) allow any alphanumeric string including leading
     413zeros (e.g., `1.2.3+00` is valid). This matches the
     414[SemVer 2.0.0 specification](https://semver.org/#spec-item-9).
    407415
    408416## Functions
    … …  
    450458* `parse(v)`: Attempt to parse a string as a semantic version, returning either
    451459  a `SemVer` object or `null`.
     460* `truncate(v, releaseType)`: Return the version with components _lower_
     461  than `releaseType` dropped off, e.g.:
     462  * `major` removes build & prerelease info and sets minor & patch to 0.
     463  * `minor` removes build & prerelease info, and sets patch to 0
     464  * `patch` removes build & prerelease info
     465  * All prerelease types remove build info only
    452466
    453467### Comparison
    … …  
    651665* `require('semver/functions/satisfies')`
    652666* `require('semver/functions/sort')`
     667* `require('semver/functions/truncate')`
    653668* `require('semver/functions/valid')`
    654669* `require('semver/ranges/gtr')`
  • node_modules/semver/classes/range.js

    r6149556 r6c7cfa6  
    9999
    100100  parseRange (range) {
     101    // strip build metadata so it can't bleed into the version
     102    range = range.replace(BUILDSTRIPRE, '')
     103
    101104    // memoize range parsing for performance.
    102105    // this is a very hot path, and fully deterministic.
    … …  
    224227const {
    225228  safeRe: re,
     229  src,
    226230  t,
    227231  comparatorTrimReplace,
    … …  
    230234} = require('../internal/re')
    231235const { FLAG_INCLUDE_PRERELEASE, FLAG_LOOSE } = require('../internal/constants')
     236
     237// unbounded global build-metadata stripper used by parseRange
     238const BUILDSTRIPRE = new RegExp(src[t.BUILD], 'g')
    232239
    233240const isNullSet = c => c.value === '<0.0.0-0'
    … …  
    271278const isX = id => !id || id.toLowerCase() === 'x' || id === '*'
    272279
     280const invalidXRangeOrder = (M, m, p) => (
     281  (isX(M) && !isX(m)) ||
     282  (isX(m) && p && !isX(p))
     283)
     284
    273285// ~, ~> --> * (any, kinda silly)
    274286// ~2, ~2.x, ~2.x.x, ~>2, ~>2.x ~>2.x.x --> >=2.0.0 <3.0.0-0
    … …  
    288300const replaceTilde = (comp, options) => {
    289301  const r = options.loose ? re[t.TILDELOOSE] : re[t.TILDE]
     302  // if we're including prereleases in the match, then the lower bound is
     303  // -0, the lowest possible prerelease value, just like x-ranges and carets.
     304  // this keeps `~1.2` equivalent to the `1.2.x` x-range it's documented as.
     305  const z = options.includePrerelease ? '-0' : ''
    290306  return comp.replace(r, (_, M, m, p, pr) => {
    291307    debug('tilde', comp, _, M, m, p, pr)
    … …  
    295311      ret = ''
    296312    } else if (isX(m)) {
    297       ret = `>=${M}.0.0 <${+M + 1}.0.0-0`
     313      ret = `>=${M}.0.0${z} <${+M + 1}.0.0-0`
    298314    } else if (isX(p)) {
    299315      // ~1.2 == >=1.2.0 <1.3.0-0
    300       ret = `>=${M}.${m}.0 <${M}.${+m + 1}.0-0`
     316      ret = `>=${M}.${m}.0${z} <${M}.${+m + 1}.0-0`
    301317    } else if (pr) {
    302318      debug('replaceTilde pr', pr)
    … …  
    367383        if (m === '0') {
    368384          ret = `>=${M}.${m}.${p
    369           }${z} <${M}.${m}.${+p + 1}-0`
     385          } <${M}.${m}.${+p + 1}-0`
    370386        } else {
    371387          ret = `>=${M}.${m}.${p
    372           }${z} <${M}.${+m + 1}.0-0`
     388          } <${M}.${+m + 1}.0-0`
    373389        }
    374390      } else {
    … …  
    396412  return comp.replace(r, (ret, gtlt, M, m, p, pr) => {
    397413    debug('xRange', comp, ret, gtlt, M, m, p, pr)
     414    if (invalidXRangeOrder(M, m, p)) {
     415      return comp
     416    }
     417
    398418    const xM = isX(M)
    399419    const xm = xM || isX(m)
  • node_modules/semver/classes/semver.js

    r6149556 r6c7cfa6  
    77const parseOptions = require('../internal/parse-options')
    88const { compareIdentifiers } = require('../internal/identifiers')
     9
     10const isPrereleaseIdentifier = (prerelease, identifier) => {
     11  const identifiers = identifier.split('.')
     12  if (identifiers.length > prerelease.length) {
     13    return false
     14  }
     15
     16  for (let i = 0; i < identifiers.length; i++) {
     17    if (compareIdentifiers(prerelease[i], identifiers[i]) !== 0) {
     18      return false
     19    }
     20  }
     21
     22  return true
     23}
     24
    925class SemVer {
    1026  constructor (version, options) {
    … …  
    310326            prerelease = [identifier]
    311327          }
    312           if (compareIdentifiers(this.prerelease[0], identifier) === 0) {
    313             if (isNaN(this.prerelease[1])) {
     328          if (isPrereleaseIdentifier(this.prerelease, identifier)) {
     329            const prereleaseBase = this.prerelease[identifier.split('.').length]
     330            if (isNaN(prereleaseBase)) {
    314331              this.prerelease = prerelease
    315332            }
  • node_modules/semver/index.js

    r6149556 r6c7cfa6  
    2929const cmp = require('./functions/cmp')
    3030const coerce = require('./functions/coerce')
     31const truncate = require('./functions/truncate')
    3132const Comparator = require('./classes/comparator')
    3233const Range = require('./classes/range')
    … …  
    6768  cmp,
    6869  coerce,
     70  truncate,
    6971  Comparator,
    7072  Range,
  • node_modules/semver/internal/re.js

    r6149556 r6c7cfa6  
    137137
    138138// Something like "2.*" or "1.2.x".
    139 // Note that "x.x" is a valid xRange identifer, meaning "any version"
     139// Note that "x.x" is a valid xRange identifier, meaning "any version"
    140140// Only the first item is strictly required.
    141141createToken('XRANGEIDENTIFIERLOOSE', `${src[t.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`)
  • node_modules/semver/package.json

    r6149556 r6c7cfa6  
    11{
    22  "name": "semver",
    3   "version": "7.7.4",
     3  "version": "7.8.5",
    44  "description": "The semantic version parser used by npm.",
    55  "main": "index.js",
    … …  
    1515  },
    1616  "devDependencies": {
    17     "@npmcli/eslint-config": "^6.0.0",
    18     "@npmcli/template-oss": "4.29.0",
     17    "@npmcli/eslint-config": "^7.0.0",
     18    "@npmcli/template-oss": "5.0.0",
    1919    "benchmark": "^2.1.4",
    2020    "tap": "^16.0.0"
    … …  
    5353  "templateOSS": {
    5454    "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
    55     "version": "4.29.0",
     55    "version": "5.0.0",
    5656    "engines": ">=10",
    5757    "distPaths": [
  • node_modules/semver/range.bnf

    r6149556 r6c7cfa6  
    1111caret      ::= '^' partial
    1212qualifier  ::= ( '-' pre )? ( '+' build )?
    13 pre        ::= parts
    14 build      ::= parts
    15 parts      ::= part ( '.' part ) *
    16 part       ::= nr | [-0-9A-Za-z]+
     13pre        ::= prepart ( '.' prepart ) *
     14prepart    ::= nr | alphanumid
     15build      ::= buildid ( '.' buildid ) *
     16alphanumid ::= ( [0-9] ) * [A-Za-z-] [-0-9A-Za-z] *
     17buildid    ::= [-0-9A-Za-z]+
  • node_modules/semver/ranges/subset.js

    r6149556 r6c7cfa6  
    175175          return false
    176176        }
    177       } else if (gt.operator === '>=' && !satisfies(gt.semver, String(c), options)) {
     177      } else if (gt.operator === '>=' && !c.test(gt.semver)) {
    178178        return false
    179179      }
    … …  
    193193          return false
    194194        }
    195       } else if (lt.operator === '<=' && !satisfies(lt.semver, String(c), options)) {
     195      } else if (lt.operator === '<=' && !c.test(lt.semver)) {
    196196        return false
    197197      }
  • server.js

    r6149556 r6c7cfa6  
    77const nodemailer = require('nodemailer');
    88const bcrypt = require('bcryptjs');
     9const { AsyncLocalStorage } = require('async_hooks');
    910require('dotenv').config();
    1011
    … …  
    356357});
    357358
    358 let transactionClient = null;
     359const transactionStorage = new AsyncLocalStorage();
    359360
    360361function dbQuery(sql, params = [], callback) {
    361     const client = transactionClient || pool;
     362    const client = transactionStorage.getStore() || pool;
     363
    362364    client.query(sql, params)
    363365        .then(result => callback(null, result))
    364366        .catch(err => callback(err));
    365367}
     368
     369const REPORT_FUNCTIONS_SQL = String.raw`
     370-- ============================================================
     371-- HANDCRAFT MARKETPLACE REPORT FUNCTIONS
     372-- PostgreSQL / exact project schema
     373-- ============================================================
     374
     375CREATE OR REPLACE FUNCTION get_orders_by_total()
     376RETURNS TABLE (
     377    order_num VARCHAR(11),
     378    client_id INTEGER,
     379    client_name TEXT,
     380    order_quantity BIGINT,
     381    order_status VARCHAR(20),
     382    payment_method VARCHAR(250),
     383    discount NUMERIC,
     384    order_total NUMERIC
     385)
     386LANGUAGE sql
     387AS $$
     388    SELECT
     389        o.order_num,
     390        o.client_id,
     391        CONCAT_WS(' ', c.first_name, c.last_name) AS client_name,
     392        COALESCE(SUM(i.quantity), 0)::BIGINT AS order_quantity,
     393        o.status,
     394        o.payment_method,
     395        COALESCE(o.discount, 0)::NUMERIC AS discount,
     396        ROUND(
     397            COALESCE(SUM(p.price * i.quantity), 0)
     398            * (1 - COALESCE(o.discount, 0) / 100.0),
     399            2
     400        ) AS order_total
     401    FROM "order" o
     402    LEFT JOIN client c ON c.client_id = o.client_id
     403    LEFT JOIN includes i ON i.order_num = o.order_num
     404    LEFT JOIN product p ON p.code = i.product_code
     405    GROUP BY
     406        o.order_num, o.client_id, c.first_name, c.last_name,
     407        o.status, o.payment_method, o.discount
     408    ORDER BY 8 DESC, o.order_num;
     409$$;
     410
     411CREATE OR REPLACE FUNCTION get_products_by_total_sales()
     412RETURNS TABLE (
     413    product_code VARCHAR(8),
     414    product_description VARCHAR(500),
     415    product_price NUMERIC,
     416    number_of_orders BIGINT,
     417    total_quantity_sold BIGINT,
     418    total_revenue NUMERIC
     419)
     420LANGUAGE sql
     421AS $$
     422    SELECT
     423        p.code,
     424        p.description,
     425        p.price::NUMERIC,
     426        COUNT(DISTINCT i.order_num) AS number_of_orders,
     427        COALESCE(SUM(i.quantity), 0)::BIGINT AS total_quantity_sold,
     428        ROUND(
     429            COALESCE(
     430                SUM(
     431                    p.price * i.quantity
     432                    * (1 - COALESCE(o.discount, 0) / 100.0)
     433                ),
     434                0
     435            ),
     436            2
     437        ) AS total_revenue
     438    FROM product p
     439    LEFT JOIN includes i ON i.product_code = p.code
     440    LEFT JOIN "order" o ON o.order_num = i.order_num
     441    GROUP BY p.code, p.description, p.price
     442    ORDER BY 4 DESC, 5 DESC, 6 DESC;
     443$$;
     444
     445CREATE OR REPLACE FUNCTION get_low_stock_high_demand_products(
     446    p_stock_threshold INTEGER,
     447    p_demand_threshold INTEGER
     448)
     449RETURNS TABLE (
     450    product_code VARCHAR(8),
     451    product_description VARCHAR(500),
     452    current_stock INTEGER,
     453    number_of_orders BIGINT,
     454    total_quantity_sold BIGINT
     455)
     456LANGUAGE sql
     457AS $$
     458    SELECT
     459        p.code,
     460        p.description,
     461        p.availability,
     462        COUNT(DISTINCT i.order_num),
     463        COALESCE(SUM(i.quantity), 0)::BIGINT
     464    FROM product p
     465    JOIN includes i ON i.product_code = p.code
     466    GROUP BY p.code, p.description, p.availability
     467    HAVING
     468        p.availability < p_stock_threshold
     469        AND COUNT(DISTINCT i.order_num) >= p_demand_threshold
     470    ORDER BY 4 DESC, 5 DESC, 3 ASC;
     471$$;
     472
     473CREATE OR REPLACE FUNCTION get_products_monthly_sales()
     474RETURNS TABLE (
     475    product_code VARCHAR(8),
     476    product_description VARCHAR(500),
     477    year INTEGER,
     478    month INTEGER,
     479    number_of_orders BIGINT,
     480    total_quantity_sold BIGINT,
     481    total_revenue NUMERIC
     482)
     483LANGUAGE sql
     484AS $$
     485    SELECT
     486        p.code,
     487        p.description,
     488        EXTRACT(YEAR FROM o.last_date_mod)::INTEGER,
     489        EXTRACT(MONTH FROM o.last_date_mod)::INTEGER,
     490        COUNT(DISTINCT o.order_num),
     491        COALESCE(SUM(i.quantity), 0)::BIGINT,
     492        ROUND(
     493            COALESCE(
     494                SUM(
     495                    p.price * i.quantity
     496                    * (1 - COALESCE(o.discount, 0) / 100.0)
     497                ),
     498                0
     499            ),
     500            2
     501        )
     502    FROM product p
     503    JOIN includes i ON i.product_code = p.code
     504    JOIN "order" o ON o.order_num = i.order_num
     505    GROUP BY
     506        p.code, p.description,
     507        EXTRACT(YEAR FROM o.last_date_mod),
     508        EXTRACT(MONTH FROM o.last_date_mod)
     509    ORDER BY 3 DESC, 4 DESC, 7 DESC;
     510$$;
     511
     512CREATE OR REPLACE FUNCTION get_stores_by_last_calendar_year_revenue()
     513RETURNS TABLE (
     514    store_id VARCHAR(3),
     515    store_name VARCHAR(50),
     516    number_of_orders BIGINT,
     517    total_quantity_sold BIGINT,
     518    total_revenue NUMERIC
     519)
     520LANGUAGE sql
     521AS $$
     522    SELECT
     523        s.store_id,
     524        s.name,
     525        COUNT(DISTINCT o.order_num),
     526        COALESCE(SUM(i.quantity), 0)::BIGINT,
     527        ROUND(
     528            COALESCE(
     529                SUM(
     530                    p.price * i.quantity
     531                    * (1 - COALESCE(o.discount, 0) / 100.0)
     532                ),
     533                0
     534            ),
     535            2
     536        )
     537    FROM store s
     538    LEFT JOIN sells se ON se.store_id = s.store_id
     539    LEFT JOIN product p ON p.code = se.product_code
     540    LEFT JOIN includes i ON i.product_code = p.code
     541    LEFT JOIN "order" o
     542        ON o.order_num = i.order_num
     543       AND o.last_date_mod >= DATE_TRUNC('year', CURRENT_DATE) - INTERVAL '1 year'
     544       AND o.last_date_mod < DATE_TRUNC('year', CURRENT_DATE)
     545    GROUP BY s.store_id, s.name
     546    ORDER BY 5 DESC, s.store_id;
     547$$;
     548
     549CREATE OR REPLACE FUNCTION get_products_never_ordered()
     550RETURNS TABLE (
     551    product_code VARCHAR(8),
     552    product_description VARCHAR(500),
     553    product_price NUMERIC,
     554    current_stock INTEGER
     555)
     556LANGUAGE sql
     557AS $$
     558    SELECT p.code, p.description, p.price::NUMERIC, p.availability
     559    FROM product p
     560    WHERE NOT EXISTS (
     561        SELECT 1
     562        FROM includes i
     563        WHERE i.product_code = p.code
     564    )
     565    ORDER BY p.code;
     566$$;
     567
     568CREATE OR REPLACE FUNCTION get_products_by_number_of_orders()
     569RETURNS TABLE (
     570    product_code VARCHAR(8),
     571    product_description VARCHAR(500),
     572    product_price NUMERIC,
     573    number_of_orders BIGINT
     574)
     575LANGUAGE sql
     576AS $$
     577    SELECT
     578        p.code,
     579        p.description,
     580        p.price::NUMERIC,
     581        COUNT(DISTINCT i.order_num)
     582    FROM product p
     583    JOIN includes i ON i.product_code = p.code
     584    GROUP BY p.code, p.description, p.price
     585    ORDER BY 4 DESC, p.code;
     586$$;
     587
     588CREATE OR REPLACE FUNCTION get_stores_by_average_review()
     589RETURNS TABLE (
     590    store_id VARCHAR(3),
     591    store_name VARCHAR(50),
     592    average_review NUMERIC,
     593    number_of_reviews BIGINT
     594)
     595LANGUAGE sql
     596AS $$
     597    WITH store_reviews AS (
     598        SELECT DISTINCT
     599            s.store_id,
     600            s.name AS store_name,
     601            r.order_num,
     602            r.rating
     603        FROM store s
     604        JOIN sells se ON se.store_id = s.store_id
     605        JOIN includes i ON i.product_code = se.product_code
     606        JOIN review r ON r.order_num = i.order_num
     607    )
     608    SELECT
     609        s.store_id,
     610        s.name,
     611        COALESCE(ROUND(AVG(sr.rating), 2), 0)::NUMERIC,
     612        COUNT(sr.order_num)
     613    FROM store s
     614    LEFT JOIN store_reviews sr ON sr.store_id = s.store_id
     615    GROUP BY s.store_id, s.name
     616    ORDER BY 3 DESC, s.store_id;
     617$$;
     618
     619CREATE OR REPLACE FUNCTION get_store_with_highest_revenue_growth()
     620RETURNS TABLE (
     621    store_id VARCHAR(3),
     622    store_name VARCHAR(50),
     623    previous_year_revenue NUMERIC,
     624    last_year_revenue NUMERIC,
     625    revenue_growth NUMERIC
     626)
     627LANGUAGE sql
     628AS $$
     629    WITH store_years AS (
     630        SELECT
     631            s.store_id,
     632            s.name AS store_name,
     633            EXTRACT(YEAR FROM o.last_date_mod)::INTEGER AS sales_year,
     634            SUM(
     635                p.price * i.quantity
     636                * (1 - COALESCE(o.discount, 0) / 100.0)
     637            ) AS revenue
     638        FROM store s
     639        JOIN sells se ON se.store_id = s.store_id
     640        JOIN includes i ON i.product_code = se.product_code
     641        JOIN product p ON p.code = i.product_code
     642        JOIN "order" o ON o.order_num = i.order_num
     643        WHERE o.last_date_mod >= DATE_TRUNC('year', CURRENT_DATE) - INTERVAL '2 years'
     644          AND o.last_date_mod < DATE_TRUNC('year', CURRENT_DATE)
     645        GROUP BY s.store_id, s.name, EXTRACT(YEAR FROM o.last_date_mod)
     646    ),
     647    comparison AS (
     648        SELECT
     649            s.store_id,
     650            s.name AS store_name,
     651            COALESCE(MAX(CASE
     652                WHEN sy.sales_year = EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER - 2
     653                THEN sy.revenue ELSE 0 END), 0) AS previous_year_revenue,
     654            COALESCE(MAX(CASE
     655                WHEN sy.sales_year = EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER - 1
     656                THEN sy.revenue ELSE 0 END), 0) AS last_year_revenue
     657        FROM store s
     658        LEFT JOIN store_years sy ON sy.store_id = s.store_id
     659        GROUP BY s.store_id, s.name
     660    )
     661    SELECT
     662        store_id,
     663        store_name,
     664        ROUND(previous_year_revenue, 2),
     665        ROUND(last_year_revenue, 2),
     666        ROUND(last_year_revenue - previous_year_revenue, 2)
     667    FROM comparison
     668    ORDER BY 5 DESC, store_id
     669    LIMIT 1;
     670$$;
     671
     672CREATE OR REPLACE FUNCTION get_clients_by_number_of_orders()
     673RETURNS TABLE (
     674    client_id INTEGER,
     675    client_name TEXT,
     676    number_of_orders BIGINT
     677)
     678LANGUAGE sql
     679AS $$
     680    SELECT
     681        c.client_id,
     682        CONCAT_WS(' ', c.first_name, c.last_name),
     683        COUNT(o.order_num)
     684    FROM client c
     685    JOIN "order" o ON o.client_id = c.client_id
     686    GROUP BY c.client_id, c.first_name, c.last_name
     687    ORDER BY 3 DESC, c.client_id;
     688$$;
     689
     690CREATE OR REPLACE FUNCTION get_approximate_orders_per_client()
     691RETURNS TABLE (
     692    total_clients BIGINT,
     693    total_orders BIGINT,
     694    approximate_orders_per_client NUMERIC
     695)
     696LANGUAGE sql
     697AS $$
     698    SELECT
     699        (SELECT COUNT(*) FROM client),
     700        (SELECT COUNT(*) FROM "order"),
     701        ROUND(
     702            (SELECT COUNT(*)::NUMERIC FROM "order")
     703            / NULLIF((SELECT COUNT(*) FROM client), 0),
     704            2
     705        );
     706$$;
     707
     708CREATE OR REPLACE FUNCTION get_clients_without_orders()
     709RETURNS TABLE (
     710    client_id INTEGER,
     711    client_name TEXT,
     712    email VARCHAR(50)
     713)
     714LANGUAGE sql
     715AS $$
     716    SELECT
     717        c.client_id,
     718        CONCAT_WS(' ', c.first_name, c.last_name),
     719        c.email
     720    FROM client c
     721    WHERE NOT EXISTS (
     722        SELECT 1 FROM "order" o WHERE o.client_id = c.client_id
     723    )
     724    ORDER BY c.client_id;
     725$$;
     726
     727CREATE OR REPLACE FUNCTION get_store_request_statistics()
     728RETURNS TABLE (
     729    store_id VARCHAR(3),
     730    store_name VARCHAR(50),
     731    total_requests BIGINT,
     732    solved_requests BIGINT,
     733    requests_in_progress BIGINT
     734)
     735LANGUAGE sql
     736AS $$
     737    SELECT
     738        s.store_id,
     739        s.name,
     740        COUNT(r.request_num),
     741        COUNT(r.request_num) FILTER (WHERE r.customer_satisfaction > 0),
     742        COUNT(r.request_num) FILTER (WHERE r.customer_satisfaction <= 0)
     743    FROM store s
     744    LEFT JOIN for_store fs ON fs.store_id = s.store_id
     745    LEFT JOIN request r ON r.request_num = fs.request_num
     746    GROUP BY s.store_id, s.name
     747    ORDER BY 3 DESC, s.store_id;
     748$$;
     749
     750CREATE OR REPLACE FUNCTION get_top_10_employees_by_requests_last_month()
     751RETURNS TABLE (
     752    employee_id VARCHAR(10),
     753    employee_name TEXT,
     754    number_of_requests BIGINT
     755)
     756LANGUAGE sql
     757AS $$
     758    SELECT
     759        e.employee_id,
     760        CONCAT_WS(' ', p.first_name, p.last_name),
     761        COUNT(DISTINCT a.request_num)
     762    FROM employees e
     763    JOIN personal p ON p.id = e.employee_id
     764    JOIN answers a ON a.personal_id = e.employee_id
     765    JOIN request r ON r.request_num = a.request_num
     766    WHERE r.date_and_time >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
     767      AND r.date_and_time < DATE_TRUNC('month', CURRENT_DATE)
     768    GROUP BY e.employee_id, p.first_name, p.last_name
     769    ORDER BY 3 DESC, e.employee_id
     770    LIMIT 10;
     771$$;
     772
     773CREATE OR REPLACE FUNCTION get_employees_by_hours_and_pay()
     774RETURNS TABLE (
     775    employee_id VARCHAR(10),
     776    employee_name TEXT,
     777    total_hours_worked NUMERIC,
     778    total_pay NUMERIC
     779)
     780LANGUAGE sql
     781AS $$
     782    SELECT
     783        e.employee_id,
     784        CONCAT_WS(' ', p.first_name, p.last_name),
     785        COALESCE(SUM(w.total_hours), 0),
     786        COALESCE(SUM(w.wage * w.total_hours), 0)
     787    FROM employees e
     788    JOIN personal p ON p.id = e.employee_id
     789    LEFT JOIN worked w ON w.personal_id = e.employee_id
     790    GROUP BY e.employee_id, p.first_name, p.last_name
     791    ORDER BY 3 DESC, 4 DESC, e.employee_id;
     792$$;
     793
     794CREATE OR REPLACE FUNCTION get_stores_average_pay()
     795RETURNS TABLE (
     796    store_id VARCHAR(3),
     797    store_name VARCHAR(50),
     798    average_pay NUMERIC
     799)
     800LANGUAGE sql
     801AS $$
     802    SELECT
     803        s.store_id,
     804        s.name,
     805        COALESCE(ROUND(AVG(w.wage), 2), 0)
     806    FROM store s
     807    LEFT JOIN worked w ON w.store_id = s.store_id
     808    GROUP BY s.store_id, s.name
     809    ORDER BY 3 DESC, s.store_id;
     810$$;
     811
     812CREATE OR REPLACE FUNCTION get_employee_product_changes_last_month()
     813RETURNS TABLE (
     814    employee_id VARCHAR(10),
     815    employee_name TEXT,
     816    number_of_product_changes BIGINT
     817)
     818LANGUAGE sql
     819AS $$
     820    SELECT
     821        e.employee_id,
     822        CONCAT_WS(' ', p.first_name, p.last_name),
     823        COUNT(mc.change_date_time)
     824    FROM employees e
     825    JOIN personal p ON p.id = e.employee_id
     826    LEFT JOIN makes_change mc
     827        ON mc.personal_id = e.employee_id
     828       AND mc.change_date_time >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
     829       AND mc.change_date_time < DATE_TRUNC('month', CURRENT_DATE)
     830    GROUP BY e.employee_id, p.first_name, p.last_name
     831    ORDER BY 3 DESC, e.employee_id;
     832$$;
     833
     834CREATE OR REPLACE FUNCTION get_stores_by_monthly_profit_and_revenue_growth()
     835RETURNS TABLE (
     836    store_id VARCHAR(3),
     837    store_name VARCHAR(50),
     838    month_and_year TEXT,
     839    monthly_profit NUMERIC,
     840    previous_month_revenue NUMERIC,
     841    current_month_revenue NUMERIC,
     842    revenue_growth NUMERIC
     843)
     844LANGUAGE sql
     845AS $$
     846    WITH monthly_revenue AS (
     847        SELECT
     848            s.store_id,
     849            s.name AS store_name,
     850            DATE_TRUNC('month', o.last_date_mod)::DATE AS month_date,
     851            SUM(
     852                p.price * i.quantity
     853                * (1 - COALESCE(o.discount, 0) / 100.0)
     854            ) AS revenue
     855        FROM store s
     856        JOIN sells se ON se.store_id = s.store_id
     857        JOIN includes i ON i.product_code = se.product_code
     858        JOIN product p ON p.code = i.product_code
     859        JOIN "order" o ON o.order_num = i.order_num
     860        GROUP BY s.store_id, s.name, DATE_TRUNC('month', o.last_date_mod)
     861    ),
     862    with_previous AS (
     863        SELECT
     864            store_id,
     865            store_name,
     866            month_date,
     867            revenue,
     868            LAG(revenue) OVER (
     869                PARTITION BY store_id
     870                ORDER BY month_date
     871            ) AS previous_revenue
     872        FROM monthly_revenue
     873    )
     874    SELECT
     875        store_id,
     876        store_name,
     877        TO_CHAR(month_date, 'YYYY-MM'),
     878        ROUND(revenue, 2),
     879        ROUND(COALESCE(previous_revenue, 0), 2),
     880        ROUND(revenue, 2),
     881        ROUND(revenue - COALESCE(previous_revenue, 0), 2)
     882    FROM with_previous
     883    ORDER BY month_date DESC, 4 DESC, store_id;
     884$$;
     885
     886CREATE OR REPLACE FUNCTION get_unapproved_reports()
     887RETURNS TABLE (
     888    report_date TIMESTAMP,
     889    store_id VARCHAR(3),
     890    overall_profit NUMERIC,
     891    sales_trend VARCHAR(100),
     892    marketing_growth VARCHAR(100),
     893    owner_signature VARCHAR(50)
     894)
     895LANGUAGE sql
     896AS $$
     897    SELECT
     898        r.date,
     899        r.store_id,
     900        r.overall_profit,
     901        r.sales_trend,
     902        r.marketing_growth,
     903        r.owner_signature
     904    FROM report r
     905    LEFT JOIN approves a
     906        ON a.report_date = r.date
     907       AND a.store_id = r.store_id
     908    WHERE a.report_date IS NULL
     909    ORDER BY r.date DESC, r.store_id;
     910$$;
     911`;
    366912
    367913const database = {
    … …  
    393939
    394940            if (normalized === 'BEGIN TRANSACTION' || normalized === 'BEGIN') {
    395                 if (transactionClient) {
     941                const existingClient = transactionStorage.getStore();
     942
     943                // A transaction is already active in this async execution context.
     944                // Do not create a second transaction on the same request.
     945                if (existingClient) {
    396946                    callback?.(null);
    397947                    return;
    398948                }
    399                 pool.connect().then(client => {
    400                     transactionClient = client;
    401                     return client.query('BEGIN');
    402                 }).then(() => callback?.(null))
     949
     950                pool.connect()
     951                    .then(client => {
     952                        return client.query('BEGIN')
     953                            .then(() => {
     954                                // Everything scheduled by the callback now inherits
     955                                // this client through AsyncLocalStorage. Other
     956                                // concurrent requests get their own transaction.
     957                                transactionStorage.run(client, () => {
     958                                    callback?.(null);
     959                                });
     960                            })
     961                            .catch(err => {
     962                                client.release();
     963                                callback?.(err);
     964                            });
     965                    })
    403966                    .catch(err => {
    404                         if (transactionClient) transactionClient.release();
    405                         transactionClient = null;
    406967                        callback?.(err);
    407968                    });
     969
    408970                return;
    409971            }
    410972
    411973            if (normalized === 'COMMIT') {
    412                 if (!transactionClient) {
     974                const client = transactionStorage.getStore();
     975
     976                if (!client) {
    413977                    callback?.(null);
    414978                    return;
    415979                }
    416                 const client = transactionClient;
     980
    417981                client.query('COMMIT')
    418982                    .then(() => {
    419                         transactionClient = null;
    420983                        client.release();
    421984                        callback?.(null);
    422985                    })
    423986                    .catch(err => {
    424                         transactionClient = null;
    425                         client.release();
    426                         callback?.(err);
     987                        // COMMIT may fail before the transaction is completed.
     988                        // Roll back before releasing the client when possible.
     989                        client.query('ROLLBACK')
     990                            .catch(() => {})
     991                            .then(() => {
     992                                client.release();
     993                                callback?.(err);
     994                            });
    427995                    });
     996
    428997                return;
    429998            }
    430999
    4311000            if (normalized === 'ROLLBACK') {
    432                 if (!transactionClient) {
     1001                const client = transactionStorage.getStore();
     1002
     1003                if (!client) {
    4331004                    callback?.(null);
    4341005                    return;
    4351006                }
    436                 const client = transactionClient;
     1007
    4371008                client.query('ROLLBACK')
    4381009                    .then(() => {
    439                         transactionClient = null;
    4401010                        client.release();
    4411011                        callback?.(null);
    4421012                    })
    4431013                    .catch(err => {
    444                         transactionClient = null;
    4451014                        client.release();
    4461015                        callback?.(err);
    4471016                    });
     1017
    4481018                return;
    4491019            }
    … …  
    4581028            });
    4591029        }
     1030    },
     1031
     1032    async installReportFunctions() {
     1033        await pool.query(REPORT_FUNCTIONS_SQL);
     1034        console.log('✅ PostgreSQL report functions installed');
     1035    },
     1036
     1037    runReport(reportName, params, callback) {
     1038        const allowed = new Set([
     1039            'get_orders_by_total',
     1040            'get_products_by_total_sales',
     1041            'get_low_stock_high_demand_products',
     1042            'get_products_monthly_sales',
     1043            'get_stores_by_last_calendar_year_revenue',
     1044            'get_products_never_ordered',
     1045            'get_products_by_number_of_orders',
     1046            'get_stores_by_average_review',
     1047            'get_store_with_highest_revenue_growth',
     1048            'get_clients_by_number_of_orders',
     1049            'get_approximate_orders_per_client',
     1050            'get_clients_without_orders',
     1051            'get_store_request_statistics',
     1052            'get_top_10_employees_by_requests_last_month',
     1053            'get_employees_by_hours_and_pay',
     1054            'get_stores_average_pay',
     1055            'get_employee_product_changes_last_month',
     1056            'get_stores_by_monthly_profit_and_revenue_growth',
     1057            'get_unapproved_reports'
     1058        ]);
     1059
     1060        if (!allowed.has(reportName)) {
     1061            callback(new Error('Unknown report: ' + reportName), null);
     1062            return;
     1063        }
     1064
     1065        const values = Array.isArray(params) ? params : [];
     1066        const placeholders = values.map((_, index) => '$' + (index + 1)).join(', ');
     1067
     1068        dbQuery(
     1069            `SELECT * FROM ${reportName}(${placeholders})`,
     1070            values,
     1071            (err, result) => callback(err, result?.rows || [])
     1072        );
    4601073    },
    4611074
    … …  
    5201133        const schema = `
    5211134            CREATE TABLE IF NOT EXISTS category (
    522                 id SERIAL PRIMARY KEY,
    523                 name VARCHAR(50) NOT NULL,
     1135                                                    id SERIAL PRIMARY KEY,
     1136                                                    name VARCHAR(50) NOT NULL,
    5241137                parent_category_id INTEGER REFERENCES category(id) NOT NULL
    525             );
     1138                );
    5261139
    5271140            CREATE TABLE IF NOT EXISTS product (
    528                 code VARCHAR(8) PRIMARY KEY DEFAULT '-1',
     1141                                                   code VARCHAR(8) PRIMARY KEY DEFAULT '-1',
    5291142                price DECIMAL(10,2) NOT NULL CHECK (price >= 0.0),
    5301143                availability INTEGER NOT NULL,
    … …  
    5341147                description VARCHAR(500) NOT NULL,
    5351148                category_id INTEGER NOT NULL REFERENCES category(id) ON DELETE SET DEFAULT
    536             );
     1149                );
    5371150
    5381151            CREATE TABLE IF NOT EXISTS image (
    539                 product_code VARCHAR(8) REFERENCES product(code) ON DELETE CASCADE,
     1152                                                 product_code VARCHAR(8) REFERENCES product(code) ON DELETE CASCADE,
    5401153                image VARCHAR NOT NULL DEFAULT 'Image NOT found!'
    541             );
     1154                );
    5421155
    5431156            CREATE TABLE IF NOT EXISTS color (
    544                 product_code VARCHAR(8) REFERENCES product(code) ON DELETE CASCADE,
     1157                                                 product_code VARCHAR(8) REFERENCES product(code) ON DELETE CASCADE,
    5451158                color VARCHAR(50)
    546             );
     1159                );
    5471160
    5481161            CREATE TABLE IF NOT EXISTS store (
    549                 store_ID VARCHAR(3) PRIMARY KEY,
     1162                                                 store_ID VARCHAR(3) PRIMARY KEY,
    5501163                name VARCHAR(50) UNIQUE NOT NULL,
    5511164                date_of_founding DATE NOT NULL,
    … …  
    5531166                store_email VARCHAR(40) UNIQUE NOT NULL CHECK (store_email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$'),
    5541167                rating DECIMAL(2,1) NOT NULL DEFAULT 0 CHECK (rating>=0.0 AND rating<=5.0)
    555             );
     1168                );
    5561169
    5571170            CREATE TABLE IF NOT EXISTS personal (
    558                 id VARCHAR(10) PRIMARY KEY,
     1171                                                    id VARCHAR(10) PRIMARY KEY,
    5591172                first_name VARCHAR(20) NOT NULL,
    5601173                last_name VARCHAR(20) NOT NULL,
    … …  
    5621175                email VARCHAR(50) UNIQUE NOT NULL CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$'),
    5631176                password VARCHAR NOT NULL
    564             );
     1177                );
    5651178
    5661179            CREATE TABLE IF NOT EXISTS permissions (
    567                 personal_is VARCHAR(10) PRIMARY KEY REFERENCES personal(id) ON DELETE CASCADE,
     1180                                                       personal_id VARCHAR(10) PRIMARY KEY REFERENCES personal(id) ON DELETE CASCADE,
    5681181                type VARCHAR(50) NOT NULL,
    5691182                authorisation VARCHAR(50) NOT NULL
    570             );
     1183                );
    5711184
    5721185            CREATE TABLE IF NOT EXISTS boss (
    573                 boss_id VARCHAR(10) PRIMARY KEY REFERENCES personal(id) ON DELETE CASCADE
    574             );
     1186                                                boss_id VARCHAR(10) PRIMARY KEY REFERENCES personal(id) ON DELETE CASCADE
     1187                );
    5751188
    5761189            CREATE TABLE IF NOT EXISTS employees (
    577                 employee_id VARCHAR(10) PRIMARY KEY REFERENCES personal(id) ON DELETE CASCADE,
     1190                                                     employee_id VARCHAR(10) PRIMARY KEY REFERENCES personal(id) ON DELETE CASCADE,
    5781191                date_of_hire DATE NOT NULL
    579             );
     1192                );
    5801193
    5811194            CREATE TABLE IF NOT EXISTS client (
    582                 client_ID SERIAL PRIMARY KEY,
    583                 first_name VARCHAR(50) NOT NULL,
     1195                                                  client_ID SERIAL PRIMARY KEY,
     1196                                                  first_name VARCHAR(50) NOT NULL,
    5841197                last_name VARCHAR(50) NOT NULL,
    5851198                email VARCHAR(50) UNIQUE NOT NULL CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$'),
    5861199                password VARCHAR NOT NULL
    587             );
     1200                );
    5881201
    5891202            CREATE TABLE IF NOT EXISTS delivery_address (
    590                 client_ID INTEGER PRIMARY KEY REFERENCES client(client_ID) ON DELETE CASCADE,
     1203                                                            client_ID INTEGER PRIMARY KEY REFERENCES client(client_ID) ON DELETE CASCADE,
    5911204                address VARCHAR(200) NOT NULL,
    5921205                city VARCHAR(30) NOT NULL,
    … …  
    5941207                country VARCHAR(40) NOT NULL,
    5951208                is_default BOOLEAN DEFAULT TRUE
    596             );
     1209                );
    5971210
    5981211            CREATE TABLE IF NOT EXISTS "order" (
    599                 order_num VARCHAR(11) PRIMARY KEY,
     1212                                                   order_num VARCHAR(11) PRIMARY KEY,
    6001213                client_ID INTEGER REFERENCES client(client_ID) ON DELETE CASCADE,
    6011214                status VARCHAR(20) NOT NULL DEFAULT 'placed order',
    … …  
    6041217                discount DECIMAL(5,2) DEFAULT 0.0 CHECK(discount>=0.0 AND discount<=100.00),
    6051218                CONSTRAINT check_status CHECK (status IN ('placed order', 'being processed', 'shipping', 'delivered', 'canceled'))
    606             );
     1219                );
    6071220
    6081221            CREATE TABLE IF NOT EXISTS review (
    609                 order_num VARCHAR(11) PRIMARY KEY REFERENCES "order"(order_num) ON DELETE CASCADE,
     1222                                                  order_num VARCHAR(11) PRIMARY KEY REFERENCES "order"(order_num) ON DELETE CASCADE,
    6101223                comment VARCHAR(300),
    6111224                rating DECIMAL(2,1) NOT NULL CHECK(rating>=0.0 AND rating<=5.0),
    6121225                last_mod_date TIMESTAMP NOT NULL
    613             );
     1226                );
    6141227
    6151228            CREATE TABLE IF NOT EXISTS refund (
    616                 refund_id SERIAL PRIMARY KEY,
    617                 order_num VARCHAR(11) REFERENCES "order"(order_num) ON DELETE CASCADE,
     1229                                                  refund_id SERIAL PRIMARY KEY,
     1230                                                  order_num VARCHAR(11) REFERENCES "order"(order_num) ON DELETE CASCADE,
    6181231                reason VARCHAR(300),
    6191232                amount DECIMAL(5,2) NOT NULL,
    6201233                status VARCHAR(100) NOT NULL DEFAULT 'requested refund',
    621                 CONSTRAINT check_refund_status CHECK (status IN ('requested refund', 'being revied', 'approved', 'not approved', 'processed'))
    622             );
     1234                CONSTRAINT check_refund_status CHECK (status IN ('requested refund', 'being reviewed', 'approved', 'not approved', 'processed'))
     1235                );
    6231236
    6241237            CREATE TABLE IF NOT EXISTS report (
    625                 date TIMESTAMP NOT NULL,
    626                 store_ID VARCHAR(3) NOT NULL REFERENCES store(store_ID) ON DELETE CASCADE,
     1238                                                  date TIMESTAMP NOT NULL,
     1239                                                  store_ID VARCHAR(3) NOT NULL REFERENCES store(store_ID) ON DELETE CASCADE,
    6271240                overall_profit NUMERIC NOT NULL DEFAULT 0.0 CHECK(overall_profit>=0),
    6281241                sales_trend VARCHAR(100) NOT NULL,
    … …  
    6301243                owner_signature VARCHAR(50) NOT NULL DEFAULT 'Not signed yet',
    6311244                PRIMARY KEY (date, store_ID)
    632             );
     1245                );
    6331246
    6341247            CREATE TABLE IF NOT EXISTS monthly_profit (
    635                 report_date TIMESTAMP NOT NULL,
    636                 store_ID VARCHAR(3) NOT NULL,
     1248                                                          report_date TIMESTAMP NOT NULL,
     1249                                                          store_ID VARCHAR(3) NOT NULL,
    6371250                month_and_year DATE NOT NULL,
    6381251                profit NUMERIC NOT NULL DEFAULT 0.0,
    6391252                PRIMARY KEY(report_date, store_ID),
    6401253                FOREIGN KEY (report_date, store_ID) REFERENCES report(date, store_ID) ON DELETE CASCADE
    641             );
     1254                );
    6421255
    6431256            CREATE TABLE IF NOT EXISTS exchanges_data (
    644                 report_date TIMESTAMP NOT NULL,
    645                 store_ID VARCHAR(3) NOT NULL,
     1257                                                          report_date TIMESTAMP NOT NULL,
     1258                                                          store_ID VARCHAR(3) NOT NULL,
    6461259                monthly_profit NUMERIC NOT NULL DEFAULT 0.0,
    6471260                date TIMESTAMP NOT NULL,
    … …  
    6501263                PRIMARY KEY (report_date, store_ID),
    6511264                FOREIGN KEY (report_date, store_ID) REFERENCES report(date, store_ID) ON DELETE CASCADE
    652             );
     1265                );
    6531266
    6541267            CREATE TABLE IF NOT EXISTS request (
    655                 request_num VARCHAR(14) PRIMARY KEY,
     1268                                                   request_num VARCHAR(14) PRIMARY KEY,
    6561269                date_and_time TIMESTAMP NOT NULL,
    6571270                problem VARCHAR(300) NOT NULL,
    6581271                notes_of_communication VARCHAR,
    6591272                customer_satisfaction NUMERIC NOT NULL
    660             );
     1273                );
    6611274
    6621275            CREATE TABLE IF NOT EXISTS makes_request (
    663                 client_ID INTEGER NOT NULL REFERENCES client(client_ID) ON DELETE CASCADE,
     1276                                                         client_ID INTEGER NOT NULL REFERENCES client(client_ID) ON DELETE CASCADE,
    6641277                order_num VARCHAR(11) UNIQUE NOT NULL REFERENCES "order"(order_num) ON DELETE CASCADE,
    6651278                PRIMARY KEY(client_ID, order_num)
    666             );
     1279                );
    6671280
    6681281            CREATE TABLE IF NOT EXISTS answers (
    669                 request_num VARCHAR(14) REFERENCES request(request_num) ON DELETE CASCADE,
     1282                                                   request_num VARCHAR(14) REFERENCES request(request_num) ON DELETE CASCADE,
    6701283                personal_id VARCHAR(10) NOT NULL REFERENCES personal(id) ON DELETE CASCADE,
    6711284                PRIMARY KEY(request_num, personal_id)
    672             );
     1285                );
    6731286
    6741287            CREATE TABLE IF NOT EXISTS for_store (
    675                 request_num VARCHAR(14) REFERENCES request(request_num) ON DELETE CASCADE,
     1288                                                     request_num VARCHAR(14) REFERENCES request(request_num) ON DELETE CASCADE,
    6761289                store_ID VARCHAR(3) REFERENCES store(store_ID) ON DELETE CASCADE,
    6771290                PRIMARY KEY(request_num, store_ID)
    678             );
     1291                );
    6791292
    6801293            CREATE TABLE IF NOT EXISTS "change" (
    681                 date_and_time TIMESTAMP NOT NULL,
    682                 product_code VARCHAR(8) REFERENCES product(code) ON DELETE CASCADE,
     1294                                                    date_and_time TIMESTAMP NOT NULL,
     1295                                                    product_code VARCHAR(8) REFERENCES product(code) ON DELETE CASCADE,
    6831296                changes VARCHAR NOT NULL,
    6841297                PRIMARY KEY (date_and_time, product_code)
    685             );
     1298                );
    6861299
    6871300            CREATE TABLE IF NOT EXISTS makes_change (
    688                 personal_id VARCHAR(10) REFERENCES personal(id) ON DELETE CASCADE,
     1301                                                        personal_id VARCHAR(10) REFERENCES personal(id) ON DELETE CASCADE,
    6891302                change_date_time TIMESTAMP,
    6901303                product_code VARCHAR(8),
    6911304                PRIMARY KEY(personal_id, change_date_time, product_code),
    6921305                FOREIGN KEY(change_date_time, product_code) REFERENCES "change"(date_and_time, product_code) ON DELETE CASCADE
    693             );
     1306                );
    6941307
    6951308            CREATE TABLE IF NOT EXISTS works_in_store (
    696                 personal_id VARCHAR(10) REFERENCES personal(id) ON DELETE CASCADE,
     1309                                                          personal_id VARCHAR(10) REFERENCES personal(id) ON DELETE CASCADE,
    6971310                store_ID VARCHAR(3) REFERENCES store(store_ID) ON DELETE CASCADE,
    6981311                PRIMARY KEY(personal_id, store_ID)
    699             );
     1312                );
    7001313
    7011314            CREATE TABLE IF NOT EXISTS worked (
    702                 personal_id VARCHAR(10) REFERENCES personal(id) ON DELETE CASCADE,
     1315                                                  personal_id VARCHAR(10) REFERENCES personal(id) ON DELETE CASCADE,
    7031316                report_date TIMESTAMP,
    7041317                store_ID VARCHAR(3),
    … …  
    7101323                FOREIGN KEY (report_date, store_ID) REFERENCES report(date, store_ID) ON DELETE CASCADE,
    7111324                CONSTRAINT check_pay_method CHECK (pay_method IN ('full_time', 'part-time', 'custom'))
    712             );
     1325                );
    7131326
    7141327            CREATE TABLE IF NOT EXISTS sells (
    715                 product_code VARCHAR(8) REFERENCES product(code) ON DELETE CASCADE,
     1328                                                 product_code VARCHAR(8) REFERENCES product(code) ON DELETE CASCADE,
    7161329                store_ID VARCHAR(3) REFERENCES store(store_ID) ON DELETE CASCADE,
    7171330                discount NUMERIC NOT NULL DEFAULT 0.0,
    7181331                PRIMARY KEY (product_code, store_ID)
    719             );
     1332                );
    7201333
    7211334            CREATE TABLE IF NOT EXISTS includes (
    722                 order_num VARCHAR(11) REFERENCES "order"(order_num) ON DELETE CASCADE,
     1335                                                    order_num VARCHAR(11) REFERENCES "order"(order_num) ON DELETE CASCADE,
    7231336                product_code VARCHAR(8) REFERENCES product(code) ON DELETE CASCADE,
    7241337                quantity INTEGER NOT NULL CHECK(quantity>=0),
    7251338                PRIMARY KEY (order_num, product_code)
    726             );
     1339                );
    7271340
    7281341            CREATE TABLE IF NOT EXISTS approves (
    729                 boss_id VARCHAR(10) REFERENCES boss(boss_id) ON DELETE CASCADE,
     1342                                                    boss_id VARCHAR(10) REFERENCES boss(boss_id) ON DELETE CASCADE,
    7301343                report_date TIMESTAMP,
    7311344                store_ID VARCHAR(3),
    … …  
    7331346                PRIMARY KEY (boss_id, report_date, store_ID),
    7341347                FOREIGN KEY (report_date, store_ID) REFERENCES report(date, store_ID) ON DELETE CASCADE
    735             );
     1348                );
    7361349
    7371350            -- These four small tables are application authentication/audit storage.
    7381351            -- They do not modify any of the project tables above.
    7391352            CREATE TABLE IF NOT EXISTS users (
    740                 id VARCHAR(50) PRIMARY KEY,
     1353                                                 id VARCHAR(50) PRIMARY KEY,
    7411354                username VARCHAR(100) UNIQUE NOT NULL,
    7421355                email VARCHAR(255) UNIQUE NOT NULL,
    … …  
    7451358                force_password_change BOOLEAN DEFAULT FALSE,
    7461359                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    747             );
     1360                );
    7481361
    7491362            CREATE TABLE IF NOT EXISTS roles (
    750                 role_id SERIAL PRIMARY KEY,
    751                 name VARCHAR(50) UNIQUE NOT NULL,
     1363                                                 role_id SERIAL PRIMARY KEY,
     1364                                                 name VARCHAR(50) UNIQUE NOT NULL,
    7521365                description TEXT
    753             );
     1366                );
    7541367
    7551368            CREATE TABLE IF NOT EXISTS user_roles (
    756                 user_id VARCHAR(50) REFERENCES users(id) ON DELETE CASCADE,
     1369                                                      user_id VARCHAR(50) REFERENCES users(id) ON DELETE CASCADE,
    7571370                role_id INTEGER REFERENCES roles(role_id) ON DELETE CASCADE,
    7581371                PRIMARY KEY(user_id, role_id)
    759             );
     1372                );
    7601373
    7611374            CREATE TABLE IF NOT EXISTS audit_log (
    762                 log_id BIGSERIAL PRIMARY KEY,
    763                 user_id VARCHAR(50),
     1375                                                     log_id BIGSERIAL PRIMARY KEY,
     1376                                                     user_id VARCHAR(50),
    7641377                action VARCHAR(100) NOT NULL,
    7651378                resource_type VARCHAR(50),
    … …  
    7681381                ip_address VARCHAR(45),
    7691382                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    770             );
     1383                );
    7711384
    7721385            CREATE INDEX IF NOT EXISTS idx_product_category ON product(category_id);
    … …  
    7831396
    7841397        await pool.query(schema);
     1398
     1399        // ------------------------------------------------------------
     1400        // Compatibility migration for older PostgreSQL databases.
     1401        //
     1402        // Some existing project databases contain a permissions table
     1403        // created by an older version of the schema with a typo such as
     1404        // personal_is instead of personal_id. CREATE TABLE IF NOT EXISTS cannot add
     1405        // missing columns to an existing table, so the admin bootstrap
     1406        // INSERT would otherwise fail with PostgreSQL error 42703.
     1407        //
     1408        // The migration is intentionally non-destructive: it keeps all
     1409        // existing rows, renames the typo when possible, and migrates the old typo column into the expected schema without deleting
     1410        // permission values.
     1411        // ------------------------------------------------------------
     1412        await pool.query(`
     1413            DO $$
     1414            BEGIN
     1415                -- Some older versions of the database used the typo
     1416                -- personal_is instead of personal_id. Rename it rather
     1417                -- than adding a second column: the old column may be NOT NULL
     1418                -- and would otherwise make the admin bootstrap INSERT fail.
     1419                IF EXISTS (
     1420                    SELECT 1
     1421                    FROM information_schema.columns
     1422                    WHERE table_schema = 'public'
     1423                      AND table_name = 'permissions'
     1424                      AND column_name = 'personal_is'
     1425                ) AND NOT EXISTS (
     1426                    SELECT 1
     1427                    FROM information_schema.columns
     1428                    WHERE table_schema = 'public'
     1429                      AND table_name = 'permissions'
     1430                      AND column_name = 'personal_id'
     1431                ) THEN
     1432                    ALTER TABLE permissions
     1433                        RENAME COLUMN personal_is TO personal_id;
     1434                END IF;
     1435
     1436                -- If neither spelling exists, add the expected column.
     1437                IF NOT EXISTS (
     1438                    SELECT 1
     1439                    FROM information_schema.columns
     1440                    WHERE table_schema = 'public'
     1441                      AND table_name = 'permissions'
     1442                      AND column_name = 'personal_id'
     1443                ) THEN
     1444                    ALTER TABLE permissions
     1445                        ADD COLUMN personal_id VARCHAR(10);
     1446                END IF;
     1447
     1448                -- Some databases were already partially migrated and therefore
     1449                -- contain BOTH personal_is and personal_id. If the old typo
     1450                -- column participates in the primary key, PostgreSQL will not
     1451                -- allow us to drop its NOT NULL requirement. Migrate the
     1452                -- primary-key data to personal_id first, then remove the old
     1453                -- typo column from the key and drop it.
     1454                IF EXISTS (
     1455                    SELECT 1
     1456                    FROM information_schema.columns
     1457                    WHERE table_schema = 'public'
     1458                      AND table_name = 'permissions'
     1459                      AND column_name = 'personal_is'
     1460                ) AND EXISTS (
     1461                    SELECT 1
     1462                    FROM information_schema.columns
     1463                    WHERE table_schema = 'public'
     1464                      AND table_name = 'permissions'
     1465                      AND column_name = 'personal_id'
     1466                ) THEN
     1467                    -- Copy old primary-key values into the new column where
     1468                    -- the new column is currently empty.
     1469                    UPDATE permissions
     1470                    SET personal_id = personal_is
     1471                    WHERE personal_id IS NULL
     1472                      AND personal_is IS NOT NULL;
     1473
     1474                    -- Remove the old typo column from the primary key.
     1475                    DO $drop_old_permission_pk$
     1476                    DECLARE
     1477                        pk_name TEXT;
     1478                    BEGIN
     1479                        SELECT tc.constraint_name
     1480                        INTO pk_name
     1481                        FROM information_schema.table_constraints tc
     1482                        JOIN information_schema.key_column_usage kcu
     1483                          ON kcu.constraint_name = tc.constraint_name
     1484                         AND kcu.table_schema = tc.table_schema
     1485                         AND kcu.table_name = tc.table_name
     1486                        WHERE tc.table_schema = 'public'
     1487                          AND tc.table_name = 'permissions'
     1488                          AND tc.constraint_type = 'PRIMARY KEY'
     1489                          AND kcu.column_name = 'personal_is'
     1490                        LIMIT 1;
     1491
     1492                        IF pk_name IS NOT NULL THEN
     1493                            EXECUTE format(
     1494                                'ALTER TABLE permissions DROP CONSTRAINT %I',
     1495                                pk_name
     1496                            );
     1497                        END IF;
     1498                    END
     1499                    $drop_old_permission_pk$;
     1500
     1501                    -- The application uses personal_id as the primary key.
     1502                    -- Drop the obsolete typo column after preserving its data.
     1503                    ALTER TABLE permissions
     1504                        DROP COLUMN personal_is;
     1505
     1506                    -- Recreate the primary key on the correct column if one
     1507                    -- was removed above and no primary key currently exists.
     1508                    IF NOT EXISTS (
     1509                        SELECT 1
     1510                        FROM information_schema.table_constraints
     1511                        WHERE table_schema = 'public'
     1512                          AND table_name = 'permissions'
     1513                          AND constraint_type = 'PRIMARY KEY'
     1514                    ) THEN
     1515                        ALTER TABLE permissions
     1516                            ADD PRIMARY KEY (personal_id);
     1517                    END IF;
     1518                END IF;
     1519
     1520                IF NOT EXISTS (
     1521                    SELECT 1
     1522                    FROM information_schema.columns
     1523                    WHERE table_schema = 'public'
     1524                      AND table_name = 'permissions'
     1525                      AND column_name = 'type'
     1526                ) THEN
     1527                    ALTER TABLE permissions
     1528                        ADD COLUMN type VARCHAR(50);
     1529                END IF;
     1530
     1531                IF NOT EXISTS (
     1532                    SELECT 1
     1533                    FROM information_schema.columns
     1534                    WHERE table_schema = 'public'
     1535                      AND table_name = 'permissions'
     1536                      AND column_name = 'authorisation'
     1537                ) THEN
     1538                    ALTER TABLE permissions
     1539                        ADD COLUMN authorisation VARCHAR(50);
     1540                END IF;
     1541            END
     1542            $$;
     1543        `);
     1544
     1545        // ON CONFLICT(personal_id) requires a unique/exclusion constraint
     1546        // that PostgreSQL can use for conflict inference. A unique index
     1547        // permits multiple NULL values, so this remains safe for any legacy
     1548        // permission rows that do not have a personal_id yet.
     1549        await pool.query(`
     1550            CREATE UNIQUE INDEX IF NOT EXISTS
     1551                permissions_personal_id_unique
     1552                ON permissions(personal_id)
     1553        `);
    7851554
    7861555        const roles = [
    … …  
    8121581        await pool.query(`INSERT INTO boss(boss_id) VALUES('000000') ON CONFLICT(boss_id) DO NOTHING`);
    8131582        await pool.query(
    814             `INSERT INTO permissions(personal_is,type,authorisation)
    815              VALUES('000000','ADMIN','full_access') ON CONFLICT(personal_is) DO NOTHING`
     1583            `INSERT INTO permissions(personal_id,type,authorisation)
     1584             VALUES('000000','ADMIN','full_access') ON CONFLICT(personal_id) DO NOTHING`
    8161585        );
    8171586        await pool.query(
    … …  
    8321601                             FILTER (WHERE r.role_id IS NOT NULL), '[]') AS roles
    8331602             FROM users u
    834              LEFT JOIN user_roles ur ON ur.user_id=u.id
    835              LEFT JOIN roles r ON r.role_id=ur.role_id
     1603                      LEFT JOIN user_roles ur ON ur.user_id=u.id
     1604                      LEFT JOIN roles r ON r.role_id=ur.role_id
    8361605             WHERE u.id=$1
    8371606             GROUP BY u.id`,
    … …  
    8471616                             FILTER (WHERE r.role_id IS NOT NULL), '[]') AS roles
    8481617             FROM users u
    849              LEFT JOIN user_roles ur ON ur.user_id=u.id
    850              LEFT JOIN roles r ON r.role_id=ur.role_id
     1618                      LEFT JOIN user_roles ur ON ur.user_id=u.id
     1619                      LEFT JOIN roles r ON r.role_id=ur.role_id
    8511620             WHERE u.username=$1 OR u.email=$1
    8521621             GROUP BY u.id
    853              LIMIT 1`,
     1622                 LIMIT 1`,
    8541623            [username],
    8551624            (err, result) => callback(err, result?.rows?.[0])
    … …  
    9371706        const sql = `SELECT p.*, c.name AS category_name, LEFT(p.code,3) AS store_id
    9381707                     FROM product p
    939                      LEFT JOIN category c ON c.id=p.category_id
    940                      ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
     1708                         LEFT JOIN category c ON c.id=p.category_id
     1709                         ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
    9411710                     ORDER BY p.code`;
    9421711        dbQuery(sql, params, (err, result) => callback(err, result?.rows || []));
    … …  
    9471716            `SELECT p.*, c.name AS category_name, LEFT(p.code,3) AS store_id
    9481717             FROM product p
    949              LEFT JOIN category c ON c.id=p.category_id
     1718                 LEFT JOIN category c ON c.id=p.category_id
    9501719             WHERE p.code=$1 LIMIT 1`,
    9511720            [String(id)],
    … …  
    9581727            `SELECT p.*, c.name AS category_name, LEFT(p.code,3) AS store_id
    9591728             FROM product p
    960              LEFT JOIN category c ON c.id=p.category_id
     1729                 LEFT JOIN category c ON c.id=p.category_id
    9611730             WHERE p.code=$1`,
    9621731            [code],
    … …  
    9851754                    `INSERT INTO sells(product_code,store_ID,discount)
    9861755                     VALUES($1,$2,$3)
    987                      ON CONFLICT(product_code,store_ID)
     1756                         ON CONFLICT(product_code,store_ID)
    9881757                     DO UPDATE SET discount=EXCLUDED.discount`,
    9891758                    [data.code,storeId,data.discount || 0],
    … …  
    10921861        dbQuery(
    10931862            `SELECT o.*, LEFT(o.order_num,3) AS store_id,
    1094                     o.last_date_mod AS order_date,
    1095                     COALESCE(json_agg(json_build_object('product_code',i.product_code,'quantity',i.quantity,'price',p.price))
    1096                              FILTER(WHERE i.product_code IS NOT NULL),'[]') AS items
     1863                 o.last_date_mod AS order_date,
     1864                 COALESCE(json_agg(json_build_object('product_code',i.product_code,'quantity',i.quantity,'price',p.price))
     1865                 FILTER(WHERE i.product_code IS NOT NULL),'[]') AS items
    10971866             FROM "order" o
    1098              LEFT JOIN includes i ON i.order_num=o.order_num
    1099              LEFT JOIN product p ON p.code=i.product_code
     1867                 LEFT JOIN includes i ON i.order_num=o.order_num
     1868                 LEFT JOIN product p ON p.code=i.product_code
    11001869             WHERE o.client_ID=$1
    11011870             GROUP BY o.order_num
    … …  
    11091878            `INSERT INTO review(order_num,comment,rating,last_mod_date)
    11101879             VALUES($1,$2,$3,CURRENT_TIMESTAMP)
    1111              RETURNING order_num`,
     1880                 RETURNING order_num`,
    11121881            [data.order_num,data.comment||null,data.rating],
    11131882            (err,result)=>callback(err,result?.rows?.[0]?.order_num)
    … …  
    11621931    getAllOrders(callback) {
    11631932        dbQuery(`SELECT o.*, LEFT(o.order_num,3) AS store_id, o.last_date_mod AS order_date,
    1164                         c.first_name,c.last_name,c.email
     1933                     c.first_name,c.last_name,c.email
    11651934                 FROM "order" o
    1166                  LEFT JOIN client c ON c.client_id=o.client_ID
     1935                     LEFT JOIN client c ON c.client_id=o.client_ID
    11671936                 ORDER BY o.last_date_mod DESC`,
    11681937            [],(err,result)=>callback(err,result?.rows||[]));
    … …  
    11721941        dbQuery(`SELECT p.*,c.name AS category_name,LEFT(p.code,3) AS store_id,s.discount
    11731942                 FROM product p
    1174                  LEFT JOIN category c ON c.id=p.category_id
    1175                  LEFT JOIN sells s ON s.product_code=p.code AND s.store_ID=$1
     1943                     LEFT JOIN category c ON c.id=p.category_id
     1944                     LEFT JOIN sells s ON s.product_code=p.code AND s.store_ID=$1
    11761945                 WHERE LEFT(p.code,3)=$1 OR EXISTS(SELECT 1 FROM sells sx WHERE sx.product_code=p.code AND sx.store_ID=$1)
    11771946                 ORDER BY p.code`,[storeId],(err,result)=>callback(err,result?.rows||[]));
    … …  
    11811950        dbQuery(`SELECT o.*,LEFT(o.order_num,3) AS store_id,o.last_date_mod AS order_date,c.first_name,c.last_name
    11821951                 FROM "order" o
    1183                  LEFT JOIN client c ON c.client_id=o.client_ID
     1952                     LEFT JOIN client c ON c.client_id=o.client_ID
    11841953                 WHERE LEFT(o.order_num,3)=$1 ORDER BY o.last_date_mod DESC`,[storeId],
    11851954            (err,result)=>callback(err,result?.rows||[]));
    … …  
    11891958        dbQuery(`SELECT p.*,e.date_of_hire,per.type,per.authorisation
    11901959                 FROM personal p JOIN works_in_store w ON w.personal_id=p.id
    1191                  LEFT JOIN employees e ON e.employee_id=p.id
    1192                  LEFT JOIN permissions per ON per.personal_is=p.id
     1960                                 LEFT JOIN employees e ON e.employee_id=p.id
     1961                                 LEFT JOIN permissions per ON per.personal_id=p.id
    11931962                 WHERE w.store_ID=$1 ORDER BY p.last_name,p.first_name`,
    11941963            [storeId],(err,result)=>callback(err,result?.rows||[]));
    … …  
    11961965
    11971966    getStoreReports(storeId, callback) {
    1198         dbQuery(`SELECT * FROM report WHERE store_ID=$1 ORDER BY date DESC`,[storeId],
    1199             (err,result)=>callback(err,result?.rows||[]));
     1967        dbQuery(
     1968            `SELECT date, store_id, overall_profit, sales_trend, marketing_growth, owner_signature
     1969             FROM report
     1970             WHERE store_id = $1
     1971             ORDER BY date DESC`,
     1972            [storeId],
     1973            (err, result) => callback(err, result?.rows || [])
     1974        );
    12001975    },
    12011976
    12021977    getStoreStats(storeId, callback) {
    1203         const sql=`SELECT
    1204             (SELECT COUNT(*) FROM product WHERE LEFT(code,3)=$1)::int AS product_count,
    1205             (SELECT COUNT(*) FROM "order" WHERE LEFT(order_num,3)=$1)::int AS order_count,
    1206             (SELECT COALESCE(SUM(i.quantity*p.price*(1-o.discount/100)),0)
    1207              FROM "order" o JOIN includes i ON i.order_num=o.order_num JOIN product p ON p.code=i.product_code
    1208              WHERE LEFT(o.order_num,3)=$1) AS revenue,
    1209             (SELECT COUNT(*) FROM works_in_store WHERE store_ID=$1)::int AS employee_count,
    1210             (SELECT COUNT(*) FROM for_store WHERE store_ID=$1)::int AS request_count,
    1211             (SELECT COUNT(*) FROM refund r JOIN "order" o ON o.order_num=r.order_num WHERE LEFT(o.order_num,3)=$1)::int AS refund_count`;
    1212         dbQuery(sql,[storeId],(err,result)=>callback(err,result?.rows?.[0]||{}));
     1978        const sql = `
     1979            SELECT
     1980                (SELECT COUNT(DISTINCT product_code)
     1981                 FROM sells
     1982                 WHERE store_ID = $1)::int AS product_count,
     1983
     1984                    (SELECT COUNT(DISTINCT o.order_num)
     1985                     FROM sells se
     1986                              JOIN includes i ON i.product_code = se.product_code
     1987                              JOIN "order" o ON o.order_num = i.order_num
     1988                     WHERE se.store_ID = $1)::int AS order_count,
     1989
     1990                    (SELECT COALESCE(SUM(
     1991                                             i.quantity * p.price
     1992                                                 * (1 - COALESCE(o.discount, 0) / 100.0)
     1993                                     ), 0)
     1994                     FROM sells se
     1995                              JOIN includes i ON i.product_code = se.product_code
     1996                              JOIN "order" o ON o.order_num = i.order_num
     1997                              JOIN product p ON p.code = i.product_code
     1998                     WHERE se.store_ID = $1) AS revenue,
     1999
     2000                (SELECT COUNT(*)
     2001                 FROM works_in_store
     2002                 WHERE store_ID = $1)::int AS employee_count,
     2003
     2004                    (SELECT COUNT(*)
     2005                     FROM for_store
     2006                     WHERE store_ID = $1)::int AS request_count,
     2007
     2008                    (SELECT COUNT(*)
     2009                     FROM refund r
     2010                              JOIN "order" o ON o.order_num = r.order_num
     2011                     WHERE LEFT(o.order_num, 3) = $1)::int AS refund_count
     2012        `;
     2013
     2014        dbQuery(sql, [storeId], (err, result) => {
     2015            callback(err, result?.rows?.[0] || {});
     2016        });
     2017    },
     2018
     2019    generateStoreReport(storeId, startDate, endDate, type, period, ownerSignature, callback) {
     2020        dbQuery(
     2021            `WITH sales AS (
     2022                SELECT COALESCE(SUM(
     2023                                        p.price * i.quantity
     2024                                            * (1 - COALESCE(o.discount, 0) / 100.0)
     2025                                ), 0) AS revenue
     2026                FROM sells se
     2027                         JOIN product p ON p.code = se.product_code
     2028                         JOIN includes i ON i.product_code = se.product_code
     2029                         JOIN "order" o ON o.order_num = i.order_num
     2030                WHERE se.store_ID = $1
     2031                  AND o.last_date_mod >= $2::timestamp
     2032                 AND o.last_date_mod < ($3::timestamp + INTERVAL '1 day')
     2033                 ),
     2034                 refunds AS (
     2035             SELECT COALESCE(SUM(rf.amount), 0) AS refund_total
     2036             FROM refund rf
     2037                 JOIN "order" o ON o.order_num = rf.order_num
     2038             WHERE LEFT(o.order_num, 3) = $1
     2039               AND o.last_date_mod >= $2::timestamp
     2040               AND o.last_date_mod < ($3::timestamp + INTERVAL '1 day')
     2041               AND rf.status IN ('approved', 'processed')
     2042                 )
     2043            SELECT sales.revenue, refunds.refund_total,
     2044                   sales.revenue - refunds.refund_total AS net_profit
     2045            FROM sales CROSS JOIN refunds`,
     2046            [storeId, startDate, endDate],
     2047            (err, result) => {
     2048                if (err) return callback(err);
     2049
     2050                const row = result.rows[0] || {};
     2051                const revenue = Number(row.revenue || 0);
     2052                const refundTotal = Number(row.refund_total || 0);
     2053                const netProfit = Number(row.net_profit || 0);
     2054
     2055                dbQuery(
     2056                    `SELECT COALESCE(SUM(
     2057                                             p.price * i.quantity
     2058                                                 * (1 - COALESCE(o.discount, 0) / 100.0)
     2059                                     ), 0) AS previous_revenue
     2060                     FROM sells se
     2061                              JOIN product p ON p.code = se.product_code
     2062                              JOIN includes i ON i.product_code = se.product_code
     2063                              JOIN "order" o ON o.order_num = i.order_num
     2064                     WHERE se.store_ID = $1
     2065                       AND o.last_date_mod >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
     2066                       AND o.last_date_mod < DATE_TRUNC('month', CURRENT_DATE)`,
     2067                    [storeId],
     2068                    (previousErr, previousResult) => {
     2069                        if (previousErr) return callback(previousErr);
     2070
     2071                        const previousRevenue = Number(previousResult.rows[0]?.previous_revenue || 0);
     2072                        const growth = previousRevenue === 0
     2073                            ? (revenue > 0 ? 100 : 0)
     2074                            : ((revenue - previousRevenue) / previousRevenue) * 100;
     2075
     2076                        dbQuery(
     2077                            `INSERT INTO report
     2078                             (date, store_ID, overall_profit, sales_trend, marketing_growth, owner_signature)
     2079                             VALUES
     2080                                 (CURRENT_TIMESTAMP, $1, $2, $3, $4, $5)
     2081                                 RETURNING date, store_ID, overall_profit, sales_trend, marketing_growth, owner_signature`,
     2082                            [
     2083                                storeId,
     2084                                Math.max(0, netProfit),
     2085                                `Revenue ${revenue.toFixed(2)}; Refunds ${refundTotal.toFixed(2)}`.slice(0, 100),
     2086                                `${growth.toFixed(2)}%`,
     2087                                ownerSignature || 'Not signed yet'
     2088                            ],
     2089                            (insertErr, insertResult) => {
     2090                                if (insertErr) return callback(insertErr);
     2091
     2092                                const report = insertResult.rows[0];
     2093
     2094                                dbQuery(
     2095                                    `INSERT INTO monthly_profit
     2096                                         (report_date, store_ID, month_and_year, profit)
     2097                                     VALUES
     2098                                         ($1, $2, DATE_TRUNC('month', $3::timestamp)::DATE, $4)
     2099                                         ON CONFLICT (report_date, store_ID)
     2100                                     DO UPDATE SET
     2101                                        month_and_year = EXCLUDED.month_and_year,
     2102                                                                                     profit = EXCLUDED.profit`,
     2103                                    [report.date, storeId, endDate, Math.max(0, netProfit)],
     2104                                    (monthlyErr) => {
     2105                                        if (monthlyErr) console.error('Warning inserting monthly profit:', monthlyErr);
     2106
     2107                                        dbQuery(
     2108                                            `INSERT INTO exchanges_data
     2109                                                 (report_date, store_ID, monthly_profit, date, sales, damages)
     2110                                             VALUES ($1, $2, $3, CURRENT_TIMESTAMP, $4, $5)
     2111                                                 ON CONFLICT (report_date, store_ID)
     2112                                             DO UPDATE SET
     2113                                                monthly_profit = EXCLUDED.monthly_profit,
     2114                                                                                                     date = EXCLUDED.date,
     2115                                                                                                     sales = EXCLUDED.sales,
     2116                                                                                                     damages = EXCLUDED.damages`,
     2117                                            [report.date, storeId, Math.max(0, netProfit), revenue, -refundTotal],
     2118                                            (exchangeErr) => {
     2119                                                if (exchangeErr) console.error('Warning inserting exchange data:', exchangeErr);
     2120                                                callback(null, report);
     2121                                            }
     2122                                        );
     2123                                    }
     2124                                );
     2125                            }
     2126                        );
     2127                    }
     2128                );
     2129            }
     2130        );
    12132131    },
    12142132
    … …  
    12162134        dbQuery(`SELECT r.*,a.personal_id AS answered_by
    12172135                 FROM request r
    1218                  JOIN for_store fs ON fs.request_num=r.request_num
    1219                  LEFT JOIN answers a ON a.request_num=r.request_num
     2136                          JOIN for_store fs ON fs.request_num=r.request_num
     2137                          LEFT JOIN answers a ON a.request_num=r.request_num
    12202138                 WHERE fs.store_ID=$1 AND (a.personal_id=$2 OR a.personal_id IS NULL)
    12212139                 ORDER BY r.date_and_time DESC`,
    … …  
    12252143    getClientStats(clientId, callback) {
    12262144        dbQuery(`SELECT
    1227             (SELECT COUNT(*) FROM "order" WHERE client_ID=$1)::int AS order_count,
    1228             (SELECT COUNT(*) FROM review r JOIN "order" o ON o.order_num=r.order_num WHERE o.client_ID=$1)::int AS review_count,
    1229             (SELECT COUNT(*) FROM makes_request WHERE client_ID=$1)::int AS request_count,
    1230             (SELECT COUNT(*) FROM refund r JOIN "order" o ON o.order_num=r.order_num WHERE o.client_ID=$1)::int AS refund_count`,
     2145                         (SELECT COUNT(*) FROM "order" WHERE client_ID=$1)::int AS order_count,
     2146                         (SELECT COUNT(*) FROM review r JOIN "order" o ON o.order_num=r.order_num WHERE o.client_ID=$1)::int AS review_count,
     2147                         (SELECT COUNT(*) FROM makes_request WHERE client_ID=$1)::int AS request_count,
     2148                         (SELECT COUNT(*) FROM refund r JOIN "order" o ON o.order_num=r.order_num WHERE o.client_ID=$1)::int AS refund_count`,
    12312149            [clientId],(err,result)=>callback(err,result?.rows?.[0]||{}));
    12322150    }
    … …  
    12432161    try {
    12442162        await database.initializeDatabase();
     2163        await database.installReportFunctions();
    12452164        console.log('✅ Database initialization completed');
    12462165    } catch (err) {
    … …  
    47685687    }
    47695688
     5689    else if (pathname === '/api/advanced-reports' && req.method === 'GET') {
     5690        requireRole('admin')(req, res, () => {
     5691            const reportName = parsedUrl.query.report;
     5692
     5693            if (!reportName) {
     5694                res.writeHead(400, { 'Content-Type': 'application/json' });
     5695                res.end(JSON.stringify({
     5696                    success: false,
     5697                    message: 'The report query parameter is required'
     5698                }));
     5699                return;
     5700            }
     5701
     5702            let params = [];
     5703
     5704            if (reportName === 'get_low_stock_high_demand_products') {
     5705                const stockThreshold = Number(parsedUrl.query.stockThreshold ?? 5);
     5706                const demandThreshold = Number(parsedUrl.query.demandThreshold ?? 5);
     5707
     5708                if (!Number.isInteger(stockThreshold) || !Number.isInteger(demandThreshold)) {
     5709                    res.writeHead(400, { 'Content-Type': 'application/json' });
     5710                    res.end(JSON.stringify({
     5711                        success: false,
     5712                        message: 'stockThreshold and demandThreshold must be integers'
     5713                    }));
     5714                    return;
     5715                }
     5716
     5717                params = [stockThreshold, demandThreshold];
     5718            }
     5719
     5720            database.runReport(reportName, params, (err, rows) => {
     5721                if (err) {
     5722                    console.error('Error executing report:', err);
     5723                    res.writeHead(500, { 'Content-Type': 'application/json' });
     5724                    res.end(JSON.stringify({
     5725                        success: false,
     5726                        message: 'Error executing report: ' + err.message
     5727                    }));
     5728                    return;
     5729                }
     5730
     5731                res.writeHead(200, { 'Content-Type': 'application/json' });
     5732                res.end(JSON.stringify({
     5733                    success: true,
     5734                    report: reportName,
     5735                    rows
     5736                }));
     5737            });
     5738        });
     5739    }
     5740
    47705741    else if (pathname === '/api/employee-tasks' && req.method === 'GET') {
    47715742        requireAuth(req, res, (userId) => {
    … …  
    49025873        requireStoreOwner()(req, res, (personalId) => {
    49035874            let body = '';
     5875
    49045876            req.on('data', chunk => {
    49055877                body += chunk.toString();
    49065878            });
     5879
    49075880            req.on('end', () => {
    4908                 const { storeId, period, startDate, endDate, type } = JSON.parse(body);
     5881                let data;
     5882
     5883                try {
     5884                    data = JSON.parse(body);
     5885                } catch (err) {
     5886                    res.writeHead(400, { 'Content-Type': 'application/json' });
     5887                    res.end(JSON.stringify({
     5888                        success: false,
     5889                        message: 'Invalid JSON request body'
     5890                    }));
     5891                    return;
     5892                }
     5893
     5894                const {
     5895                    storeId,
     5896                    period,
     5897                    startDate,
     5898                    endDate,
     5899                    type,
     5900                    ownerSignature
     5901                } = data;
    49095902
    49105903                if (!storeId || !period || !startDate || !endDate || !type) {
    49115904                    res.writeHead(400, { 'Content-Type': 'application/json' });
    4912                     res.end(JSON.stringify({ success: false, message: 'All fields are required' }));
     5905                    res.end(JSON.stringify({
     5906                        success: false,
     5907                        message: 'All fields are required'
     5908                    }));
    49135909                    return;
    49145910                }
    … …  
    49205916                        if (err || !ownsStore) {
    49215917                            res.writeHead(403, { 'Content-Type': 'application/json' });
    4922                             res.end(JSON.stringify({ success: false, message: 'You are not authorized to generate reports for this store' }));
     5918                            res.end(JSON.stringify({
     5919                                success: false,
     5920                                message: 'You are not authorized to generate reports for this store'
     5921                            }));
    49235922                            return;
    49245923                        }
    49255924
    4926                         const reportId = 'RPT' + Date.now().toString().slice(-6);
    4927 
    4928                         database.database.run(
    4929                             'INSERT INTO report (date, store_ID, overall_profit, sales_trend, marketing_growth, owner_signature) VALUES (CURRENT_TIMESTAMP, $1, 0, $2, $3, $4)',
    4930                             [storeId, period, type, 'Not signed yet'],
    4931                             function(err) {
    4932                                 if (err) {
    4933                                     console.error('Error generating report:', err);
     5925                        database.generateStoreReport(
     5926                            storeId,
     5927                            startDate,
     5928                            endDate,
     5929                            type,
     5930                            period,
     5931                            ownerSignature,
     5932                            (reportErr, report) => {
     5933                                if (reportErr) {
     5934                                    console.error('Error generating report:', reportErr);
    49345935                                    res.writeHead(500, { 'Content-Type': 'application/json' });
    4935                                     res.end(JSON.stringify({ success: false, message: 'Error generating report: ' + err.message }));
    4936                                 } else {
    4937                                     database.logAudit(personalId, 'REPORT_GENERATED', 'report', reportId, `Report generated: ${type} for ${period}`, ipAddress);
    4938 
    4939                                     res.writeHead(200, { 'Content-Type': 'application/json' });
    49405936                                    res.end(JSON.stringify({
    4941                                         success: true,
    4942                                         message: 'Report generated successfully',
    4943                                         reportId: reportId,
    4944                                         report: {
    4945                                             id: reportId,
    4946                                             storeId: storeId,
    4947                                             period: period,
    4948                                             startDate: startDate,
    4949                                             endDate: endDate,
    4950                                             type: type,
    4951                                             generatedBy: personalId,
    4952                                             generatedAt: new Date().toISOString()
    4953                                         }
     5937                                        success: false,
     5938                                        message: 'Error generating report: ' + reportErr.message
    49545939                                    }));
     5940                                    return;
    49555941                                }
     5942
     5943                                const reportId =
     5944                                    'RPT' +
     5945                                    new Date(report.date).getTime().toString().slice(-6);
     5946
     5947                                database.logAudit(
     5948                                    personalId,
     5949                                    'REPORT_GENERATED',
     5950                                    'report',
     5951                                    reportId,
     5952                                    `Report generated: ${type} for ${period}`,
     5953                                    ipAddress
     5954                                );
     5955
     5956                                res.writeHead(200, { 'Content-Type': 'application/json' });
     5957                                res.end(JSON.stringify({
     5958                                    success: true,
     5959                                    message: 'Report generated successfully',
     5960                                    reportId,
     5961                                    report: {
     5962                                        id: reportId,
     5963                                        storeId: report.store_id,
     5964                                        period,
     5965                                        startDate,
     5966                                        endDate,
     5967                                        type,
     5968                                        generatedBy: personalId,
     5969                                        generatedAt: report.date,
     5970                                        overallProfit: report.overall_profit,
     5971                                        salesTrend: report.sales_trend,
     5972                                        marketingGrowth: report.marketing_growth,
     5973                                        ownerSignature: report.owner_signature
     5974                                    }
     5975                                }));
    49565976                            }
    49575977                        );
Note: See TracChangeset for help on using the changeset viewer.