BrandRecommendService.java 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. package com.pharmacopoeia.service;
  2. import com.pharmacopoeia.entity.Brand;
  3. import com.pharmacopoeia.entity.BrandRecommendRule;
  4. import com.pharmacopoeia.repository.BrandRecommendRuleRepository;
  5. import com.pharmacopoeia.repository.BrandRepository;
  6. import org.jetbrains.annotations.NotNull;
  7. import org.springframework.stereotype.Service;
  8. import org.springframework.transaction.annotation.Transactional;
  9. import java.nio.charset.StandardCharsets;
  10. import java.util.*;
  11. import java.util.function.Consumer;
  12. import java.util.function.Function;
  13. import java.util.stream.Collectors;
  14. @Service
  15. public class BrandRecommendService {
  16. private final BrandRecommendRuleRepository ruleRepository;
  17. private final BrandRepository brandRepository;
  18. public BrandRecommendService(BrandRecommendRuleRepository ruleRepository,
  19. BrandRepository brandRepository) {
  20. this.ruleRepository = ruleRepository;
  21. this.brandRepository = brandRepository;
  22. }
  23. // ==================== field constants ====================
  24. // Chinese CSV headers -> Java field name mapping
  25. private static final Map<String, String> CSV_HEADER_TO_FIELD = new LinkedHashMap<>() {{
  26. put("品牌名称", "name");
  27. put("功能主治", "function_indication");
  28. put("用法用量", "usage_dosage");
  29. put("禁忌", "contraindication");
  30. put("成分", "ingredients");
  31. put("性状", "properties");
  32. put("规格", "specification");
  33. put("不良反应", "adverse_reactions");
  34. put("注意事项", "precautions");
  35. put("执行标准", "execution_standard");
  36. put("贮藏", "storage");
  37. put("跳转链接", "jump_url");
  38. put("描述", "description");
  39. put("排序", "sort_order");
  40. }};
  41. private static final List<String> BRAND_CSV_COLS = new ArrayList<>(CSV_HEADER_TO_FIELD.keySet());
  42. private static final List<String> BRAND_FIELD_NAMES = new ArrayList<>(CSV_HEADER_TO_FIELD.values());
  43. private static final String BRANDS_CSV_HEADER = String.join(",", BRAND_CSV_COLS);
  44. // Rules CSV Chinese headers -> field name
  45. private static final Map<String, String> RULES_CSV_HEADER_TO_FIELD = new LinkedHashMap<>() {{
  46. put("关键词", "keyword");
  47. put("品牌ID", "brand_id");
  48. put("品牌名称", "brand_name");
  49. put("层级", "tier");
  50. put("是否启用", "is_active");
  51. }};
  52. private static final List<String> RULES_CSV_COLS = new ArrayList<>(RULES_CSV_HEADER_TO_FIELD.keySet());
  53. private static final String RULES_CSV_HEADER = String.join(",", RULES_CSV_COLS);
  54. // non-name, non-sort_order columns (field names for internal use)
  55. private static final List<String> BRAND_TEXT_COLS = BRAND_FIELD_NAMES.stream()
  56. .filter(c -> !"name".equals(c) && !"sort_order".equals(c))
  57. .toList();
  58. // ==================== tier matching ====================
  59. public List<Map<String, Object>> match(List<Map<String, Object>> sources, String fullAnswer) {
  60. List<BrandRecommendRule> rules = ruleRepository.findByIsActiveTrueOrderByTierAscKeywordAsc();
  61. return rules.isEmpty() ? List.of() : doMatch(rules, sources, fullAnswer);
  62. }
  63. private List<Map<String, Object>> doMatch(List<BrandRecommendRule> rules,
  64. List<Map<String, Object>> sources, String fullAnswer) {
  65. Map<Integer, List<BrandRecommendRule>> byTier = rules.stream()
  66. .collect(Collectors.groupingBy(BrandRecommendRule::getTier, LinkedHashMap::new, Collectors.toList()));
  67. Set<String> texts = sources.stream()
  68. .map(s -> (String) s.getOrDefault("name", ""))
  69. .filter(n -> !n.isEmpty())
  70. .collect(Collectors.toCollection(HashSet::new));
  71. Optional.ofNullable(fullAnswer).filter(a -> !a.isEmpty()).ifPresent(texts::add);
  72. return byTier.values().stream()
  73. .map(tierRules -> findMatches(tierRules, texts))
  74. .filter(m -> !m.isEmpty())
  75. .findFirst()
  76. .orElse(List.of());
  77. }
  78. private List<Map<String, Object>> findMatches(List<BrandRecommendRule> rules, Set<String> targets) {
  79. Set<Integer> brandIds = rules.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
  80. Map<Integer, Brand> brandMap = brandRepository.findAllById(brandIds).stream()
  81. .collect(Collectors.toMap(Brand::getId, b -> b));
  82. Set<Integer> seenRuleIds = new HashSet<>();
  83. Set<Integer> seenBrandIds = new HashSet<>();
  84. List<Map<String, Object>> result = new ArrayList<>();
  85. for (BrandRecommendRule rule : rules) {
  86. String kwLower = rule.getKeyword().toLowerCase();
  87. boolean hit = targets.stream().anyMatch(t -> t.toLowerCase().contains(kwLower));
  88. if (hit && seenRuleIds.add(rule.getId()) && seenBrandIds.add(rule.getBrandId())) {
  89. result.add(buildBrandResult(rule, brandMap.get(rule.getBrandId())));
  90. }
  91. }
  92. return result;
  93. }
  94. private Map<String, Object> buildBrandResult(BrandRecommendRule rule, Brand brand) {
  95. Map<String, Object> m = new LinkedHashMap<>();
  96. m.put("brand_id", rule.getBrandId());
  97. m.put("matched_keyword", rule.getKeyword());
  98. m.put("tier", rule.getTier());
  99. if (brand != null) {
  100. m.put("brand_name", brand.getName());
  101. m.put("description", nullToEmpty(brand.getDescription()));
  102. m.put("function", nullToEmpty(brand.getFunctionIndication()));
  103. m.put("usage_dosage", nullToEmpty(brand.getUsageDosage()));
  104. m.put("contraindication", nullToEmpty(brand.getContraindication()));
  105. m.put("ingredients", nullToEmpty(brand.getIngredients()));
  106. m.put("properties", nullToEmpty(brand.getProperties()));
  107. m.put("specification", nullToEmpty(brand.getSpecification()));
  108. m.put("adverse_reactions", nullToEmpty(brand.getAdverseReactions()));
  109. m.put("precautions", nullToEmpty(brand.getPrecautions()));
  110. m.put("execution_standard", nullToEmpty(brand.getExecutionStandard()));
  111. m.put("storage", nullToEmpty(brand.getStorage()));
  112. m.put("jump_url", nullToEmpty(brand.getJumpUrl()));
  113. }
  114. return m;
  115. }
  116. // ==================== brand CRUD ====================
  117. public Map<String, Object> listBrands(String keyword, int page, int pageSize) {
  118. List<Brand> all = brandRepository.findAllByOrderBySortOrderAsc();
  119. Optional.ofNullable(keyword)
  120. .filter(k -> !k.isBlank())
  121. .ifPresent(k -> {
  122. String kw = k.toLowerCase();
  123. all.removeIf(b -> !b.getName().toLowerCase().contains(kw));
  124. });
  125. return paginate(all, page, pageSize);
  126. }
  127. @Transactional
  128. public Map<String, Object> createBrand(Map<String, Object> body) {
  129. String name = require(body);
  130. if (brandRepository.existsByNameAndIdNot(name.trim(), null)) {
  131. throw new IllegalArgumentException("brand name already exists");
  132. }
  133. Brand brand = Brand.builder()
  134. .name(name.trim())
  135. .functionIndication(str(body, "function_indication"))
  136. .usageDosage(str(body, "usage_dosage"))
  137. .contraindication(str(body, "contraindication"))
  138. .ingredients(str(body, "ingredients"))
  139. .properties(str(body, "properties"))
  140. .specification(str(body, "specification"))
  141. .adverseReactions(str(body, "adverse_reactions"))
  142. .precautions(str(body, "precautions"))
  143. .executionStandard(str(body, "execution_standard"))
  144. .storage(str(body, "storage"))
  145. .jumpUrl(str(body, "jump_url"))
  146. .description(str(body, "description"))
  147. .sortOrder(intVal(body))
  148. .isActive(true)
  149. .build();
  150. brand = brandRepository.save(brand);
  151. return Map.of("ok", true, "id", brand.getId(), "message", "created");
  152. }
  153. @Transactional
  154. public Map<String, Object> updateBrand(int id, Map<String, Object> body) {
  155. Brand b = brandRepository.findById(id)
  156. .orElseThrow(() -> new IllegalArgumentException("brand not found"));
  157. Optional.ofNullable(body.get("name"))
  158. .map(Object::toString).map(String::trim).filter(n -> !n.isBlank())
  159. .ifPresent(n -> {
  160. if (brandRepository.existsByNameAndIdNot(n, id)) {
  161. throw new IllegalArgumentException("brand name already exists");
  162. }
  163. b.setName(n);
  164. });
  165. BRAND_TEXT_COLS.forEach(key -> applyStr(body, key, fieldSetter(b, key)));
  166. Optional.ofNullable(body.get("sort_order"))
  167. .filter(v -> v instanceof Number)
  168. .ifPresent(v -> b.setSortOrder(((Number) v).intValue()));
  169. Optional.ofNullable(body.get("is_active"))
  170. .filter(v -> v instanceof Boolean)
  171. .ifPresent(v -> b.setIsActive((Boolean) v));
  172. brandRepository.save(b);
  173. return Map.of("ok", true, "id", id, "message", "updated");
  174. }
  175. @Transactional
  176. public Map<String, Object> deleteBrand(int id) {
  177. brandRepository.findById(id)
  178. .orElseThrow(() -> new IllegalArgumentException("brand not found"));
  179. brandRepository.deleteById(id);
  180. return Map.of("ok", true, "message", "deleted");
  181. }
  182. public Map<String, Object> getBrand(int id) {
  183. Brand b = brandRepository.findById(id)
  184. .orElseThrow(() -> new IllegalArgumentException("brand not found"));
  185. Map<String, Object> m = new LinkedHashMap<>();
  186. m.put("id", b.getId());
  187. m.put("name", b.getName());
  188. BRAND_FIELD_NAMES.stream().filter(c -> !"name".equals(c))
  189. .forEach(c -> m.put(c, nullToEmpty(brandField(b, c))));
  190. m.put("is_active", b.getIsActive());
  191. m.put("created_at", b.getCreatedAt());
  192. m.put("updated_at", b.getUpdatedAt());
  193. return m;
  194. }
  195. // ==================== rule CRUD ====================
  196. public Map<String, Object> listRules(String keyword, int page, int pageSize) {
  197. List<BrandRecommendRule> all = ruleRepository.findAllByOrderByTierAscKeywordAsc();
  198. Optional.ofNullable(keyword)
  199. .filter(k -> !k.isBlank())
  200. .ifPresent(k -> all.removeIf(r -> !r.getKeyword().contains(k)));
  201. Set<Integer> brandIds = all.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
  202. Map<Integer, String> brandNames = brandRepository.findAllById(brandIds).stream()
  203. .collect(Collectors.toMap(Brand::getId, Brand::getName));
  204. Map<String, Object> result = new HashMap<>(paginate(all, page, pageSize));
  205. @SuppressWarnings("unchecked")
  206. List<BrandRecommendRule> items = (List<BrandRecommendRule>) result.get("items");
  207. List<Map<String, Object>> enriched = items.stream().map(r -> {
  208. Map<String, Object> m = new LinkedHashMap<>();
  209. m.put("id", r.getId());
  210. m.put("keyword", r.getKeyword());
  211. m.put("brand_id", r.getBrandId());
  212. m.put("brand_name", brandNames.getOrDefault(r.getBrandId(), ""));
  213. m.put("tier", r.getTier());
  214. m.put("is_active", r.getIsActive());
  215. m.put("created_at", r.getCreatedAt());
  216. m.put("updated_at", r.getUpdatedAt());
  217. return m;
  218. }).collect(Collectors.toList());
  219. result.put("items", enriched);
  220. return result;
  221. }
  222. @Transactional
  223. public Map<String, Object> createRule(String keyword, int brandId, int tier) {
  224. Optional.ofNullable(keyword).filter(k -> !k.isBlank())
  225. .orElseThrow(() -> new IllegalArgumentException("keyword required"));
  226. Optional.of(brandId).filter(brandRepository::existsById)
  227. .orElseThrow(() -> new IllegalArgumentException("brand not found"));
  228. if (ruleRepository.existsByKeywordAndIdNot(keyword.trim(), null)) {
  229. throw new IllegalArgumentException("keyword already exists");
  230. }
  231. BrandRecommendRule rule = BrandRecommendRule.builder()
  232. .keyword(keyword.trim()).brandId(brandId).tier(tier).isActive(true).build();
  233. rule = ruleRepository.save(rule);
  234. return Map.of("ok", true, "id", rule.getId(), "message", "created");
  235. }
  236. @Transactional
  237. public Map<String, Object> updateRule(int id, String keyword, Integer brandId, Integer tier, Boolean isActive) {
  238. BrandRecommendRule rule = ruleRepository.findById(id)
  239. .orElseThrow(() -> new IllegalArgumentException("rule not found"));
  240. Optional.ofNullable(keyword).filter(k -> !k.isBlank()).ifPresent(k -> {
  241. if (ruleRepository.existsByKeywordAndIdNot(k.trim(), id)) {
  242. throw new IllegalArgumentException("keyword already exists");
  243. }
  244. rule.setKeyword(k.trim());
  245. });
  246. Optional.ofNullable(brandId)
  247. .filter(brandRepository::existsById)
  248. .ifPresent(rule::setBrandId);
  249. Optional.ofNullable(tier).ifPresent(rule::setTier);
  250. Optional.ofNullable(isActive).ifPresent(rule::setIsActive);
  251. ruleRepository.save(rule);
  252. return Map.of("ok", true, "id", id, "message", "updated");
  253. }
  254. @Transactional
  255. public Map<String, Object> deleteRule(int id) {
  256. ruleRepository.findById(id)
  257. .orElseThrow(() -> new IllegalArgumentException("rule not found"));
  258. ruleRepository.deleteById(id);
  259. return Map.of("ok", true, "message", "deleted");
  260. }
  261. @Transactional
  262. public Map<String, Object> toggleRule(int id, Boolean isActive) {
  263. BrandRecommendRule rule = ruleRepository.findById(id)
  264. .orElseThrow(() -> new IllegalArgumentException("rule not found"));
  265. boolean newState = isActive != null ? isActive : !Boolean.TRUE.equals(rule.getIsActive());
  266. rule.setIsActive(newState);
  267. ruleRepository.save(rule);
  268. return Map.of("ok", true, "id", id, "is_active", newState,
  269. "message", newState ? "enabled" : "disabled");
  270. }
  271. // ==================== CSV import/export ====================
  272. public Map<String, Object> importBrandsCsv(String csv) {
  273. List<String[]> rows = parseCsv(csv);
  274. Map<String, Integer> colIdx = getIntegerMap(rows);
  275. int success = 0, skipped = 0, failed = 0;
  276. for (int i = 1; i < rows.size(); i++) {
  277. String[] cols = rows.get(i);
  278. try {
  279. String name = cell(cols, colIdx, "品牌名称");
  280. if (name == null || name.isEmpty()) { failed++; continue; }
  281. if (brandRepository.existsByNameAndIdNot(name, null)) { skipped++; continue; }
  282. brandRepository.save(Brand.builder()
  283. .name(name)
  284. .functionIndication(cell(cols, colIdx, "功能主治"))
  285. .usageDosage(cell(cols, colIdx, "用法用量"))
  286. .contraindication(cell(cols, colIdx, "禁忌"))
  287. .ingredients(cell(cols, colIdx, "成分"))
  288. .properties(cell(cols, colIdx, "性状"))
  289. .specification(cell(cols, colIdx, "规格"))
  290. .adverseReactions(cell(cols, colIdx, "不良反应"))
  291. .precautions(cell(cols, colIdx, "注意事项"))
  292. .executionStandard(cell(cols, colIdx, "执行标准"))
  293. .storage(cell(cols, colIdx, "贮藏"))
  294. .jumpUrl(cell(cols, colIdx, "跳转链接"))
  295. .description(cell(cols, colIdx, "描述"))
  296. .sortOrder(cellInt(cols, colIdx, "排序"))
  297. .isActive(true)
  298. .build());
  299. success++;
  300. } catch (Exception e) { failed++; }
  301. }
  302. return Map.of("ok", true, "success", success, "skipped", skipped, "failed", failed);
  303. }
  304. @NotNull
  305. private static Map<String, Integer> getIntegerMap(List<String[]> rows) {
  306. if (rows.isEmpty()) {
  307. throw new IllegalArgumentException("CSV format incorrect: expected header " + BRANDS_CSV_HEADER);
  308. }
  309. // Build header -> index map from the first row
  310. String[] header = rows.getFirst();
  311. Map<String, Integer> colIdx = new LinkedHashMap<>();
  312. for (int i = 0; i < header.length; i++) {
  313. colIdx.put(header[i].trim().replace("\"", ""), i);
  314. }
  315. // Verify all expected headers are present
  316. for (String h : BRAND_CSV_COLS) {
  317. if (!colIdx.containsKey(h)) {
  318. throw new IllegalArgumentException("CSV missing column: " + h);
  319. }
  320. }
  321. return colIdx;
  322. }
  323. public String exportBrandsCsv(Set<Integer> ids) {
  324. List<Brand> brands = ids.isEmpty() ? List.of() : brandRepository.findAllById(ids);
  325. StringBuilder sb = new StringBuilder(BRANDS_CSV_HEADER).append("\n");
  326. List<Function<Brand, String>> extractors = List.of(
  327. b -> esc(b.getName()),
  328. b -> esc(b.getFunctionIndication()),
  329. b -> esc(b.getUsageDosage()),
  330. b -> esc(b.getContraindication()),
  331. b -> esc(b.getIngredients()),
  332. b -> esc(b.getProperties()),
  333. b -> esc(b.getSpecification()),
  334. b -> esc(b.getAdverseReactions()),
  335. b -> esc(b.getPrecautions()),
  336. b -> esc(b.getExecutionStandard()),
  337. b -> esc(b.getStorage()),
  338. b -> esc(b.getJumpUrl()),
  339. b -> esc(b.getDescription()),
  340. b -> String.valueOf(b.getSortOrder())
  341. );
  342. brands.forEach(b -> sb.append(extractors.stream()
  343. .map(f -> f.apply(b)).collect(Collectors.joining(","))).append("\n"));
  344. return sb.toString();
  345. }
  346. public Map<String, Object> importRulesCsv(String csv) {
  347. List<String[]> rows = parseCsv(csv);
  348. Map<String, Integer> colIdx = getStringIntegerMap(rows);
  349. int success = 0, skipped = 0, failed = 0;
  350. for (int i = 1; i < rows.size(); i++) {
  351. String[] cols = rows.get(i);
  352. try {
  353. String kw = cell(cols, colIdx, "关键词");
  354. if (kw == null || kw.isEmpty()) { failed++; continue; }
  355. if (ruleRepository.existsByKeywordAndIdNot(kw, null)) { skipped++; continue; }
  356. int brandId = cellInt(cols, colIdx, "品牌ID");
  357. String brandName = cell(cols, colIdx, "品牌名称");
  358. Brand matched = brandId > 0 && brandRepository.existsById(brandId)
  359. ? null
  360. : brandRepository.findByName(brandName);
  361. int resolvedId = matched != null ? matched.getId() : brandId;
  362. if (resolvedId <= 0 || !brandRepository.existsById(resolvedId)) { failed++; continue; }
  363. int tier = cellInt(cols, colIdx, "层级");
  364. if (tier <= 0) {
  365. tier = 1;
  366. }
  367. boolean isActive = !"false".equalsIgnoreCase(cell(cols, colIdx, "是否启用"));
  368. ruleRepository.save(BrandRecommendRule.builder()
  369. .keyword(kw).brandId(resolvedId).tier(tier).isActive(isActive).build());
  370. success++;
  371. } catch (Exception e) { failed++; }
  372. }
  373. return Map.of("ok", true, "success", success, "skipped", skipped, "failed", failed);
  374. }
  375. @NotNull
  376. private static Map<String, Integer> getStringIntegerMap(List<String[]> rows) {
  377. if (rows.isEmpty()) {
  378. throw new IllegalArgumentException("CSV格式不正确,期望表头: " + RULES_CSV_HEADER);
  379. }
  380. // Build header -> index map, stripping quotes/spaces from header names
  381. String[] header = rows.getFirst();
  382. Map<String, Integer> colIdx = new LinkedHashMap<>();
  383. for (int i = 0; i < header.length; i++) {
  384. colIdx.put(header[i].trim().replace("\"", ""), i);
  385. }
  386. // Verify all expected headers are present
  387. for (String h : RULES_CSV_COLS) {
  388. if (!colIdx.containsKey(h)) {
  389. throw new IllegalArgumentException("CSV缺少列: " + h);
  390. }
  391. }
  392. return colIdx;
  393. }
  394. public String exportRulesCsv(Set<Integer> ids) {
  395. List<BrandRecommendRule> rules = ids.isEmpty() ? List.of() : ruleRepository.findAllById(ids);
  396. Set<Integer> brandIds = rules.stream().map(BrandRecommendRule::getBrandId).collect(Collectors.toSet());
  397. Map<Integer, String> brandNames = brandRepository.findAllById(brandIds).stream()
  398. .collect(Collectors.toMap(Brand::getId, Brand::getName));
  399. StringBuilder sb = new StringBuilder(RULES_CSV_HEADER).append("\n");
  400. rules.forEach(r -> sb.append(esc(r.getKeyword())).append(",")
  401. .append(r.getBrandId()).append(",")
  402. .append(esc(brandNames.getOrDefault(r.getBrandId(), ""))).append(",")
  403. .append(r.getTier()).append(",")
  404. .append(r.getIsActive()).append("\n"));
  405. return sb.toString();
  406. }
  407. public byte[] exportBrandsCsvBytes(Set<Integer> ids) {
  408. return exportBrandsCsv(ids).getBytes(StandardCharsets.UTF_8);
  409. }
  410. public byte[] exportRulesCsvBytes(Set<Integer> ids) {
  411. return exportRulesCsv(ids).getBytes(StandardCharsets.UTF_8);
  412. }
  413. // ==================== helpers ====================
  414. private static <T> Map<String, Object> paginate(List<T> all, int page, int pageSize) {
  415. int total = all.size();
  416. int from = (page - 1) * pageSize;
  417. int to = Math.min(from + pageSize, total);
  418. List<T> items = from < total ? all.subList(from, to) : List.of();
  419. return Map.of(
  420. "items", items,
  421. "page", page,
  422. "page_size", pageSize,
  423. "total", total,
  424. "total_pages", (int) Math.ceil((double) total / pageSize)
  425. );
  426. }
  427. /** Parse CSV content into rows, respecting quoted fields.
  428. * Newlines inside "..." quoted fields do NOT split rows. */
  429. private static List<String[]> parseCsv(String csv) {
  430. List<String[]> rows = new ArrayList<>();
  431. csv = csv.replace("\r\n", "\n").replace("\r", "\n");
  432. // Split into logical lines: \n inside a quoted field stays part of the cell value
  433. List<String> lines = new ArrayList<>();
  434. StringBuilder lineBuf = new StringBuilder();
  435. boolean inQuotes = false;
  436. for (int i = 0; i < csv.length(); i++) {
  437. char ch = csv.charAt(i);
  438. if (inQuotes) {
  439. lineBuf.append(ch);
  440. if (ch == '"' && i + 1 < csv.length() && csv.charAt(i + 1) == '"') {
  441. lineBuf.append('"');
  442. i++;
  443. } else if (ch == '"') {
  444. inQuotes = false;
  445. }
  446. } else {
  447. if (ch == '"') {
  448. inQuotes = true;
  449. lineBuf.append(ch);
  450. } else if (ch == '\n') {
  451. lines.add(lineBuf.toString());
  452. lineBuf.setLength(0);
  453. } else {
  454. lineBuf.append(ch);
  455. }
  456. }
  457. }
  458. if (!lineBuf.isEmpty()) {
  459. lines.add(lineBuf.toString());
  460. }
  461. for (String line : lines) {
  462. String trimmed = line.trim();
  463. if (trimmed.isEmpty() || trimmed.startsWith("#")) {
  464. continue;
  465. }
  466. rows.add(parseCsvRow(trimmed));
  467. }
  468. return rows;
  469. }
  470. /** Parse a single CSV row into cells, respecting "..." quoted fields */
  471. private static String[] parseCsvRow(String line) {
  472. List<String> cells = new ArrayList<>();
  473. StringBuilder sb = new StringBuilder();
  474. boolean inQuotes = false;
  475. for (int i = 0; i < line.length(); i++) {
  476. char ch = line.charAt(i);
  477. if (inQuotes) {
  478. if (ch == '"') {
  479. if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
  480. sb.append('"');
  481. i++;
  482. } else {
  483. inQuotes = false;
  484. }
  485. } else {
  486. sb.append(ch);
  487. }
  488. } else {
  489. if (ch == '"') {
  490. inQuotes = true;
  491. } else if (ch == ',') {
  492. cells.add(sb.toString().trim());
  493. sb.setLength(0);
  494. } else {
  495. sb.append(ch);
  496. }
  497. }
  498. }
  499. cells.add(sb.toString().trim());
  500. return cells.toArray(new String[0]);
  501. }
  502. private static String require(Map<String, Object> body) {
  503. String v = str(body, "name");
  504. if (v == null || v.isBlank()) {
  505. throw new IllegalArgumentException("brand name required");
  506. }
  507. return v;
  508. }
  509. private static Consumer<String> fieldSetter(Brand b, String col) {
  510. return switch (col) {
  511. case "function_indication" -> b::setFunctionIndication;
  512. case "usage_dosage" -> b::setUsageDosage;
  513. case "contraindication" -> b::setContraindication;
  514. case "ingredients" -> b::setIngredients;
  515. case "properties" -> b::setProperties;
  516. case "specification" -> b::setSpecification;
  517. case "adverse_reactions" -> b::setAdverseReactions;
  518. case "precautions" -> b::setPrecautions;
  519. case "execution_standard" -> b::setExecutionStandard;
  520. case "storage" -> b::setStorage;
  521. case "jump_url" -> b::setJumpUrl;
  522. case "description" -> b::setDescription;
  523. default -> v -> {};
  524. };
  525. }
  526. private static String brandField(Brand b, String col) {
  527. return switch (col) {
  528. case "function_indication" -> b.getFunctionIndication();
  529. case "usage_dosage" -> b.getUsageDosage();
  530. case "contraindication" -> b.getContraindication();
  531. case "ingredients" -> b.getIngredients();
  532. case "properties" -> b.getProperties();
  533. case "specification" -> b.getSpecification();
  534. case "adverse_reactions" -> b.getAdverseReactions();
  535. case "precautions" -> b.getPrecautions();
  536. case "execution_standard" -> b.getExecutionStandard();
  537. case "storage" -> b.getStorage();
  538. case "jump_url" -> b.getJumpUrl();
  539. case "description" -> b.getDescription();
  540. case "sort_order" -> String.valueOf(b.getSortOrder());
  541. default -> "";
  542. };
  543. }
  544. private static String str(Map<String, Object> body, String key) {
  545. return Optional.ofNullable(body.get(key))
  546. .map(Object::toString).map(String::trim)
  547. .filter(s -> !s.isEmpty())
  548. .orElse(null);
  549. }
  550. private static String nullToEmpty(String s) { return s != null ? s : ""; }
  551. private static void applyStr(Map<String, Object> body, String key, Consumer<String> setter) {
  552. Optional.ofNullable(body.get(key))
  553. .ifPresent(v -> {
  554. String s = v.toString().trim();
  555. if (!s.isEmpty()) {
  556. setter.accept(s);
  557. }
  558. });
  559. }
  560. private static String cell(String[] cols, Map<String, Integer> colIdx, String header) {
  561. Integer idx = colIdx.get(header);
  562. if (idx == null || idx >= cols.length) {
  563. return null;
  564. }
  565. String v = cols[idx].trim();
  566. // Strip surrounding double quotes
  567. if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
  568. v = v.substring(1, v.length() - 1).trim();
  569. }
  570. return v.isEmpty() ? null : v;
  571. }
  572. private static int cellInt(String[] cols, Map<String, Integer> colIdx, String header) {
  573. String v = cell(cols, colIdx, header);
  574. if (v == null || v.isEmpty()) {
  575. return 0;
  576. }
  577. try { return Integer.parseInt(v); } catch (NumberFormatException e) { return 0; }
  578. }
  579. private static String esc(String s) {
  580. return (s == null || s.isEmpty()) ? "" : "\"" + s.replace("\"", "\"\"") + "\"";
  581. }
  582. private static int intVal(Map<String, Object> body) {
  583. return Optional.ofNullable(body.get("sort_order"))
  584. .filter(v -> v instanceof Number)
  585. .map(v -> ((Number) v).intValue())
  586. .orElse(0);
  587. }
  588. }