diff --git a/RestroHub/build.gradle b/RestroHub/build.gradle index 8af798a7..c292eb67 100644 --- a/RestroHub/build.gradle +++ b/RestroHub/build.gradle @@ -84,6 +84,9 @@ dependencies { // Logging (JSON format for production) implementation 'ch.qos.logback.contrib:logback-json-classic:0.1.5' implementation 'ch.qos.logback.contrib:logback-jackson:0.1.5' + + //Excel Dependency + implementation 'org.apache.poi:poi-ooxml:5.2.3' } // tasks.named('test') { diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/category/repository/CategoryRepository.java b/RestroHub/src/main/java/com/restroly/qrmenu/category/repository/CategoryRepository.java index 5a85328d..80b75767 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/category/repository/CategoryRepository.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/category/repository/CategoryRepository.java @@ -1,6 +1,7 @@ package com.restroly.qrmenu.category.repository; import java.util.List; +import java.util.Set; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -16,4 +17,6 @@ public interface CategoryRepository extends JpaRepository { Page findByIsDeleteFalse(Pageable pageable); + List findByNameInAndIsDeleteFalse(Set names); + } diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/config/ExcelMappingConfig.java b/RestroHub/src/main/java/com/restroly/qrmenu/config/ExcelMappingConfig.java new file mode 100644 index 00000000..8b42fe53 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/config/ExcelMappingConfig.java @@ -0,0 +1,19 @@ +package com.restroly.qrmenu.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +import java.util.Map; + +@Getter +@Setter +@Configuration +@PropertySource("classpath:templateConfig.properties") +@ConfigurationProperties(prefix = "restroly.excel") +public class ExcelMappingConfig { + private String templatePath; + private Map foodMapping; +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/excel/controller/ExcelFeatureController.java b/RestroHub/src/main/java/com/restroly/qrmenu/excel/controller/ExcelFeatureController.java new file mode 100644 index 00000000..ad8301f0 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/excel/controller/ExcelFeatureController.java @@ -0,0 +1,112 @@ +package com.restroly.qrmenu.excel.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.restroly.qrmenu.common.dto.ApiResponse; +import com.restroly.qrmenu.excel.service.MenuExcelService; +import com.restroly.qrmenu.exception.ApiErrorResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +import static com.restroly.qrmenu.common.util.ApiConstants.SECURE_API_VERSION; + +@RestController +@RequestMapping(SECURE_API_VERSION + "/excel") +public class ExcelFeatureController { + @Autowired + private MenuExcelService excelService; + + @PostMapping("/menu/{branchId}") + @Operation(summary = "Import menu data from Excel file", description = "Uploads and processes an Excel file containing menu data for the specified branch. Existing categories, menu items, variants, and addon mappings are created or updated based on the spreadsheet contents.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Menu imported successfully", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(value = """ + { + "success": true, + "message": "Successfully Imported Menu", + "data": null, + "timestamp": "2024-01-15T10:30:00" + } + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid file format or validation failed", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiErrorResponse.class), examples = @ExampleObject(value = """ + { + "status": 400, + "error": "BAD_REQUEST", + "message": "Invalid Excel file format", + "path": "/api/v1/excel/menu/1", + "timestamp": "2024-01-15T10:30:00", + "traceId": "abc123" + } + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Branch not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiErrorResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "Error while processing Excel file", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiErrorResponse.class))) + }) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Excel file containing menu data", required = true, content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, schema = @Schema(type = "object", requiredProperties = { + "file" }))) + public ResponseEntity> importData(@RequestParam("file") MultipartFile file, @PathVariable Long branchId) + throws Exception { + excelService.processImport(file, branchId); + ApiResponse response = ApiResponse.builder() + .success(true) + .message("Successfully Imported Menu") + .data(null) + .build(); + return ResponseEntity.ok(response); + } + + // ============================================================================================================================================================================================== + @GetMapping(value = "/menu/template", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + @Operation(summary = "Download menu import template", description = "Downloads a pre-formatted Excel template that can be used to import menu data into the system.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Template downloaded successfully", content = @Content(mediaType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", schema = @Schema(type = "string", format = "binary"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "Failed to generate template", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiErrorResponse.class))) + }) + public ResponseEntity getTemplate() throws Exception { + byte[] excelBytes = excelService.getTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=Branch_Menu_Template.xlsx"); + return ResponseEntity.ok() + .headers(headers) + .body(excelBytes); + } + + // ======================================================================================================================================= + @GetMapping(value = "/menu/{branchId}", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + @Operation(summary = "Export branch menu to Excel", description = "Generates and downloads an Excel file containing all categories, menu items, variants, addons, and related mappings for the specified branch.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Menu exported successfully", content = @Content(mediaType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", schema = @Schema(type = "string", format = "binary"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Branch not found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiErrorResponse.class), examples = @ExampleObject(value = """ + { + "status": 404, + "error": "NOT_FOUND", + "message": "Branch not found with id: 1", + "path": "/api/v1/excel/menu/1", + "timestamp": "2024-01-15T10:30:00", + "traceId": "abc123" + } + """))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "Failed to generate Excel export", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiErrorResponse.class))) + }) + public ResponseEntity exportData(@PathVariable Long branchId) throws Exception { + byte[] excelBytes = excelService.exportMenuToExcel(branchId); + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=Branch_" + branchId + "_Menu.xlsx"); + return ResponseEntity.ok() + .headers(headers) + .body(excelBytes); + } + +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/MenuExcelService.java b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/MenuExcelService.java new file mode 100644 index 00000000..c97f1ca1 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/MenuExcelService.java @@ -0,0 +1,9 @@ +package com.restroly.qrmenu.excel.service; + +import org.springframework.web.multipart.MultipartFile; + +public interface MenuExcelService { + public void processImport(MultipartFile file, Long branchId) throws Exception; + public byte[] exportMenuToExcel(Long branchId); + public byte[] getTemplate(); +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/GenericExcelExportService.java b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/GenericExcelExportService.java new file mode 100644 index 00000000..dfebf675 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/GenericExcelExportService.java @@ -0,0 +1,7 @@ +package com.restroly.qrmenu.excel.service.generic; + +import java.util.List; + +public interface GenericExcelExportService { + byte[] exportToExcel(List data); +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/GenericExcelImportService.java b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/GenericExcelImportService.java new file mode 100644 index 00000000..e57fc040 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/GenericExcelImportService.java @@ -0,0 +1,80 @@ +package com.restroly.qrmenu.excel.service.generic; + +import java.util.List; +import java.util.Set; + +import org.springframework.web.multipart.MultipartFile; + +public interface GenericExcelImportService{ + final Set TRUTHY_VALUES = Set.of( + // --- Core Tech Standards --- + "true", "1", "t", + + // --- 🇬🇧 English --- + "yes", "y", "yea", "yeah", "yep", "sure", "ok", + + // --- 🇮🇳 Hindi & Urdu --- + "haan", "han", "ji", "हाँ", "जी", "ہاں", "جی", + + // --- 🇮🇳 Bengali & Assamese --- + "haa", "hyan", "hya", "hoy", "হ্যাঁ", "হয়", + + // --- 🇮🇳 Marathi & Gujarati --- + "ho", "ha", "हो", "હા", + + // --- 🇮🇳 Punjabi --- + "aho", "ਹਾਂ", + + // --- 🇮🇳 Odia --- + "hna", "aw", "ହଁ", + + // --- 🇮🇳 Tamil --- + "aam", "aama", "aamam", "ஆம்", "ஆமாம்", + + // --- 🇮🇳 Telugu --- + "avunu", "అవును", + + // --- 🇮🇳 Kannada --- + "howdu", "ಹೌದು", + + // --- 🇮🇳 Malayalam --- + "athe", "അതെ", + + // --- 🇸🇦 Arabic --- + "naam", "na'am", "aywa", "نعم", "أيوا", + + // --- 🇨🇳 Chinese (Mandarin) --- + "shi", "dui", "是", "对", + + // --- 🇫🇷 French --- + "oui", "vrai", + + // --- 🇩🇪 German --- + "ja", "wahr", + + // --- 🇪🇸 Spanish & 🇵🇹 Portuguese --- + "si", "sí", "s", "sim", "verdadero", "verdade", + + // --- 🇯🇵 Japanese --- + "hai", "はい", + + // --- 🇰🇷 Korean --- + "ne", "ye", "네", "예", + + // --- 🇹🇭 Thai --- + "chai", "ใช่", + + // --- 🇻🇳 Vietnamese --- + "co", "có", "vang", "vâng", "da", "dạ", "đúng", + + // --- 🇷🇺 Russian --- + "da", "да", + + // --- 🇮🇩 Indonesian & 🇲🇾 Malay --- + "ya", "iya", + + // --- 🇹🇷 Turkish --- + "evet", "e" + ); + List parseExcel(MultipartFile file) throws Exception; +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/impl/GenericExcelExportServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/impl/GenericExcelExportServiceImpl.java new file mode 100644 index 00000000..965fbd4e --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/impl/GenericExcelExportServiceImpl.java @@ -0,0 +1,58 @@ +package com.restroly.qrmenu.excel.service.generic.impl; + +import java.io.ByteArrayOutputStream; +import java.util.List; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +import com.restroly.qrmenu.excel.service.generic.GenericExcelExportService; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class GenericExcelExportServiceImpl implements GenericExcelExportService { + + /** + * Child class defines how to build sheets, rows, and cells using the provided data. + */ + protected abstract void buildWorkbookFromData(Workbook workbook, List data) throws Exception; + + @Override + public byte[] exportToExcel(List data) { + try (Workbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + + log.debug("Created empty workbook. Delegating to child class for data population."); + + // Hand control to the child class to build out the sheets and rows + buildWorkbookFromData(workbook, data); + + workbook.write(out); + return out.toByteArray(); + + } catch (Exception e) { + log.error("Failed to generate Excel file", e); + throw new RuntimeException("Failed to generate Excel file: " + e.getMessage(), e); + } + } + + // --- Protected Reusable Cell Writing Utilities --- + + protected void setCellValue(Cell cell, String value) { + if (value != null) cell.setCellValue(value); + } + + protected void setCellValue(Cell cell, Double value) { + if (value != null) cell.setCellValue(value); + } + + protected void setCellValue(Cell cell, Integer value) { + if (value != null) cell.setCellValue(value); + } + + protected void setCellValue(Cell cell, Boolean value) { + if (value != null) cell.setCellValue(value); + } +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/impl/GenericExcelImportServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/impl/GenericExcelImportServiceImpl.java new file mode 100644 index 00000000..403a3a8d --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/impl/GenericExcelImportServiceImpl.java @@ -0,0 +1,78 @@ +package com.restroly.qrmenu.excel.service.generic.impl; + +import java.io.InputStream; +import java.util.List; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.web.multipart.MultipartFile; + +import com.restroly.qrmenu.excel.service.generic.GenericExcelImportService; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class GenericExcelImportServiceImpl implements GenericExcelImportService { + + /** + * Child class defines how to traverse the Workbook and extract data into a List. + */ + protected abstract List extractDataFromWorkbook(Workbook workbook) throws Exception; + + @Override + public List parseExcel(MultipartFile file) throws Exception { + try (InputStream is = file.getInputStream(); + Workbook workbook = new XSSFWorkbook(is)) { + + log.debug("Successfully opened Excel file stream. Delegating to child class for extraction."); + return extractDataFromWorkbook(workbook); + } + } + + // --- Protected Reusable Cell Extraction Utilities --- + + protected String getCellValueAsString(Cell cell) { + if (cell == null) + return null; + return switch (cell.getCellType()) { + case STRING -> cell.getStringCellValue().trim(); + case NUMERIC -> String.valueOf((int) cell.getNumericCellValue()); + case BOOLEAN -> String.valueOf(cell.getBooleanCellValue()); + default -> null; + }; + } + + protected Double getCellValueAsDouble(Cell cell) { + if (cell == null) return null; + if (cell.getCellType() == CellType.NUMERIC) { + return cell.getNumericCellValue(); + } else if (cell.getCellType() == CellType.STRING) { + try { + return Double.parseDouble(cell.getStringCellValue().trim()); + } catch (NumberFormatException e) { + return 0.0; + } + } + return null; + } + + protected Integer getCellValueAsInteger(Cell cell) { + if (cell == null || cell.getCellType() != CellType.NUMERIC) + return null; + return (int) cell.getNumericCellValue(); + } + + protected Boolean getCellValueAsBoolean(Cell cell) { + if (cell == null) + return null; + if (cell.getCellType() == CellType.BOOLEAN) + return cell.getBooleanCellValue(); + if (cell.getCellType() == CellType.STRING) { + String val = cell.getStringCellValue().trim().toLowerCase(); + return TRUTHY_VALUES.contains(val); + } + return null; + } +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/impl/GenericExcelServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/impl/GenericExcelServiceImpl.java new file mode 100644 index 00000000..3cd0e2c5 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/generic/impl/GenericExcelServiceImpl.java @@ -0,0 +1,127 @@ +package com.restroly.qrmenu.excel.service.generic.impl; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.web.multipart.MultipartFile; + +import com.restroly.qrmenu.excel.service.generic.GenericExcelExportService; +import com.restroly.qrmenu.excel.service.generic.GenericExcelImportService; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.List; + +public abstract class GenericExcelServiceImpl implements GenericExcelImportService, GenericExcelExportService { + + // ====================== ABSTRACT METHODS TO OVERRIDE ====================== + + /** + * Child class defines how to traverse the Workbook and extract data into a + * List. + */ + protected abstract List extractDataFromWorkbook(Workbook workbook) throws Exception; + + /** + * Child class defines how to build sheets, rows, and cells using the provided + * data. + */ + protected abstract void buildWorkbookFromData(Workbook workbook, List data) throws Exception; + + // ====================== IMPORT BOILERPLATE ================================ + + /** + * Handles file streams, creates the Workbook, and safely closes resources. + */ + public List parseExcel(MultipartFile file) throws Exception { + try (InputStream is = file.getInputStream(); + Workbook workbook = new XSSFWorkbook(is)) { + + // Delegate the actual parsing logic to the child class + return extractDataFromWorkbook(workbook); + } + } + + // ====================== EXPORT BOILERPLATE ================================ + + /** + * Creates a Workbook, delegates building to child, and converts to a byte array + * for download. + */ + public byte[] exportToExcel(List data) { + try (Workbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + + // Delegate the actual sheet/row creation to the child class! + buildWorkbookFromData(workbook, data); + + workbook.write(out); + return out.toByteArray(); + + } catch (Exception e) { + throw new RuntimeException("Failed to generate Excel file: " + e.getMessage(), e); + } + } + + // ========================== CELL UTILITIES =============================== + + protected String getCellValueAsString(Cell cell) { + if (cell == null) + return null; + return switch (cell.getCellType()) { + case STRING -> cell.getStringCellValue().trim(); + case NUMERIC -> String.valueOf((int) cell.getNumericCellValue()); + case BOOLEAN -> String.valueOf(cell.getBooleanCellValue()); + default -> null; + }; + } + + protected Double getCellValueAsDouble(Cell cell) { + if (cell == null) + return null; + if (cell.getCellType() == CellType.NUMERIC) { + return cell.getNumericCellValue(); + } else if (cell.getCellType() == CellType.STRING) { + try { + return Double.parseDouble(cell.getStringCellValue().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Invalid Number Format: Cannot parse '" + cell.getStringCellValue() + "' as a valid Price."); + } + } + return null; + } + + protected Boolean getCellValueAsBoolean(Cell cell) { + if (cell == null) + return null; + if (cell.getCellType() == CellType.BOOLEAN) + return cell.getBooleanCellValue(); + if (cell.getCellType() == CellType.STRING) { + String val = cell.getStringCellValue().trim().toLowerCase(); + return TRUTHY_VALUES.contains(val); + } + return null; + } + + protected void setCellValue(Cell cell, String value) { + if (value != null) + cell.setCellValue(value); + } + + protected void setCellValue(Cell cell, Double value) { + if (value != null) + cell.setCellValue(value); + } + + protected void setCellValue(Cell cell, Integer value) { + if (value != null) + cell.setCellValue(value); + } + + protected void setCellValue(Cell cell, Boolean value) { + if (value != null) + cell.setCellValue(value); + } +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/impl/MenuExcelServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/impl/MenuExcelServiceImpl.java new file mode 100644 index 00000000..0f0c202f --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/excel/service/impl/MenuExcelServiceImpl.java @@ -0,0 +1,507 @@ +package com.restroly.qrmenu.excel.service.impl; + +import com.restroly.qrmenu.branch.entity.Branch; +import com.restroly.qrmenu.branch.repository.BranchRepository; +import com.restroly.qrmenu.category.entity.Category; +import com.restroly.qrmenu.category.repository.CategoryRepository; +import com.restroly.qrmenu.config.ExcelMappingConfig; +import com.restroly.qrmenu.excel.service.MenuExcelService; +import com.restroly.qrmenu.excel.service.generic.impl.GenericExcelServiceImpl; +import com.restroly.qrmenu.exception.ResourceNotFoundException; +import com.restroly.qrmenu.food.entity.Food; +import com.restroly.qrmenu.food.repository.FoodRepository; +import com.restroly.qrmenu.menu.entity.Menu; +import com.restroly.qrmenu.menu.repository.MenuRepository; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.math.BigDecimal; +import java.sql.Date; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +//================================= INTERNAL DTOs FOR PARSING DATA ================================ +@Data +class ParsedMenu { + String name; + String desc; + List categories = new ArrayList<>(); +} + +@Data +class ParsedCategory { + String name; + String desc; + List foods = new ArrayList<>(); +} + +@Data +class ParsedFood { + String name; + String desc; + Double price; + Boolean isVeg; + Boolean isAvailable; + String imageUrl; +} + +@Service +@RequiredArgsConstructor +@Slf4j +public class MenuExcelServiceImpl extends GenericExcelServiceImpl implements MenuExcelService { + + private final MenuRepository menuRepository; + private final CategoryRepository categoryRepository; + private final FoodRepository foodRepository; + private final BranchRepository branchRepository; + + private final ExcelMappingConfig excelConfig; + + // ========================================== IMPORT PART + // ============================================== + @Override + public void processImport(MultipartFile file, Long branchId) throws Exception { + Branch branch = branchRepository.findById(branchId) + .orElseThrow(() -> new ResourceNotFoundException("Branch not found")); + + List parsedData = this.parseExcel(file); + + if (parsedData.isEmpty()) + throw new IllegalArgumentException("File empty"); + + saveParsedDataToDatabase(branch, parsedData); + } + + @Transactional(rollbackFor = Exception.class) + public void saveParsedDataToDatabase(Branch branch, List parsedData) { + Map categoryMap = upsertCategories(parsedData); + Map menuMap = upsertMenus(parsedData, branch); + linkMenusAndCategories(parsedData, menuMap, categoryMap); + upsertFoodItems(parsedData, categoryMap); + } + + @Override + protected List extractDataFromWorkbook(Workbook workbook) { + List menus = new ArrayList<>(); + + for (int sheetIdx = 0; sheetIdx < workbook.getNumberOfSheets(); sheetIdx++) { + Sheet sheet = workbook.getSheetAt(sheetIdx); + Row menuRow = sheet.getRow(0); + String extractedMenuName = getCellValueAsString(menuRow.getCell(1)); + if (extractedMenuName == null || extractedMenuName.isEmpty()) { + throw new IllegalArgumentException( + "Format Error in Sheet '" + sheet.getSheetName() + + "': The Menu Name cannot be blank. Please provide a name in cell B1." + ); + } + ParsedMenu currentMenu = new ParsedMenu(); + currentMenu.setName(getCellValueAsString(menuRow.getCell(1))); + currentMenu.setDesc(getCellValueAsString(menuRow.getCell(3))); + ParsedCategory currentCategory = null; + + for (int r = 1; r <= sheet.getLastRowNum(); r++) { + Row row = sheet.getRow(r); + if (row == null) + continue; + boolean isRowEmpty = true; + for (int c = Math.max(0, row.getFirstCellNum()); c < row.getLastCellNum(); c++) { + Cell cell = row.getCell(c); + if (cell != null && cell.getCellType() != CellType.BLANK) { + isRowEmpty = false; + break; + } + } + if (isRowEmpty) continue; + + String colA = getCellValueAsString(row.getCell(0)); + + if (colA == null || colA.isEmpty()) + throw new IllegalArgumentException( + "Format Error in Sheet '" + sheet.getSheetName() + "' at Row " + (r + 1) + + ": Column A cannot be blank if the row contains data." + ); + else if (colA.toLowerCase().startsWith("category")) { + currentCategory = new ParsedCategory(); + currentCategory.setName(getCellValueAsString(row.getCell(1))); + currentCategory.setDesc(getCellValueAsString(row.getCell(3))); + currentMenu.getCategories().add(currentCategory); + } + else if (colA.toLowerCase().startsWith("food")) + continue; + else if (currentCategory != null) { + ParsedFood food = new ParsedFood(); + food.setName(colA); + food.setDesc(getCellValueAsString(row.getCell(1))); + food.setPrice(getCellValueAsDouble(row.getCell(2))); + food.setIsVeg(getCellValueAsBoolean(row.getCell(3))); + food.setIsAvailable(getCellValueAsBoolean(row.getCell(4))); + food.setImageUrl(getCellValueAsString(row.getCell(5))); + currentCategory.getFoods().add(food); + } else { + throw new IllegalArgumentException( + "Format Error in Sheet '" + sheet.getSheetName() + "' at Row " + (r + 1) + + ": Found data before a valid 'Category Name:' header was declared."); + } + } + menus.add(currentMenu); + } + return menus; + } + + private Map upsertCategories(List data) { + Map uniqueCategories = new HashMap<>(); + + for (ParsedMenu menu : data) { + for (ParsedCategory cat : menu.getCategories()) { + if (cat.getName() != null && !cat.getName().trim().isEmpty()) { + uniqueCategories.putIfAbsent(cat.getName().trim(), cat.getDesc()); + } + } + } + + List existingCats = categoryRepository.findByNameInAndIsDeleteFalse(uniqueCategories.keySet()); + + Map categoryMap = existingCats.stream() + .filter(c -> uniqueCategories.containsKey(c.getName())) + .collect(Collectors.toMap(Category::getName, c -> c)); + + List newCats = new ArrayList<>(); + for (Map.Entry entry : uniqueCategories.entrySet()) { + if (!categoryMap.containsKey(entry.getKey())) { + Category cat = Category.builder() + .name(entry.getKey()) + .description(entry.getValue()) + .isDelete(false) + .updatedDate(LocalDateTime.now()) + .foods(new HashSet<>()) + .menu(new HashSet<>()) + .build(); + newCats.add(cat); + } + } + + if (!newCats.isEmpty()) { + List savedCats = categoryRepository.saveAll(newCats); + savedCats.forEach(c -> categoryMap.put(c.getName(), c)); + } + + return categoryMap; + } + + private Map upsertMenus(List data, Branch branch) { + Set uniqueNames = data.stream() + .map(ParsedMenu::getName) + .filter(name -> name != null && !name.trim().isEmpty()) + .map(String::trim) + .collect(Collectors.toSet()); + + List existingMenus = menuRepository.findByBranch_BranchIdAndIsDeletedFalse(branch.getBranchId()); + Map menuMap = existingMenus.stream() + .filter(m -> uniqueNames.contains(m.getMenuName())) + .collect(Collectors.toMap(Menu::getMenuName, m -> m)); + + List newMenus = new ArrayList<>(); + long currentTime = System.currentTimeMillis(); + + for (String name : uniqueNames) { + if (!menuMap.containsKey(name)) { + String desc = data.stream() + .filter(r -> name.equals(r.getName())) + .map(r -> r.getDesc() != null ? r.getDesc() : "") + .findFirst() + .orElse(""); + + Menu menu = Menu.builder() + .menuName(name) + .menuDesc(desc) + .branch(branch) + .isDeleted(false) + .createdDate(new Date(currentTime)) + .updatedDate(new Date(currentTime)) + .categories(new ArrayList<>()) + .build(); + newMenus.add(menu); + } + } + + if (!newMenus.isEmpty()) { + List savedMenus = menuRepository.saveAll(newMenus); + savedMenus.forEach(m -> menuMap.put(m.getMenuName(), m)); + } + + return menuMap; + } + + private void linkMenusAndCategories(List data, Map menuMap, + Map categoryMap) { + Set menusToUpdate = new HashSet<>(); + + for (ParsedMenu parsedMenu : data) { + Menu menu = menuMap.get(parsedMenu.getName().trim()); + if (menu == null) + continue; + + for (ParsedCategory parsedCat : parsedMenu.getCategories()) { + Category category = categoryMap.get(parsedCat.getName().trim()); + if (category != null) { + if (menu.getCategories() == null) { + menu.setCategories(new ArrayList<>()); + } + + boolean categoryExists = menu.getCategories().stream() + .anyMatch(c -> c.getCategoryId().equals(category.getCategoryId())); + + if (!categoryExists) { + menu.getCategories().add(category); + menusToUpdate.add(menu); + } + } + } + } + + if (!menusToUpdate.isEmpty()) { + menuRepository.saveAll(menusToUpdate); + } + } + + private void upsertFoodItems(List data, Map categoryMap) { + Map uniqueItems = new HashMap<>(); + + for (ParsedMenu menu : data) { + for (ParsedCategory cat : menu.getCategories()) { + for (ParsedFood food : cat.getFoods()) { + if (food.getName() != null && !food.getName().trim().isEmpty() && cat.getName() != null) { + String key = food.getName().trim() + "|" + cat.getName().trim(); + uniqueItems.put(key, food); + } + } + } + } + + List categoryIds = categoryMap.values().stream().map(Category::getCategoryId).toList(); + + if (categoryIds.isEmpty()) + return; + + List existingItems = foodRepository.findByCategory_CategoryIdInAndIsDeleteFalse(categoryIds); + + Map foodItemMap = existingItems.stream() + .collect(Collectors.toMap(f -> f.getName().trim() + "|" + f.getCategory().getName().trim(), f -> f)); + + List itemsToSave = new ArrayList<>(); + + for (Map.Entry entry : uniqueItems.entrySet()) { + ParsedFood parsedFood = entry.getValue(); + String key = entry.getKey(); + String categoryName = key.split("\\|")[1]; + + Category category = categoryMap.get(categoryName); + if (category == null) + continue; + + Food item = foodItemMap.getOrDefault(key, Food.builder().build()); + + item.setName(parsedFood.getName().trim()); + item.setDescription(parsedFood.getDesc()); + item.setPrice(parsedFood.getPrice() != null ? BigDecimal.valueOf(parsedFood.getPrice()) : BigDecimal.ZERO); + item.setImageUrl(parsedFood.getImageUrl()); + item.setIsAvailable(parsedFood.getIsAvailable() == null || parsedFood.getIsAvailable()); + item.setIsVeg(parsedFood.getIsVeg() != null && parsedFood.getIsVeg()); + item.setIsDelete(false); + item.setCategory(category); + + itemsToSave.add(item); + } + + if (!itemsToSave.isEmpty()) { + foodRepository.saveAll(itemsToSave); + } + } + + // ======================================= EXPORT PART ============================================== + + @Transactional(readOnly = true) + @Override + public byte[] exportMenuToExcel(Long branchId) { + if (!branchRepository.existsById(branchId)) + throw new ResourceNotFoundException("Branch not found"); + + List menus = menuRepository.findByBranch_BranchIdAndIsDeletedFalse(branchId); + + // This will ensure the blank branches gets template if they click export + if (menus.isEmpty()) { + return getTemplate(); + } + + List parsedData = convertEntitiesToParsedMenu(menus); + + return generateFromTemplate(parsedData); + } + + @Override + public byte[] getTemplate() { + org.springframework.core.io.ClassPathResource resource = + new org.springframework.core.io.ClassPathResource(excelConfig.getTemplatePath()); + try (InputStream is = resource.getInputStream()) { + return is.readAllBytes(); + } catch (Exception e) { + log.error("Failed to read static template file from resources", e); + throw new RuntimeException("Template file not found at: " + excelConfig.getTemplatePath(), e); + } + } + + private byte[] generateFromTemplate(List data) { + org.springframework.core.io.ClassPathResource resource = new org.springframework.core.io.ClassPathResource( + excelConfig.getTemplatePath()); + + try (InputStream is = resource.getInputStream(); + Workbook workbook = WorkbookFactory.create(is); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + + CellStyle boldStyle = workbook.createCellStyle(); + Font boldFont = workbook.createFont(); + boldFont.setBold(true); + boldStyle.setFont(boldFont); + + Sheet templateSheet = workbook.getSheetAt(0); + + for (int m = 0; m < data.size(); m++) { + ParsedMenu menu = data.get(m); + + String safeSheetName = menu.getName().replaceAll("[\\\\/?*\\[\\]]", "").trim(); + if (safeSheetName.isEmpty()) + safeSheetName = "Menu_" + m; + if (safeSheetName.length() > 31) + safeSheetName = safeSheetName.substring(0, 31); + + Sheet sheet = (m == 0) ? templateSheet : workbook.cloneSheet(0); + workbook.setSheetName(workbook.getSheetIndex(sheet), safeSheetName); + + int rowIdx = 0; + + // --- MENU HEADER --- + Row menuRow = sheet.createRow(rowIdx++); + createStyledCell(menuRow, 0, "Menu Name:", boldStyle); + createStyledCell(menuRow, 1, menu.getName(), null); + createStyledCell(menuRow, 2, "Description:", boldStyle); + createStyledCell(menuRow, 3, menu.getDesc(), null); + rowIdx++; + + // --- CATEGORIES & FOODS --- + for (ParsedCategory category : menu.getCategories()) { + Row catRow = sheet.createRow(rowIdx++); + createStyledCell(catRow, 0, "Category Name:", boldStyle); + createStyledCell(catRow, 1, category.getName(), null); + createStyledCell(catRow, 2, "Description:", boldStyle); + createStyledCell(catRow, 3, category.getDesc(), null); + + Row foodHeaderRow = sheet.createRow(rowIdx++); + + String[] foodHeaders = {"Food Name", "Description", "Price", "Is Veg", "Is Available", "Image URL"}; + for (int i = 0; i < foodHeaders.length; i++) { + createStyledCell(foodHeaderRow, i, foodHeaders[i], boldStyle); + } + + for (ParsedFood food : category.getFoods()) { + Row fRow = sheet.createRow(rowIdx++); + createStyledCell(fRow, 0, food.getName(), null); + createStyledCell(fRow, 1, food.getDesc(), null); + createStyledCell(fRow, 2, food.getPrice(), null); + createStyledCell(fRow, 3, food.getIsVeg(), null); + createStyledCell(fRow, 4, food.getIsAvailable(), null); + createStyledCell(fRow, 5, food.getImageUrl(), null); + } + rowIdx++; + } + for (int i = 0; i < 6; i++) + sheet.autoSizeColumn(i); + } + + workbook.write(out); + return out.toByteArray(); + + } catch (Exception e) { + log.error("Failed to generate Excel file from template", e); + throw new RuntimeException("Failed to generate Excel from template: " + e.getMessage(), e); + } + } + + @Override + protected void buildWorkbookFromData(Workbook workbook, List data) { + + } + + private List convertEntitiesToParsedMenu(List menus) { + List parsedData = new ArrayList<>(); + + if (menus == null || menus.isEmpty()) { + return parsedData; + } + + for (Menu menu : menus) { + // Skip soft-deleted menus + if (menu.isDeleted()) + continue; + + ParsedMenu pMenu = new ParsedMenu(); + pMenu.setName(menu.getMenuName()); + pMenu.setDesc(menu.getMenuDesc()); + + if (menu.getCategories() != null) { + for (Category cat : menu.getCategories()) { + // Skip soft-deleted categories + if (Boolean.TRUE.equals(cat.getIsDelete())) + continue; + + ParsedCategory pCat = new ParsedCategory(); + pCat.setName(cat.getName()); + pCat.setDesc(cat.getDescription()); + + if (cat.getFoods() != null) { + for (Food food : cat.getFoods()) { + // Skip soft-deleted foods + if (Boolean.TRUE.equals(food.getIsDelete())) + continue; + + ParsedFood pFood = new ParsedFood(); + pFood.setName(food.getName()); + pFood.setDesc(food.getDescription()); + // Handle BigDecimal to Double conversion safely + pFood.setPrice(food.getPrice() != null ? food.getPrice().doubleValue() : 0.0); + pFood.setIsVeg(food.getIsVeg()); + pFood.setIsAvailable(food.getIsAvailable()); + pFood.setImageUrl(food.getImageUrl()); + + pCat.getFoods().add(pFood); + } + } + pMenu.getCategories().add(pCat); + } + } + parsedData.add(pMenu); + } + + return parsedData; + } + + private void createStyledCell(Row row, int colIdx, Object value, CellStyle style) { + Cell cell = row.createCell(colIdx); + if (value instanceof String) + cell.setCellValue((String) value); + else if (value instanceof Double) + cell.setCellValue((Double) value); + else if (value instanceof Boolean) + cell.setCellValue((Boolean) value); + if (style != null) + cell.setCellStyle(style); + } +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/food/repository/FoodRepository.java b/RestroHub/src/main/java/com/restroly/qrmenu/food/repository/FoodRepository.java index cfafbbfe..44059d83 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/food/repository/FoodRepository.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/food/repository/FoodRepository.java @@ -162,4 +162,5 @@ int updateAvailability( boolean existsByNameIgnoreCaseAndCategory_CategoryIdAndFoodIdNot(String name, Long categoryId, Long foodId); + List findByCategory_CategoryIdInAndIsDeleteFalse(List categoryIds); } \ No newline at end of file diff --git a/RestroHub/src/main/resources/assets/Menu_Template.xlsx b/RestroHub/src/main/resources/assets/Menu_Template.xlsx new file mode 100644 index 00000000..219c9423 Binary files /dev/null and b/RestroHub/src/main/resources/assets/Menu_Template.xlsx differ diff --git a/RestroHub/src/main/resources/templateConfig.properties b/RestroHub/src/main/resources/templateConfig.properties new file mode 100644 index 00000000..ddb4410b --- /dev/null +++ b/RestroHub/src/main/resources/templateConfig.properties @@ -0,0 +1,10 @@ +# src/main/resources/templateConfig.properties + +restroly.excel.template-path=assets/Menu_Template.xlsx + +restroly.excel.food-mapping.Food\ Name=name +restroly.excel.food-mapping.Description=desc +restroly.excel.food-mapping.Price=price +restroly.excel.food-mapping.Is\ Veg=isVeg +restroly.excel.food-mapping.Is\ Available=isAvailable +restroly.excel.food-mapping.Image\ URL=imageUrl \ No newline at end of file