Changes between Version 2 and Version 3 of ApplicationDevelopment


Ignore:
Timestamp:
09/04/25 22:49:26 (3 days ago)
Author:
221164
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • ApplicationDevelopment

    v2 v3  
    66|| 3 || Assign Employees to Shifts ||
    77|| 4 || View and Manage Menu Categories ||
    8 || 5 || View and Manage Menu Products ||
     8|| 5 || Manage Menu Products ||
    99|| 6 || View and Manage Restaurant Tables ||
    1010|| 7 || Create and Manage Customer Reservations ||
     
    2222
    2323== Assign Employees to Shifts
    24 A manager accesses the /admin/assignments page, which displays all scheduled shifts and a form to assign employees to them. The controller responsible for this is as follows:
    25 
    26 {{{
    27     @GetMapping("/admin/assignments")
    28     public String showAssignments(Model model) {
    29         model.addAttribute("shifts", shiftService.getAllShifts());
    30         model.addAttribute("employees", employeeService.getAllEmployees());
    31         model.addAttribute("assignments", assignmentService.getAllAssignments());
    32         model.addAttribute("assignmentForm", new CreateAssignmentDto());
    33         // In a real app, this would likely be part of a larger admin dashboard layout
    34         return "admin/assignments";
    35     }
    36 }}}
    37 
     24A manager accesses the /api/assignments endpoint to schedule employees for specific shifts. The frontend displays existing assignments and a form for creating new ones. For this, the following controller is responsible:
     25{{{
     26@PostMapping
     27public ResponseEntity<AssignmentDto> createAssignment(@RequestBody CreateAssignmentDto dto, Authentication authentication) {
     28try {
     29String managerEmail = authentication.getName();
     30Assignment assignment = assignmentService.createAssignment(dto, managerEmail);
     31return ResponseEntity.status(HttpStatus.CREATED).body(AssignmentDto.fromAssignment(assignment));
     32} catch (SecurityException e) {
     33return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
     34}
     35}
     36}}}
    3837[[Image(shifts.png)]]
    3938[[Image(assignments.png)]]
    4039
    41 After selecting a shift, an employee, and submitting the form, a POST request is sent to the controller for processing.
    42 
    43 {{{
    44 
    45     @PostMapping("/admin/assignments")
    46     public String createAssignment(@Valid @ModelAttribute("assignmentForm") CreateAssignmentDto dto) {
    47         // The authenticated user's email would be retrieved from the SecurityContext
    48         String managerEmail = SecurityContextHolder.getContext().getAuthentication().getName();
    49         assignmentService.createAssignment(dto, managerEmail);
    50         return "redirect:/admin/assignments";
     40The controller extracts the manager's email from the security context and passes the request data to the assignmentService. To ensure data integrity, the service method is transactional. It validates that the user is a manager and that the specified employee and shift exist before creating and saving a new Assignment entity.
     41
     42{{{
     43@Override
     44public Assignment createAssignment(CreateAssignmentDto dto, String managerEmail) {
     45Manager manager = getManagerByEmail(managerEmail);
     46Employee employee = employeeRepository.findById(dto.employeeId())
     47.orElseThrow(() -> new EmployeeNotFoundException(dto.employeeId()));
     48Shift shift = shiftRepository.findById(dto.shiftId())
     49.orElseThrow(() -> new ShiftNotFoundException(dto.shiftId()));
     50
     51    Assignment assignment = new Assignment(
     52            dto.clockInTime(),
     53            dto.clockOutTime(),
     54            manager,
     55            employee,
     56            shift
     57    );
     58
     59    return assignmentRepository.save(assignment);
     60}
     61}}}
     62The same implementation, translated into the SQL query that executes in the background, would look like this:
     63{{{
     64DO $$
     65DECLARE
     66v_manager_id BIGINT;
     67v_employee_id BIGINT;
     68v_shift_id BIGINT;
     69BEGIN
     70-- Assume manager_id=3, employee_id=1, shift_id=1 from the form
     71v_manager_id := 3;
     72v_employee_id := 1;
     73v_shift_id := 1;
     74
     75    INSERT INTO assignments (manager_id, employee_id, shift_id, clock_in_time, clock_out_time)
     76    VALUES (v_manager_id, v_employee_id, v_shift_id, NULL, NULL);
     77
     78    COMMIT;
     79END $$;
     80}}}
     81
     82== Manage Menu Products
     83A manager can add new items to the menu via the /api/products/add endpoint. The controller takes a CreateProductDto and passes it to the service layer.
     84[[Image(product-list.png)]]
     85[[Image(create-product.png)]]
     86{{{
     87@Operation(summary = "Create new product")
     88@PostMapping("/add")
     89public ResponseEntity<ProductDto> save(@RequestBody CreateProductDto dto) {
     90return ResponseEntity.status(HttpStatus.CREATED).body(ProductDto.from(productService.createProduct(dto)));
     91}
     92}}}
     93The service logic in ProductServiceImpl is responsible for creating the Product entity. A key feature is the conditional creation of an associated Inventory record. If the manageInventory flag is set to true, a new inventory entry is created in the same transaction. This ensures that a product meant to have its stock tracked will always have an inventory record from the moment of its creation.
     94
     95{{{
     96@Override
     97public Product createProduct(CreateProductDto dto) {
     98Product productTmp = new Product();
     99if (dto.name() != null) {
     100productTmp.setName(dto.name());
     101}
     102if (dto.price() != null) {
     103productTmp.setPrice(dto.price());
     104}
     105if(dto.taxClass()!=null){
     106productTmp.setTaxClass(dto.taxClass());
     107}
     108
     109    productTmp.setCategory(categoryService.findById(dto.categoryId()));
     110    productTmp.setDescription(dto.description());
     111
     112    if(dto.manageInventory()!=null){
     113        productTmp.setManageInventory(dto.manageInventory());
    51114    }
    52 }}}
    53 The controller calls a function from the assignmentService to create the assignment. Inside the service, a new instance of the Assignment entity is created and populated with data from the form. To ensure data integrity (an assignment cannot exist without a valid shift, employee, and manager), the method is annotated with @Transactional. The operation will only succeed if the new Assignment object is saved to the database correctly.
    54 
    55 {{{
    56 
    57     @Override
    58     @Transactional
    59     public Assignment createAssignment(CreateAssignmentDto dto, String managerEmail) {
    60         Manager manager = managerRepository.findByUserEmail(managerEmail)
    61                 .orElseThrow(() -> new EntityNotFoundException("Manager not found"));
    62         Employee employee = employeeRepository.findById(dto.getEmployeeId())
    63                 .orElseThrow(() -> new EntityNotFoundException("Employee not found"));
    64         Shift shift = shiftRepository.findById(dto.getShiftId())
    65                 .orElseThrow(() -> new EntityNotFoundException("Shift not found"));
    66 
    67         Assignment assignment = new Assignment();
    68         assignment.setManager(manager);
    69         assignment.setEmployee(employee);
    70         assignment.setShift(shift);
    71         // Clock-in/out times are initially null
    72 
    73         return assignmentRepository.save(assignment);
     115    Product product = productRepository.save(productTmp);
     116    if(product.getManageInventory() == Boolean.TRUE){
     117        Inventory inventory = new Inventory(product, dto.quantity(), dto.restockLevel());
     118        inventoryRepository.save(inventory);
    74119    }
    75 }}}
    76 
    77 The same implementation, translated into the SQL query that executes in the background, would look like this:
    78 
    79 {{{
    80 
     120    return product;
     121}
     122}}}
     123The equivalent transactional SQL block for creating a product with inventory tracking would be:
     124{{{
    81125DO $$
    82     DECLARE
    83         v_manager_id BIGINT;
    84         v_employee_id BIGINT;
    85         v_shift_id BIGINT;
    86     BEGIN
    87         -- Assume manager_id=3, employee_id=1, shift_id=1 from the form
    88         v_manager_id := 3;
    89         v_employee_id := 1;
    90         v_shift_id := 1;
    91 
    92         INSERT INTO assignments (manager_id, employee_id, shift_id, clock_in_time, clock_out_time)
    93         VALUES (v_manager_id, v_employee_id, v_shift_id, NULL, NULL);
    94 
    95         COMMIT;
    96     END $$;
    97 }}}
     126DECLARE
     127new_product_id BIGINT;
     128BEGIN
     129INSERT INTO products (name, description, price, category_id, manage_inventory, tax_class)
     130VALUES ('Cheeseburger', 'Classic beef burger with cheese', 450.00, 3, TRUE, 'A')
     131RETURNING id INTO new_product_id;
     132
     133    INSERT INTO inventories (product_id, quantity, restock_level)
     134    VALUES (new_product_id, 50, 10);
     135
     136    COMMIT;
     137END $$;
     138}}}
     139
    98140== Create and Manage In-House Orders (Tabs)
    99 A server (Front Staff) who has clocked into their shift can create and manage orders for tables. When they select a table, they are presented with an interface to create a new order or manage an existing one. The controller first fetches all open orders.
    100 
    101 {{{
    102 
    103     @GetMapping("/orders/open")
    104     public String getOpenOrders(Model model) {
    105         model.addAttribute("openOrders", orderService.findOpenOrders());
    106         return "orders/open_orders";
    107     }
     141A server (Front Staff) creates a new order for a table by sending a POST request to /api/orders/tab. The controller authenticates the user and delegates the creation logic to the OrderService.
     142
     143{{{
     144@Operation(summary = "Create a new tab order for a logged-in front staff member")
     145@PostMapping("/tab")
     146public ResponseEntity<OrderDto> createTabOrder(@RequestBody CreateOrderDto dto, Authentication authentication) {
     147String userEmail = authentication.getName();
     148return ResponseEntity.ok(OrderDto.from(orderService.createTabOrder(dto, userEmail)));
     149}
    108150}}}
    109151[[Image(open-orders.png)]]
    110 
    111 To prevent unauthorized actions, the system validates all operations on the server side. When adding an item to an order, the system first verifies that the order exists and that the employee is active.
    112 
    113 {{{
    114 
    115     @PostMapping("/orders/{orderId}/items")
    116     public String addItemToOrder(@PathVariable Long orderId, @Valid CreateOrderItemDto itemDto, Authentication authentication) {
    117         UserDetails userDetails = (UserDetails) authentication.getPrincipal();
    118         // Custom logic to get employeeId from userDetails
    119         Long employeeId = userService.findEmployeeIdByUsername(userDetails.getUsername());
    120 
    121         if (!employeeService.isOnActiveShift(employeeId)) {
    122             throw new IllegalStateException("Employee is not on an active shift.");
    123         }
    124 
    125         orderService.addItemToOrder(orderId, itemDto);
    126         return "redirect:/orders/" + orderId;
    127     }
    128 }}}
    129152[[Image(order-details.png)]]
    130 [[Image(add-items-product.png)]]
    131 
    132 The orderService handles the business logic of finding the product, calculating the price, and adding the new OrderItem to the Order. This entire process is wrapped in a transaction to ensure that an order item is not created unless all conditions are met (e.g., product exists, order is open).
    133 
    134 {{{
    135 
    136     @Override
    137     @Transactional
    138     public OrderItem addItemToOrder(Long orderId, CreateOrderItemDto itemDto) {
    139         Order order = orderRepository.findById(orderId)
    140                 .orElseThrow(() -> new EntityNotFoundException("Order not found"));
    141         if (!"PENDING".equals(order.getStatus())) {
    142             throw new IllegalStateException("Order is not open for modifications.");
    143         }
    144         Product product = productRepository.findById(itemDto.getProductId())
    145                 .orElseThrow(() -> new EntityNotFoundException("Product not found"));
    146 
    147         OrderItem newItem = new OrderItem();
    148         newItem.setOrder(order);
    149         newItem.setProduct(product);
    150         newItem.setQuantity(itemDto.getQuantity());
    151         newItem.setPrice(product.getPrice()); // Price at the time of order
    152         newItem.setProcessed(false);
    153 
    154         return orderItemRepository.save(newItem);
    155     }
    156 }}}
    157 The corresponding SQL operations for creating a new tab order and adding two items would be:
    158 
    159 {{{
    160 
     153
     154The service implementation, createTabOrder, is annotated with @Transactional and @CheckOnDuty (a custom annotation). This ensures the operation is atomic and that the staff member is clocked in. The service verifies the user is a FrontStaff member, finds the specified table, and constructs the TabOrder along with its associated OrderItems.
     155
     156{{{
     157@Override
     158@Transactional
     159@CheckOnDuty
     160public TabOrder createTabOrder(CreateOrderDto dto, String userEmail) {
     161log.debug("User {} creating a tab order for table {}", userEmail, dto.tableNumber());
     162User user = userRepository.findByEmail(userEmail)
     163.orElseThrow(() -> new UsernameNotFoundException("User with email " + userEmail + " not found."));
     164if (!(user instanceof FrontStaff)) {
     165throw new SecurityException("User is not authorized to create tab orders.");
     166}
     167TabOrder tabOrder = new TabOrder();
     168RestaurantTable table = tableRepository.findById(dto.tableNumber())
     169.orElseThrow(() -> new TableNotFoundException(dto.tableNumber()));
     170tabOrder.setRestaurantTable(table);
     171tabOrder.setFrontStaff((FrontStaff) user);
     172tabOrder.setTimestamp(LocalDateTime.now());
     173tabOrder.setStatus(dto.status());
     174if (dto.orderItems() != null && !dto.orderItems().isEmpty()) {
     175List<OrderItem> orderItems = dto.orderItems().stream().map(itemDto -> {
     176OrderItem item = new OrderItem();
     177item.setOrder(tabOrder);
     178// ... set other item properties
     179Product product = productRepository.findById(itemDto.productId())
     180.orElseThrow(() -> new ProductNotFoundException(itemDto.productId()));
     181item.setProduct(product);
     182return item;
     183}).collect(Collectors.toList());
     184tabOrder.setOrderItems(orderItems);
     185}
     186return tabOrderRepository.save(tabOrder);
     187}
     188}}}
     189The corresponding SQL operations for creating a new tab order and adding an item would be:
     190
     191{{{
    161192DO $$
    162     DECLARE
    163         new_order_id BIGINT;
    164         v_product_1_price DECIMAL(10,2);
    165         v_product_2_price DECIMAL(10,2);
    166     BEGIN
    167         -- Create the main order record
    168         INSERT INTO orders (status, datetime) VALUES ('PENDING', NOW())
    169         RETURNING id INTO new_order_id;
    170 
    171         -- Link it as a Tab Order for a specific staff and table
    172         INSERT INTO tab_orders (order_id, front_staff_id, table_number)
    173         VALUES (new_order_id, 1, 2);
    174 
    175         -- Get current prices
    176         SELECT price INTO v_product_1_price FROM products WHERE id = 1;
    177         SELECT price INTO v_product_2_price FROM products WHERE id = 2;
    178 
    179         -- Add items to the order
    180         INSERT INTO order_items (order_id, product_id, is_processed, quantity, price)
    181         VALUES (new_order_id, 1, FALSE, 2, v_product_1_price);
    182 
    183         INSERT INTO order_items (order_id, product_id, is_processed, quantity, price)
    184         VALUES (new_order_id, 2, FALSE, 1, v_product_2_price);
    185 
    186         COMMIT;
    187     END $$;
    188 }}}
     193DECLARE
     194new_order_id BIGINT;
     195v_product_price DECIMAL(10,2);
     196BEGIN
     197-- Create the main order record
     198INSERT INTO orders (status, datetime) VALUES ('PENDING', NOW())
     199RETURNING id INTO new_order_id;
     200
     201    -- Link it as a Tab Order for a specific staff and table
     202    INSERT INTO tab_orders (order_id, front_staff_id, table_number)
     203    VALUES (new_order_id, 1, 2);
     204
     205    -- Get current price
     206    SELECT price INTO v_product_price FROM products WHERE id = 1;
     207
     208    -- Add item to the order
     209    INSERT INTO order_items (order_id, product_id, is_processed, quantity, price)
     210    VALUES (new_order_id, 1, FALSE, 2, v_product_price);
     211
     212    COMMIT;
     213END $$;
     214}}}
     215
    189216== Process a Payment for an Order
    190 When customers are ready to pay, the server initiates the payment process from the order details screen.
    191 
     217When customers are ready to pay, the server initiates the payment process from the order details screen by calling the /api/payments endpoint.
    192218[[Image(payment.png)]]
    193219
    194 This action is managed via a database trigger for maximum reliability. When a new record is inserted into the payments table, the payments_mark_order_paid trigger automatically fires. This ensures that an order's status is updated to PAID in the same transaction as the payment, guaranteeing that an order cannot be paid for without its status being updated accordingly.
    195 
    196 {{{
    197 
    198     @Override
    199     @Transactional // The trigger makes this transaction encompass the order update as well
    200     public Payment createPayment(CreatePaymentDto dto) {
    201         Order order = orderRepository.findById(dto.getOrderId())
    202             .orElseThrow(() -> new EntityNotFoundException("Order not found"));
    203 
    204         // Additional logic can go here, e.g., validating the payment amount
    205         // against the order total.
    206 
    207         Payment payment = new Payment();
    208         payment.setOrder(order);
    209         payment.setAmount(dto.getAmount());
    210         payment.setPaymentType(dto.getPaymentType());
    211         payment.setTipAmount(dto.getTipAmount());
    212 
    213         return paymentRepository.save(payment);
    214     }
     220This action is primarily handled by the PaymentServiceImpl. The createPayment method is annotated with @Transactional and @CheckOnDuty. A critical design choice is the use of a database trigger (payments_mark_order_paid) to update the order's status to PAID. This offloads the responsibility from the application layer to the database, ensuring atomicity. Furthermore, after the payment is successfully saved, the service calls mvRefresher.refreshPaymentsMvAfterCommit() to schedule a refresh of the analytics materialized view, ensuring reports are kept up-to-date.
     221
     222{{{
     223@Override
     224@Transactional
     225@CheckOnDuty
     226public Payment createPayment(CreatePaymentDto dto) {
     227log.info("Creating payment for orderId: {}", dto.orderId());
     228Order order = orderRepository.findById(dto.orderId())
     229.orElseThrow(() -> new OrderNotFoundException(dto.orderId()));
     230
     231    Payment payment = new Payment();
     232    payment.setAmount(dto.amount());
     233    payment.setTipAmount(dto.tipAmount());
     234    payment.setPaymentType(dto.paymentType());
     235    payment.setTimestamp(LocalDateTime.now());
     236    payment.setOrder(order);
     237
     238    Payment saved = paymentRepository.save(payment);
     239
     240    // Schedule MV refresh after the transaction commits
     241    mvRefresher.refreshPaymentsMvAfterCommit();
     242
     243    return saved;
     244}
    215245}}}
    216246The trigger and the INSERT statement are defined in SQL as follows:
    217 
    218 {{{
    219 
     247{{{
    220248CREATE OR REPLACE FUNCTION payments_mark_order_paid() RETURNS trigger AS $$
    221249BEGIN
    222     UPDATE orders
    223     SET status = 'PAID'
    224     WHERE id = NEW.order_id;
    225     RETURN NEW;
     250UPDATE orders
     251SET status = 'PAID'
     252WHERE id = NEW.order_id;
     253RETURN NEW;
    226254END;
    227255$$ LANGUAGE plpgsql;
    228256
    229 
    230 CREATE TRIGGER trg_payments_mark_order_paid
    231     AFTER INSERT ON payments
    232     FOR EACH ROW EXECUTE FUNCTION payments_mark_order_paid();
    233 }}}
    234 {{{
    235257-- The SQL executed by the application:
    236 
    237258INSERT INTO payments(order_id, amount, payment_type, tip_amount)
    238259VALUES (1, 850.00, 'cash', 50.00);
     
    240261
    241262== View Sales Analytics and Reports
    242 The public page /analytics provides data on sales performance, with options to filter by different criteria. Given that the orders, order_items, and payments tables will grow to contain a massive number of records, running complex aggregations on every page load would heavily strain the database.
    243 
    244 To solve this, we implemented a Materialized View (mv_payments_daily_channel) that is refreshed periodically. In our case, this could be every 30 minutes. This way, instead of the application overloading the Database Engine with expensive queries, it serves pre-calculated data directly from the view. The view is defined as follows:
    245 
    246 {{{
    247 
     263The page at /api/analytics/reveunueByChannel provides data on sales performance. Given that transaction tables will grow very large, running complex aggregations on every request is inefficient.
     264
     265To solve this, we implemented a Materialized View (mv_payments_daily_channel). This view pre-aggregates sales data daily, broken down by order channel (TAB vs. ONLINE). When a user requests the report, the application queries this small, fast view instead of the large transaction tables.
     266[[Image(analytics-1.png)]]
     267[[Image(monthly-revenue-split-orders.png)]]
     268{{{
     269@GetMapping("/reveunueByChannel")
     270public AnalyticsByChannelResponse paymentsDailyChannel(
     271@RequestParam(required = false)
     272@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
     273@RequestParam(required = false)
     274@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
     275) {
     276return analyticsService.getPaymentsDailyChannel(from, to);
     277}
     278}}}
     279The service implementation fetches the pre-aggregated rows from the materialized view's repository (mvRepo) and then performs final calculations like totals and groupings in Java memory, which is extremely fast.
     280
     281{{{
     282@Override
     283public AnalyticsByChannelResponse getPaymentsDailyChannel(LocalDate from, LocalDate to) {
     284if (from == null) from = LocalDate.now().minusDays(30);
     285if (to == null)   to   = LocalDate.now();
     286
     287    // pass null channel to fetch ALL channels (JPQL has :channel is null guard)
     288    List<PaymentsDailyChannel> rows = mvRepo.findRange(from, to, null);
     289
     290    // group by channel
     291    Map<String, List<PaymentsDailyChannel>> byChannel = rows.stream()
     292            .collect(Collectors.groupingBy(PaymentsDailyChannel::getChannel));
     293
     294    // ...further processing in Java to build the final DTO...
     295
     296    return new AnalyticsByChannelResponse(from, to, channels, grandOrders, grandRevenue, grandTips);
     297}
     298}}}
     299The Materialized View definition in SQL is:
     300{{{
    248301CREATE MATERIALIZED VIEW IF NOT EXISTS mv_payments_daily_channel AS
    249302WITH orders_channel AS (
    250   SELECT
    251     o.id AS order_id,
    252     CASE
    253       WHEN EXISTS (SELECT 1 FROM tab_orders t WHERE t.order_id = o.id) THEN 'TAB'
    254       WHEN EXISTS (SELECT 1 FROM online_orders oo WHERE oo.order_id = o.id) THEN 'ONLINE'
    255       ELSE 'UNKNOWN'
    256     END AS channel
    257   FROM orders o
     303SELECT
     304o.id AS order_id,
     305CASE
     306WHEN EXISTS (SELECT 1 FROM tab_orders t WHERE t.order_id = o.id) THEN 'TAB'
     307WHEN EXISTS (SELECT 1 FROM online_orders oo WHERE oo.order_id = o.id) THEN 'ONLINE'
     308ELSE 'UNKNOWN'
     309END AS channel
     310FROM orders o
    258311)
    259312SELECT
    260     (date_trunc('day', p.created_at))::date AS day,
    261     oc.channel,
    262     COUNT(DISTINCT p.order_id) AS paid_orders_cnt,
    263     SUM(p.amount)::numeric(14,2) AS revenue,
    264     SUM(p.tip_amount)::numeric(14,2) AS tip_total
     313(date_trunc('day', p.created_at))::date AS day,
     314oc.channel,
     315COUNT(DISTINCT p.order_id) AS paid_orders_cnt,
     316SUM(p.amount)::numeric(14,2) AS revenue,
     317SUM(p.tip_amount)::numeric(14,2) AS tip_total
    265318FROM payments p
    266319JOIN orders_channel oc ON oc.order_id = p.order_id
    267320GROUP BY (date_trunc('day', p.created_at))::date, oc.channel;
    268321}}}
    269 When a user accesses the analytics page, the controller queries this simple, fast materialized view.
    270 
    271 {{{
    272 
    273     @GetMapping("/analytics/by-channel")
    274     public String getPaymentsByChannel(Model model,
    275                                          @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
    276                                          @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
    277         LocalDate fromDate = (from == null) ? LocalDate.now().minusDays(30) : from;
    278         LocalDate toDate = (to == null) ? LocalDate.now() : to;
    279 
    280         // This service method reads from the materialized view
    281         AnalyticsByChannelResponse response = analyticsService.getPaymentsDailyChannel(fromDate, toDate);
    282 
    283         model.addAttribute("analyticsData", response);
    284         model.addAttribute("replaceTemplate", "analytics_dashboard");
    285         return "index";
    286     }
    287 }}}
    288 [[Image(analytics-1.png)]]
    289 [[Image(monthly-revenue-split-orders.png)]]
    290 
    291 To keep the data in the materialized view up-to-date, a scheduled method is implemented in the application. This method runs every 30 minutes and executes the refresh command.
    292 
    293 
    294 {{{
    295 
    296     @Scheduled(cron = "0 */30 * * * *") // Runs every 30 minutes
    297     public void refreshPaymentAnalytics() {
    298         // This repository method executes a native query: "REFRESH MATERIALIZED VIEW mv_payments_daily_channel;"
    299         analyticsRepository.refreshDailyChannelView();
    300         log.info("Materialized view mv_payments_daily_channel has been refreshed.");
    301     }
    302 }}}