1
This commit is contained in:
+111
@@ -0,0 +1,111 @@
|
|||||||
|
package com.ruoyi.web.controller.jarvis;
|
||||||
|
|
||||||
|
import com.ruoyi.common.annotation.Log;
|
||||||
|
import com.ruoyi.common.core.controller.BaseController;
|
||||||
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
|
import com.ruoyi.common.enums.BusinessType;
|
||||||
|
import com.ruoyi.jarvis.domain.dto.OrderProfitAnalyzeRequest;
|
||||||
|
import com.ruoyi.jarvis.service.IOrderProfitAnalysisService;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 慢单订单/利润 AI 分析
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/jarvis/order-profit-analysis")
|
||||||
|
public class OrderProfitAnalysisController extends BaseController {
|
||||||
|
|
||||||
|
private final IOrderProfitAnalysisService orderProfitAnalysisService;
|
||||||
|
|
||||||
|
public OrderProfitAnalysisController(IOrderProfitAnalysisService orderProfitAnalysisService) {
|
||||||
|
this.orderProfitAnalysisService = orderProfitAnalysisService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览统计 JSON(本期 / 上期 / 对比),不调用大模型。
|
||||||
|
*/
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public AjaxResult stats(@RequestParam(value = "periodType", required = false) String periodType,
|
||||||
|
@RequestParam(value = "date", required = false) String date) {
|
||||||
|
try {
|
||||||
|
return AjaxResult.success(orderProfitAnalysisService.buildStats(periodType, date));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("预览利润统计失败", e);
|
||||||
|
return AjaxResult.error("预览失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 套固定提示词调用已配置大模型;push=true 时推企微。
|
||||||
|
*/
|
||||||
|
@Log(title = "慢单利润AI分析", businessType = BusinessType.OTHER)
|
||||||
|
@PostMapping("/analyze")
|
||||||
|
public AjaxResult analyze(@RequestBody(required = false) OrderProfitAnalyzeRequest request) {
|
||||||
|
try {
|
||||||
|
if (request == null) {
|
||||||
|
request = new OrderProfitAnalyzeRequest();
|
||||||
|
}
|
||||||
|
boolean push = Boolean.TRUE.equals(request.getPush());
|
||||||
|
String trigger = request.getTrigger();
|
||||||
|
Map<String, Object> data = orderProfitAnalysisService.analyze(
|
||||||
|
request.getPeriodType(), request.getDate(), push, request.getProfileId(), trigger);
|
||||||
|
return AjaxResult.success(data);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("利润 AI 分析失败", e);
|
||||||
|
return AjaxResult.error("分析失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试推送:按所选周期分析并立即推企微,记入发送记录(trigger=test)。
|
||||||
|
*/
|
||||||
|
@Log(title = "慢单利润AI分析测试推送", businessType = BusinessType.OTHER)
|
||||||
|
@PostMapping("/test")
|
||||||
|
public AjaxResult test(@RequestBody(required = false) OrderProfitAnalyzeRequest request) {
|
||||||
|
try {
|
||||||
|
if (request == null) {
|
||||||
|
request = new OrderProfitAnalyzeRequest();
|
||||||
|
}
|
||||||
|
Map<String, Object> data = orderProfitAnalysisService.analyze(
|
||||||
|
request.getPeriodType(), request.getDate(), true, request.getProfileId(), "test");
|
||||||
|
return AjaxResult.success(data);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("利润分析测试推送失败", e);
|
||||||
|
return AjaxResult.error("测试失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送记录(新的在前)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/send-logs")
|
||||||
|
public AjaxResult sendLogs(@RequestParam(value = "limit", required = false) Integer limit) {
|
||||||
|
try {
|
||||||
|
int n = limit == null ? 30 : limit;
|
||||||
|
return AjaxResult.success(orderProfitAnalysisService.listSendLogs(n));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("读取利润分析发送记录失败", e);
|
||||||
|
return AjaxResult.error("读取失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次分析结果(按周期)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/latest")
|
||||||
|
public AjaxResult latest(@RequestParam(value = "periodType", required = false) String periodType) {
|
||||||
|
try {
|
||||||
|
return AjaxResult.success(orderProfitAnalysisService.latest(periodType));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("读取最近利润分析失败", e);
|
||||||
|
return AjaxResult.error("读取失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,6 +222,14 @@ jarvis:
|
|||||||
# 获取评论接口服务地址(后端转发,避免前端跨域)
|
# 获取评论接口服务地址(后端转发,避免前端跨域)
|
||||||
fetch-comments:
|
fetch-comments:
|
||||||
base-url: http://192.168.8.60:5008
|
base-url: http://192.168.8.60:5008
|
||||||
|
# 慢单利润/订单 AI 分析:聚合成 JSON → 固定提示词 → 已激活大模型 → 企微推送
|
||||||
|
order-profit-analysis:
|
||||||
|
enabled: true
|
||||||
|
# 接收人 UserID,多个逗号或 |;留空则回退 goofish-notify-touser
|
||||||
|
touser: ""
|
||||||
|
daily-cron: "0 0 21 * * ?"
|
||||||
|
weekly-cron: "0 20 8 ? * MON"
|
||||||
|
monthly-cron: "0 30 8 1 * ?"
|
||||||
# 企微经 wxSend 调用本接口时校验(须与 wxSend 配置一致)
|
# 企微经 wxSend 调用本接口时校验(须与 wxSend 配置一致)
|
||||||
wecom:
|
wecom:
|
||||||
inbound-secret: jarvis_wecom_bridge_change_me
|
inbound-secret: jarvis_wecom_bridge_change_me
|
||||||
|
|||||||
@@ -219,6 +219,12 @@ jarvis:
|
|||||||
# 获取评论接口服务地址(后端转发)
|
# 获取评论接口服务地址(后端转发)
|
||||||
fetch-comments:
|
fetch-comments:
|
||||||
base-url: http://192.168.8.60:5008
|
base-url: http://192.168.8.60:5008
|
||||||
|
order-profit-analysis:
|
||||||
|
enabled: true
|
||||||
|
touser: ""
|
||||||
|
daily-cron: "0 0 23 * * ?"
|
||||||
|
weekly-cron: "0 20 8 ? * MON"
|
||||||
|
monthly-cron: "0 30 8 1 * ?"
|
||||||
wecom:
|
wecom:
|
||||||
inbound-secret: jarvis_wecom_bridge_change_me
|
inbound-secret: jarvis_wecom_bridge_change_me
|
||||||
wxsend-base-url: https://wxts.van333.cn
|
wxsend-base-url: https://wxts.van333.cn
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.ruoyi.jarvis.domain.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 慢单利润 AI 分析请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class OrderProfitAnalyzeRequest {
|
||||||
|
|
||||||
|
/** day / week / month */
|
||||||
|
private String periodType;
|
||||||
|
|
||||||
|
/** 锚点日期 yyyy-MM-dd */
|
||||||
|
private String date;
|
||||||
|
|
||||||
|
/** 是否推送到企微 */
|
||||||
|
private Boolean push;
|
||||||
|
|
||||||
|
/** 可选:指定 LLM profileId,空则用当前激活配置 */
|
||||||
|
private String profileId;
|
||||||
|
|
||||||
|
/** scheduled / manual / test */
|
||||||
|
private String trigger;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.ruoyi.jarvis.service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 慢单订单/利润 AI 分析:先聚合成 JSON,再套固定提示词调用已配置大模型。
|
||||||
|
*/
|
||||||
|
public interface IOrderProfitAnalysisService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅生成统计 JSON(含本期、上期、对比),不调用 AI。
|
||||||
|
*
|
||||||
|
* @param periodType day / week / month
|
||||||
|
* @param date 锚点日期 yyyy-MM-dd,空则按周期取最近完整区间
|
||||||
|
*/
|
||||||
|
Map<String, Object> buildStats(String periodType, String date);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计 + AI 分析;push=true 时推送到企微。
|
||||||
|
*
|
||||||
|
* @param trigger scheduled / manual / test,写入发送记录
|
||||||
|
*/
|
||||||
|
Map<String, Object> analyze(String periodType, String date, boolean push, String profileId, String trigger);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取最近一次分析结果(Redis)。
|
||||||
|
*/
|
||||||
|
Map<String, Object> latest(String periodType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送/测试发送记录,新的在前。
|
||||||
|
*/
|
||||||
|
java.util.List<Map<String, Object>> listSendLogs(int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务入口:日报推当天,周报/月报推上一完整区间。
|
||||||
|
*/
|
||||||
|
void runScheduled(String periodType);
|
||||||
|
}
|
||||||
+814
@@ -0,0 +1,814 @@
|
|||||||
|
package com.ruoyi.jarvis.service.impl;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.alibaba.fastjson2.JSONWriter;
|
||||||
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
|
import com.ruoyi.jarvis.domain.JDOrder;
|
||||||
|
import com.ruoyi.jarvis.service.IJDOrderService;
|
||||||
|
import com.ruoyi.jarvis.service.IOrderProfitAnalysisService;
|
||||||
|
import com.ruoyi.jarvis.service.IPhoneForwardActivePush;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 慢单利润/订单 AI 分析。数据来自 jd_order,提示词走 Redis social_media:prompt:order:profit_analysis,
|
||||||
|
* 推理转发 Jarvis /jarvis/social-media/llm/test(与闲鱼种草同一套激活配置)。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class OrderProfitAnalysisServiceImpl implements IOrderProfitAnalysisService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OrderProfitAnalysisServiceImpl.class);
|
||||||
|
|
||||||
|
public static final String PROMPT_KEY = "order:profit_analysis";
|
||||||
|
private static final String REDIS_PROMPT_PREFIX = "social_media:prompt:";
|
||||||
|
private static final String REDIS_LATEST_PREFIX = "order_profit_analysis:latest:";
|
||||||
|
private static final String REDIS_SEND_LOG = "order_profit_analysis:send_log";
|
||||||
|
private static final int SEND_LOG_MAX = 80;
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||||
|
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
private static final int TOP_N = 15;
|
||||||
|
/** 企微文本约 2048 字节,按 UTF-8 切段并留余量 */
|
||||||
|
private static final int WECOM_CHUNK_BYTES = 1800;
|
||||||
|
|
||||||
|
public static final String DEFAULT_PROMPT =
|
||||||
|
"你是电商分销订单与利润分析助手。下面是已经汇总好的 JSON 统计(慢单 jd_order;"
|
||||||
|
+ "仅 is_count_enabled=1 参与;已退款单不计入付款/后返/利润)。\n\n"
|
||||||
|
+ "请基于数据写一份简洁中文报告,不要编造 JSON 里没有的数字。结构如下:\n"
|
||||||
|
+ "1. 周期结论(1-2 句)\n"
|
||||||
|
+ "2. 订单与利润对比:本期 vs 上期,写明增减和幅度\n"
|
||||||
|
+ "3. 结构拆解:分销标记 / 型号 / 下单人前缀的亮点与异常\n"
|
||||||
|
+ "4. 风险:退款、利润缺失、额外成本\n"
|
||||||
|
+ "5. 建议:1-3 条可执行动作\n\n"
|
||||||
|
+ "输出纯文本,适合企业微信阅读,控制在 800 字以内,可用简单条目,不要用 Markdown 代码块。\n\n"
|
||||||
|
+ "统计数据:\n{{stats_json}}";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IJDOrderService jdOrderService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private IPhoneForwardActivePush phoneForwardActivePush;
|
||||||
|
|
||||||
|
@Value("${jarvis.server.jarvis-java.base-url:http://127.0.0.1:6666}")
|
||||||
|
private String jarvisBaseUrl;
|
||||||
|
|
||||||
|
@Value("${jarvis.order-profit-analysis.enabled:true}")
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Value("${jarvis.order-profit-analysis.touser:}")
|
||||||
|
private String analysisTouser;
|
||||||
|
|
||||||
|
@Value("${jarvis.wecom.goofish-notify-touser:}")
|
||||||
|
private String fallbackTouser;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> buildStats(String periodType, String date) {
|
||||||
|
PeriodRange current = resolvePeriod(periodType, date, false);
|
||||||
|
return assemblePayload(periodType, current, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> analyze(String periodType, String date, boolean push, String profileId, String trigger) {
|
||||||
|
PeriodRange current = resolvePeriod(periodType, date, false);
|
||||||
|
return doAnalyze(periodType, current, push, profileId, trigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> latest(String periodType) {
|
||||||
|
String type = normalizePeriod(periodType);
|
||||||
|
if (redisTemplate == null) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
String raw = redisTemplate.opsForValue().get(REDIS_LATEST_PREFIX + type);
|
||||||
|
if (StringUtils.isEmpty(raw)) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
JSONObject jo = JSON.parseObject(raw);
|
||||||
|
return jo == null ? Collections.emptyMap() : new LinkedHashMap<>(jo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> listSendLogs(int limit) {
|
||||||
|
if (redisTemplate == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
int n = limit <= 0 ? 30 : Math.min(limit, SEND_LOG_MAX);
|
||||||
|
List<String> raw = redisTemplate.opsForList().range(REDIS_SEND_LOG, 0, n - 1);
|
||||||
|
if (raw == null || raw.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (String s : raw) {
|
||||||
|
if (StringUtils.isEmpty(s)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JSONObject jo = JSON.parseObject(s);
|
||||||
|
if (jo != null) {
|
||||||
|
out.add(new LinkedHashMap<>(jo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runScheduled(String periodType) {
|
||||||
|
if (!enabled) {
|
||||||
|
log.info("慢单利润分析定时任务已关闭 periodType={}", periodType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PeriodRange current = resolvePeriod(periodType, null, true);
|
||||||
|
try {
|
||||||
|
Map<String, Object> result = doAnalyze(periodType, current, true, null, "scheduled");
|
||||||
|
log.info("慢单利润分析定时完成 period={} pushed={} reportLen={}",
|
||||||
|
current.label, result.get("pushed"),
|
||||||
|
result.get("report") == null ? 0 : String.valueOf(result.get("report")).length());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("慢单利润分析定时失败 periodType={} range={}", periodType, current.label, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> doAnalyze(String periodType, PeriodRange current, boolean push, String profileId,
|
||||||
|
String trigger) {
|
||||||
|
Map<String, Object> stats = assemblePayload(periodType, current, true);
|
||||||
|
String statsJson = JSON.toJSONString(stats, JSONWriter.Feature.PrettyFormat);
|
||||||
|
String prompt = loadPrompt().replace("{{stats_json}}", statsJson);
|
||||||
|
|
||||||
|
String report;
|
||||||
|
String aiError = null;
|
||||||
|
try {
|
||||||
|
report = callJarvisLlm(prompt, profileId);
|
||||||
|
if (StringUtils.isEmpty(report)) {
|
||||||
|
throw new RuntimeException("大模型返回为空");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
aiError = e.getMessage();
|
||||||
|
log.warn("慢单利润 AI 分析失败,改用数字摘要: {}", aiError);
|
||||||
|
report = buildFallbackReport(stats, aiError);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean pushed = false;
|
||||||
|
String pushError = null;
|
||||||
|
String toUser = resolveTouser();
|
||||||
|
if (push) {
|
||||||
|
try {
|
||||||
|
pushed = pushReport(current.titlePrefix, report, toUser);
|
||||||
|
if (!pushed) {
|
||||||
|
pushError = StringUtils.isEmpty(toUser)
|
||||||
|
? "未配置接收人(touser / goofish-notify-touser)"
|
||||||
|
: "推送未成功(检查 wxSend)";
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
pushError = e.getMessage();
|
||||||
|
log.warn("慢单利润分析推送失败: {}", pushError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("periodType", normalizePeriod(periodType));
|
||||||
|
result.put("periodLabel", current.label);
|
||||||
|
result.put("generatedAt", java.time.LocalDateTime.now(ZONE).format(TS));
|
||||||
|
result.put("trigger", normalizeTrigger(trigger));
|
||||||
|
result.put("stats", stats);
|
||||||
|
result.put("report", report);
|
||||||
|
result.put("aiError", aiError);
|
||||||
|
result.put("pushed", pushed);
|
||||||
|
result.put("pushError", pushError);
|
||||||
|
result.put("toUser", toUser);
|
||||||
|
cacheLatest(normalizePeriod(periodType), result);
|
||||||
|
if (push) {
|
||||||
|
appendSendLog(result, stats);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> assemblePayload(String periodType, PeriodRange current, boolean forLlm) {
|
||||||
|
PeriodRange previous = current.previous();
|
||||||
|
Map<String, Object> cur = summarize(current);
|
||||||
|
Map<String, Object> prev = summarize(previous);
|
||||||
|
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("periodType", current.type);
|
||||||
|
payload.put("periodLabel", current.label);
|
||||||
|
payload.put("timezone", ZONE.getId());
|
||||||
|
payload.put("current", cur);
|
||||||
|
payload.put("previous", prev);
|
||||||
|
payload.put("compare", compare(cur, prev));
|
||||||
|
if (!forLlm) {
|
||||||
|
payload.put("generatedAt", java.time.LocalDateTime.now(ZONE).format(TS));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> summarize(PeriodRange range) {
|
||||||
|
JDOrder query = new JDOrder();
|
||||||
|
query.getParams().put("beginTime", range.begin.format(DAY));
|
||||||
|
query.getParams().put("endTime", range.end.format(DAY));
|
||||||
|
List<JDOrder> rows = jdOrderService.selectJDOrderList(query);
|
||||||
|
if (rows == null) {
|
||||||
|
rows = Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
int orderCount = 0;
|
||||||
|
int refundCount = 0;
|
||||||
|
int missingProfitCount = 0;
|
||||||
|
BigDecimal payment = BigDecimal.ZERO;
|
||||||
|
BigDecimal rebate = BigDecimal.ZERO;
|
||||||
|
BigDecimal profit = BigDecimal.ZERO;
|
||||||
|
BigDecimal extraCost = BigDecimal.ZERO;
|
||||||
|
BigDecimal selling = BigDecimal.ZERO;
|
||||||
|
Map<String, GroupAcc> byMark = new LinkedHashMap<>();
|
||||||
|
Map<String, GroupAcc> byModel = new LinkedHashMap<>();
|
||||||
|
Map<String, GroupAcc> byBuyer = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (JDOrder o : rows) {
|
||||||
|
if (!isCountEnabled(o)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
orderCount++;
|
||||||
|
boolean refunded = o.getIsRefunded() != null && o.getIsRefunded() == 1;
|
||||||
|
if (refunded) {
|
||||||
|
refundCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
BigDecimal pay = money(o.getPaymentAmount());
|
||||||
|
BigDecimal reb = money(o.getRebateAmount());
|
||||||
|
BigDecimal extra = money(o.getExtraCost());
|
||||||
|
BigDecimal sell = money(o.getSellingPrice());
|
||||||
|
payment = payment.add(pay);
|
||||||
|
rebate = rebate.add(reb);
|
||||||
|
extraCost = extraCost.add(extra);
|
||||||
|
selling = selling.add(sell);
|
||||||
|
if (o.getProfit() == null) {
|
||||||
|
missingProfitCount++;
|
||||||
|
} else {
|
||||||
|
profit = profit.add(money(o.getProfit()));
|
||||||
|
}
|
||||||
|
addGroup(byMark, blankTo(o.getDistributionMark(), "未标记"), pay, reb, extra, o.getProfit());
|
||||||
|
addGroup(byModel, modelKey(o), pay, reb, extra, o.getProfit());
|
||||||
|
addGroup(byBuyer, buyerGroupKey(o.getBuyer()), pay, reb, extra, o.getProfit());
|
||||||
|
}
|
||||||
|
|
||||||
|
int valid = orderCount - refundCount;
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("beginDate", range.begin.format(DAY));
|
||||||
|
out.put("endDate", range.end.format(DAY));
|
||||||
|
out.put("label", range.label);
|
||||||
|
out.put("orderCount", orderCount);
|
||||||
|
out.put("refundCount", refundCount);
|
||||||
|
out.put("validOrderCount", valid);
|
||||||
|
out.put("missingProfitCount", missingProfitCount);
|
||||||
|
out.put("totalPayment", num(payment));
|
||||||
|
out.put("totalRebate", num(rebate));
|
||||||
|
out.put("totalCost", num(payment.subtract(rebate)));
|
||||||
|
out.put("totalSellingPrice", num(selling));
|
||||||
|
out.put("totalExtraCost", num(extraCost));
|
||||||
|
out.put("totalProfit", num(profit));
|
||||||
|
BigDecimal avg = valid > 0
|
||||||
|
? profit.divide(BigDecimal.valueOf(valid), 2, RoundingMode.HALF_UP)
|
||||||
|
: BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
out.put("avgProfit", num(avg));
|
||||||
|
BigDecimal rate = payment.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
? profit.multiply(BigDecimal.valueOf(100)).divide(payment, 2, RoundingMode.HALF_UP)
|
||||||
|
: BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
out.put("profitRatePct", num(rate));
|
||||||
|
out.put("byMark", toGroupList(byMark, Integer.MAX_VALUE));
|
||||||
|
out.put("byModel", toGroupList(byModel, TOP_N));
|
||||||
|
out.put("byBuyer", toGroupList(byBuyer, TOP_N));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> compare(Map<String, Object> cur, Map<String, Object> prev) {
|
||||||
|
Map<String, Object> c = new LinkedHashMap<>();
|
||||||
|
putDelta(c, "orderCount", cur, prev);
|
||||||
|
putDelta(c, "refundCount", cur, prev);
|
||||||
|
putDelta(c, "totalPayment", cur, prev);
|
||||||
|
putDelta(c, "totalRebate", cur, prev);
|
||||||
|
putDelta(c, "totalProfit", cur, prev);
|
||||||
|
putDelta(c, "avgProfit", cur, prev);
|
||||||
|
putDelta(c, "profitRatePct", cur, prev);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void putDelta(Map<String, Object> out, String key, Map<String, Object> cur, Map<String, Object> prev) {
|
||||||
|
BigDecimal a = toDecimal(cur.get(key));
|
||||||
|
BigDecimal b = toDecimal(prev.get(key));
|
||||||
|
BigDecimal delta = a.subtract(b).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("current", num(a));
|
||||||
|
item.put("previous", num(b));
|
||||||
|
item.put("delta", num(delta));
|
||||||
|
BigDecimal pct;
|
||||||
|
if (b.compareTo(BigDecimal.ZERO) == 0) {
|
||||||
|
pct = a.compareTo(BigDecimal.ZERO) == 0
|
||||||
|
? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
|
||||||
|
: BigDecimal.valueOf(100).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
} else {
|
||||||
|
pct = delta.multiply(BigDecimal.valueOf(100)).divide(b.abs(), 2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
item.put("changePct", num(pct));
|
||||||
|
out.put(key, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loadPrompt() {
|
||||||
|
if (redisTemplate != null) {
|
||||||
|
try {
|
||||||
|
String stored = redisTemplate.opsForValue().get(REDIS_PROMPT_PREFIX + PROMPT_KEY);
|
||||||
|
if (StringUtils.isNotEmpty(stored)) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("读取利润分析提示词失败,使用内置默认: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String callJarvisLlm(String message, String profileId) throws Exception {
|
||||||
|
String base = jarvisBaseUrl == null ? "" : jarvisBaseUrl.trim();
|
||||||
|
if (base.endsWith("/")) {
|
||||||
|
base = base.substring(0, base.length() - 1);
|
||||||
|
}
|
||||||
|
String url = base + "/jarvis/social-media/llm/test";
|
||||||
|
JSONObject body = new JSONObject();
|
||||||
|
body.put("message", message);
|
||||||
|
if (StringUtils.isNotEmpty(profileId)) {
|
||||||
|
body.put("profileId", profileId.trim());
|
||||||
|
}
|
||||||
|
String resp = postJson(url, body.toJSONString(), 15000, 180000);
|
||||||
|
if (StringUtils.isEmpty(resp)) {
|
||||||
|
throw new RuntimeException("Jarvis 返回为空,请检查服务可达性");
|
||||||
|
}
|
||||||
|
Object parsed = JSON.parse(resp);
|
||||||
|
if (!(parsed instanceof JSONObject)) {
|
||||||
|
throw new RuntimeException("Jarvis 响应格式错误");
|
||||||
|
}
|
||||||
|
JSONObject jo = (JSONObject) parsed;
|
||||||
|
if (!Integer.valueOf(200).equals(jo.getInteger("code"))) {
|
||||||
|
throw new RuntimeException(StringUtils.isEmpty(jo.getString("msg")) ? "Jarvis 调用失败" : jo.getString("msg"));
|
||||||
|
}
|
||||||
|
Object dataObj = jo.get("data");
|
||||||
|
if (!(dataObj instanceof JSONObject)) {
|
||||||
|
throw new RuntimeException("Jarvis data 结构异常");
|
||||||
|
}
|
||||||
|
JSONObject data = (JSONObject) dataObj;
|
||||||
|
if (!Boolean.TRUE.equals(data.getBoolean("success"))) {
|
||||||
|
throw new RuntimeException(StringUtils.isEmpty(data.getString("error")) ? "大模型调用失败" : data.getString("error"));
|
||||||
|
}
|
||||||
|
return data.getString("reply");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean pushReport(String title, String report, String toUser) {
|
||||||
|
if (phoneForwardActivePush == null) {
|
||||||
|
log.warn("利润分析推送跳过:IPhoneForwardActivePush 不可用");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (StringUtils.isEmpty(toUser)) {
|
||||||
|
log.warn("利润分析推送跳过:未配置 jarvis.order-profit-analysis.touser 或 jarvis.wecom.goofish-notify-touser");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String full = (title == null ? "" : title) + "\n" + (report == null ? "" : report.trim());
|
||||||
|
List<String> chunks = splitUtf8Chunks(full, WECOM_CHUNK_BYTES);
|
||||||
|
return phoneForwardActivePush.schedulePushChunks(toUser, null, chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveTouser() {
|
||||||
|
return normalizeTouser(StringUtils.isNotEmpty(analysisTouser) ? analysisTouser : fallbackTouser);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void appendSendLog(Map<String, Object> result, Map<String, Object> stats) {
|
||||||
|
if (redisTemplate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("id", UUID.randomUUID().toString().replace("-", ""));
|
||||||
|
row.put("sentAt", result.get("generatedAt"));
|
||||||
|
row.put("trigger", result.get("trigger"));
|
||||||
|
row.put("periodType", result.get("periodType"));
|
||||||
|
row.put("periodLabel", result.get("periodLabel"));
|
||||||
|
row.put("toUser", result.get("toUser"));
|
||||||
|
row.put("pushed", result.get("pushed"));
|
||||||
|
row.put("pushError", result.get("pushError"));
|
||||||
|
row.put("aiError", result.get("aiError"));
|
||||||
|
Map<String, Object> cur = stats == null ? null : (Map<String, Object>) stats.get("current");
|
||||||
|
if (cur != null) {
|
||||||
|
row.put("orderCount", cur.get("orderCount"));
|
||||||
|
row.put("totalProfit", cur.get("totalProfit"));
|
||||||
|
}
|
||||||
|
String report = result.get("report") == null ? "" : String.valueOf(result.get("report"));
|
||||||
|
row.put("report", report);
|
||||||
|
redisTemplate.opsForList().leftPush(REDIS_SEND_LOG, JSON.toJSONString(row));
|
||||||
|
redisTemplate.opsForList().trim(REDIS_SEND_LOG, 0, SEND_LOG_MAX - 1);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("写入利润分析发送记录失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeTrigger(String trigger) {
|
||||||
|
if (StringUtils.isEmpty(trigger)) {
|
||||||
|
return "manual";
|
||||||
|
}
|
||||||
|
String t = trigger.trim().toLowerCase();
|
||||||
|
if ("scheduled".equals(t) || "test".equals(t) || "manual".equals(t)) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
return "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cacheLatest(String periodType, Map<String, Object> result) {
|
||||||
|
if (redisTemplate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
redisTemplate.opsForValue().set(REDIS_LATEST_PREFIX + periodType,
|
||||||
|
JSON.toJSONString(result), 40, TimeUnit.DAYS);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("缓存利润分析结果失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private String buildFallbackReport(Map<String, Object> stats, String aiError) {
|
||||||
|
Map<String, Object> cur = (Map<String, Object>) stats.get("current");
|
||||||
|
Map<String, Object> cmp = (Map<String, Object>) stats.get("compare");
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(stats.get("periodLabel")).append('\n');
|
||||||
|
sb.append("订单 ").append(cur.get("orderCount"))
|
||||||
|
.append("(退款 ").append(cur.get("refundCount")).append(") ")
|
||||||
|
.append("利润 ").append(cur.get("totalProfit"))
|
||||||
|
.append(formatCmp(cmp, "totalProfit")).append('\n');
|
||||||
|
sb.append("付款 ").append(cur.get("totalPayment"))
|
||||||
|
.append(formatCmp(cmp, "totalPayment"))
|
||||||
|
.append(" 后返 ").append(cur.get("totalRebate")).append('\n');
|
||||||
|
List<Map<String, Object>> marks = (List<Map<String, Object>>) cur.get("byMark");
|
||||||
|
if (marks != null && !marks.isEmpty()) {
|
||||||
|
sb.append("分销:");
|
||||||
|
int n = 0;
|
||||||
|
for (Map<String, Object> m : marks) {
|
||||||
|
if (n > 0) {
|
||||||
|
sb.append(";");
|
||||||
|
}
|
||||||
|
sb.append(m.get("name")).append(' ').append(m.get("orderCount")).append("单/")
|
||||||
|
.append(m.get("profit"));
|
||||||
|
if (++n >= 6) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(aiError)) {
|
||||||
|
sb.append("(AI 分析失败:").append(aiError).append(')');
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static String formatCmp(Map<String, Object> cmp, String key) {
|
||||||
|
if (cmp == null || !(cmp.get(key) instanceof Map)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
Map<String, Object> item = (Map<String, Object>) cmp.get(key);
|
||||||
|
Object pct = item.get("changePct");
|
||||||
|
Object delta = item.get("delta");
|
||||||
|
if (pct == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "(环比 " + signed(delta) + " / " + signed(pct) + "%)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String signed(Object v) {
|
||||||
|
BigDecimal d = toDecimal(v);
|
||||||
|
if (d.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
return "+" + num(d);
|
||||||
|
}
|
||||||
|
return String.valueOf(num(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
private PeriodRange resolvePeriod(String periodType, String date, boolean scheduled) {
|
||||||
|
String type = normalizePeriod(periodType);
|
||||||
|
LocalDate today = LocalDate.now(ZONE);
|
||||||
|
LocalDate anchor = scheduled ? null : parseDate(date);
|
||||||
|
if (anchor == null) {
|
||||||
|
if ("week".equals(type)) {
|
||||||
|
anchor = today.minusWeeks(1).with(DayOfWeek.MONDAY);
|
||||||
|
} else if ("month".equals(type)) {
|
||||||
|
anchor = today.minusMonths(1).withDayOfMonth(1);
|
||||||
|
} else {
|
||||||
|
// 日报:当天晚上推当天,页面预览默认也是今天
|
||||||
|
anchor = today;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("week".equals(type)) {
|
||||||
|
LocalDate begin = anchor.with(DayOfWeek.MONDAY);
|
||||||
|
LocalDate end = begin.plusDays(6);
|
||||||
|
return new PeriodRange(type, begin, end,
|
||||||
|
begin.format(DAY) + " ~ " + end.format(DAY) + " 周报",
|
||||||
|
"【慢单利润周报】" + begin.format(DAY) + " ~ " + end.format(DAY));
|
||||||
|
}
|
||||||
|
if ("month".equals(type)) {
|
||||||
|
LocalDate begin = anchor.with(TemporalAdjusters.firstDayOfMonth());
|
||||||
|
LocalDate end = anchor.with(TemporalAdjusters.lastDayOfMonth());
|
||||||
|
return new PeriodRange(type, begin, end,
|
||||||
|
begin.getYear() + "年" + begin.getMonthValue() + "月 月报",
|
||||||
|
"【慢单利润月报】" + begin.getYear() + "年" + begin.getMonthValue() + "月");
|
||||||
|
}
|
||||||
|
return new PeriodRange("day", anchor, anchor,
|
||||||
|
anchor.format(DAY) + " 日报",
|
||||||
|
"【慢单利润日报】" + anchor.format(DAY));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizePeriod(String periodType) {
|
||||||
|
if (periodType == null) {
|
||||||
|
return "day";
|
||||||
|
}
|
||||||
|
String t = periodType.trim().toLowerCase();
|
||||||
|
if ("week".equals(t) || "month".equals(t) || "day".equals(t)) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
if ("周".equals(periodType) || "weekly".equals(t)) {
|
||||||
|
return "week";
|
||||||
|
}
|
||||||
|
if ("月".equals(periodType) || "monthly".equals(t)) {
|
||||||
|
return "month";
|
||||||
|
}
|
||||||
|
return "day";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate parseDate(String date) {
|
||||||
|
if (StringUtils.isEmpty(date)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(date.trim(), DAY);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isCountEnabled(JDOrder o) {
|
||||||
|
return o.getIsCountEnabled() == null || o.getIsCountEnabled() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String modelKey(JDOrder o) {
|
||||||
|
String a = o.getModelNumber() == null ? "" : o.getModelNumber().trim();
|
||||||
|
String b = o.getModelShop() == null ? "" : o.getModelShop().trim();
|
||||||
|
String k = a + b;
|
||||||
|
return k.isEmpty() ? "未填型号" : k;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buyerGroupKey(String buyer) {
|
||||||
|
if (buyer == null) {
|
||||||
|
return "未填下单人";
|
||||||
|
}
|
||||||
|
String t = buyer.trim();
|
||||||
|
if (t.isEmpty()) {
|
||||||
|
return "未填下单人";
|
||||||
|
}
|
||||||
|
int idx = t.indexOf('-');
|
||||||
|
if (idx > 0) {
|
||||||
|
String prefix = t.substring(0, idx).trim();
|
||||||
|
return prefix.isEmpty() ? t : prefix;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addGroup(Map<String, GroupAcc> map, String name, BigDecimal pay, BigDecimal reb,
|
||||||
|
BigDecimal extra, Double profit) {
|
||||||
|
GroupAcc acc = map.computeIfAbsent(name, k -> new GroupAcc());
|
||||||
|
acc.orderCount++;
|
||||||
|
acc.payment = acc.payment.add(pay);
|
||||||
|
acc.rebate = acc.rebate.add(reb);
|
||||||
|
acc.extraCost = acc.extraCost.add(extra);
|
||||||
|
if (profit != null) {
|
||||||
|
acc.profit = acc.profit.add(money(profit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Map<String, Object>> toGroupList(Map<String, GroupAcc> map, int limit) {
|
||||||
|
List<Map.Entry<String, GroupAcc>> list = new ArrayList<>(map.entrySet());
|
||||||
|
list.sort(Comparator
|
||||||
|
.comparing((Map.Entry<String, GroupAcc> e) -> e.getValue().profit, Comparator.reverseOrder())
|
||||||
|
.thenComparing(e -> e.getValue().orderCount, Comparator.reverseOrder()));
|
||||||
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
int n = 0;
|
||||||
|
for (Map.Entry<String, GroupAcc> e : list) {
|
||||||
|
if (n++ >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
GroupAcc a = e.getValue();
|
||||||
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("name", e.getKey());
|
||||||
|
row.put("orderCount", a.orderCount);
|
||||||
|
row.put("payment", num(a.payment));
|
||||||
|
row.put("rebate", num(a.rebate));
|
||||||
|
row.put("extraCost", num(a.extraCost));
|
||||||
|
row.put("profit", num(a.profit));
|
||||||
|
out.add(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal money(Double v) {
|
||||||
|
if (v == null) {
|
||||||
|
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
return BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object num(BigDecimal v) {
|
||||||
|
return v == null ? 0.00 : v.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal toDecimal(Object v) {
|
||||||
|
if (v == null) {
|
||||||
|
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
if (v instanceof BigDecimal) {
|
||||||
|
return ((BigDecimal) v).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
if (v instanceof Number) {
|
||||||
|
return BigDecimal.valueOf(((Number) v).doubleValue()).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new BigDecimal(String.valueOf(v)).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankTo(String s, String def) {
|
||||||
|
return StringUtils.isEmpty(s) ? def : s.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeTouser(String raw) {
|
||||||
|
if (StringUtils.isEmpty(raw)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String[] parts = raw.split("[,|,;;\\s]+");
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (String p : parts) {
|
||||||
|
if (StringUtils.isEmpty(p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sb.length() > 0) {
|
||||||
|
sb.append('|');
|
||||||
|
}
|
||||||
|
sb.append(p.trim());
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> splitUtf8Chunks(String text, int maxBytes) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (text == null) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
String s = text.trim();
|
||||||
|
if (s.isEmpty()) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
int i = 0;
|
||||||
|
while (i < s.length()) {
|
||||||
|
int end = i;
|
||||||
|
int bytes = 0;
|
||||||
|
int lastBreak = i;
|
||||||
|
while (end < s.length()) {
|
||||||
|
int cp = s.codePointAt(end);
|
||||||
|
int charLen = Character.charCount(cp);
|
||||||
|
int b = new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
if (bytes + b > maxBytes) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bytes += b;
|
||||||
|
end += charLen;
|
||||||
|
if (s.charAt(end - 1) == '\n') {
|
||||||
|
lastBreak = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end == i) {
|
||||||
|
end = i + Character.charCount(s.codePointAt(i));
|
||||||
|
} else if (lastBreak > i && end < s.length()) {
|
||||||
|
end = lastBreak;
|
||||||
|
}
|
||||||
|
String piece = s.substring(i, end).trim();
|
||||||
|
if (!piece.isEmpty()) {
|
||||||
|
out.add(piece);
|
||||||
|
}
|
||||||
|
i = end;
|
||||||
|
while (i < s.length() && s.charAt(i) == '\n') {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String postJson(String url, String json, int connectMs, int readMs) throws Exception {
|
||||||
|
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||||
|
try {
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setConnectTimeout(connectMs);
|
||||||
|
conn.setReadTimeout(readMs);
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(bytes);
|
||||||
|
}
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
InputStream is = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream();
|
||||||
|
String resp = readAll(is);
|
||||||
|
if (code < 200 || code >= 300) {
|
||||||
|
throw new RuntimeException("HTTP " + code + (StringUtils.isEmpty(resp) ? "" : (" " + resp)));
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
} finally {
|
||||||
|
conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readAll(InputStream is) throws java.io.IOException {
|
||||||
|
if (is == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
byte[] buf = new byte[4096];
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int n;
|
||||||
|
while ((n = is.read(buf)) >= 0) {
|
||||||
|
sb.append(new String(buf, 0, n, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class PeriodRange {
|
||||||
|
final String type;
|
||||||
|
final LocalDate begin;
|
||||||
|
final LocalDate end;
|
||||||
|
final String label;
|
||||||
|
final String titlePrefix;
|
||||||
|
|
||||||
|
PeriodRange(String type, LocalDate begin, LocalDate end, String label, String titlePrefix) {
|
||||||
|
this.type = type;
|
||||||
|
this.begin = begin;
|
||||||
|
this.end = end;
|
||||||
|
this.label = label;
|
||||||
|
this.titlePrefix = titlePrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
PeriodRange previous() {
|
||||||
|
if ("week".equals(type)) {
|
||||||
|
LocalDate b = begin.minusWeeks(1);
|
||||||
|
LocalDate e = end.minusWeeks(1);
|
||||||
|
return new PeriodRange(type, b, e,
|
||||||
|
b.format(DAY) + " ~ " + e.format(DAY) + " 上周",
|
||||||
|
titlePrefix);
|
||||||
|
}
|
||||||
|
if ("month".equals(type)) {
|
||||||
|
LocalDate b = begin.minusMonths(1).with(TemporalAdjusters.firstDayOfMonth());
|
||||||
|
LocalDate e = b.with(TemporalAdjusters.lastDayOfMonth());
|
||||||
|
return new PeriodRange(type, b, e,
|
||||||
|
b.getYear() + "年" + b.getMonthValue() + "月 上月",
|
||||||
|
titlePrefix);
|
||||||
|
}
|
||||||
|
LocalDate d = begin.minusDays(1);
|
||||||
|
return new PeriodRange(type, d, d, d.format(DAY) + " 昨日对比", titlePrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class GroupAcc {
|
||||||
|
int orderCount;
|
||||||
|
BigDecimal payment = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal rebate = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal extraCost = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal profit = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -51,7 +51,8 @@ public class SocialMediaServiceImpl implements ISocialMediaService
|
|||||||
"xianyu:wenan_base",
|
"xianyu:wenan_base",
|
||||||
"xianyu:jiaonixiadan_extra",
|
"xianyu:jiaonixiadan_extra",
|
||||||
"xianyu:title_clean_regex",
|
"xianyu:title_clean_regex",
|
||||||
"xianyu:seed_note_prompt"
|
"xianyu:seed_note_prompt",
|
||||||
|
"order:profit_analysis"
|
||||||
};
|
};
|
||||||
|
|
||||||
// 模板说明
|
// 模板说明
|
||||||
@@ -64,6 +65,7 @@ public class SocialMediaServiceImpl implements ISocialMediaService
|
|||||||
put("xianyu:jiaonixiadan_extra", "闲鱼文案·教你下单版尾部附加说明\n接在「更新日期:yyyy-MM-dd」之后(纯文本)");
|
put("xianyu:jiaonixiadan_extra", "闲鱼文案·教你下单版尾部附加说明\n接在「更新日期:yyyy-MM-dd」之后(纯文本)");
|
||||||
put("xianyu:title_clean_regex", "闲鱼文案·标题/型号清洗正则\n从标题与型号备注中移除营销敏感片段;须为 Java 正则,匹配到的内容会被删除");
|
put("xianyu:title_clean_regex", "闲鱼文案·标题/型号清洗正则\n从标题与型号备注中移除营销敏感片段;须为 Java 正则,匹配到的内容会被删除");
|
||||||
put("xianyu:seed_note_prompt", "闲鱼文案·种草文案提示词模板\n占位符:{{goods_title}} {{goods_model}} {{goods_type}} {{goods_brand}} {{channel_source}} {{official_price}} {{sell_price}} {{warranty}} {{delivery_install}}");
|
put("xianyu:seed_note_prompt", "闲鱼文案·种草文案提示词模板\n占位符:{{goods_title}} {{goods_model}} {{goods_type}} {{goods_brand}} {{channel_source}} {{official_price}} {{sell_price}} {{warranty}} {{delivery_install}}");
|
||||||
|
put("order:profit_analysis", "慢单利润/订单 AI 分析提示词\n占位符:{{stats_json}} — 本期/上期/对比统计 JSON");
|
||||||
}};
|
}};
|
||||||
|
|
||||||
/** 多套大模型 JSON 存储,与 Jarvis_java SocialMediaLlmClient 一致 */
|
/** 多套大模型 JSON 存储,与 Jarvis_java SocialMediaLlmClient 一致 */
|
||||||
@@ -186,6 +188,7 @@ public class SocialMediaServiceImpl implements ISocialMediaService
|
|||||||
put("xianyu:jiaonixiadan_extra", DEFAULT_XIANYU_JIAONIXIADAN_EXTRA);
|
put("xianyu:jiaonixiadan_extra", DEFAULT_XIANYU_JIAONIXIADAN_EXTRA);
|
||||||
put("xianyu:title_clean_regex", DEFAULT_XIANYU_TITLE_CLEAN_REGEX);
|
put("xianyu:title_clean_regex", DEFAULT_XIANYU_TITLE_CLEAN_REGEX);
|
||||||
put("xianyu:seed_note_prompt", DEFAULT_XIANYU_SEED_NOTE_PROMPT);
|
put("xianyu:seed_note_prompt", DEFAULT_XIANYU_SEED_NOTE_PROMPT);
|
||||||
|
put("order:profit_analysis", OrderProfitAnalysisServiceImpl.DEFAULT_PROMPT);
|
||||||
}};
|
}};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.ruoyi.jarvis.task;
|
||||||
|
|
||||||
|
import com.ruoyi.jarvis.service.IOrderProfitAnalysisService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 慢单利润/订单 AI 分析定时推送:日报(当天晚上推当天)、周报(上周一至周日)、月报(上月)。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OrderProfitAnalysisTask {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OrderProfitAnalysisTask.class);
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private IOrderProfitAnalysisService orderProfitAnalysisService;
|
||||||
|
|
||||||
|
@Scheduled(cron = "${jarvis.order-profit-analysis.daily-cron:0 0 21 * * ?}")
|
||||||
|
public void daily() {
|
||||||
|
run("day");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "${jarvis.order-profit-analysis.weekly-cron:0 20 8 ? * MON}")
|
||||||
|
public void weekly() {
|
||||||
|
run("week");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "${jarvis.order-profit-analysis.monthly-cron:0 30 8 1 * ?}")
|
||||||
|
public void monthly() {
|
||||||
|
run("month");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void run(String periodType) {
|
||||||
|
try {
|
||||||
|
orderProfitAnalysisService.runScheduled(periodType);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("慢单利润分析定时任务异常 periodType={}", periodType, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user