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 | | |
| 24 | A 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 |
| 27 | public ResponseEntity<AssignmentDto> createAssignment(@RequestBody CreateAssignmentDto dto, Authentication authentication) { |
| 28 | try { |
| 29 | String managerEmail = authentication.getName(); |
| 30 | Assignment assignment = assignmentService.createAssignment(dto, managerEmail); |
| 31 | return ResponseEntity.status(HttpStatus.CREATED).body(AssignmentDto.fromAssignment(assignment)); |
| 32 | } catch (SecurityException e) { |
| 33 | return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); |
| 34 | } |
| 35 | } |
| 36 | }}} |
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"; |
| 40 | The 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 |
| 44 | public Assignment createAssignment(CreateAssignmentDto dto, String managerEmail) { |
| 45 | Manager manager = getManagerByEmail(managerEmail); |
| 46 | Employee employee = employeeRepository.findById(dto.employeeId()) |
| 47 | .orElseThrow(() -> new EmployeeNotFoundException(dto.employeeId())); |
| 48 | Shift 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 | }}} |
| 62 | The same implementation, translated into the SQL query that executes in the background, would look like this: |
| 63 | {{{ |
| 64 | DO $$ |
| 65 | DECLARE |
| 66 | v_manager_id BIGINT; |
| 67 | v_employee_id BIGINT; |
| 68 | v_shift_id BIGINT; |
| 69 | BEGIN |
| 70 | -- Assume manager_id=3, employee_id=1, shift_id=1 from the form |
| 71 | v_manager_id := 3; |
| 72 | v_employee_id := 1; |
| 73 | v_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; |
| 79 | END $$; |
| 80 | }}} |
| 81 | |
| 82 | == Manage Menu Products |
| 83 | A 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") |
| 89 | public ResponseEntity<ProductDto> save(@RequestBody CreateProductDto dto) { |
| 90 | return ResponseEntity.status(HttpStatus.CREATED).body(ProductDto.from(productService.createProduct(dto))); |
| 91 | } |
| 92 | }}} |
| 93 | The 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 |
| 97 | public Product createProduct(CreateProductDto dto) { |
| 98 | Product productTmp = new Product(); |
| 99 | if (dto.name() != null) { |
| 100 | productTmp.setName(dto.name()); |
| 101 | } |
| 102 | if (dto.price() != null) { |
| 103 | productTmp.setPrice(dto.price()); |
| 104 | } |
| 105 | if(dto.taxClass()!=null){ |
| 106 | productTmp.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()); |
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); |
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 | | }}} |
| 126 | DECLARE |
| 127 | new_product_id BIGINT; |
| 128 | BEGIN |
| 129 | INSERT INTO products (name, description, price, category_id, manage_inventory, tax_class) |
| 130 | VALUES ('Cheeseburger', 'Classic beef burger with cheese', 450.00, 3, TRUE, 'A') |
| 131 | RETURNING 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; |
| 137 | END $$; |
| 138 | }}} |
| 139 | |
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 | | } |
| 141 | A 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") |
| 146 | public ResponseEntity<OrderDto> createTabOrder(@RequestBody CreateOrderDto dto, Authentication authentication) { |
| 147 | String userEmail = authentication.getName(); |
| 148 | return ResponseEntity.ok(OrderDto.from(orderService.createTabOrder(dto, userEmail))); |
| 149 | } |
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 | | }}} |
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 | |
| 154 | The 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 |
| 160 | public TabOrder createTabOrder(CreateOrderDto dto, String userEmail) { |
| 161 | log.debug("User {} creating a tab order for table {}", userEmail, dto.tableNumber()); |
| 162 | User user = userRepository.findByEmail(userEmail) |
| 163 | .orElseThrow(() -> new UsernameNotFoundException("User with email " + userEmail + " not found.")); |
| 164 | if (!(user instanceof FrontStaff)) { |
| 165 | throw new SecurityException("User is not authorized to create tab orders."); |
| 166 | } |
| 167 | TabOrder tabOrder = new TabOrder(); |
| 168 | RestaurantTable table = tableRepository.findById(dto.tableNumber()) |
| 169 | .orElseThrow(() -> new TableNotFoundException(dto.tableNumber())); |
| 170 | tabOrder.setRestaurantTable(table); |
| 171 | tabOrder.setFrontStaff((FrontStaff) user); |
| 172 | tabOrder.setTimestamp(LocalDateTime.now()); |
| 173 | tabOrder.setStatus(dto.status()); |
| 174 | if (dto.orderItems() != null && !dto.orderItems().isEmpty()) { |
| 175 | List<OrderItem> orderItems = dto.orderItems().stream().map(itemDto -> { |
| 176 | OrderItem item = new OrderItem(); |
| 177 | item.setOrder(tabOrder); |
| 178 | // ... set other item properties |
| 179 | Product product = productRepository.findById(itemDto.productId()) |
| 180 | .orElseThrow(() -> new ProductNotFoundException(itemDto.productId())); |
| 181 | item.setProduct(product); |
| 182 | return item; |
| 183 | }).collect(Collectors.toList()); |
| 184 | tabOrder.setOrderItems(orderItems); |
| 185 | } |
| 186 | return tabOrderRepository.save(tabOrder); |
| 187 | } |
| 188 | }}} |
| 189 | The corresponding SQL operations for creating a new tab order and adding an item would be: |
| 190 | |
| 191 | {{{ |
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 | | }}} |
| 193 | DECLARE |
| 194 | new_order_id BIGINT; |
| 195 | v_product_price DECIMAL(10,2); |
| 196 | BEGIN |
| 197 | -- Create the main order record |
| 198 | INSERT INTO orders (status, datetime) VALUES ('PENDING', NOW()) |
| 199 | RETURNING 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; |
| 213 | END $$; |
| 214 | }}} |
| 215 | |
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 | | } |
| 220 | This 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 |
| 226 | public Payment createPayment(CreatePaymentDto dto) { |
| 227 | log.info("Creating payment for orderId: {}", dto.orderId()); |
| 228 | Order 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 | } |
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 | | |
| 263 | The 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 | |
| 265 | To 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") |
| 270 | public 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 | ) { |
| 276 | return analyticsService.getPaymentsDailyChannel(from, to); |
| 277 | } |
| 278 | }}} |
| 279 | The 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 |
| 283 | public AnalyticsByChannelResponse getPaymentsDailyChannel(LocalDate from, LocalDate to) { |
| 284 | if (from == null) from = LocalDate.now().minusDays(30); |
| 285 | if (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 | }}} |
| 299 | The Materialized View definition in SQL is: |
| 300 | {{{ |
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, |
| 314 | oc.channel, |
| 315 | COUNT(DISTINCT p.order_id) AS paid_orders_cnt, |
| 316 | SUM(p.amount)::numeric(14,2) AS revenue, |
| 317 | SUM(p.tip_amount)::numeric(14,2) AS tip_total |
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 | | }}} |