|
|
@@ -4,6 +4,7 @@ import com.pharmacopoeia.entity.Brand;
|
|
|
import com.pharmacopoeia.entity.BrandRecommendRule;
|
|
|
import com.pharmacopoeia.repository.BrandRecommendRuleRepository;
|
|
|
import com.pharmacopoeia.repository.BrandRepository;
|
|
|
+import org.jetbrains.annotations.NotNull;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
|
@@ -27,16 +28,42 @@ public class BrandRecommendService {
|
|
|
|
|
|
// ==================== field constants ====================
|
|
|
|
|
|
- private static final List<String> BRAND_CSV_COLS = List.of(
|
|
|
- "name", "function_indication", "usage_dosage", "contraindication",
|
|
|
- "ingredients", "properties", "specification", "adverse_reactions",
|
|
|
- "precautions", "execution_standard", "storage", "jump_url", "description", "sort_order"
|
|
|
- );
|
|
|
+ // Chinese CSV headers -> Java field name mapping
|
|
|
+ private static final Map<String, String> CSV_HEADER_TO_FIELD = new LinkedHashMap<>() {{
|
|
|
+ put("品牌名称", "name");
|
|
|
+ put("功能主治", "function_indication");
|
|
|
+ put("用法用量", "usage_dosage");
|
|
|
+ put("禁忌", "contraindication");
|
|
|
+ put("成分", "ingredients");
|
|
|
+ put("性状", "properties");
|
|
|
+ put("规格", "specification");
|
|
|
+ put("不良反应", "adverse_reactions");
|
|
|
+ put("注意事项", "precautions");
|
|
|
+ put("执行标准", "execution_standard");
|
|
|
+ put("贮藏", "storage");
|
|
|
+ put("跳转链接", "jump_url");
|
|
|
+ put("描述", "description");
|
|
|
+ put("排序", "sort_order");
|
|
|
+ }};
|
|
|
+
|
|
|
+ private static final List<String> BRAND_CSV_COLS = new ArrayList<>(CSV_HEADER_TO_FIELD.keySet());
|
|
|
+
|
|
|
+ private static final List<String> BRAND_FIELD_NAMES = new ArrayList<>(CSV_HEADER_TO_FIELD.values());
|
|
|
private static final String BRANDS_CSV_HEADER = String.join(",", BRAND_CSV_COLS);
|
|
|
- private static final String RULES_CSV_HEADER = "keyword,brand_id,brand_name,tier,is_active";
|
|
|
|
|
|
- // non-name, non-sort_order columns
|
|
|
- private static final List<String> BRAND_TEXT_COLS = BRAND_CSV_COLS.stream()
|
|
|
+ // Rules CSV Chinese headers -> field name
|
|
|
+ private static final Map<String, String> RULES_CSV_HEADER_TO_FIELD = new LinkedHashMap<>() {{
|
|
|
+ put("关键词", "keyword");
|
|
|
+ put("品牌ID", "brand_id");
|
|
|
+ put("品牌名称", "brand_name");
|
|
|
+ put("层级", "tier");
|
|
|
+ put("是否启用", "is_active");
|
|
|
+ }};
|
|
|
+ private static final List<String> RULES_CSV_COLS = new ArrayList<>(RULES_CSV_HEADER_TO_FIELD.keySet());
|
|
|
+ private static final String RULES_CSV_HEADER = String.join(",", RULES_CSV_COLS);
|
|
|
+
|
|
|
+ // non-name, non-sort_order columns (field names for internal use)
|
|
|
+ private static final List<String> BRAND_TEXT_COLS = BRAND_FIELD_NAMES.stream()
|
|
|
.filter(c -> !"name".equals(c) && !"sort_order".equals(c))
|
|
|
.toList();
|
|
|
|
|
|
@@ -189,7 +216,7 @@ public class BrandRecommendService {
|
|
|
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
m.put("id", b.getId());
|
|
|
m.put("name", b.getName());
|
|
|
- BRAND_CSV_COLS.stream().filter(c -> !"name".equals(c))
|
|
|
+ BRAND_FIELD_NAMES.stream().filter(c -> !"name".equals(c))
|
|
|
.forEach(c -> m.put(c, nullToEmpty(brandField(b, c))));
|
|
|
m.put("is_active", b.getIsActive());
|
|
|
m.put("created_at", b.getCreatedAt());
|
|
|
@@ -287,38 +314,59 @@ public class BrandRecommendService {
|
|
|
// ==================== CSV import/export ====================
|
|
|
|
|
|
public Map<String, Object> importBrandsCsv(String csv) {
|
|
|
- List<String> lines = csvLines(csv);
|
|
|
- if (lines.isEmpty() || !lines.getFirst().equals(BRANDS_CSV_HEADER)) {
|
|
|
- throw new IllegalArgumentException("CSV format incorrect: expected header " + BRANDS_CSV_HEADER);
|
|
|
- }
|
|
|
+ List<String[]> rows = parseCsv(csv);
|
|
|
+ Map<String, Integer> colIdx = getIntegerMap(rows);
|
|
|
|
|
|
- int success = 0, failed = 0;
|
|
|
- for (int i = 1; i < lines.size(); i++) {
|
|
|
- String[] cols = lines.get(i).split(",", BRAND_CSV_COLS.size());
|
|
|
+ int success = 0, skipped = 0, failed = 0;
|
|
|
+ for (int i = 1; i < rows.size(); i++) {
|
|
|
+ String[] cols = rows.get(i);
|
|
|
try {
|
|
|
- String name = cols[0].trim();
|
|
|
- if (name.isEmpty() || brandRepository.existsByNameAndIdNot(name, null)) { failed++; continue; }
|
|
|
+ String name = cell(cols, colIdx, "品牌名称");
|
|
|
+ if (name == null || name.isEmpty()) { failed++; continue; }
|
|
|
+ if (brandRepository.existsByNameAndIdNot(name, null)) { skipped++; continue; }
|
|
|
brandRepository.save(Brand.builder()
|
|
|
.name(name)
|
|
|
- .functionIndication(col(cols, 1))
|
|
|
- .usageDosage(col(cols, 2))
|
|
|
- .contraindication(col(cols, 3))
|
|
|
- .ingredients(col(cols, 4))
|
|
|
- .properties(col(cols, 5))
|
|
|
- .specification(col(cols, 6))
|
|
|
- .adverseReactions(col(cols, 7))
|
|
|
- .precautions(col(cols, 8))
|
|
|
- .executionStandard(col(cols, 9))
|
|
|
- .storage(col(cols, 10))
|
|
|
- .jumpUrl(col(cols, 11))
|
|
|
- .description(col(cols, 12))
|
|
|
- .sortOrder(cols.length > 13 ? Integer.parseInt(cols[13].trim()) : 0)
|
|
|
+ .functionIndication(cell(cols, colIdx, "功能主治"))
|
|
|
+ .usageDosage(cell(cols, colIdx, "用法用量"))
|
|
|
+ .contraindication(cell(cols, colIdx, "禁忌"))
|
|
|
+ .ingredients(cell(cols, colIdx, "成分"))
|
|
|
+ .properties(cell(cols, colIdx, "性状"))
|
|
|
+ .specification(cell(cols, colIdx, "规格"))
|
|
|
+ .adverseReactions(cell(cols, colIdx, "不良反应"))
|
|
|
+ .precautions(cell(cols, colIdx, "注意事项"))
|
|
|
+ .executionStandard(cell(cols, colIdx, "执行标准"))
|
|
|
+ .storage(cell(cols, colIdx, "贮藏"))
|
|
|
+ .jumpUrl(cell(cols, colIdx, "跳转链接"))
|
|
|
+ .description(cell(cols, colIdx, "描述"))
|
|
|
+ .sortOrder(cellInt(cols, colIdx, "排序"))
|
|
|
.isActive(true)
|
|
|
.build());
|
|
|
success++;
|
|
|
} catch (Exception e) { failed++; }
|
|
|
}
|
|
|
- return Map.of("ok", true, "success", success, "failed", failed);
|
|
|
+ return Map.of("ok", true, "success", success, "skipped", skipped, "failed", failed);
|
|
|
+ }
|
|
|
+
|
|
|
+ @NotNull
|
|
|
+ private static Map<String, Integer> getIntegerMap(List<String[]> rows) {
|
|
|
+ if (rows.isEmpty()) {
|
|
|
+ throw new IllegalArgumentException("CSV format incorrect: expected header " + BRANDS_CSV_HEADER);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build header -> index map from the first row
|
|
|
+ String[] header = rows.getFirst();
|
|
|
+ Map<String, Integer> colIdx = new LinkedHashMap<>();
|
|
|
+ for (int i = 0; i < header.length; i++) {
|
|
|
+ colIdx.put(header[i].trim().replace("\"", ""), i);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify all expected headers are present
|
|
|
+ for (String h : BRAND_CSV_COLS) {
|
|
|
+ if (!colIdx.containsKey(h)) {
|
|
|
+ throw new IllegalArgumentException("CSV missing column: " + h);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return colIdx;
|
|
|
}
|
|
|
|
|
|
public String exportBrandsCsv(Set<Integer> ids) {
|
|
|
@@ -346,34 +394,58 @@ public class BrandRecommendService {
|
|
|
}
|
|
|
|
|
|
public Map<String, Object> importRulesCsv(String csv) {
|
|
|
- List<String> lines = csvLines(csv);
|
|
|
- if (lines.isEmpty() || !lines.getFirst().equals(RULES_CSV_HEADER)) {
|
|
|
- throw new IllegalArgumentException("CSV format incorrect: expected header " + RULES_CSV_HEADER);
|
|
|
- }
|
|
|
+ List<String[]> rows = parseCsv(csv);
|
|
|
+ Map<String, Integer> colIdx = getStringIntegerMap(rows);
|
|
|
|
|
|
- int success = 0, failed = 0;
|
|
|
- for (int i = 1; i < lines.size(); i++) {
|
|
|
- String[] cols = lines.get(i).split(",", 5);
|
|
|
+ int success = 0, skipped = 0, failed = 0;
|
|
|
+ for (int i = 1; i < rows.size(); i++) {
|
|
|
+ String[] cols = rows.get(i);
|
|
|
try {
|
|
|
- String kw = cols[0].trim();
|
|
|
- if (kw.isEmpty()) { failed++; continue; }
|
|
|
+ String kw = cell(cols, colIdx, "关键词");
|
|
|
+ if (kw == null || kw.isEmpty()) { failed++; continue; }
|
|
|
+ if (ruleRepository.existsByKeywordAndIdNot(kw, null)) { skipped++; continue; }
|
|
|
|
|
|
- int brandId = cols.length > 1 ? parseIntOrDefault(cols[1], 0) : 0;
|
|
|
- String brandName = cols.length > 2 ? cols[2].trim() : "";
|
|
|
+ int brandId = cellInt(cols, colIdx, "品牌ID");
|
|
|
+ String brandName = cell(cols, colIdx, "品牌名称");
|
|
|
Brand matched = brandId > 0 && brandRepository.existsById(brandId)
|
|
|
? null
|
|
|
: brandRepository.findByName(brandName);
|
|
|
int resolvedId = matched != null ? matched.getId() : brandId;
|
|
|
if (resolvedId <= 0 || !brandRepository.existsById(resolvedId)) { failed++; continue; }
|
|
|
|
|
|
- int tier = cols.length > 3 ? parseIntOrDefault(cols[3], 1) : 1;
|
|
|
- if (ruleRepository.existsByKeywordAndIdNot(kw, null)) { failed++; continue; }
|
|
|
+ int tier = cellInt(cols, colIdx, "层级");
|
|
|
+ if (tier <= 0) {
|
|
|
+ tier = 1;
|
|
|
+ }
|
|
|
+ boolean isActive = !"false".equalsIgnoreCase(cell(cols, colIdx, "是否启用"));
|
|
|
ruleRepository.save(BrandRecommendRule.builder()
|
|
|
- .keyword(kw).brandId(resolvedId).tier(tier).isActive(true).build());
|
|
|
+ .keyword(kw).brandId(resolvedId).tier(tier).isActive(isActive).build());
|
|
|
success++;
|
|
|
} catch (Exception e) { failed++; }
|
|
|
}
|
|
|
- return Map.of("ok", true, "success", success, "failed", failed);
|
|
|
+ return Map.of("ok", true, "success", success, "skipped", skipped, "failed", failed);
|
|
|
+ }
|
|
|
+
|
|
|
+ @NotNull
|
|
|
+ private static Map<String, Integer> getStringIntegerMap(List<String[]> rows) {
|
|
|
+ if (rows.isEmpty()) {
|
|
|
+ throw new IllegalArgumentException("CSV格式不正确,期望表头: " + RULES_CSV_HEADER);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build header -> index map, stripping quotes/spaces from header names
|
|
|
+ String[] header = rows.getFirst();
|
|
|
+ Map<String, Integer> colIdx = new LinkedHashMap<>();
|
|
|
+ for (int i = 0; i < header.length; i++) {
|
|
|
+ colIdx.put(header[i].trim().replace("\"", ""), i);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify all expected headers are present
|
|
|
+ for (String h : RULES_CSV_COLS) {
|
|
|
+ if (!colIdx.containsKey(h)) {
|
|
|
+ throw new IllegalArgumentException("CSV缺少列: " + h);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return colIdx;
|
|
|
}
|
|
|
|
|
|
public String exportRulesCsv(Set<Integer> ids) {
|
|
|
@@ -382,9 +454,9 @@ public class BrandRecommendService {
|
|
|
Map<Integer, String> brandNames = brandRepository.findAllById(brandIds).stream()
|
|
|
.collect(Collectors.toMap(Brand::getId, Brand::getName));
|
|
|
StringBuilder sb = new StringBuilder(RULES_CSV_HEADER).append("\n");
|
|
|
- rules.forEach(r -> sb.append(r.getKeyword()).append(",")
|
|
|
+ rules.forEach(r -> sb.append(esc(r.getKeyword())).append(",")
|
|
|
.append(r.getBrandId()).append(",")
|
|
|
- .append(brandNames.getOrDefault(r.getBrandId(), "")).append(",")
|
|
|
+ .append(esc(brandNames.getOrDefault(r.getBrandId(), ""))).append(",")
|
|
|
.append(r.getTier()).append(",")
|
|
|
.append(r.getIsActive()).append("\n"));
|
|
|
return sb.toString();
|
|
|
@@ -414,11 +486,83 @@ public class BrandRecommendService {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- private static List<String> csvLines(String csv) {
|
|
|
- return Arrays.stream(csv.replace("\r\n", "\n").replace("\r", "\n").split("\n"))
|
|
|
- .map(String::trim)
|
|
|
- .filter(s -> !s.isEmpty() && !s.startsWith("#"))
|
|
|
- .collect(Collectors.toList());
|
|
|
+ /** Parse CSV content into rows, respecting quoted fields.
|
|
|
+ * Newlines inside "..." quoted fields do NOT split rows. */
|
|
|
+ private static List<String[]> parseCsv(String csv) {
|
|
|
+ List<String[]> rows = new ArrayList<>();
|
|
|
+ csv = csv.replace("\r\n", "\n").replace("\r", "\n");
|
|
|
+
|
|
|
+ // Split into logical lines: \n inside a quoted field stays part of the cell value
|
|
|
+ List<String> lines = new ArrayList<>();
|
|
|
+ StringBuilder lineBuf = new StringBuilder();
|
|
|
+ boolean inQuotes = false;
|
|
|
+ for (int i = 0; i < csv.length(); i++) {
|
|
|
+ char ch = csv.charAt(i);
|
|
|
+ if (inQuotes) {
|
|
|
+ lineBuf.append(ch);
|
|
|
+ if (ch == '"' && i + 1 < csv.length() && csv.charAt(i + 1) == '"') {
|
|
|
+ lineBuf.append('"');
|
|
|
+ i++;
|
|
|
+ } else if (ch == '"') {
|
|
|
+ inQuotes = false;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (ch == '"') {
|
|
|
+ inQuotes = true;
|
|
|
+ lineBuf.append(ch);
|
|
|
+ } else if (ch == '\n') {
|
|
|
+ lines.add(lineBuf.toString());
|
|
|
+ lineBuf.setLength(0);
|
|
|
+ } else {
|
|
|
+ lineBuf.append(ch);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!lineBuf.isEmpty()) {
|
|
|
+ lines.add(lineBuf.toString());
|
|
|
+ }
|
|
|
+
|
|
|
+ for (String line : lines) {
|
|
|
+ String trimmed = line.trim();
|
|
|
+ if (trimmed.isEmpty() || trimmed.startsWith("#")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ rows.add(parseCsvRow(trimmed));
|
|
|
+ }
|
|
|
+ return rows;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Parse a single CSV row into cells, respecting "..." quoted fields */
|
|
|
+ private static String[] parseCsvRow(String line) {
|
|
|
+ List<String> cells = new ArrayList<>();
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ boolean inQuotes = false;
|
|
|
+ for (int i = 0; i < line.length(); i++) {
|
|
|
+ char ch = line.charAt(i);
|
|
|
+ if (inQuotes) {
|
|
|
+ if (ch == '"') {
|
|
|
+ if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
|
|
|
+ sb.append('"');
|
|
|
+ i++;
|
|
|
+ } else {
|
|
|
+ inQuotes = false;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ sb.append(ch);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (ch == '"') {
|
|
|
+ inQuotes = true;
|
|
|
+ } else if (ch == ',') {
|
|
|
+ cells.add(sb.toString().trim());
|
|
|
+ sb.setLength(0);
|
|
|
+ } else {
|
|
|
+ sb.append(ch);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ cells.add(sb.toString().trim());
|
|
|
+ return cells.toArray(new String[0]);
|
|
|
}
|
|
|
|
|
|
private static String require(Map<String, Object> body) {
|
|
|
@@ -429,10 +573,6 @@ public class BrandRecommendService {
|
|
|
return v;
|
|
|
}
|
|
|
|
|
|
- private static int parseIntOrDefault(String s, int def) {
|
|
|
- try { return Integer.parseInt(s.trim()); } catch (NumberFormatException e) { return def; }
|
|
|
- }
|
|
|
-
|
|
|
private static Consumer<String> fieldSetter(Brand b, String col) {
|
|
|
return switch (col) {
|
|
|
case "function_indication" -> b::setFunctionIndication;
|
|
|
@@ -489,8 +629,25 @@ public class BrandRecommendService {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- private static String col(String[] cols, int idx) {
|
|
|
- return idx < cols.length ? Optional.of(cols[idx].trim()).filter(s -> !s.isEmpty()).orElse(null) : null;
|
|
|
+ private static String cell(String[] cols, Map<String, Integer> colIdx, String header) {
|
|
|
+ Integer idx = colIdx.get(header);
|
|
|
+ if (idx == null || idx >= cols.length) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String v = cols[idx].trim();
|
|
|
+ // Strip surrounding double quotes
|
|
|
+ if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
|
|
|
+ v = v.substring(1, v.length() - 1).trim();
|
|
|
+ }
|
|
|
+ return v.isEmpty() ? null : v;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int cellInt(String[] cols, Map<String, Integer> colIdx, String header) {
|
|
|
+ String v = cell(cols, colIdx, header);
|
|
|
+ if (v == null || v.isEmpty()) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ try { return Integer.parseInt(v); } catch (NumberFormatException e) { return 0; }
|
|
|
}
|
|
|
|
|
|
private static String esc(String s) {
|