Compare commits

..
15 Commits
Author SHA1 Message Date
timi d65424e5bf Merge pull request 'v1.0.43' (#55) from dev into master
Reviewed-on: #55
2026-09-11 17:26:21 +00:00
Timi f0b72b70e7 v1.0.43
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-12 01:24:03 +08:00
Timi daa5a6aa5e fix off product sku for keep fulfillment 2026-09-12 01:23:47 +08:00
timi 2a35793baf Merge pull request 'v1.0.42' (#54) from dev into master
Reviewed-on: #54
2026-09-11 16:16:04 +00:00
Timi 4a157e9578 v1.0.42
CI / build-deploy (pull_request) Successful in 24s
CI / notify-on-failure (pull_request) Skipped
2026-09-12 00:11:21 +08:00
Timi eabb4c6aa6 fix event record CONTINUOUS_DAYS rank 2026-09-12 00:11:07 +08:00
timi d22df698b6 Merge pull request 'v1.0.41' (#53) from dev into master
Reviewed-on: #53
2026-09-11 15:55:02 +00:00
Timi cb6051cc41 v1.0.41
CI / build-deploy (pull_request) Successful in 59s
CI / notify-on-failure (pull_request) Skipped
2026-09-11 23:53:06 +08:00
Timi 1d1009b121 add customer list for GaoCustomerDailyStateView 2026-09-11 23:52:43 +08:00
Timi 94f3fbb867 add customer age and disease chart 2026-09-11 20:24:09 +08:00
timi 1771bf9909 Merge pull request 'v1.0.40' (#52) from dev into master
Reviewed-on: #52
2026-09-10 16:29:28 +00:00
Timi fc4d55517b v1.0.40
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-11 00:26:14 +08:00
Timi ec72a99d74 response customer pinyin 2026-09-11 00:24:58 +08:00
Timi a431db6e6e fix article detail request method 2026-09-10 20:27:53 +08:00
Timi 9cfb31b015 support pinyin for product 2026-09-10 20:27:32 +08:00
35 changed files with 446 additions and 44 deletions
+6 -1
View File
@@ -11,7 +11,7 @@
<groupId>com.imyeyu.timiserverapi</groupId>
<artifactId>TimiServerAPI</artifactId>
<version>1.0.39</version>
<version>1.0.43</version>
<packaging>jar</packaging>
<name>TimiServerAPI</name>
<description>imyeyu.com API</description>
@@ -215,6 +215,11 @@
<artifactId>tinypinyin</artifactId>
<version>2.0.3</version>
</dependency>
<dependency>
<groupId>com.hankcs</groupId>
<artifactId>hanlp</artifactId>
<version>portable-1.8.4</version>
</dependency>
<dependency>
<groupId>com.imyeyu.font.icon</groupId>
<artifactId>timi-icon-font</artifactId>
@@ -39,6 +39,7 @@ public class ArticleController {
/// @return 文章分页
@JsonView(ResponseView.Public.class)
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.ARTICLE_READ)
@PostMapping("/list")
public PageResult<Article> list(@RequestBody Page<Article> page) {
return service.page(page);
@@ -50,7 +51,7 @@ public class ArticleController {
/// @return 文章详情
@JsonView(ResponseView.Public.class)
@RequestRateLimit
@PostMapping("/detail")
@RequestMapping("/detail")
public Article detail(@RequestParam String id) {
Article article = service.get(id);
article.setAttachmentList(attachmentService.listByBizId(Attachment.BizType.ARTICLE, article.getId()));
@@ -5,6 +5,8 @@ import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.service.GaoCustomerChartService;
import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
import com.imyeyu.api.modules.gao.vo.GaoCustomerChartReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordCalendarView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordDailyStatView;
import com.imyeyu.java.bean.timi.TimiException;
@@ -51,4 +53,15 @@ public class GaoCustomerChartController {
req.setStoreId(storeScopeService.resolveStoreId(req.getStoreId()));
return service.eventRecordCalendar(req);
}
/// 查询客户病症词频
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.STAT_READ)
@PostMapping("/customer/disease/word")
public List<GaoCustomerDiseaseWordView> customerDiseaseWord(@RequestBody GaoCustomerDiseaseWordReq req) {
TimiException.required(req, "not found req");
req.setStoreId(storeScopeService.resolveStoreId(req.getStoreId()));
return service.customerDiseaseWord(req);
}
}
@@ -4,6 +4,7 @@ import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.service.GaoStoreChartService;
import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordCalendarView;
@@ -54,6 +55,15 @@ public class GaoStoreChartController {
return service.customerGender(storeScopeService.resolveStoreId(storeId));
}
/// 查询客户年龄分布
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.STAT_READ)
@PostMapping("/customer/age")
public List<GaoCustomerAgeStatView> customerAge(@RequestParam String storeId) {
return service.customerAge(storeScopeService.resolveStoreId(storeId));
}
/// 查询登记事件每日统计,可同时支撑登记趋势和事件结构图表
@AOPLog
@RequestRateLimit
@@ -6,7 +6,6 @@ import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.entity.TagApply;
import com.imyeyu.api.modules.user.bean.Gender;
import com.imyeyu.api.modules.user.entity.User;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.imyeyu.java.TimiJava;
import com.imyeyu.spring.annotation.table.Column;
import com.imyeyu.spring.annotation.table.Transient;
@@ -62,15 +61,12 @@ public class GaoCustomer extends UUIDEntity {
private String name;
@Column("name_pinyin_full")
@JsonIgnore
private String namePinyinFull;
@Column("name_pinyin_initial")
@JsonIgnore
private String namePinyinInitial;
@Column("name_pinyin_mixed")
@JsonIgnore
private String namePinyinMixed;
/// 性别
@@ -1,6 +1,8 @@
package com.imyeyu.api.modules.gao.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.spring.annotation.table.Column;
import com.imyeyu.spring.annotation.table.Transient;
import com.imyeyu.spring.entity.UUIDEntity;
import lombok.Data;
@@ -61,6 +63,18 @@ public class GaoProduct extends UUIDEntity {
/// 产品名称
private String name;
@Column("name_pinyin_full")
@JsonIgnore
private String namePinyinFull;
@Column("name_pinyin_initial")
@JsonIgnore
private String namePinyinInitial;
@Column("name_pinyin_mixed")
@JsonIgnore
private String namePinyinMixed;
/// 产品类型
private ProductType productType;
@@ -1,7 +1,10 @@
package com.imyeyu.api.modules.gao.mapper;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateCustomerView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseTextView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPage;
import com.imyeyu.spring.mapper.BaseMapper;
@@ -27,6 +30,14 @@ public interface GaoCustomerMapper extends BaseMapper<GaoCustomer, String> {
/// @return 每日新增客户数
List<GaoCustomerDailyStateView> stateDailyNewCustomer(@Param("storeId") String storeId, @Param("beginCreatedAt") Long beginCreatedAt, @Param("endCreatedAt") Long endCreatedAt);
/// 查询每日新增客户摘要
///
/// @param storeId 门店 ID
/// @param beginCreatedAt 开始创建时间
/// @param endCreatedAt 结束创建时间
/// @return 每日新增客户摘要
List<GaoCustomerDailyStateCustomerView> selectDailyNewCustomer(@Param("storeId") String storeId, @Param("beginCreatedAt") Long beginCreatedAt, @Param("endCreatedAt") Long endCreatedAt);
/// 统计开始时间前客户总数
///
/// @param beginCreatedAt 开始创建时间
@@ -38,6 +49,18 @@ public interface GaoCustomerMapper extends BaseMapper<GaoCustomer, String> {
/// @return 性别统计列表
List<GaoCustomerGenderStatView> stateGender(@Param("storeId") String storeId);
/// 统计客户年龄分布
///
/// @param storeId 门店 ID
/// @return 客户年龄列表
List<GaoCustomerAgeStatView> stateAge(@Param("storeId") String storeId);
/// 查询客户病症原始文本
///
/// @param storeId 门店 ID
/// @return 客户备注和病症
List<GaoCustomerDiseaseTextView> selectDiseaseTextByStoreId(@Param("storeId") String storeId);
/// 查询客户总数
///
/// @param page 查询条件
@@ -1,6 +1,8 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.vo.GaoCustomerChartReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordCalendarView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordDailyStatView;
@@ -17,4 +19,7 @@ public interface GaoCustomerChartService {
/// 查询客户登记日历记录
List<GaoEventRecordCalendarView> eventRecordCalendar(GaoCustomerChartReq req);
/// 查询客户病症词频
List<GaoCustomerDiseaseWordView> customerDiseaseWord(GaoCustomerDiseaseWordReq req);
}
@@ -1,6 +1,7 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPage;
@@ -87,4 +88,10 @@ public interface GaoCustomerService extends BaseService<GaoCustomer, String> {
/// @param storeId 门店 ID
/// @return 性别统计列表
List<GaoCustomerGenderStatView> stateGender(String storeId);
/// 统计客户年龄分布
///
/// @param storeId 门店 ID
/// @return 客户年龄列表
List<GaoCustomerAgeStatView> stateAge(String storeId);
}
@@ -33,6 +33,14 @@ public interface GaoProductSkuService extends BaseService<GaoProductSku, String>
/// 查询并校验可销售 SKU 单位
GaoProductSkuUnitSelection getSellableSkuUnit(String storeId, String skuId, String skuUnitId);
/// 查询并校验可用于历史订单履约的 SKU 单位
///
/// @param storeId 门店 ID
/// @param skuId SKU ID
/// @param skuUnitId SKU 单位 ID
/// @return SKU 单位选择结果
GaoProductSkuUnitSelection getFulfillableSkuUnit(String storeId, String skuId, String skuUnitId);
/// 查询并校验 SKU 单位
GaoProductSkuUnitSelection getSkuUnit(String storeId, String skuId, String skuUnitId, boolean purchase);
@@ -1,6 +1,7 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordCalendarView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordDailyStatView;
@@ -21,6 +22,9 @@ public interface GaoStoreChartService {
/// 查询客户性别统计
List<GaoCustomerGenderStatView> customerGender(String storeId);
/// 查询客户年龄分布
List<GaoCustomerAgeStatView> customerAge(String storeId);
/// 查询登记事件每日统计
List<GaoEventRecordDailyStatView> eventRecordDailyStat(GaoStoreChartReq req);
@@ -1,15 +1,23 @@
package com.imyeyu.api.modules.gao.service.implement;
import com.hankcs.hanlp.seg.common.Term;
import com.hankcs.hanlp.tokenizer.StandardTokenizer;
import com.imyeyu.api.modules.gao.mapper.GaoCustomerMapper;
import com.imyeyu.api.modules.gao.service.GaoCustomerChartService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
import com.imyeyu.api.modules.gao.vo.GaoCustomerChartReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseTextView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordReq;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseWordView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordCalendarView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordDailyStatView;
import com.imyeyu.java.bean.timi.TimiException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/// 客户报表服务实现
///
@@ -20,6 +28,7 @@ import java.util.List;
public class GaoCustomerChartServiceImplement implements GaoCustomerChartService {
private final GaoEventRecordService eventRecordService;
private final GaoCustomerMapper customerMapper;
@Override
public List<GaoEventRecordDailyStatView> eventRecordDailyStat(GaoCustomerChartReq req) {
@@ -33,6 +42,42 @@ public class GaoCustomerChartServiceImplement implements GaoCustomerChartService
return eventRecordService.calendarByCustomerRange(req);
}
@Override
public List<GaoCustomerDiseaseWordView> customerDiseaseWord(GaoCustomerDiseaseWordReq req) {
TimiException.required(req, "not found req");
TimiException.required(req.getStoreId(), "not found req.storeId");
int topN = normalizeTopN(req.getTopN());
Map<String, Long> wordCountMap = new HashMap<>();
for (GaoCustomerDiseaseTextView customer : customerMapper.selectDiseaseTextByStoreId(req.getStoreId())) {
countNounWord(customer.getDisease(), wordCountMap);
countNounWord(customer.getRemark(), wordCountMap);
}
return wordCountMap.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed().thenComparing(Map.Entry.comparingByKey()))
.limit(topN)
.map(item -> new GaoCustomerDiseaseWordView(item.getKey(), item.getValue()))
.toList();
}
private void countNounWord(String text, Map<String, Long> wordCountMap) {
if (text == null || text.isBlank()) {
return;
}
for (Term term : StandardTokenizer.segment(text)) {
if (term.nature != null && term.nature.toString().startsWith("n")) {
wordCountMap.merge(term.word, 1L, Long::sum);
}
}
}
private int normalizeTopN(Integer topN) {
if (topN == null) {
return 60;
}
return Math.clamp(topN, 1, 100);
}
private void checkRangeReq(GaoCustomerChartReq req) {
TimiException.required(req, "not found req");
TimiException.required(req.getStoreId(), "not found req.storeId");
@@ -14,7 +14,9 @@ import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
import com.imyeyu.api.modules.gao.service.GaoPointAccountService;
import com.imyeyu.api.modules.gao.util.CustomerXSSFBuilder;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateCustomerView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPage;
import com.imyeyu.api.modules.gao.vo.GaoCustomerTrendStateReq;
@@ -410,6 +412,10 @@ public class GaoCustomerServiceImplement extends AbstractEntityService<GaoCustom
for (GaoCustomerDailyStateView view : mapper.stateDailyNewCustomer(req.getStoreId(), beginCreatedAt, endCreatedAt)) {
newCustomerCountMap.put(view.getDateValue(), TimiJava.defaultIfNull(view.getNewCustomerCount(), 0L));
}
Map<Integer, List<GaoCustomerDailyStateCustomerView>> customerListMap = new HashMap<>();
for (GaoCustomerDailyStateCustomerView customer : mapper.selectDailyNewCustomer(req.getStoreId(), beginCreatedAt, endCreatedAt)) {
customerListMap.computeIfAbsent(customer.getDateValue(), key -> new ArrayList<>()).add(customer);
}
long totalCustomerCount = TimiJava.defaultIfNull(mapper.countCreatedBefore(req.getStoreId(), beginCreatedAt), 0L);
List<GaoCustomerDailyStateView> list = new ArrayList<>();
for (LocalDate date = beginDate; !date.isAfter(endDate); date = date.plusDays(1)) {
@@ -420,6 +426,7 @@ public class GaoCustomerServiceImplement extends AbstractEntityService<GaoCustom
view.setDateValue(dateValue);
view.setNewCustomerCount(newCustomerCount);
view.setTotalCustomerCount(totalCustomerCount);
view.setCustomerList(customerListMap.getOrDefault(dateValue, List.of()));
list.add(view);
}
return list;
@@ -431,6 +438,12 @@ public class GaoCustomerServiceImplement extends AbstractEntityService<GaoCustom
return mapper.stateGender(storeId);
}
@Override
public List<GaoCustomerAgeStatView> stateAge(String storeId) {
TimiException.required(storeId, "not found storeId");
return mapper.stateAge(storeId);
}
private void changeInvitedCount(String customerId, long delta) {
if (TimiJava.isEmpty(customerId) || delta == 0) {
return;
@@ -450,12 +450,32 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
GaoSalesOrderItem.ItemType expectedItemType = order.getOrderType() == GaoSalesOrder.OrderType.PACKAGE
? GaoSalesOrderItem.ItemType.PACKAGE_ENTITLEMENT : GaoSalesOrderItem.ItemType.DIRECT_PRODUCT;
TimiException.requiredTrue(source.getItemType() == expectedItemType, "订单项类型与订单类型不匹配");
GaoProductSkuUnitSelection selection = skuService.getSellableSkuUnit(order.getStoreId(), source.getSkuId(), requestItem.getSkuUnitId());
if (requestItem.getSkuUnitId().equals(source.getSkuUnitId())) {
result.add(toSourceByOrderSnapshot(order, source, requestItem.getQuantity(), checkAvailable));
continue;
}
GaoProductSkuUnitSelection selection = skuService.getFulfillableSkuUnit(order.getStoreId(), source.getSkuId(), requestItem.getSkuUnitId());
result.add(toSource(order, source, selection.getProduct(), selection.getSku(), selection.getUnit(), requestItem.getQuantity(), checkAvailable));
}
return result;
}
private SourceEntry toSourceByOrderSnapshot(GaoSalesOrder order, GaoSalesOrderItem source, BigDecimal quantity, boolean checkAvailable) {
GaoProductSku sku = skuService.get(source.getSkuId());
TimiException.required(sku, "履约来源 SKU 不存在");
GaoProduct product = productService.get(sku.getProductId());
TimiException.required(product, "履约来源产品不存在");
TimiException.requiredTrue(order.getStoreId().equals(product.getStoreId()), "履约来源产品不属于当前门店");
quantity = requiredPositiveQuantity(quantity, "履约数量");
if (!Boolean.TRUE.equals(sku.getAllowDecimalQuantity())) {
TimiException.requiredTrue(quantity.stripTrailingZeros().scale() <= 0, "该产品不允许小数数量");
}
BigDecimal baseQuantityPerUnit = requiredBaseQuantityPerUnit(source.getBaseQuantityPerUnitSnapshot());
BigDecimal baseQuantity = toBaseQuantity(quantity, baseQuantityPerUnit, "履约基础数量最多保留三位小数");
return buildSource(order, source, source.getSkuUnitId(), source.getUnitSnapshot(), baseQuantityPerUnit, quantity,
baseQuantity, product, sku, checkAvailable);
}
private List<SourceEntry> loadPersistedSources(GaoSalesOrder order, List<GaoOrderFulfillmentItem> itemList) {
TimiException.requiredTrue(itemList != null && !itemList.isEmpty(), "履约单没有明细");
List<GaoOrderFulfillmentItem> sortedItemList = new ArrayList<>(itemList);
@@ -17,6 +17,7 @@ import com.imyeyu.api.modules.gao.vo.GaoProductPage;
import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.api.util.PinyinUtil;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiCode;
import com.imyeyu.java.bean.timi.TimiException;
@@ -166,6 +167,7 @@ public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct
TimiException.required(product.getStoreId(), "not found product.storeId");
product.setCode(requiredText(product.getCode(), "产品编码不能为空"));
product.setName(requiredText(product.getName(), "产品名称不能为空"));
fillPinyin(product);
product.setProductType(TimiJava.defaultIfNull(product.getProductType(), GaoProduct.ProductType.GOODS));
product.setStockEnabled(TimiJava.defaultIfNull(product.getStockEnabled(), product.getProductType() != GaoProduct.ProductType.SERVICE));
TimiException.requiredTrue(product.getProductType() != GaoProduct.ProductType.SERVICE || !product.getStockEnabled(), "SERVICE 产品不能启用库存");
@@ -261,4 +263,14 @@ public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct
TimiException.requiredTrue(!result.isEmpty(), message);
return result;
}
/// 填充产品名称拼音搜索字段
///
/// @param product 产品
private void fillPinyin(GaoProduct product) {
String name = product.getName();
product.setNamePinyinFull(PinyinUtil.full(name));
product.setNamePinyinInitial(PinyinUtil.initial(name));
product.setNamePinyinMixed(PinyinUtil.mixed(name));
}
}
@@ -109,6 +109,22 @@ public class GaoProductSkuServiceImplement extends AbstractEntityService<GaoProd
return getSkuUnit(storeId, skuId, skuUnitId, false);
}
@Override
public GaoProductSkuUnitSelection getFulfillableSkuUnit(String storeId, String skuId, String skuUnitId) {
TimiException.required(storeId, "门店 ID 不能为空");
TimiException.required(skuId, "SKU ID 不能为空");
TimiException.required(skuUnitId, "SKU 单位 ID 不能为空");
GaoProductSku sku = mapper.selectForUpdate(skuId);
TimiException.required(sku, "SKU 不存在");
TimiException.requiredTrue(storeId.equals(sku.getStoreId()), "SKU 不属于当前门店");
GaoProduct product = productServiceProvider.getObject().get(sku.getProductId());
TimiException.required(product, "产品不存在");
GaoProductSkuUnit unit = unitMapper.selectForUpdate(skuUnitId);
TimiException.required(unit, "SKU 单位不存在");
TimiException.requiredTrue(skuId.equals(unit.getSkuId()), "SKU 单位不属于当前 SKU");
return new GaoProductSkuUnitSelection(product, sku, unit);
}
@Override
public GaoProductSkuUnitSelection getSkuUnit(String storeId, String skuId, String skuUnitId, boolean purchase) {
TimiException.required(storeId, "not found storeId");
@@ -354,11 +370,8 @@ public class GaoProductSkuServiceImplement extends AbstractEntityService<GaoProd
TimiException.requiredTrue(1 == unitMapper.updateUnit(unit), "SKU 单位更新失败");
}
}
for (GaoProductSkuUnit existing : existingUnitList) {
if (!retainedIdSet.contains(existing.getId())) {
unitMapper.delete(existing.getId());
}
}
TimiException.requiredTrue(existingUnitList.stream().allMatch(existing -> retainedIdSet.contains(existing.getId())),
"已保存的 SKU 单位不能删除,请关闭允许销售和允许采购");
}
private void saveSpecValues(String skuId, List<String> specValueIdList) {
@@ -3,6 +3,7 @@ package com.imyeyu.api.modules.gao.service.implement;
import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
import com.imyeyu.api.modules.gao.service.GaoStoreChartService;
import com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerTrendStateReq;
@@ -43,6 +44,12 @@ public class GaoStoreChartServiceImplement implements GaoStoreChartService {
return customerService.stateGender(storeId);
}
@Override
public List<GaoCustomerAgeStatView> customerAge(String storeId) {
TimiException.required(storeId, "not found storeId");
return customerService.stateAge(storeId);
}
@Override
public List<GaoEventRecordDailyStatView> eventRecordDailyStat(GaoStoreChartReq req) {
checkRangeReq(req);
@@ -0,0 +1,18 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
///
/// 客户年龄统计视图
///
/// @author Codex
/// @since 2026-09-11
@Data
public class GaoCustomerAgeStatView {
/// 客户年龄
private Long age;
/// 该年龄的客户数量
private Long count;
}
@@ -0,0 +1,20 @@
package com.imyeyu.api.modules.gao.vo;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
///
/// 客户每日统计中的客户摘要
///
/// @author Codex
/// @since 2026-09-11
@Data
public class GaoCustomerDailyStateCustomerView {
/// 用于服务层按日期分组,不返回给前端
@JsonIgnore
private Integer dateValue;
/// 客户姓名
private String name;
}
@@ -2,6 +2,8 @@ package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
import java.util.List;
///
/// 客户每日统计视图
///
@@ -18,4 +20,7 @@ public class GaoCustomerDailyStateView {
/// 截止当日客户总数
private Long totalCustomerCount;
/// 当日新增客户列表
private List<GaoCustomerDailyStateCustomerView> customerList;
}
@@ -0,0 +1,19 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
/// 客户病症原始文本
///
/// 只用于后端分词,不对外返回。
///
/// @author Codex
/// @since 2026-09-11
@Data
public class GaoCustomerDiseaseTextView {
/// 备注
private String remark;
/// 病症
private String disease;
}
@@ -0,0 +1,17 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
/// 客户病症词频查询请求
///
/// @author Codex
/// @since 2026-09-11
@Data
public class GaoCustomerDiseaseWordReq {
/// 门店 ID
private String storeId;
/// 返回词语数量
private Integer topN;
}
@@ -0,0 +1,19 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
/// 客户病症词频
///
/// @author Codex
/// @since 2026-09-11
@Data
@AllArgsConstructor
public class GaoCustomerDiseaseWordView {
/// 词语
private String word;
/// 词语出现次数
private Long value;
}
@@ -23,4 +23,10 @@ public class GaoCustomerPage extends Page<GaoCustomer> {
/// 标签 ID 列表,客户必须同时拥有列表内全部标签
private List<String> tagIdList;
/// 录入时间起始,包含,Unix 毫秒时间戳
private Long beginCreatedAt;
/// 录入时间结束,包含,Unix 毫秒时间戳
private Long endCreatedAt;
}
@@ -30,6 +30,6 @@ public class GaoProductPage extends BasePage {
/// 产品类型
private GaoProduct.ProductType productType;
/// 名称或编码关键词
/// 名称、拼音或编码关键词
private String keyword;
}
@@ -18,6 +18,6 @@ public class GaoProductStockPage extends BasePage {
/// 产品 ID
private String productId;
/// 产品或 SKU 名称、编码、规格摘要关键词
/// 产品或 SKU 名称、拼音、编码、规格摘要关键词
private String keyword;
}
@@ -19,7 +19,7 @@ public class GaoProductStockRecordPage extends BasePage {
/// 产品 ID
private String productId;
/// 产品名称或编码关键词
/// 产品名称、拼音或编码关键词
private String keyword;
/// 来源业务类型
@@ -1,19 +0,0 @@
package com.imyeyu.api.modules.system.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
/**
*
*
* @author 夜雨
* @since 2026-04-23 16:31
*/
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/system/setting")
public class SettingController {
}
@@ -0,0 +1,55 @@
package db.migration.timiserver;
import com.imyeyu.api.util.PinyinUtil;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
/// 回填 GAO 产品名称拼音搜索字段
///
/// @author Codex
/// @since 2026-09-10
public class V78__backfill_gao_product_pinyin_search extends BaseJavaMigration {
private static final int BATCH_SIZE = 500;
@Override
public void migrate(Context context) throws Exception {
Connection connection = context.getConnection();
try (
PreparedStatement select = connection.prepareStatement("""
SELECT `id`, `name`
FROM `gao_product`
WHERE `name` IS NOT NULL
AND (`name_pinyin_full` IS NULL OR `name_pinyin_initial` IS NULL OR `name_pinyin_mixed` IS NULL)
""");
PreparedStatement update = connection.prepareStatement("""
UPDATE `gao_product`
SET `name_pinyin_full` = ?, `name_pinyin_initial` = ?, `name_pinyin_mixed` = ?
WHERE `id` = ?
""")) {
try (ResultSet resultSet = select.executeQuery()) {
int batchSize = 0;
while (resultSet.next()) {
String name = resultSet.getString("name");
update.setString(1, PinyinUtil.full(name));
update.setString(2, PinyinUtil.initial(name));
update.setString(3, PinyinUtil.mixed(name));
update.setString(4, resultSet.getString("id"));
update.addBatch();
batchSize++;
if (batchSize >= BATCH_SIZE) {
update.executeBatch();
batchSize = 0;
}
}
if (0 < batchSize) {
update.executeBatch();
}
}
}
}
}
@@ -0,0 +1,7 @@
ALTER TABLE `gao_product`
ADD COLUMN `name_pinyin_full` VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '产品名称全拼' AFTER `name`,
ADD COLUMN `name_pinyin_initial` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '产品名称拼音首字母' AFTER `name_pinyin_full`,
ADD COLUMN `name_pinyin_mixed` VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '产品名称混合拼音' AFTER `name_pinyin_initial`,
ADD KEY `idx_gao_product_store_name_pinyin_full` (`store_id`, `deleted_at`, `name_pinyin_full`(128)),
ADD KEY `idx_gao_product_store_name_pinyin_initial` (`store_id`, `deleted_at`, `name_pinyin_initial`),
ADD KEY `idx_gao_product_store_name_pinyin_mixed` (`store_id`, `deleted_at`, `name_pinyin_mixed`(128));
@@ -49,6 +49,12 @@
HAVING COUNT(DISTINCT `ta`.`tag_id`) = #{tagCount}
)
</if>
<if test="beginCreatedAt != null">
AND `c`.`created_at` &gt;= #{beginCreatedAt}
</if>
<if test="endCreatedAt != null">
AND `c`.`created_at` &lt;= #{endCreatedAt}
</if>
</sql>
<select id="countByQuery" resultType="long">
@@ -92,6 +98,19 @@
ORDER BY `dateValue` ASC
</select>
<select id="selectDailyNewCustomer" resultType="com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateCustomerView">
SELECT
`name`,
CAST(DATE_FORMAT(FROM_UNIXTIME(`created_at` / 1000), '%Y%m%d') AS UNSIGNED) AS `dateValue`
FROM `gao_customer`
WHERE
`store_id` = #{storeId}
AND `created_at` &gt;= #{beginCreatedAt}
AND `created_at` &lt;= #{endCreatedAt}
AND `deleted_at` IS NULL
ORDER BY `dateValue` ASC, `created_at` ASC, `id` ASC
</select>
<select id="countCreatedBefore" resultType="java.lang.Long">
SELECT
COUNT(1)
@@ -112,4 +131,31 @@
AND `deleted_at` IS NULL
GROUP BY IFNULL(CAST(`gender` AS CHAR), 'UNKNOWN')
</select>
<select id="stateAge" resultType="com.imyeyu.api.modules.gao.vo.GaoCustomerAgeStatView">
SELECT
COALESCE(`age`, TIMESTAMPDIFF(YEAR, `birthdate`, CURDATE())) AS `age`,
COUNT(1) AS `count`
FROM `gao_customer`
WHERE
`store_id` = #{storeId}
AND `deleted_at` IS NULL
AND (`age` IS NOT NULL OR `birthdate` IS NOT NULL)
GROUP BY COALESCE(`age`, TIMESTAMPDIFF(YEAR, `birthdate`, CURDATE()))
ORDER BY COALESCE(`age`, TIMESTAMPDIFF(YEAR, `birthdate`, CURDATE())) ASC
</select>
<select id="selectDiseaseTextByStoreId" resultType="com.imyeyu.api.modules.gao.vo.GaoCustomerDiseaseTextView">
SELECT
`remark`,
`disease`
FROM `gao_customer`
WHERE
`store_id` = #{storeId}
AND `deleted_at` IS NULL
AND (
(`remark` IS NOT NULL AND `remark` != '')
OR (`disease` IS NOT NULL AND `disease` != '')
)
</select>
</mapper>
@@ -348,10 +348,23 @@
FROM `daily_stat` d
JOIN `open_calendar` o ON d.`registeredDateValue` = o.`dateValue`
),
`customer_stat` AS (
SELECT
d.`customerId`,
MAX(CASE WHEN d.`registeredDateValue` = #{todayDateValue} THEN 1 ELSE 0 END) AS `hasToday`
FROM `daily_stat` d
GROUP BY d.`customerId`
),
`anchor_date` AS (
SELECT
MAX(`openIndex`) AS `anchorOpenIndex`
FROM `open_calendar`
s.`customerId`,
CASE
WHEN s.`hasToday` = 1 THEN MAX(CASE WHEN c.`dateValue` = #{todayDateValue} THEN c.`openIndex` END)
ELSE MAX(CASE WHEN c.`dateValue` &lt; #{todayDateValue} THEN c.`openIndex` END)
END AS `anchorOpenIndex`
FROM `customer_stat` s
CROSS JOIN `open_calendar` c
GROUP BY s.`customerId`, s.`hasToday`
),
`continuous_stat` AS (
SELECT
@@ -361,7 +374,7 @@
ELSE COUNT(1)
END AS `recentContinuousEventDays`
FROM `continuous_ordered` o
JOIN `anchor_date` a
JOIN `anchor_date` a ON o.`customerId` = a.`customerId`
WHERE
o.`openIndex` + o.`dayIndex` = a.`anchorOpenIndex` + 1
<if test="eventIdList == null or eventIdList.size() != 1">
@@ -13,7 +13,7 @@
</choose>
<if test="status != null">AND `status` = #{status}</if>
<if test="productType != null">AND `product_type` = #{productType}</if>
<if test="keyword != null and keyword.trim() != ''">AND (`code` LIKE CONCAT('%', #{keyword}, '%') OR `name` LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="keyword != null and keyword.trim() != ''">AND (`code` LIKE CONCAT('%', #{keyword}, '%') OR `name` LIKE CONCAT('%', #{keyword}, '%') OR `name_pinyin_full` LIKE CONCAT(#{keyword}, '%') OR `name_pinyin_initial` LIKE CONCAT(#{keyword}, '%') OR `name_pinyin_mixed` LIKE CONCAT(#{keyword}, '%'))</if>
</select>
<select id="selectByQuery" resultType="com.imyeyu.api.modules.gao.entity.GaoProduct">
SELECT * FROM `gao_product`
@@ -27,7 +27,7 @@
</choose>
<if test="status != null">AND `status` = #{status}</if>
<if test="productType != null">AND `product_type` = #{productType}</if>
<if test="keyword != null and keyword.trim() != ''">AND (`code` LIKE CONCAT('%', #{keyword}, '%') OR `name` LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="keyword != null and keyword.trim() != ''">AND (`code` LIKE CONCAT('%', #{keyword}, '%') OR `name` LIKE CONCAT('%', #{keyword}, '%') OR `name_pinyin_full` LIKE CONCAT(#{keyword}, '%') OR `name_pinyin_initial` LIKE CONCAT(#{keyword}, '%') OR `name_pinyin_mixed` LIKE CONCAT(#{keyword}, '%'))</if>
ORDER BY `sort`, `name`, `code`, `created_at` DESC
LIMIT #{offset}, #{limit}
</select>
@@ -23,7 +23,7 @@
AND k.`stock_enabled` = 1 AND k.`status` = 'ACTIVE'
<if test="storeId != null and storeId != ''">AND p.`store_id` = #{storeId}</if>
<if test="productId != null and productId != ''">AND k.`product_id` = #{productId}</if>
<if test="keyword != null and keyword.trim() != ''">AND (p.`code` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%') OR k.`code` LIKE CONCAT('%', #{keyword}, '%') OR k.`name` LIKE CONCAT('%', #{keyword}, '%') OR k.`spec_summary` LIKE CONCAT('%', #{keyword}, '%') OR k.`barcode` LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="keyword != null and keyword.trim() != ''">AND (p.`code` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%') OR p.`name_pinyin_full` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_initial` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_mixed` LIKE CONCAT(#{keyword}, '%') OR k.`code` LIKE CONCAT('%', #{keyword}, '%') OR k.`name` LIKE CONCAT('%', #{keyword}, '%') OR k.`spec_summary` LIKE CONCAT('%', #{keyword}, '%') OR k.`barcode` LIKE CONCAT('%', #{keyword}, '%'))</if>
</select>
<select id="selectByQuery" resultType="com.imyeyu.api.modules.gao.entity.GaoProductStock">
SELECT s.`id`,
@@ -45,7 +45,7 @@
AND k.`stock_enabled` = 1 AND k.`status` = 'ACTIVE'
<if test="storeId != null and storeId != ''">AND p.`store_id` = #{storeId}</if>
<if test="productId != null and productId != ''">AND k.`product_id` = #{productId}</if>
<if test="keyword != null and keyword.trim() != ''">AND (p.`code` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%') OR k.`code` LIKE CONCAT('%', #{keyword}, '%') OR k.`name` LIKE CONCAT('%', #{keyword}, '%') OR k.`spec_summary` LIKE CONCAT('%', #{keyword}, '%') OR k.`barcode` LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="keyword != null and keyword.trim() != ''">AND (p.`code` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%') OR p.`name_pinyin_full` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_initial` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_mixed` LIKE CONCAT(#{keyword}, '%') OR k.`code` LIKE CONCAT('%', #{keyword}, '%') OR k.`name` LIKE CONCAT('%', #{keyword}, '%') OR k.`spec_summary` LIKE CONCAT('%', #{keyword}, '%') OR k.`barcode` LIKE CONCAT('%', #{keyword}, '%'))</if>
ORDER BY p.`sort`, p.`name`, p.`code`, k.`sort`, k.`name`, k.`code`
LIMIT #{offset}, #{limit}
</select>
@@ -57,7 +57,7 @@
AND p.`stock_enabled` = 1
AND k.`stock_enabled` = 1 AND k.`status` = 'ACTIVE'
<if test="storeId != null and storeId != ''">AND p.`store_id` = #{storeId}</if>
<if test="keyword != null and keyword.trim() != ''">AND (p.`code` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="keyword != null and keyword.trim() != ''">AND (p.`code` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%') OR p.`name_pinyin_full` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_initial` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_mixed` LIKE CONCAT(#{keyword}, '%'))</if>
</select>
<select id="selectProductByQuery" resultType="com.imyeyu.api.modules.gao.vo.GaoProductStockProductView">
SELECT p.`id` AS `product_id`,
@@ -79,7 +79,7 @@
AND p.`stock_enabled` = 1
AND k.`stock_enabled` = 1 AND k.`status` = 'ACTIVE'
<if test="storeId != null and storeId != ''">AND p.`store_id` = #{storeId}</if>
<if test="keyword != null and keyword.trim() != ''">AND (p.`code` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="keyword != null and keyword.trim() != ''">AND (p.`code` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%') OR p.`name_pinyin_full` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_initial` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_mixed` LIKE CONCAT(#{keyword}, '%'))</if>
GROUP BY p.`id`, p.`code`, p.`name`, p.`product_type`, p.`sort`
ORDER BY p.`sort`, p.`name`, p.`code`
LIMIT #{offset}, #{limit}
@@ -31,7 +31,7 @@
1 = 1
<if test="storeId != null and storeId != ''">AND r.`store_id` = #{storeId}</if>
<if test="productId != null and productId != ''">AND k.`product_id` = #{productId}</if>
<if test="keyword != null and keyword.trim() != ''">AND (r.`product_name_snapshot` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%') OR p.`code` LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="keyword != null and keyword.trim() != ''">AND (r.`product_name_snapshot` LIKE CONCAT('%', #{keyword}, '%') OR p.`name` LIKE CONCAT('%', #{keyword}, '%') OR p.`name_pinyin_full` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_initial` LIKE CONCAT(#{keyword}, '%') OR p.`name_pinyin_mixed` LIKE CONCAT(#{keyword}, '%') OR p.`code` LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="bizType != null">AND r.`biz_type` = #{bizType}</if>
<if test="bizId != null and bizId != ''">AND r.`biz_id` = #{bizId}</if>
<if test="operationType != null">AND r.`operation_type` = #{operationType}</if>