29 Commits

Author SHA1 Message Date
wuweihua 039c0d4858 Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-11-03 18:16:08 +08:00
caojia 43299e5329 Merge branch 'test' 2026-11-03 17:39:19 +08:00
caojia 3fe34c471c Merge branch 'test' 2026-11-02 16:54:26 +08:00
caojia 134855fc31 Merge branch 'test' 2026-11-02 10:07:19 +08:00
caojia 01cee41301 Merge branch 'test' 2026-11-02 09:17:44 +08:00
caojia 1f0d757e29 🐛 按当天截止时间筛选账单订单 2026-10-30 15:55:12 +08:00
caojia 762cad2c0d ✨ 新增应收账单日期类型筛选 2026-10-30 15:23:20 +08:00
wuweihua 7ef3f40a4f Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-29 18:19:48 +08:00
caojia 58e41b669d Merge branch 'test' 2026-10-29 16:59:06 +08:00
caojia 9a48abbacd Merge branch 'test' 2026-10-28 22:25:05 +08:00
wuweihua f95edbdf0a Merge branch 'master' of D:\wuweihua\BMS with conflicts. 2026-10-28 15:57:58 +08:00
wuweihua b30dfdf675 Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-28 15:56:10 +08:00
wuweihua 9a393fe7dc Merge branch 'master' of D:\wuweihua\BMS with conflicts. 2026-10-28 15:32:21 +08:00
wuweihua a2468099c2 Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-28 15:30:47 +08:00
caojia cbed4dc2a3 Merge branch 'test' 2026-10-28 09:28:14 +08:00
caojia d66b96b2ff Merge branch 'test' 2026-10-27 18:25:18 +08:00
wuweihua cdfc90556d 隐藏修改计费重 2026-10-27 17:51:40 +08:00
wuweihua dfa90d6e33 Merge branch 'master' of D:\wuweihua\BMS with conflicts. 2026-10-27 16:25:11 +08:00
wuweihua 4a34075022 Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-27 16:24:36 +08:00
wuweihua 6a03300f2f Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-27 10:31:58 +08:00
liuminghui 7496f8cbc6 Merge branch 'test' 2026-10-27 10:21:57 +08:00
wuweihua 2f2305825a Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-27 01:10:31 +08:00
wuweihua b79cd6f995 Merge branch 'master' of D:\wuweihua\BMS with conflicts. 2026-10-27 00:17:37 +08:00
wuweihua 70a3c34c20 Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-27 00:16:40 +08:00
caojia 2b5633190e Merge branch 'test' 2026-10-26 18:01:23 +08:00
wuweihua 77dfb4291b Merge remote-tracking branch 'origin/test' 2026-10-26 17:13:44 +08:00
caojia d71b7f04fe Merge branch 'test' 2026-10-26 17:09:01 +08:00
caojia f8ae347846 Merge branch 'test' 2026-10-26 16:39:11 +08:00
wuweihua 94991a1ed3 Merge pull request '修改操作文档' (#1) from test into master
Reviewed-on: #1
2026-10-26 15:23:59 +08:00
272 changed files with 1380 additions and 31261 deletions
@@ -31,11 +31,9 @@ import com.bms.ar.domain.ArBillGenerateTask;
import com.bms.ar.domain.ArBillLog;
import com.bms.ar.domain.vo.ArBillActionRequest;
import com.bms.ar.domain.vo.ArBillGenerateQuery;
import com.bms.ar.domain.vo.ArBillGeneratePreviewExportRow;
import com.bms.ar.domain.vo.ArBillGenerateRequest;
import com.bms.ar.domain.vo.ArBillListExportRow;
import com.bms.ar.domain.vo.ArBillQuery;
import com.bms.ar.enums.ArBillDateType;
import com.bms.ar.service.IArBillService;
import com.bms.ar.security.ArBillScopeResolver;
import com.bms.common.annotation.Log;
@@ -175,28 +173,6 @@ public class ArBillController extends BaseController {
return result;
}
/** 按生成预览使用的相同筛选条件导出全部匹配订单,不受分页参数影响。 */
@Log(title = "应收账单生成订单", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('ar:bill:generate')")
@PostMapping("/generate/preview/export")
public void exportGeneratePreview(HttpServletResponse response, ArBillGenerateQuery query,
HttpServletRequest request) {
rejectMultiValueParameter(request, "customerId");
ArBillGenerateQuery exportQuery = query == null ? new ArBillGenerateQuery() : query;
List<PrcOrderBill> bills = billService.previewBill(exportQuery);
List<ArBillGeneratePreviewExportRow> rows = new ArrayList<>(bills.size());
for (PrcOrderBill bill : bills) {
rows.add(ArBillGeneratePreviewExportRow.from(bill));
}
ExcelUtil<ArBillGeneratePreviewExportRow> util = new ExcelUtil<>(ArBillGeneratePreviewExportRow.class);
if (ArBillDateType.PICKUP_DATE == ArBillDateType.of(exportQuery.getDateType())) {
util.hideColumn("bizDate");
} else {
util.hideColumn("pickupDate");
}
util.exportExcel(response, rows, "应收账单待生成订单");
}
/** 查询需要从全选结果中排除的订单;空条件不执行查询,避免返回全部订单。 */
@PreAuthorize("@ss.hasPermi('ar:bill:generate')")
@PostMapping("/generate/exclusion-preview")
@@ -1,94 +0,0 @@
package com.bms.web.controller.data;
import java.util.List;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.bms.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.exception.ServiceException;
import com.bms.price.domain.PayPostcodeGroup;
import com.bms.price.domain.PayPostcodeLine;
import com.bms.price.service.IPayPostcodeService;
/**
* 数据管理下的应付邮编配置。Excel 导入后按国家和附加费金额合并。
*/
@RestController
@RequestMapping("/data/payPostcode")
public class PayPostcodeController extends BaseController
{
@Autowired
private IPayPostcodeService payPostcodeService;
@PreAuthorize("@ss.hasPermi('data:payPostcode:list')")
@GetMapping("/list")
public TableDataInfo list(PayPostcodeGroup query)
{
startPage();
List<PayPostcodeGroup> list = payPostcodeService.selectGroupList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('data:payPostcode:query')")
@GetMapping("/{groupId}")
public AjaxResult getInfo(@PathVariable Long groupId)
{
return success(payPostcodeService.selectGroupById(groupId));
}
@PreAuthorize("@ss.hasPermi('data:payPostcode:edit')")
@Log(title = "应付邮编配置", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody PayPostcodeGroup group)
{
return toAjax(payPostcodeService.updateGroup(group));
}
@PreAuthorize("@ss.hasPermi('data:payPostcode:query')")
@GetMapping("/line")
public TableDataInfo lines(PayPostcodeLine query)
{
startPage();
List<PayPostcodeLine> list = payPostcodeService.selectLineList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('data:payPostcode:import')")
@Log(title = "应付邮编配置", businessType = BusinessType.IMPORT)
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public AjaxResult importExcel(MultipartFile file) throws Exception
{
if (file == null || file.isEmpty())
{
throw new ServiceException("请选择 Excel 文件");
}
String name = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase(Locale.ROOT);
if (!name.endsWith(".xlsx"))
{
throw new ServiceException("只支持 xlsx 文件");
}
return success(payPostcodeService.importExcel(file.getBytes(), file.getOriginalFilename(), getUsername()));
}
@PreAuthorize("@ss.hasPermi('data:payPostcode:remove')")
@Log(title = "应付邮编配置", businessType = BusinessType.DELETE)
@DeleteMapping("/{groupIds}")
public AjaxResult remove(@PathVariable Long[] groupIds)
{
return toAjax(payPostcodeService.deleteByIds(groupIds));
}
}
@@ -1,96 +0,0 @@
package com.bms.web.controller.masterdata;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.bms.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.utils.poi.ExcelUtil;
import com.bms.masterdata.domain.LineProduct;
import com.bms.masterdata.service.ILineProductService;
/**
* 路线产品管理(srv_line_product)
*/
@RestController
@RequestMapping("/masterdata/lineproduct")
public class LineProductController extends BaseController
{
@Autowired
private ILineProductService lineProductService;
@PreAuthorize("@ss.hasPermi('data:lineProduct:list')")
@GetMapping("/list")
public TableDataInfo list(LineProduct query)
{
startPage();
List<LineProduct> list = lineProductService.selectLineProductList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('data:lineProduct:export')")
@Log(title = "路线产品", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, LineProduct query)
{
List<LineProduct> list = lineProductService.selectLineProductList(query);
ExcelUtil<LineProduct> util = new ExcelUtil<LineProduct>(LineProduct.class);
util.exportExcel(response, list, "路线产品");
}
@PreAuthorize("@ss.hasPermi('data:lineProduct:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable String id)
{
return success(lineProductService.selectLineProductById(id));
}
@PreAuthorize("@ss.hasPermi('data:lineProduct:add')")
@Log(title = "路线产品", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody LineProduct product)
{
String username = getUsername();
product.setCreateName(username);
product.setModifier(username);
return toAjax(lineProductService.insertLineProduct(product));
}
@PreAuthorize("@ss.hasPermi('data:lineProduct:edit')")
@Log(title = "路线产品", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody LineProduct product)
{
product.setModifier(getUsername());
return toAjax(lineProductService.updateLineProduct(product));
}
@PreAuthorize("@ss.hasPermi('data:lineProduct:edit')")
@Log(title = "路线产品", businessType = BusinessType.UPDATE)
@PutMapping("/changeStatus")
public AjaxResult changeStatus(@RequestBody LineProduct product)
{
product.setModifier(getUsername());
return toAjax(lineProductService.updateLineProductStatus(product));
}
@PreAuthorize("@ss.hasPermi('data:lineProduct:remove')")
@Log(title = "路线产品", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable String[] ids)
{
return toAjax(lineProductService.deleteLineProductByIds(ids));
}
}
@@ -16,7 +16,6 @@ import com.bms.common.core.page.TableSupport;
import com.bms.price.search.BillSearchPage;
import com.bms.price.search.EsLogType;
import com.bms.price.search.IBillSearchStore;
import com.bms.price.search.PriceChangeGroupPage;
/**
* 来自 ES 的价格变更前后日志(销售价 / 产品价 / 红线价)。
@@ -32,40 +31,12 @@ public class PriceLogController extends BaseController
@GetMapping("/list")
public TableDataInfo list(
@RequestParam(value = "bizId", required = false) String bizId,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "psvId", required = false) Long psvId,
@RequestParam(value = "priceKind", required = false) String priceKind,
@RequestParam(value = "productCode", required = false) String productCode,
@RequestParam(value = "updateTime", required = false) String updateTime,
@RequestParam(value = "updateTimePrefix", required = false) String updateTimePrefix,
@RequestParam(value = "countryKeyword", required = false) String countryKeyword)
@RequestParam(value = "status", required = false) String status)
{
PageDomain pageDomain = TableSupport.buildPageRequest();
int pageNum = pageDomain.getPageNum() == null ? 1 : pageDomain.getPageNum().intValue();
int pageSize = pageDomain.getPageSize() == null ? 10 : pageDomain.getPageSize().intValue();
BillSearchPage page = billSearchStore.search(EsLogType.PRICE_CHANGE, bizId, status, psvId, priceKind,
productCode, updateTime, updateTimePrefix, countryKeyword, pageNum, pageSize);
TableDataInfo rsp = new TableDataInfo();
rsp.setCode(HttpStatus.SUCCESS);
rsp.setMsg("查询成功");
rsp.setRows(page.getRows());
rsp.setTotal(page.getTotal());
return rsp;
}
/** Group by productCode + write time for package-version drill-down. */
@PreAuthorize("@ss.hasPermi('monitor:priceLog:list')")
@GetMapping("/groups")
public TableDataInfo groups(
@RequestParam(value = "psvId", required = true) Long psvId,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "priceKind", required = false) String priceKind)
{
PageDomain pageDomain = TableSupport.buildPageRequest();
int pageNum = pageDomain.getPageNum() == null ? 1 : pageDomain.getPageNum().intValue();
int pageSize = pageDomain.getPageSize() == null ? 10 : pageDomain.getPageSize().intValue();
PriceChangeGroupPage page = billSearchStore.searchPriceChangeGroups(psvId, priceKind, status, pageNum,
pageSize);
BillSearchPage page = billSearchStore.search(EsLogType.PRICE_CHANGE, bizId, status, pageNum, pageSize);
TableDataInfo rsp = new TableDataInfo();
rsp.setCode(HttpStatus.SUCCESS);
rsp.setMsg("查询成功");
@@ -22,12 +22,9 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.bms.common.annotation.Log;
import com.bms.common.constant.HttpStatus;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.PageDomain;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.core.page.TableSupport;
import com.bms.common.enums.BusinessType;
import com.bms.common.utils.DateUtils;
import com.bms.common.utils.MessageUtils;
@@ -57,11 +54,6 @@ import com.bms.price.domain.vo.TmsPriceQuoteRequest;
import com.bms.price.domain.vo.TmsPriceQuoteResult;
import com.bms.price.enums.BillTaskSuccessStatus;
import com.bms.price.enums.BillTaskType;
import com.bms.price.search.BillSearchPage;
import com.bms.price.search.BillSyncSnapshot;
import com.bms.price.search.EsBizLogSupport;
import com.bms.price.search.EsLogType;
import com.bms.price.search.IBillSearchStore;
import com.bms.price.service.IPrcBillingTaskService;
import com.bms.price.service.IPrcOrderBillService;
import com.bms.price.service.ITmsPriceQuoteService;
@@ -92,9 +84,6 @@ public class BillingController extends BaseController
@Autowired
private OrderBillProductCheckService orderBillProductCheckService;
@Autowired
private IBillSearchStore billSearchStore;
/**
* Historical quote list.
*/
@@ -316,14 +305,6 @@ public class BillingController extends BaseController
return success(task);
}
@PreAuthorize("@ss.hasPermi('price:orderBill:rebill')")
@Log(title = "Order bill local rebill cancel", businessType = BusinessType.UPDATE)
@PostMapping("/orderBill/rebill/task/{taskId}/cancel")
public AjaxResult cancelLocalRebill(@PathVariable("taskId") String taskId)
{
return success(prcOrderBillService.cancelLocalRebill(taskId));
}
/**
* Order no options for remote-search dropdown (bill list multi search).
*/
@@ -366,97 +347,6 @@ public class BillingController extends BaseController
return success(prcOrderBillService.getBill(billId));
}
/**
* ES BILL logs for this order (billing / upstream sync / fail snapshots).
* bizId prefers transferNo then orderNo (same as write path).
*/
@PreAuthorize("@ss.hasPermi('price:billing:bill') or @ss.hasPermi('price:orderBill:list')")
@GetMapping("/bill/{billId}/history")
public TableDataInfo billHistory(@PathVariable("billId") String billId)
{
if (StringUtils.isEmpty(billId))
{
return getDataTable(new ArrayList<BillSyncSnapshot>());
}
TmsPriceQuoteResult bill = prcOrderBillService.getBill(billId.trim());
if (bill == null)
{
TableDataInfo empty = new TableDataInfo();
empty.setCode(HttpStatus.SUCCESS);
empty.setMsg("查询成功");
empty.setRows(new ArrayList<BillSyncSnapshot>());
empty.setTotal(0L);
return empty;
}
PageDomain pageDomain = TableSupport.buildPageRequest();
int pageNum = pageDomain.getPageNum() == null ? 1 : pageDomain.getPageNum().intValue();
int pageSize = pageDomain.getPageSize() == null ? 10 : pageDomain.getPageSize().intValue();
String primary = EsBizLogSupport.billBizId(bill.getTransferNo(), bill.getOrderNo());
BillSearchPage page = billSearchStore.search(EsLogType.BILL, primary, null, pageNum, pageSize);
// Also pull by orderNo when transferNo was used as bizId, so older orderNo-keyed rows appear.
if (StringUtils.isNotEmpty(bill.getTransferNo()) && StringUtils.isNotEmpty(bill.getOrderNo())
&& !bill.getTransferNo().trim().equalsIgnoreCase(bill.getOrderNo().trim())
&& pageNum == 1)
{
BillSearchPage byOrder = billSearchStore.search(EsLogType.BILL, bill.getOrderNo().trim(), null, 1,
Math.max(pageSize, 50));
page = mergeBillHistory(page, byOrder, pageSize);
}
TableDataInfo rsp = new TableDataInfo();
rsp.setCode(HttpStatus.SUCCESS);
rsp.setMsg("查询成功");
rsp.setRows(page.getRows());
rsp.setTotal(page.getTotal());
return rsp;
}
private BillSearchPage mergeBillHistory(BillSearchPage primary, BillSearchPage secondary, int pageSize)
{
BillSearchPage out = new BillSearchPage();
Map<String, BillSyncSnapshot> byId = new HashMap<String, BillSyncSnapshot>();
if (primary != null && primary.getRows() != null)
{
for (BillSyncSnapshot row : primary.getRows())
{
if (row != null && StringUtils.isNotEmpty(row.getDocId()))
{
byId.put(row.getDocId(), row);
}
}
}
if (secondary != null && secondary.getRows() != null)
{
for (BillSyncSnapshot row : secondary.getRows())
{
if (row != null && StringUtils.isNotEmpty(row.getDocId()) && !byId.containsKey(row.getDocId()))
{
byId.put(row.getDocId(), row);
}
}
}
List<BillSyncSnapshot> merged = new ArrayList<BillSyncSnapshot>(byId.values());
merged.sort((a, b) -> {
String ta = a == null || a.getUpdateTime() == null ? "" : a.getUpdateTime();
String tb = b == null || b.getUpdateTime() == null ? "" : b.getUpdateTime();
return tb.compareTo(ta);
});
long total = Math.max(primary == null ? 0L : primary.getTotal(), merged.size());
if (secondary != null)
{
total = Math.max(total, secondary.getTotal());
}
out.setTotal(total);
if (merged.size() > pageSize)
{
out.setRows(new ArrayList<BillSyncSnapshot>(merged.subList(0, pageSize)));
}
else
{
out.setRows(merged);
}
return out;
}
/**
* Refund one receivable bill via billing task billType=REFUND.
*/
@@ -1,453 +0,0 @@
package com.bms.web.controller.price;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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 org.springframework.web.multipart.MultipartFile;
import com.bms.price.service.support.UploadFileBatch;
import com.bms.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.utils.MessageUtils;
import com.bms.common.utils.StringUtils;
import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcCustomerPriceSetBatch;
import com.bms.price.domain.PrcPriceExcelFile;
import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetDetail;
import com.bms.price.domain.PrcPriceSetSaveTask;
import com.bms.price.domain.PrcPriceSetVersion;
import com.bms.price.service.IPrcPriceExcelFileService;
import com.bms.price.service.IPrcPriceSetService;
/**
* Exclusive quote (shares prc_price_set with PACKAGE)
*
* @author bms
*/
@RestController
@RequestMapping("/price/exclusive")
public class ExclusiveQuoteController extends BaseController
{
@Autowired
private IPrcPriceSetService priceSetService;
@Autowired
private IPrcPriceExcelFileService priceExcelFileService;
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/list")
public TableDataInfo list(PrcPriceSet set)
{
set.setSetCategory(PrcPriceSet.CATEGORY_EXCLUSIVE);
startPage();
List<PrcPriceSet> list = priceSetService.selectPrcPriceSetList(set);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:query')")
@GetMapping("/{priceSetId:[0-9]+}")
public AjaxResult getInfo(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return success(priceSetService.selectPrcPriceSetById(priceSetId));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:add')")
@Log(title = "Exclusive quote", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody PrcPriceSet set)
{
if (set.getPriceSetId() != null)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_EXCLUSIVE);
}
set.setSetCategory(PrcPriceSet.CATEGORY_EXCLUSIVE);
set.setCreateBy(getUsername());
set.setUpdateBy(getUsername());
String taskId = priceSetService.submitSaveAsync(set);
// Do not pass bare String to success(): overload puts it into msg, not data.
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:query')")
@GetMapping("/save/task/{taskId}")
public AjaxResult getSaveTask(@PathVariable String taskId)
{
PrcPriceSetSaveTask task = priceSetService.getSaveTask(taskId);
if (task == null)
{
return error(MessageUtils.message("price.set.save.task.notExists"));
}
return success(task);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody PrcPriceSet set)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_EXCLUSIVE);
set.setSetCategory(PrcPriceSet.CATEGORY_EXCLUSIVE);
set.setUpdateBy(getUsername());
return toAjax(priceSetService.updatePrcPriceSet(set));
}
/** Update package name and note only. */
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote basic", businessType = BusinessType.UPDATE)
@PutMapping("/basic")
public AjaxResult editBasic(@RequestBody PrcPriceSet set)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_EXCLUSIVE);
set.setUpdateBy(getUsername());
return toAjax(priceSetService.updateBasicInfo(set));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:remove')")
@Log(title = "Exclusive quote", businessType = BusinessType.DELETE)
@DeleteMapping("/{priceSetIds}")
public AjaxResult remove(@PathVariable Long[] priceSetIds)
{
if (priceSetIds != null)
{
for (Long id : priceSetIds)
{
priceSetService.assertSetCategory(id, PrcPriceSet.CATEGORY_EXCLUSIVE);
}
}
return toAjax(priceSetService.deletePrcPriceSetByIds(priceSetIds));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:audit')")
@Log(title = "Exclusive quote提交审核", businessType = BusinessType.UPDATE)
@PutMapping("/submit/{priceSetId}")
public AjaxResult submit(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return toAjax(priceSetService.submit(priceSetId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:audit')")
@Log(title = "Exclusive quote生效", businessType = BusinessType.UPDATE)
@PutMapping("/approve/{priceSetId}")
public AjaxResult approve(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return toAjax(priceSetService.approve(priceSetId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:audit')")
@Log(title = "Exclusive quote驳回", businessType = BusinessType.UPDATE)
@PutMapping("/reject/{priceSetId}")
public AjaxResult reject(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return toAjax(priceSetService.reject(priceSetId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote version activate", businessType = BusinessType.UPDATE)
@PutMapping("/version/activate/{psvId}")
public AjaxResult activateVersion(@PathVariable Long psvId, @RequestBody(required = false) PrcPriceSetVersion body)
{
Date effectiveTime = body == null ? null : body.getEffectiveTime();
return toAjax(priceSetService.activateVersion(psvId, effectiveTime, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:add')")
@Log(title = "Exclusive quote version copy", businessType = BusinessType.INSERT)
@PostMapping("/version/copy/{psvId}")
public AjaxResult copyVersion(@PathVariable Long psvId)
{
Long newPsvId = priceSetService.copyVersion(psvId, getUsername());
return success(newPsvId);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:add')")
@Log(title = "Exclusive quote version create", businessType = BusinessType.INSERT)
@PostMapping("/version/create/{priceSetId}")
public AjaxResult createVersion(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
Long newPsvId = priceSetService.createVersion(priceSetId, getUsername());
return success(newPsvId);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote version delete", businessType = BusinessType.DELETE)
@DeleteMapping("/version/{psvId}")
public AjaxResult deleteVersion(@PathVariable Long psvId)
{
return toAjax(priceSetService.deleteVersion(psvId, getUsername()));
}
/** Import excel attachments for a version (prc_price_excel_file.psv_id). */
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/version/{psvId}/excelFiles")
public AjaxResult versionExcelFiles(@PathVariable Long psvId)
{
List<PrcPriceExcelFile> list = priceExcelFileService.selectByPsvId(psvId);
return success(list);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:audit')")
@Log(title = "Exclusive quote disable", businessType = BusinessType.UPDATE)
@PutMapping("/disable/{priceSetId}")
public AjaxResult disable(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return toAjax(priceSetService.disable(priceSetId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:audit')")
@Log(title = "Exclusive quote enable", businessType = BusinessType.UPDATE)
@PutMapping("/enable/{priceSetId}")
public AjaxResult enable(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
return toAjax(priceSetService.enable(priceSetId, getUsername()));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:add')")
@Log(title = "Exclusive quote copy", businessType = BusinessType.INSERT)
@PostMapping("/copy/{priceSetId}")
public AjaxResult copy(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
Long id = priceSetService.copy(priceSetId, getUsername());
return success(id);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:query')")
@PostMapping("/validate")
public AjaxResult validate(@RequestBody PrcPriceSet set)
{
return success(priceSetService.validate(set));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/detail/list")
public TableDataInfo detailList(PrcPriceSetDetail detail)
{
startPage();
List<PrcPriceSetDetail> list = priceSetService.selectDetailList(detail);
return getDataTable(list);
}
/** All detail rows for one product (no page); used by edit-price drawer. */
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/detail/byProduct")
public AjaxResult detailByProduct(@RequestParam("psvId") Long psvId,
@RequestParam("productCode") String productCode)
{
return success(priceSetService.selectDetailListByProduct(psvId, productCode));
}
/** All detail rows for a version (no page); batch prefetch instead of N byProduct calls. */
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/detail/byVersion")
public AjaxResult detailByVersion(@RequestParam("psvId") Long psvId)
{
return success(priceSetService.selectDetailListByVersion(psvId));
}
/**
* 版本明细搜索下拉选项:按版本去重后的 产品/国家/分区
*/
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/detail/options")
public AjaxResult detailOptions(@RequestParam("psvId") Long psvId)
{
return success(priceSetService.selectDetailOptions(psvId));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote明细", businessType = BusinessType.INSERT)
@PostMapping("/detail")
public AjaxResult addDetail(@RequestBody PrcPriceSetDetail detail)
{
return toAjax(priceSetService.insertDetail(detail));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote明细", businessType = BusinessType.UPDATE)
@PutMapping("/detail")
public AjaxResult editDetail(@RequestBody PrcPriceSetDetail detail)
{
return toAjax(priceSetService.updateDetail(detail));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote价格导入", businessType = BusinessType.IMPORT)
@PostMapping("/detail/importPrices")
public AjaxResult importDetailPrices(
@RequestParam(value = "files", required = false) MultipartFile[] files,
@RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam("priceSetId") Long priceSetId,
@RequestParam(value = "psvId", required = false) Long psvId,
@RequestParam(value = "updateOnly", required = false, defaultValue = "false") boolean updateOnly,
@RequestParam(value = "replaceByProductCountry", required = false, defaultValue = "false") boolean replaceByProductCountry,
@RequestParam(value = "countryCode", required = false) String countryCode)
throws Exception
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
String taskId = priceSetService.submitImportDetailPricesAsync(priceSetId, psvId, UploadFileBatch.collect(files, file),
getUsername(), updateOnly, replaceByProductCountry, countryCode);
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
/**
* Preview pull product library (no DB write). For create wizard before finish.
*/
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit') or @ss.hasPermi('price:exclusive:add')")
@PostMapping("/detail/previewPullFromLibrary")
public AjaxResult previewPullFromLibrary()
{
return success(priceSetService.previewPullFromLibrary());
}
/**
* Preview Excel import (no DB write). Optional detailsJson for updateOnly / build-price merge.
* Optional countryCode = single-country dedicated-line Excel (same as product country import).
*/
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit') or @ss.hasPermi('price:exclusive:add')")
@PostMapping("/detail/previewImportPrices")
public AjaxResult previewImportPrices(
@RequestParam(value = "files", required = false) MultipartFile[] files,
@RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam(value = "updateOnly", required = false, defaultValue = "false") boolean updateOnly,
@RequestParam(value = "replaceByProductCountry", required = false, defaultValue = "false") boolean replaceByProductCountry,
@RequestParam(value = "countryCode", required = false) String countryCode,
@RequestParam(value = "detailsJson", required = false) String detailsJson) throws Exception
{
java.util.List<MultipartFile> batch = UploadFileBatch.collect(files, file);
java.util.List<byte[]> bytesList = new java.util.ArrayList<byte[]>();
java.util.List<String> names = new java.util.ArrayList<String>();
for (MultipartFile item : batch)
{
bytesList.add(item.getBytes());
names.add(item.getOriginalFilename());
}
List<PrcPriceSetDetail> existing = null;
if (StringUtils.isNotEmpty(detailsJson))
{
existing = com.alibaba.fastjson2.JSON.parseArray(detailsJson, PrcPriceSetDetail.class);
}
return success(priceSetService.previewImportDetailPricesMany(
bytesList, names, updateOnly, existing, replaceByProductCountry, countryCode, true));
}
/**
* Async pull product library into version details. Returns taskId; poll /save/task/{taskId}.
*/
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote拉取产品", businessType = BusinessType.IMPORT)
@PostMapping("/detail/pullFromLibrary")
public AjaxResult pullFromLibrary(@RequestParam("priceSetId") Long priceSetId,
@RequestParam(value = "psvId", required = false) Long psvId,
@RequestParam(value = "force", required = false, defaultValue = "false") boolean force)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_EXCLUSIVE);
String taskId = priceSetService.submitPullFromLibraryAsync(priceSetId, psvId, force, getUsername());
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
/**
* Async batch write of detail surcharge snapshots. Returns taskId; poll /save/task/{taskId}.
*/
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote附加费", businessType = BusinessType.UPDATE)
@PostMapping("/detail/surcharge/async")
public AjaxResult batchSurchargeAsync(@RequestBody List<PrcPriceSetDetail> details)
{
String taskId = priceSetService.submitDetailSurchargeAsync(details);
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit') or @ss.hasPermi('price:exclusive:forceEdit')")
@Log(title = "Exclusive quote明细", businessType = BusinessType.DELETE)
@DeleteMapping("/detail/{psdIds}")
public AjaxResult removeDetail(@PathVariable Long[] psdIds)
{
return toAjax(priceSetService.deleteDetailByIds(psdIds));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:list')")
@GetMapping("/customer/list")
public TableDataInfo customerList(PrcCustomerPriceSet customer)
{
startPage();
List<PrcCustomerPriceSet> list = priceSetService.selectCustomerList(customer);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote客户绑定", businessType = BusinessType.INSERT)
@PostMapping("/customer")
public AjaxResult addCustomer(@RequestBody PrcCustomerPriceSet customer)
{
if (customer != null && customer.getPriceSetId() != null)
{
priceSetService.assertSetCategory(customer.getPriceSetId(), PrcPriceSet.CATEGORY_EXCLUSIVE);
}
customer.setCreateBy(getUsername());
return toAjax(priceSetService.insertCustomer(customer));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote客户批量绑定", businessType = BusinessType.INSERT)
@PostMapping("/customer/batch")
public AjaxResult batchAddCustomer(@RequestBody PrcCustomerPriceSetBatch batch)
{
if (batch != null && batch.getPriceSetId() != null)
{
priceSetService.assertSetCategory(batch.getPriceSetId(), PrcPriceSet.CATEGORY_EXCLUSIVE);
}
batch.setCreateBy(getUsername());
batch.setUpdateBy(getUsername());
return success(priceSetService.batchInsertCustomers(batch));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote客户绑定", businessType = BusinessType.UPDATE)
@PutMapping("/customer")
public AjaxResult editCustomer(@RequestBody PrcCustomerPriceSet customer)
{
customer.setUpdateBy(getUsername());
return toAjax(priceSetService.updateCustomer(customer));
}
@PreAuthorize("@ss.hasPermi('price:exclusive:edit')")
@Log(title = "Exclusive quote客户绑定", businessType = BusinessType.DELETE)
@DeleteMapping("/customer/{customerPricesetIds}")
public AjaxResult removeCustomer(@PathVariable Long[] customerPricesetIds)
{
return toAjax(priceSetService.deleteCustomerByIds(customerPricesetIds));
}
}
@@ -1,122 +0,0 @@
package com.bms.web.controller.price;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.price.domain.PayBill;
import com.bms.price.domain.PayBillCommand;
import com.bms.price.service.IPayBillService;
/**
* 账单管理下的应付账单。
*/
@RestController
@RequestMapping("/price/payBill")
public class PayBillController extends BaseController
{
@Autowired
private IPayBillService payBillService;
/**
* 分页查询应付账单。
*
* @param query 账单号、供应商、状态、币种,空着的条件不过滤
* @return 账单列表,不含费用明细
*/
@PreAuthorize("@ss.hasPermi('price:payBill:list')")
@GetMapping("/list")
public TableDataInfo list(PayBill query)
{
startPage();
List<PayBill> list = payBillService.selectPayBillList(query);
return getDataTable(list);
}
/**
* 查询一张应付账单的明细和操作日志。
*
* @param billId 应付账单ID
* @return 账单头、费用行和日志
*/
@PreAuthorize("@ss.hasPermi('price:payBill:query')")
@GetMapping("/{billId}")
public AjaxResult detail(@PathVariable Long billId)
{
return success(payBillService.selectPayBillDetail(billId));
}
/**
* 预览本次能生成账单的订单。只查询,不重算费用,也不写账单。
*
* @param command 查询方式,以及订单号、计费日或提单号
* @return 可生成订单、未找到的单号、已经入账的订单
*/
@PreAuthorize("@ss.hasPermi('price:payBill:query')")
@PostMapping("/preview")
public AjaxResult preview(@RequestBody PayBillCommand command)
{
return success(payBillService.preview(command));
}
/**
* 生成应付账单。先重算每票订单的尾程应付,再把当时的费用抄进草稿账单。
*
* @param command 供应商,以及按订单号、计费日或提单号划定的范围
* @return 刚生成的草稿账单
*/
@PreAuthorize("@ss.hasPermi('price:payBill:add')")
@PostMapping
public AjaxResult generate(@RequestBody PayBillCommand command)
{
return success(payBillService.generate(command));
}
/**
* 确认草稿账单。确认后才可以登记付款。
*
* @param billId 应付账单ID
*/
@PreAuthorize("@ss.hasPermi('price:payBill:confirm')")
@PutMapping("/confirm/{billId}")
public AjaxResult confirm(@PathVariable Long billId)
{
payBillService.confirm(billId);
return success();
}
/**
* 把已确认的账单登记为已付款。已付款的账单不能直接作废。
*
* @param billId 应付账单ID
*/
@PreAuthorize("@ss.hasPermi('price:payBill:confirm')")
@PutMapping("/paid/{billId}")
public AjaxResult paid(@PathVariable Long billId)
{
payBillService.markPaid(billId);
return success();
}
/**
* 作废应付账单。账单和费用行保留,只改状态并记下作废原因。
*
* @param command 账单ID和作废原因
*/
@PreAuthorize("@ss.hasPermi('price:payBill:void')")
@PutMapping("/void")
public AjaxResult voidBill(@RequestBody PayBillCommand command)
{
payBillService.voidBill(command);
return success();
}
}
@@ -1,249 +0,0 @@
package com.bms.web.controller.price;
import java.util.List;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.bms.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.file.FileUtils;
import com.bms.common.utils.poi.ExcelUtil;
import com.bms.price.domain.ChannelProductMap;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PayProductTerm;
import com.bms.price.domain.PaySaveCommand;
import com.bms.price.pay.PayDetailExporter;
import com.bms.price.service.IPayOrderCostService;
import com.bms.price.service.IPayProductService;
import jakarta.servlet.http.HttpServletResponse;
/**
* 价格基础下的应付配置。导入先预览,确认后才入库。
*/
@RestController
@RequestMapping("/price/pay")
public class PayProductController extends BaseController
{
@Autowired
private IPayProductService payProductService;
@Autowired
private IPayOrderCostService payOrderCostService;
@PreAuthorize("@ss.hasPermi('price:pay:list')")
@GetMapping("/list")
public TableDataInfo list(PayProduct query)
{
startPage();
List<PayProduct> list = payProductService.selectPayProductList(query);
return getDataTable(list);
}
/** 一个产品的价格版本和有效期 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:query,price:pay:edit')")
@GetMapping("/term/list")
public AjaxResult termList(@RequestParam Long productId)
{
return success(payProductService.listTerms(productId));
}
@PreAuthorize("@ss.hasPermi('price:pay:edit')")
@Log(title = "应付有效期", businessType = BusinessType.UPDATE)
@PutMapping("/term")
public AjaxResult updateTerm(@RequestBody PayProductTerm term)
{
return toAjax(payProductService.updateTerm(term));
}
@PreAuthorize("@ss.hasPermi('price:pay:edit')")
@Log(title = "应付有效期", businessType = BusinessType.UPDATE)
@PutMapping("/term/status")
public AjaxResult changeTermStatus(@RequestBody PayProductTerm term)
{
return toAjax(payProductService.changeTermStatus(term));
}
/** 按订单的渠道服务商和应付产品生成应付账单 */
@PreAuthorize("@ss.hasAnyPermi('price:billing:bill,price:pay:edit')")
@Log(title = "应付账单", businessType = BusinessType.INSERT)
@PostMapping("/order/{billId}")
public AjaxResult generateOrderCost(@PathVariable String billId)
{
return success(payOrderCostService.generate(billId));
}
/** 下载某一版导入时上传的源文件 */
@PreAuthorize("@ss.hasPermi('price:pay:query')")
@PostMapping("/{productId}/source")
public void downloadSource(@PathVariable Long productId, HttpServletResponse response) throws Exception
{
String filename = payProductService.sourceFileName(productId);
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
FileUtils.setAttachmentResponseHeader(response, filename);
payProductService.writeSource(productId, response.getOutputStream());
}
@PreAuthorize("@ss.hasPermi('price:pay:query')")
@GetMapping("/{productId}")
public AjaxResult getInfo(@PathVariable Long productId)
{
return success(payProductService.selectPayProductDetail(productId));
}
/** 下拉:已导入的公共模板,供渠道页选择。payType 为空时按应付 AP */
@PreAuthorize("@ss.hasAnyPermi('price:pay:query,data:serviceChannel:list,data:serviceChannel:edit')")
@GetMapping("/options")
public AjaxResult options(@RequestParam(value = "payType", required = false) String payType)
{
return success(payProductService.selectTemplateOptions(payType));
}
/** 渠道当前价格副本 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:query,data:serviceChannel:query,data:serviceChannel:edit')")
@GetMapping("/channel/{channelId}")
public AjaxResult channelPrice(@PathVariable String channelId)
{
return success(payProductService.selectChannelPrice(channelId));
}
/** 渠道只绑定产品编号,以后按有效期取价 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:edit,data:serviceChannel:edit')")
@Log(title = "渠道应付产品", businessType = BusinessType.UPDATE)
@PutMapping("/channel/{channelId}/bind")
public AjaxResult bindChannel(@PathVariable String channelId, @RequestBody ChannelProductMap link)
{
String payType = link == null ? null : link.getPayType();
if (link == null || link.getProductId() == null)
{
payProductService.unbindChannel(channelId, payType);
return success();
}
payProductService.bindChannel(channelId, link.getProductId(), link.getPriceSetId(), payType);
return success(payProductService.selectChannelPrice(channelId));
}
/** 对照两个价格版本 */
@PreAuthorize("@ss.hasPermi('price:pay:query')")
@GetMapping("/compare")
public AjaxResult compare(@RequestParam Long leftId, @RequestParam Long rightId)
{
return success(payProductService.compare(leftId, rightId));
}
/** 给已绑定的产品追加一版,产品编号不变 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:import,data:serviceChannel:edit')")
@Log(title = "渠道应付价格", businessType = BusinessType.INSERT)
@PostMapping("/channel/{channelId}")
public AjaxResult saveChannel(@PathVariable String channelId, @RequestBody PayProduct product)
{
return success(payProductService.saveChannelVersion(channelId, product));
}
/** 重新导入渠道价格,生成新版本,不改模板 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:import,data:serviceChannel:edit')")
@Log(title = "渠道应付价格", businessType = BusinessType.IMPORT)
@PostMapping(value = "/channel/{channelId}/reimport", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public AjaxResult reimportChannel(@PathVariable String channelId, MultipartFile file) throws Exception
{
assertWorkbook(file);
return success(payProductService.reimportChannel(channelId, file.getBytes(), file.getOriginalFilename()));
}
/** 按字典匹配工作表。dictValue 为空时读取文件里所有能对上的表 */
@PreAuthorize("@ss.hasPermi('price:pay:import')")
@Log(title = "应付配置", businessType = BusinessType.IMPORT)
@PostMapping(value = "/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public AjaxResult preview(MultipartFile file, @RequestParam(value = "dictValue", required = false) String dictValue,
@RequestParam(value = "progressId", required = false) String progressId) throws Exception
{
assertWorkbook(file);
return success(payProductService.preview(file.getBytes(), file.getOriginalFilename(), dictValue, progressId));
}
/** 导入弹框轮询解析进度。没有进度编号时返回等待中。 */
@PreAuthorize("@ss.hasPermi('price:pay:import')")
@GetMapping("/preview/progress")
public AjaxResult previewProgress(@RequestParam(value = "progressId", required = false) String progressId)
{
return success(payProductService.parseProgress(progressId));
}
/** 修改已导入的公共模板 */
@PreAuthorize("@ss.hasPermi('price:pay:import')")
@Log(title = "应付配置", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody PayProduct product)
{
return toAjax(payProductService.updateDetail(product));
}
/** 保存预览中勾选的产品 */
@PreAuthorize("@ss.hasPermi('price:pay:import')")
@Log(title = "应付配置", businessType = BusinessType.INSERT)
@PostMapping("/save")
public AjaxResult save(@RequestBody PaySaveCommand command)
{
return success(payProductService.save(command));
}
@PreAuthorize("@ss.hasPermi('price:pay:remove')")
@Log(title = "应付配置", businessType = BusinessType.DELETE)
@DeleteMapping("/{productIds}")
public AjaxResult remove(@PathVariable Long[] productIds)
{
return toAjax(payProductService.deletePayProducts(productIds));
}
@PreAuthorize("@ss.hasPermi('price:pay:export')")
@Log(title = "应付配置", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, PayProduct query)
{
List<PayProduct> list = payProductService.selectPayProductList(query);
new ExcelUtil<>(PayProduct.class).exportExcel(response, list, "应付产品");
}
/** 按当前搜索条件导出产品、重量段、附加费和其他配置 */
@PreAuthorize("@ss.hasPermi('price:pay:export')")
@Log(title = "应付配置明细", businessType = BusinessType.EXPORT)
@PostMapping("/exportDetail")
public void exportDetail(HttpServletResponse response, PayProduct query) throws Exception
{
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
PayDetailExporter.write(response.getOutputStream(), payProductService.selectPayProductDetails(query));
}
private void assertWorkbook(MultipartFile file)
{
if (file == null || file.isEmpty())
{
throw new ServiceException("请选择价卡文件");
}
String filename = file.getOriginalFilename();
if (filename == null || !filename.toLowerCase(Locale.ROOT).endsWith(".xlsx"))
{
throw new ServiceException("请上传xlsx价卡");
}
if (file.getSize() > 30L * 1024 * 1024)
{
throw new ServiceException("价卡文件不能超过30MB");
}
}
}
@@ -55,8 +55,6 @@ public class PriceSetController extends BaseController
@GetMapping("/list")
public TableDataInfo list(PrcPriceSet set)
{
// Ordinary package module: never mix exclusive quotes into this list.
set.setSetCategory(PrcPriceSet.CATEGORY_PACKAGE);
startPage();
List<PrcPriceSet> list = priceSetService.selectPrcPriceSetList(set);
return getDataTable(list);
@@ -66,7 +64,6 @@ public class PriceSetController extends BaseController
@GetMapping("/{priceSetId:[0-9]+}")
public AjaxResult getInfo(@PathVariable Long priceSetId)
{
priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_PACKAGE);
return success(priceSetService.selectPrcPriceSetById(priceSetId));
}
@@ -75,11 +72,6 @@ public class PriceSetController extends BaseController
@PostMapping
public AjaxResult add(@Validated @RequestBody PrcPriceSet set)
{
if (set.getPriceSetId() != null)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_PACKAGE);
}
set.setSetCategory(PrcPriceSet.CATEGORY_PACKAGE);
set.setCreateBy(getUsername());
set.setUpdateBy(getUsername());
String taskId = priceSetService.submitSaveAsync(set);
@@ -106,8 +98,6 @@ public class PriceSetController extends BaseController
@PutMapping
public AjaxResult edit(@Validated @RequestBody PrcPriceSet set)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_PACKAGE);
set.setSetCategory(PrcPriceSet.CATEGORY_PACKAGE);
set.setUpdateBy(getUsername());
return toAjax(priceSetService.updatePrcPriceSet(set));
}
@@ -118,7 +108,6 @@ public class PriceSetController extends BaseController
@PutMapping("/basic")
public AjaxResult editBasic(@RequestBody PrcPriceSet set)
{
priceSetService.assertSetCategory(set.getPriceSetId(), PrcPriceSet.CATEGORY_PACKAGE);
set.setUpdateBy(getUsername());
return toAjax(priceSetService.updateBasicInfo(set));
}
@@ -1,26 +1,18 @@
package com.bms.web.controller.price;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
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 com.bms.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.utils.StringUtils;
import com.bms.price.airbill.domain.SrvBillAirFeeLog;
import com.bms.price.airbill.domain.SrvBillAirFeeSave;
import com.bms.price.domain.vo.SrvBillAirQuery;
import com.bms.price.domain.vo.SrvBillAirVo;
import com.bms.price.service.ISrvBillAirFeeService;
import com.bms.price.service.ISrvBillAirService;
/**
@@ -33,9 +25,6 @@ public class SrvBillAirController extends BaseController
@Autowired
private ISrvBillAirService srvBillAirService;
@Autowired
private ISrvBillAirFeeService srvBillAirFeeService;
/**
* 提单列表
*/
@@ -66,32 +55,4 @@ public class SrvBillAirController extends BaseController
}
return success(vo);
}
/** 一票提单下未删除的费用 */
@PreAuthorize("@ss.hasPermi('price:airBill:fee')")
@GetMapping("/fee/list")
public AjaxResult feeList(@RequestParam("billCode") String billCode)
{
return success(srvBillAirFeeService.listFees(billCode));
}
/** 提单费用的新增、修改、删除日志,按页返回 */
@PreAuthorize("@ss.hasPermi('price:airBill:fee')")
@GetMapping("/fee/log")
public TableDataInfo feeLog(@RequestParam("billCode") String billCode)
{
List<SrvBillAirFeeLog> list = srvBillAirFeeService.listLogs(billCode);
TableDataInfo table = getDataTable(list);
table.setRows(new ArrayList<>(list));
return table;
}
/** 只保存本次新增、修改、删除的费用,并写入提单日志 */
@PreAuthorize("@ss.hasPermi('price:airBill:fee')")
@Log(title = "提单费用", businessType = BusinessType.UPDATE)
@PostMapping("/fee/save")
public AjaxResult saveFee(@RequestBody SrvBillAirFeeSave command)
{
return success(srvBillAirFeeService.save(command));
}
}
@@ -1,135 +0,0 @@
package com.bms.web.controller.report;
import org.springframework.security.access.prepost.PreAuthorize;
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.RestController;
import com.bms.ar.profit.ArCommissionRate;
import com.bms.ar.profit.ArCommissionService;
import com.bms.ar.profit.ArProfitQuery;
import com.bms.ar.profit.ArProfitService;
import com.bms.ar.profit.ArWriteoffRate;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PathVariable;
import jakarta.servlet.http.HttpServletResponse;
import com.bms.ar.profit.ArProfitRow;
import com.bms.ar.profit.ArCommissionResult;
import com.bms.common.utils.poi.ExcelUtil;
import com.bms.price.enums.ProfitAreaRule;
import com.bms.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
@RestController
@RequestMapping("/report/ar-profit")
public class ArProfitController extends BaseController
{
private final ArProfitService report;
private final ArCommissionService commissions;
public ArProfitController(ArProfitService report, ArCommissionService commissions)
{
this.report = report;
this.commissions = commissions;
}
@PreAuthorize("@ss.hasPermi('report:arProfit:list')")
@GetMapping("/summary")
public AjaxResult summary(ArProfitQuery query) { return success(report.selectSummary(query)); }
@PreAuthorize("@ss.hasPermi('report:arProfit:list')")
@GetMapping("/sales-options")
public AjaxResult salesOptions(@RequestParam(required = false) String keyword) { return success(report.selectSalesOptions(keyword)); }
@PreAuthorize("@ss.hasPermi('report:arProfit:list')")
@GetMapping("/area-rules")
public AjaxResult areaRules() { return success(ProfitAreaRule.areaRules()); }
@PreAuthorize("@ss.hasPermi('report:arProfit:list')")
@GetMapping("/orders/{detailId}/fees")
public AjaxResult fees(@PathVariable Long detailId) { return success(report.selectFees(detailId)); }
@PreAuthorize("@ss.hasPermi('report:arProfit:list')")
@GetMapping("/bills/{billId}/details")
public TableDataInfo billDetails(@PathVariable Long billId)
{
startPage();
return getDataTable(report.selectBillDetails(billId));
}
@PreAuthorize("@ss.hasPermi('report:arProfit:list')")
@GetMapping("/bills")
public TableDataInfo bills(ArProfitQuery query)
{
startPage();
return getDataTable(report.selectBills(query));
}
@PreAuthorize("@ss.hasPermi('report:arProfit:commission')")
@GetMapping("/rates")
public AjaxResult rates() { return success(commissions.selectRates()); }
@Log(title = "账单毛利提成比例", businessType = BusinessType.INSERT)
@PreAuthorize("@ss.hasPermi('report:arProfit:commission')")
@PostMapping("/rates")
public AjaxResult addRate(@RequestBody ArCommissionRate rate)
{
commissions.addRate(rate);
return success();
}
@PreAuthorize("@ss.hasPermi('report:arProfit:commission')")
@GetMapping("/writeoffs/missing-rates")
public TableDataInfo missingRates()
{
startPage();
return getDataTable(commissions.missingRates());
}
@Log(title = "历史核销提成比例确认", businessType = BusinessType.UPDATE)
@PreAuthorize("@ss.hasPermi('report:arProfit:commission')")
@PostMapping("/writeoffs/confirm-rate")
public AjaxResult confirmRate(@RequestBody ArWriteoffRate input)
{
commissions.confirmMissingRate(input);
return success();
}
@PreAuthorize("@ss.hasPermi('report:arProfit:commission')")
@GetMapping("/commissions")
public TableDataInfo commissions(@RequestParam(required = false) Long billId,
@RequestParam(defaultValue = "false") boolean includeReversed)
{
startPage();
return getDataTable(commissions.results(billId, includeReversed));
}
@PreAuthorize("@ss.hasPermi('report:arProfit:commission')")
@GetMapping("/commissions/summary")
public AjaxResult commissionSummary(@RequestParam(required = false) Long billId) { return success(commissions.summary(billId)); }
@PreAuthorize("@ss.hasPermi('report:arProfit:commission')")
@GetMapping("/commissions/{billId}/writeoffs")
public AjaxResult commissionWriteoffs(@PathVariable Long billId) { return success(commissions.writeoffRates(billId)); }
@Log(title = "应收账单提成明细", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('report:arProfit:commission')")
@PostMapping("/commissions/export")
public void exportCommissions(HttpServletResponse response, @RequestParam(required = false) Long billId)
{
new ExcelUtil<>(ArCommissionResult.class).exportExcel(response, commissions.results(billId, false), "应收账单提成明细");
}
@Log(title = "应收账单毛利分析", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('report:arProfit:export')")
@PostMapping("/export")
public void export(HttpServletResponse response, ArProfitQuery query)
{
ExcelUtil<ArProfitRow> excel = new ExcelUtil<>(ArProfitRow.class);
excel.exportExcel(response, report.selectBills(query), "应收账单毛利分析");
}
}
@@ -16,7 +16,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.bms.common.annotation.Log;
import com.bms.common.config.BmsConfig;
@@ -32,7 +31,6 @@ import com.bms.common.utils.uuid.IdUtils;
import com.bms.common.utils.poi.ExcelUtil;
// 提成功能暂停使用:import com.bms.price.domain.PrcProfitCommissionRate;
import com.bms.price.domain.PrcProfitReportExport;
import com.bms.price.enums.ProfitAreaRule;
import com.bms.price.domain.vo.PrcProfitReportOrderRow;
import com.bms.price.domain.vo.PrcProfitReportProductRow;
import com.bms.price.domain.vo.PrcProfitReportQuery;
@@ -50,23 +48,6 @@ public class PrcProfitReportController extends BaseController
@Autowired
private OssStorage ossStorage;
/** 客户筛选选项,沿用毛利报表的访问权限和查询范围。 */
@PreAuthorize("@ss.hasPermi('report:profit:list')")
@GetMapping("/customer-options")
public TableDataInfo customerOptions(@RequestParam(required = false) String keywords)
{
startPage();
return getDataTable(profitReportService.selectCustomerOptions(keywords));
}
/** 客户区域字典值与毛利规则的映射。 */
@PreAuthorize("@ss.hasPermi('report:profit:list')")
@GetMapping("/area-rules")
public AjaxResult areaRules()
{
return success(profitReportService.selectAreaRules());
}
@PreAuthorize("@ss.hasPermi('report:profit:list')")
@GetMapping("/summary")
public AjaxResult summary(PrcProfitReportQuery query)
@@ -127,7 +108,7 @@ public class PrcProfitReportController extends BaseController
try
{
List<PrcProfitReportOrderRow> rows = profitReportService.selectOrderRows(query);
return finishExport(export, rows, PrcProfitReportOrderRow.class, "订单毛利报表", query.getProfitRule());
return finishExport(export, rows, PrcProfitReportOrderRow.class, "订单毛利报表");
}
catch (RuntimeException e)
{
@@ -145,7 +126,7 @@ public class PrcProfitReportController extends BaseController
try
{
List<PrcProfitReportProductRow> rows = profitReportService.selectProductRows(query);
return finishExport(export, rows, PrcProfitReportProductRow.class, "产品毛利报表", query.getProfitRule());
return finishExport(export, rows, PrcProfitReportProductRow.class, "产品毛利报表");
}
catch (RuntimeException e)
{
@@ -209,8 +190,7 @@ public class PrcProfitReportController extends BaseController
return export;
}
private <T> AjaxResult finishExport(PrcProfitReportExport export, List<T> rows, Class<T> rowType, String sheetName,
String profitRule)
private <T> AjaxResult finishExport(PrcProfitReportExport export, List<T> rows, Class<T> rowType, String sheetName)
{
Path temp = null;
try
@@ -218,12 +198,7 @@ public class PrcProfitReportController extends BaseController
temp = Files.createTempFile("profit-report-", ".xlsx");
try (OutputStream output = Files.newOutputStream(temp))
{
ExcelUtil<T> excelUtil = new ExcelUtil<>(rowType);
if (ProfitAreaRule.GUANGZHOU.name().equals(profitRule))
{
excelUtil.hideColumn("costAmount", "redlineGrossProfit", "redlineGrossMarginRate");
}
excelUtil.exportExcelToStream(rows, sheetName, output);
new ExcelUtil<T>(rowType).exportExcelToStream(rows, sheetName, output);
}
String objectKey = "report/profit/" + DateUtils.datePath() + "/" + IdUtils.fastSimpleUUID() + ".xlsx";
export.setStorageKey(ossStorage.upload(temp, objectKey,
@@ -1,80 +0,0 @@
package com.bms.web.controller.system;
import com.bms.common.annotation.Log;
import com.bms.common.constant.Constants;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.domain.entity.SysUser;
import com.bms.common.core.domain.model.LoginUser;
import com.bms.common.enums.BusinessType;
import com.bms.common.enums.UserStatus;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.SecurityUtils;
import com.bms.framework.web.service.TokenService;
import com.bms.framework.web.service.UserDetailsServiceImpl;
import com.bms.system.service.ISysUserService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 管理员以目标用户身份排查问题。 */
@RestController
@RequestMapping("/system/user/impersonation")
public class SysImpersonationController {
private static final long MAX_DURATION_MILLIS = 30 * 60 * 1000L;
private final ISysUserService userService;
private final UserDetailsServiceImpl userDetailsService;
private final TokenService tokenService;
public SysImpersonationController(ISysUserService userService, UserDetailsServiceImpl userDetailsService,
TokenService tokenService) {
this.userService = userService;
this.userDetailsService = userDetailsService;
this.tokenService = tokenService;
}
@PreAuthorize("@ss.hasPermi('system:user:impersonate')")
@Log(title = "用户代登录", businessType = BusinessType.GRANT, isSaveResponseData = false)
@PostMapping("/{userId}")
public AjaxResult start(@PathVariable Long userId) {
LoginUser operator = SecurityUtils.getLoginUser();
// 项目中超级管理员是 user_id=1;代登录不能嵌套,也不能用于进入管理员账号。
if (!SecurityUtils.isAdmin() || operator.isImpersonating()) {
throw new ServiceException("仅超级管理员可以代登录");
}
if (userId == null || SecurityUtils.isAdmin(userId) || userId.equals(operator.getUserId())) {
throw new ServiceException("不能代登录管理员账号或当前账号");
}
userService.checkUserDataScope(userId);
SysUser target = userService.selectUserById(userId);
if (target == null || !UserStatus.OK.getCode().equals(target.getDelFlag())
|| !UserStatus.OK.getCode().equals(target.getStatus())) {
throw new ServiceException("目标用户不存在、已删除或已停用");
}
if (target.getRoles() != null && target.getRoles().stream().anyMatch(role -> role != null
&& (role.isAdmin() || Constants.SUPER_ADMIN.equals(role.getRoleKey())))) {
throw new ServiceException("不能代登录管理员角色账号");
}
LoginUser actingUser = (LoginUser) userDetailsService.createLoginUser(target);
actingUser.setImpersonatorId(operator.getUserId());
actingUser.setImpersonatorName(operator.getUsername());
actingUser.setImpersonatorToken(operator.getToken());
actingUser.setImpersonationExpireTime(System.currentTimeMillis() + MAX_DURATION_MILLIS);
AjaxResult result = AjaxResult.success();
result.put(Constants.TOKEN, tokenService.createToken(actingUser));
return result;
}
@Log(title = "退出用户代登录", businessType = BusinessType.OTHER, isSaveResponseData = false)
@PostMapping("/exit")
public AjaxResult exit() {
LoginUser user = SecurityUtils.getLoginUser();
if (!user.isImpersonating()) {
throw new ServiceException("当前不是代登录会话");
}
tokenService.delLoginUser(user.getToken());
return AjaxResult.success();
}
}
@@ -87,7 +87,6 @@ public class SysLoginController
ajax.put("user", user);
ajax.put("roles", roles);
ajax.put("permissions", permissions);
ajax.put("impersonating", loginUser.isImpersonating());
ajax.put("pwdChrtype", getSysAccountChrtype());
ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate()));
ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate()));
@@ -38,8 +38,8 @@ spring:
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量(local rebill 16 workers + UI/MQ headroom)
maxActive: 40
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
@@ -51,8 +51,8 @@ spring:
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量(local rebill 16 workers + UI/MQ headroom)
maxActive: 40
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
@@ -40,8 +40,6 @@ bms:
# 同步已直接复用订单行(跳过每单 Oracle 公网查询),瓶颈在 MySQL 计费写入,
# 同客户行锁冲突由 billWithDbRetry 3 次重试兜底,16 路实测吞吐最高。
order-source-sync-threads: 16
# Order-list local rebill workers (2~24). Pair with Druid maxActive >= threads + 8.
local-rebill-threads: 16
# Night auto-rebill: hours 0-8, pull 500 (all statuses) -> bill -> next 500 until empty / 09:00.
# Empty page => no billing.
night-retry:
@@ -224,11 +224,6 @@ price.bill.billingFail.weight=计费失败:销售价未匹配重量段
price.bill.billingFail.catalogProduct=计费失败:产品价未匹配产品
price.bill.billingFail.catalogCountry=计费失败:产品价未匹配国家
price.bill.billingFail.catalogWeight=计费失败:产品价未匹配重量段
price.bill.billingFail.packageNotEnabled=计费失败:套餐未启用
price.bill.billingFail.noActivePackageVersion=计费失败:计费日无生效套餐版本
price.bill.billingFail.noCustomerPackage=计费失败:客户未绑定套餐
price.bill.packageNotEnabled=客户已绑定套餐但套餐未启用,无法计费
price.quote.packageNotEnabled=客户已绑定套餐但套餐未启用,无法试算
price.bill.export.filterRequired=请先设置列表查询条件后再导出
price.bill.export.taskNotExists=导出任务不存在或已过期
price.bill.export.tooMany=匹配订单超过上限 {0} 条,请缩小筛选范围
@@ -237,6 +232,3 @@ price.bill.export.notReady=导出文件尚未生成或已失败
price.bill.export.fileMissing=导出文件不存在或路径非法
price.set.customer.batch.empty=请选择要绑定的客户
price.set.customer.batch.done=批量绑定完成:共 {0} 个客户,新绑定 {1} 个,更换 {2} 个,跳过 {3} 个
price.bill.recalc.taskCancelled=本地重新计费已取消
price.bill.recalc.taskCancelNotRunning=任务已结束或不存在,无法取消
price.bill.recalc.taskIdRequired=任务ID不能为空
@@ -1,357 +0,0 @@
package com.bms.price.pay;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import com.bms.price.domain.PayOrderCostResult;
import com.bms.price.domain.PayPostcodeGroup;
import com.bms.price.domain.PayPostcodeLine;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PaySurcharge;
import com.bms.price.domain.PayWeightBand;
class PayCostCalculatorTest
{
@Test
void royalMailChargesTicketPriceAndMatchingSurcharges()
{
PayOrderCostResult result = PayCostCalculator.calculate(royalMail(), new BigDecimal("4"),
new BigDecimal("50"), new BigDecimal("40"), new BigDecimal("30"), "IV1 1AA", null, 1);
assertEquals(new BigDecimal("3.42"), result.getPayTotal());
assertEquals("GBP", result.getCurrency());
assertTrue(hasFee(result, "基础运费", "1.62"));
assertTrue(hasFee(result, "偏远地区附加费", "1.80"));
assertTrue(result.getFees().stream().noneMatch(fee -> fee.getFeeName().contains("超大")));
assertTrue(result.getSkipped().stream().anyMatch(text -> text.contains("签名费")));
}
@Test
void spainCttAddsPerKgAfterTheLastWeightBand()
{
PayProduct product = new PayProduct();
product.setProductName("西班牙CTT");
product.setCurrency("EUR");
product.setWeightBands(new ArrayList<>(List.of(
band("0", "1", "2.10", "Mainland / Mad High Density"),
band("4", "5", "3.18", "Mainland / Mad High Density"))));
PaySurcharge extra = fee(31L, "每公斤续重", "0.27", "EUR", "EXTRA_KG", "5");
extra.setZoneName("Mainland / Mad High Density");
extra.setPriceUnit("EUR/KG");
product.setSurcharges(List.of(extra));
PayOrderCostResult inside = PayCostCalculator.calculate(product, new BigDecimal("5"),
null, null, null, null, "Mainland / Mad High Density", null, 1);
assertTrue(hasFee(inside, "基础运费", "3.18"));
assertTrue(inside.getFees().stream().noneMatch(fee -> "每公斤续重".equals(fee.getFeeName())));
PayOrderCostResult over = PayCostCalculator.calculate(product, new BigDecimal("7"),
null, null, null, null, "Mainland / Mad High Density", null, 1);
assertTrue(hasFee(over, "基础运费", "3.18"));
assertTrue(hasFee(over, "每公斤续重", "0.54"));
assertEquals(new BigDecimal("3.72"), over.getPayTotal());
}
@Test
void extraKgFollowsTheSameServiceSection()
{
PayProduct product = new PayProduct();
product.setProductName("岛屿");
product.setCurrency("EUR");
PayWeightBand standard = band("0", "1", "13.12", "Canary Islands / Minor Islands");
standard.setSectionName("2. INSULAR RATES / STANDARD");
standard.setWeightLabel("Up to 1 kg");
PayWeightBand express = band("0", "1", "14.10", "Canary Islands / Minor Islands");
express.setSectionName("2. INSULAR RATES / EXPRESS");
express.setWeightLabel("Up to 1 kg");
product.setWeightBands(new ArrayList<>(List.of(standard, express)));
PaySurcharge standardKg = fee(41L, "每公斤续重", "1.86", "EUR", "EXTRA_KG", "1");
standardKg.setZoneName("Canary Islands / Minor Islands");
standardKg.setSectionName("2. INSULAR RATES / STANDARD");
standardKg.setPriceUnit("EUR/KG");
PaySurcharge expressKg = fee(42L, "每公斤续重", "4.72", "EUR", "EXTRA_KG", "1");
expressKg.setZoneName("Canary Islands / Minor Islands");
expressKg.setSectionName("2. INSULAR RATES / EXPRESS");
expressKg.setPriceUnit("EUR/KG");
product.setSurcharges(List.of(standardKg, expressKg));
PayOrderCostResult over = PayCostCalculator.calculate(product, new BigDecimal("2"),
null, null, null, null, "Canary Islands / Minor Islands", null, 1);
assertTrue(hasFee(over, "基础运费", "13.12"));
assertTrue(hasFee(over, "每公斤续重", "1.86"));
assertEquals(1, over.getFees().stream().filter(fee -> "每公斤续重".equals(fee.getFeeName())).count());
assertEquals(new BigDecimal("14.98"), over.getPayTotal());
}
@Test
void overweightOutsideTheCardDoesNotInventFreight()
{
PayOrderCostResult result = PayCostCalculator.calculate(royalMail(), new BigDecimal("21"),
null, null, null, "SW1A 1AA", null, 1);
assertTrue(result.getFees().isEmpty());
assertTrue(result.getMessage().contains("超出"));
}
@Test
void oversizeUsesTheHigherPercentWhenBothTiersMatch()
{
PayOrderCostResult result = PayCostCalculator.calculate(spainOneZone(), new BigDecimal("1"),
new BigDecimal("130"), new BigDecimal("50"), new BigDecimal("40"), "28001", null, 1);
assertTrue(hasFee(result, "基础运费", "2.27"));
assertTrue(hasFee(result, "特大包裹收费", "1.59"));
assertEquals(1, result.getFees().stream().filter(fee -> "特大包裹收费".equals(fee.getFeeName())).count());
}
@Test
void postcodeOutsideTheRemoteListUsesMainland()
{
PayProduct product = evri();
PayOrderCostResult result = PayCostCalculator.calculate(product, new BigDecimal("0.522"),
new BigDecimal("30.1"), new BigDecimal("24"), new BigDecimal("15.1"), "LL22 8PJ",
"UK Mainland\n英国大陆", null, 1);
assertEquals(new BigDecimal("1.57"), result.getPayTotal());
assertTrue(hasFee(result, "基础运费", "1.57"));
}
@Test
void remoteZoneUsesMainlandFreightAndItsOwnSurcharge()
{
PayProduct product = new PayProduct();
product.setProductName("Evri 48H");
product.setCurrency("GBP");
product.setWeightBands(new ArrayList<>(List.of(
band("0", "1", "1.51", "UK Mainland"),
band("1", "2", "1.72", "UK Mainland"))));
PaySurcharge light = fee(1L, "偏远附加费", "5.95", "GBP", "ZONE", null);
light.setZoneName("Highlands& Islands");
light.setWeightMin(BigDecimal.ZERO);
light.setWeightMax(BigDecimal.ONE);
PaySurcharge heavy = fee(2L, "偏远附加费", "6.20", "GBP", "ZONE", null);
heavy.setZoneName("Highlands& Islands");
heavy.setWeightMin(BigDecimal.ONE);
heavy.setWeightMax(new BigDecimal("2"));
product.setSurcharges(List.of(light, heavy));
PayOrderCostResult remote = PayCostCalculator.calculate(product, new BigDecimal("0.5"),
null, null, null, "IV1 1AA", "Highlands& Islands", null, 1);
assertTrue(hasFee(remote, "基础运费", "1.51"));
assertTrue(hasFee(remote, "偏远附加费", "5.95"));
assertEquals(1, remote.getFees().stream().filter(fee -> "偏远附加费".equals(fee.getFeeName())).count());
PayOrderCostResult mainland = PayCostCalculator.calculate(product, new BigDecimal("0.5"),
null, null, null, "LL22 8PJ", "UK Mainland", null, 1);
assertTrue(hasFee(mainland, "基础运费", "1.51"));
assertTrue(mainland.getFees().stream().noneMatch(fee -> "偏远附加费".equals(fee.getFeeName())));
}
@Test
void spanishPrefixChargesFirstWeightThenExtraKg()
{
PayProduct product = new PayProduct();
product.setProductName("CTT 48H");
product.setCurrency("EUR");
product.setWeightBands(new ArrayList<>(List.of(band("0", "5", "2.05", "马德里"))));
PaySurcharge first = fee(1L, "偏远附加费", "7.1", "EUR", "POSTCODE", "07");
first.setZoneName("Balearic Islands");
first.setPostcodeParam("07");
first.setWeightMin(BigDecimal.ZERO);
first.setWeightMax(BigDecimal.ONE);
PaySurcharge extra = fee(2L, "偏远附加费", "4", "EUR", "EXTRA_KG", "1");
extra.setZoneName("Balearic Islands");
extra.setPostcodeParam("07");
extra.setPriceUnit("EUR/KG");
product.setSurcharges(List.of(first, extra));
PayOrderCostResult light = PayCostCalculator.calculate(product, new BigDecimal("0.8"),
null, null, null, "07008", "马德里", null, 1);
assertTrue(hasFee(light, "基础运费", "2.05"));
assertTrue(hasFee(light, "偏远附加费", "7.1"));
assertEquals(1, light.getFees().stream().filter(fee -> "偏远附加费".equals(fee.getFeeName())).count());
PayOrderCostResult heavy = PayCostCalculator.calculate(product, new BigDecimal("3"),
null, null, null, "07008", "马德里", null, 1);
assertTrue(hasFee(heavy, "基础运费", "2.05"));
assertTrue(hasFee(heavy, "偏远附加费", "7.1"));
assertTrue(hasFee(heavy, "偏远附加费", "8"));
PayOrderCostResult mainland = PayCostCalculator.calculate(product, new BigDecimal("3"),
null, null, null, "28001", "马德里", null, 1);
assertTrue(mainland.getFees().stream().noneMatch(fee -> "偏远附加费".equals(fee.getFeeName())));
assertTrue(PaySurchargeRules.matchesPostcode("07,35,38,51,52,9", "35001"));
assertTrue(PaySurchargeRules.matchesPostcode("07,35,38,51,52,9", "9000"));
assertFalse(PaySurchargeRules.matchesPostcode("07,35,38,51,52,9", "28001"));
assertTrue(PaySurchargeRules.matchesPostcode("IV,HS,KA27-28", "KA27 8AA"));
assertEquals("07", PaySurchargeRules.remotePrefixes("Balearic Islands 07邮编开头"));
assertEquals("35,38", PaySurchargeRules.remotePrefixes("Canary Islands 35、38邮编开头"));
assertEquals("9", PaySurchargeRules.remotePrefixes("Portuguese Island 9邮编开头"));
assertEquals("", PaySurchargeRules.remotePrefixes("岛屿邮编地区"));
}
@Test
void productPostcodeBookChoosesTheMatchingRemoteFee()
{
List<PayRemoteBook.Hit> hits = List.of(new PayRemoteBook.Hit("偏远附加费", new BigDecimal("1.80"), "", "Highlands"));
PayOrderCostResult result = PayCostCalculator.calculate(royalMail(), new BigDecimal("4"),
null, null, null, "IV1 1AA", null, null, 1, hits);
assertTrue(hasFee(result, "偏远地区附加费", "1.80"));
assertTrue(result.getFees().stream().noneMatch(fee -> "偏远地区附加费".equals(fee.getFeeName())
&& fee.getAmount().compareTo(new BigDecimal("1.00")) == 0));
}
@Test
void linkedRemoteBookWithoutAHitDoesNotUseThePriceCardList()
{
PayOrderCostResult result = PayCostCalculator.calculate(royalMail(), new BigDecimal("4"),
null, null, null, "IV1 1AA", null, null, 1, List.of());
assertTrue(result.getFees().stream().noneMatch(fee -> fee.getFeeName().contains("偏远")));
assertTrue(hasFee(result, "基础运费", "1.62"));
}
@Test
void remoteBookChargesAManualIslandFeeAndPerKg()
{
PayProduct product = new PayProduct();
product.setProductName("德国DPD挂号-国际件");
product.setCurrency("EUR");
product.setWeightBands(new ArrayList<>(List.of(band("0", "5", "3.20", ""))));
PaySurcharge island = fee(31L, "岛屿附加费", "12", "EUR", "MANUAL", null);
PaySurcharge perKg = fee(32L, "偏远附加费", "0.35", "EUR", "MANUAL", null);
perKg.setPriceUnit("EUR/KG");
product.setSurcharges(List.of(island, perKg));
List<PayRemoteBook.Hit> hits = List.of(new PayRemoteBook.Hit("", null, "", "岛屿"));
PayOrderCostResult result = PayCostCalculator.calculate(product, new BigDecimal("2"),
null, null, null, "1234", null, null, 1, hits);
assertTrue(hasFee(result, "岛屿附加费", "12"));
assertTrue(hasFee(result, "偏远附加费", "0.70"));
}
@Test
void remoteHitsFollowTheProductGroupAndCountry()
{
PayPostcodeGroup germany = group(44L, "西班牙GLS", "REMOTE", "", "DE");
PayPostcodeGroup france = group(45L, "西班牙GLS", "REMOTE", "", "FR");
PayPostcodeLine berlin = line(44L, "10115");
PayPostcodeLine paris = line(45L, "75001");
List<PayRemoteBook.Hit> de = PayRemoteBook.hits(List.of(germany, france), List.of(berlin, paris), "10115", "DE", "德国");
assertEquals(1, de.size());
List<PayRemoteBook.Hit> none = PayRemoteBook.hits(List.of(germany, france), List.of(berlin, paris), "28001", "ES", "西班牙");
assertTrue(none.isEmpty());
PayPostcodeGroup delivery = group(42L, "西班牙CTT", "", "", "ES");
assertTrue(PayRemoteBook.hits(List.of(delivery), List.of(line(42L, "28001")), "28001", "ES", "西班牙") == null);
}
@Test
void severalZonesCannotBePricedFromTheOrder()
{
PayProduct product = spainOneZone();
PayWeightBand rural = band("0.01", "30", "3.12", "Rural Resto");
product.getWeightBands().add(rural);
PayOrderCostResult result = PayCostCalculator.calculate(product, new BigDecimal("1"),
null, null, null, "28001", null, 1);
assertTrue(result.getFees().isEmpty());
assertTrue(result.getMessage().contains("多个地区"));
}
private static boolean hasFee(PayOrderCostResult result, String name, String amount)
{
return result.getFees().stream().anyMatch(fee -> name.equals(fee.getFeeName())
&& fee.getAmount().compareTo(new BigDecimal(amount)) == 0);
}
private static PayProduct royalMail()
{
PayProduct product = new PayProduct();
product.setProductName("英国RM");
product.setWeightBands(List.of(
band("0", "5", "1.62", ""),
band("5.001", "10", "2.26", ""),
band("10.001", "15", "3.51", ""),
band("15.001", "20", "5.18", "")));
product.getWeightBands().forEach(band -> {
band.setChargeName("每票");
band.setPriceUnit("GBP/票");
});
product.setSurcharges(List.of(
fee(11L, "偏远地区附加费", "1.80", "GBP", "POSTCODE", "IV,HS,KA27-28"),
fee(12L, "偏远地区附加费", "1.00", "GBP", "POSTCODE", "BT,TR21-25"),
fee(13L, "签名费", "0.70", "GBP", "MANUAL", null),
fee(14L, "超大附加费(包裹)", "12.50", "GBP", "SIZE", "box:61*46*46;volume:0.129"),
fee(15L, "超重附加费(包裹)", "12.50", "GBP", "WEIGHT", "20")));
return product;
}
private static PayProduct spainOneZone()
{
PayProduct product = new PayProduct();
product.setProductName("西邮-西班牙(主岛)");
PayWeightBand band = band("0.01", "30", "2.27", "MAD-BCN Dense");
band.setChargeName("每件");
band.setPriceUnit("EUR/件");
product.setWeightBands(new ArrayList<>(List.of(band)));
product.setSurcharges(List.of(
fee(21L, "特大包裹收费", "35", "%", "SIZE", "side:120;sum:210;join:or"),
fee(22L, "特大包裹收费", "70", "%", "SIZE", "side:120;sum:210;join:and")));
product.getSurcharges().get(0).setChargeText("运费另加35%");
product.getSurcharges().get(1).setChargeText("运费另加70%");
return product;
}
private static PayProduct evri()
{
PayProduct product = new PayProduct();
product.setProductName("英国evri");
product.setCurrency("GBP");
product.setWeightBands(new ArrayList<>(List.of(
band("0", "2", "1.57", "UK Mainland\n英国大陆"),
band("0", "2", "4.78", "Highlands& Islands\n高地和岛屿"))));
product.getWeightBands().forEach(row -> row.setPriceUnit("GBP/ITEM"));
return product;
}
private static PayWeightBand band(String min, String max, String price, String zone)
{
PayWeightBand band = new PayWeightBand();
band.setWeightMin(new BigDecimal(min));
band.setWeightMax(new BigDecimal(max));
band.setWeightLabel(min + "-" + max);
band.setPrice(new BigDecimal(price));
band.setZoneName(zone);
return band;
}
private static PayPostcodeGroup group(Long id, String quoteSheet, String coverage, String feeName, String country)
{
PayPostcodeGroup group = new PayPostcodeGroup();
group.setGroupId(id);
group.setQuoteSheet(quoteSheet);
group.setCoverage(coverage);
group.setFeeName(feeName);
group.setCountryCode(country);
group.setConfigKind("ZONE");
return group;
}
private static PayPostcodeLine line(Long groupId, String postcode)
{
PayPostcodeLine line = new PayPostcodeLine();
line.setGroupId(groupId);
line.setPostcode(postcode);
return line;
}
private static PaySurcharge fee(Long id, String name, String amount, String currency, String rule, String param)
{
PaySurcharge fee = new PaySurcharge();
fee.setSurchargeId(id);
fee.setFeeName(name);
fee.setAmount(new BigDecimal(amount));
fee.setCurrency(currency);
fee.setChargeText(amount + " " + currency);
fee.setCalcRule(rule);
fee.setCalcParam(param);
return fee;
}
}
@@ -1,188 +0,0 @@
package com.bms.price.pay;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Arrays;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import com.bms.price.domain.PayPostcodeGroup;
/**
* 用 10 月价卡核对邮编星号、附加费合并和分区原文。
*/
class PayPostcodeImporterTest
{
private final PayPostcodeImporter importer = new PayPostcodeImporter();
@Test
void stripsStarAndMergesSameFee() throws Exception
{
List<PayPostcodeGroup> groups = importer.parse(Files.readAllBytes(file("32、")), "32.xlsx");
List<PayPostcodeGroup> fees = groups.stream().filter(item -> "FEE".equals(item.getConfigKind()) && "英国附加费".equals(item.getSheetName())).toList();
assertEquals(4, fees.size(), fees.stream().map(item -> item.getFeeName() + "=" + item.getFeeText() + " x" + item.getLineCount()).toList().toString());
PayPostcodeGroup remote195 = one(fees, "偏远附加费", "1.95GBP/票");
PayPostcodeGroup remote115 = one(fees, "偏远附加费", "1.15GBP/票");
PayPostcodeGroup evri = one(fees, "偏远附加费", "5.95GBP/票");
PayPostcodeGroup air = one(fees, "空气减排附加费", "0.45GBP/票");
assertEquals("Royal Mail", remote195.getChannelName());
assertEquals("Royal Mail", remote115.getChannelName());
assertEquals("EVRI", evri.getChannelName());
assertEquals("EVRI", air.getChannelName());
assertEquals(15, remote195.getLineCount());
assertTrue(remote195.getLines().stream().anyMatch(line -> "PA60-78".equals(line.getPostcode())));
assertTrue(remote195.getLines().stream().anyMatch(line -> "PA80".equals(line.getPostcode())));
assertTrue(remote195.getLines().stream().anyMatch(line -> "KA27-28".equals(line.getPostcode())));
assertEquals(6, remote115.getLineCount());
assertTrue(remote115.getLines().stream().anyMatch(line -> "AB35-38".equals(line.getPostcode())));
assertTrue(remote115.getLines().stream().anyMatch(line -> "AB53-56".equals(line.getPostcode())));
assertTrue(evri.getLineCount() > 100 && evri.getLineCount() < 500);
assertTrue(evri.getLines().stream().anyMatch(line -> "GY".equals(line.getPostcode()) && "GY*".equals(line.getPostcodeRaw())));
assertTrue(air.getLineCount() > 70000);
assertEquals("英国", air.getCountryName());
assertEquals("REMOTE", remote195.getCoverage());
assertEquals("REMOTE", remote115.getCoverage());
assertEquals("REMOTE", evri.getCoverage());
assertEquals("", air.getCoverage());
assertTrue(groups.stream().noneMatch(item -> "Evri 48H".equals(item.getSheetName())));
PayPostcodeGroup italy = groups.stream().filter(item -> "iMile邮编分区".equals(item.getSheetName()) && "IT".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("ZONE", italy.getConfigKind());
assertEquals("", italy.getCoverage());
assertEquals("IT-iMile", italy.getChannelName());
assertTrue(italy.getLines().stream().anyMatch(line -> "00010".equals(line.getPostcode()) && "province(no dense)".equals(line.getZoneName()) && "Lazio".equals(line.getRegionName()) && "ROMA".equals(line.getCityName())));
assertTrue(groups.stream().anyMatch(item -> "iMile邮编分区".equals(item.getSheetName()) && "DE".equals(item.getCountryCode()) && "DE-iMile".equals(item.getChannelName()) && item.getLines().stream().anyMatch(line -> "01001".equals(line.getPostcode()))));
PayPostcodeGroup spain = groups.stream().filter(item -> "CTT邮编分区".equals(item.getSheetName())).findFirst().orElseThrow();
assertEquals("西班牙", spain.getCountryName());
assertEquals("CTT", spain.getChannelName());
assertTrue(spain.getLines().stream().anyMatch(line -> "01110".equals(line.getPostcode()) && "Low".equals(line.getZoneName()) && "SANTIKURUTZE KANPEZU".equals(line.getCityName())));
PayPostcodeGroup france = groups.stream().filter(item -> "Colissimo邮编分区".equals(item.getSheetName())).findFirst().orElseThrow();
assertEquals("Colissimo", france.getChannelName());
assertTrue(france.getLines().stream().anyMatch(line -> "77244".equals(line.getPostcode()) && "Moyenne Densité".equals(line.getZoneName())));
PayPostcodeGroup poste = groups.stream().filter(item -> "PosteItaliane邮编分区".equals(item.getSheetName()) && item.getLines().stream().anyMatch(line -> "City (dense)".equals(line.getZoneName()))).findFirst().orElseThrow();
assertTrue(poste.getLines().stream().noneMatch(line -> "City".equals(line.getZoneName())));
}
@Test
void stripStarOnlyRemovesTrailingMark()
{
assertEquals("GY", PayPostcodeImporter.stripStar("GY*"));
assertEquals("PO30", PayPostcodeImporter.stripStar("PO30*"));
assertEquals("KA27-28", PayPostcodeImporter.stripStar("KA27-28"));
assertEquals("Royal Mail", PayPostcodeImporter.channelOf(Arrays.asList("Royal Mail偏远地区附加费")));
assertEquals("EVRI", PayPostcodeImporter.channelOf(Arrays.asList("EVRI偏远&空气减排地区附加费")));
assertEquals("CTT", PayPostcodeImporter.channelOf(Arrays.asList("Código Postal", "CTT Density")));
assertEquals("GOFO", PayPostcodeImporter.channelOf(Arrays.asList("法国GOFO可达邮编")));
assertEquals("REACH", PayPostcodeImporter.coverageOf("法国GOFO可达邮编", "", ""));
assertEquals("UNREACH", PayPostcodeImporter.coverageOf("德国DPD挂号(DEDPD)不可达邮编", "", ""));
assertEquals("REMOTE", PayPostcodeImporter.coverageOf("evri 偏远", "", ""));
assertEquals("", PayPostcodeImporter.coverageOf("英国附加费", "EVRI偏远&空气减排地区附加费", "空气减排附加费"));
List<PayPostcodeImporter.PostcodeToken> kept = PayPostcodeImporter.expandPostcode("KA27-28");
assertEquals("KA27-28", kept.get(0).postcode);
assertEquals("", kept.get(0).end);
List<PayPostcodeImporter.PostcodeToken> range = PayPostcodeImporter.expandPostcode("57030-57039");
assertEquals("57030", range.get(0).postcode);
assertEquals("57039", range.get(0).end);
}
@Test
void reachableUnreachableAndRemoteKeepTheirOwnCountry() throws Exception
{
List<PayPostcodeGroup> may = importer.parse(Files.readAllBytes(file("20、")), "20.xlsx");
PayPostcodeGroup cirro = may.stream().filter(item -> item.getSheetName().contains("Cirro可派")).findFirst().orElseThrow();
assertEquals("REACH", cirro.getCoverage());
assertEquals("法国", cirro.getCountryName());
assertEquals("Cirro", cirro.getChannelName());
assertTrue(cirro.getLines().stream().anyMatch(line -> "94350".equals(line.getPostcode()) && "Zone1".equals(line.getZoneName()) && "Paris".equals(line.getRegionName())));
PayPostcodeGroup germany = may.stream().filter(item -> "西班牙GLS 偏远邮编".equals(item.getSheetName()) && "DE".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("REMOTE", germany.getCoverage());
assertEquals("德国", germany.getCountryName());
assertEquals("GLS", germany.getChannelName());
assertTrue(germany.getLines().stream().anyMatch(line -> "18565".equals(line.getPostcode()) && "Hiddensee".equals(line.getRegionName())));
PayPostcodeGroup italyRemote = may.stream().filter(item -> "西班牙GLS 偏远邮编".equals(item.getSheetName()) && "IT".equals(item.getCountryCode())).findFirst().orElseThrow();
assertTrue(italyRemote.getLines().stream().anyMatch(line -> "57030".equals(line.getPostcode()) && "57039".equals(line.getPostcodeEnd())));
PayPostcodeGroup blocked = may.stream().filter(item -> item.getSheetName().contains("不可达")).findFirst().orElseThrow();
assertEquals("UNREACH", blocked.getCoverage());
assertEquals("德国", blocked.getCountryName());
assertEquals("DPD", blocked.getChannelName());
assertTrue(blocked.getLines().stream().anyMatch(line -> "18565".equals(line.getPostcode()) && "18565".equals(line.getPostcodeEnd())));
PayPostcodeGroup denmark = may.stream().filter(item -> item.getSheetName().contains("岛屿") && "DK".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("REMOTE", denmark.getCoverage());
assertEquals("丹麦", denmark.getCountryName());
assertEquals("DPD", denmark.getChannelName());
assertTrue(denmark.getLines().stream().anyMatch(line -> "4592".equals(line.getPostcode())));
PayPostcodeGroup estonia = may.stream().filter(item -> item.getSheetName().contains("岛屿") && "EE".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("爱沙尼亚", estonia.getCountryName());
assertEquals("REMOTE", estonia.getCoverage());
assertTrue(estonia.getLines().stream().anyMatch(line -> "88001".equals(line.getPostcode()) && "88005".equals(line.getPostcodeEnd())));
List<PayPostcodeGroup> yide = importer.parse(Files.readAllBytes(file("26、")), "26.xlsx");
PayPostcodeGroup gofo = yide.stream().filter(item -> item.getSheetName().contains("GOFO可达")).findFirst().orElseThrow();
assertEquals("REACH", gofo.getCoverage());
assertEquals("法国", gofo.getCountryName());
assertEquals("GOFO", gofo.getChannelName());
assertTrue(gofo.getLines().stream().anyMatch(line -> "94350".equals(line.getPostcode()) && "Paris".equals(line.getRegionName())));
PayPostcodeGroup evriRemote = yide.stream().filter(item -> "evri 偏远".equals(item.getSheetName())).findFirst().orElseThrow();
assertEquals("REMOTE", evriRemote.getCoverage());
assertEquals("英国", evriRemote.getCountryName());
assertEquals("evri", evriRemote.getChannelName());
assertTrue(evriRemote.getLines().stream().anyMatch(line -> "GY".equals(line.getPostcode()) && "GY*".equals(line.getPostcodeRaw())));
PayPostcodeGroup peuItaly = yide.stream().filter(item -> item.getSheetName().contains("不可达") && "IT".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("UNREACH", peuItaly.getCoverage());
assertEquals("意大利", peuItaly.getCountryName());
assertTrue(peuItaly.getLines().stream().anyMatch(line -> "23041".equals(line.getPostcode())));
List<PayPostcodeGroup> weishi = importer.parse(Files.readAllBytes(file("8、")), "8.xlsx");
PayPostcodeGroup cttZone = weishi.stream().filter(item -> "西班牙CTT邮编分区".equals(item.getSheetName())).findFirst().orElseThrow();
assertEquals("末端", cttZone.getServiceType());
assertEquals("西班牙", cttZone.getCatalogCountry());
assertEquals("CTT", cttZone.getProductName());
assertEquals("西班牙CTT", cttZone.getQuoteSheet());
assertEquals("", cttZone.getCoverage() == null ? "" : cttZone.getCoverage());
assertTrue(cttZone.getLines().stream().anyMatch(line -> "01110".equals(line.getPostcode()) && "Low".equals(line.getZoneName()) && "ALAVA".equals(line.getRegionName())));
PayPostcodeGroup cirroMay = weishi.stream().filter(item -> item.getSheetName().contains("Cirro可派")).findFirst().orElseThrow();
assertEquals("末端", cirroMay.getServiceType());
assertEquals("法国", cirroMay.getCatalogCountry());
assertEquals("cirro", cirroMay.getProductName());
assertEquals("法国Cirro", cirroMay.getQuoteSheet());
assertEquals("REACH", cirroMay.getCoverage());
PayPostcodeGroup glsMay = weishi.stream().filter(item -> "西班牙GLS 偏远邮编".equals(item.getSheetName()) && "DE".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("西班牙", glsMay.getCatalogCountry());
assertEquals("GLS", glsMay.getProductName());
assertEquals("西班牙GLS", glsMay.getQuoteSheet());
assertEquals("德国", glsMay.getCountryName());
assertEquals("REMOTE", glsMay.getCoverage());
PayPostcodeGroup blockedMay = weishi.stream().filter(item -> item.getSheetName().contains("不可达")).findFirst().orElseThrow();
assertEquals("德国", blockedMay.getCatalogCountry());
assertEquals("DPD", blockedMay.getProductName());
assertTrue(blockedMay.getQuoteSheet().contains("DEDPD"));
assertEquals("UNREACH", blockedMay.getCoverage());
PayPostcodeGroup islandMay = weishi.stream().filter(item -> item.getSheetName().contains("岛屿") && "DK".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("DPD国际件", islandMay.getProductName());
assertEquals("德国DPD挂号-国际件", islandMay.getQuoteSheet());
assertEquals("REMOTE", islandMay.getCoverage());
assertTrue(weishi.stream().noneMatch(item -> item.getSheetName().contains("危机燃油") || item.getSheetName().contains("高风险")));
PayPostcodeGroup peuFrance = yide.stream().filter(item -> item.getSheetName().contains("不可达") && "FR".equals(item.getCountryCode())).findFirst().orElseThrow();
assertTrue(peuFrance.getLines().stream().anyMatch(line -> "98700".equals(line.getPostcode()) && "98799".equals(line.getPostcodeEnd())));
}
private static PayPostcodeGroup one(List<PayPostcodeGroup> fees, String name, String text)
{
return fees.stream().filter(item -> name.equals(item.getFeeName()) && text.equals(item.getFeeText())).findFirst().orElseThrow();
}
private Path file(String prefix) throws Exception
{
try (Stream<Path> files = Files.list(Path.of("D:/应付供应商")))
{
return files.filter(path -> path.getFileName().toString().startsWith(prefix) && path.toString().endsWith(".xlsx")).findFirst().orElseThrow();
}
}
}
@@ -1,768 +0,0 @@
package com.bms.price.pay;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import com.bms.price.domain.PayOrderCostResult;
import com.bms.price.domain.PayPreview;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PaySurcharge;
import com.bms.price.domain.PayWeightBand;
/**
* 用现有应付价卡核对目录产品和重量段、附加费是否被读出来。
*/
class PayWorkbookParserTest
{
private final PayWorkbookParser parser = new PayWorkbookParser();
@Test
void octoberCatalogKeepsTwelveProductSheets() throws Exception
{
PayPreview preview = parse("9、");
assertEquals(12, preview.getProducts().size());
PayProduct evri = product(preview, "Evri 48H");
assertTrue(evri.getWeightBands().stream().anyMatch(band -> decimal(band, "1.51") && "英国大陆".equals(zoneTail(band))));
assertTrue(evri.getSurcharges().stream().anyMatch(fee -> "战争附加费".equals(fee.getFeeName()) && fee.getChargeText().contains("2.5")));
PayProduct ctt = product(preview, "CTT 48H");
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> band.getWeightLabel() != null && band.getWeightLabel().contains("首重") && decimal(band, "2.05")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "每增加 1Kg".equals(band.getWeightLabel()) || (band.getWeightLabel() != null && band.getWeightLabel().contains("每增加"))));
}
@Test
void mayCardReadsDhlAndDpdWithoutZoneSheets() throws Exception
{
PayPreview preview = parse("8、");
assertEquals(8, preview.getProducts().size());
PayProduct dhl = product(preview, "DHL泛欧");
assertEquals("威狮", dhl.getSupplierName());
assertTrue(dhl.getRelatedSheet() != null && dhl.getRelatedSheet().contains("高风险"));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> decimal(band, "9.71") && band.getCountryName() != null && band.getCountryName().contains("丹麦")),
dhl.getParseStatus() + " " + dhl.getSheetName() + " " + dhl.getWeightBands().stream().limit(6)
.map(band -> band.getCountryName() + "/" + band.getChargeName() + "/" + band.getPrice()).toList());
assertEquals("末端", dhl.getServiceType());
assertEquals("德国", dhl.getCountryScope());
assertEquals("DHL泛欧", dhl.getSheetName());
PaySurcharge afghan = dhl.getSurcharges().stream()
.filter(fee -> "高风险地区附加费".equals(fee.getFeeName()) && "AF".equals(fee.getCalcParam())).findFirst().orElseThrow();
assertEquals("Afghanistan", afghan.getZoneName());
assertEquals("RMB", afghan.getCurrency());
assertEquals("RMB/KG", afghan.getPriceUnit());
assertTrue(afghan.getChargeText().contains("10.4") && afghan.getChargeText().contains("RMB/kg"));
assertTrue(decimalFee(afghan, "10.4"));
assertEquals("MANUAL", afghan.getCalcRule());
assertTrue(afghan.getFeeDesc().contains("非欧洲"));
PayProduct dpd = preview.getProducts().stream().filter(item -> "DPD".equals(item.getProductName())).findFirst().orElseThrow();
assertEquals("末端", dpd.getServiceType());
assertEquals("德国", dpd.getCountryScope());
assertTrue(dpd.getSheetName().contains("DEDPD"));
assertTrue(dpd.getRelatedSheet().contains("不可达"));
assertTrue(dpd.getRelatedSheet().contains("危机燃油"));
assertTrue(dpd.getWeightBands().stream().anyMatch(band -> decimal(band, "3.29")));
assertTrue(dpd.getSurcharges().stream().anyMatch(fee -> "道路通行费".equals(fee.getFeeName()) && decimalFee(fee, "0.14")));
assertTrue(dpd.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("旺季") && fee.getEffectiveStart() != null));
PaySurcharge bracket = dpd.getSurcharges().stream()
.filter(fee -> "危机燃油附加费".equals(fee.getFeeName()) && "1.95|2".equals(fee.getCalcParam())).findFirst().orElseThrow();
assertEquals("1.95 ≤ 油价 < 2", bracket.getChargeText());
assertTrue(decimalFee(bracket, "0.0379"));
assertEquals("MANUAL", bracket.getCalcRule());
PaySurcharge zero = dpd.getSurcharges().stream()
.filter(fee -> "危机燃油附加费".equals(fee.getFeeName()) && "|1.35".equals(fee.getCalcParam())).findFirst().orElseThrow();
assertEquals("油价 < 1.35", zero.getChargeText());
assertTrue(decimalFee(zero, "0"));
PayProduct ctt = product(preview, "CTT");
assertEquals("末端", ctt.getServiceType());
assertEquals("西班牙", ctt.getCountryScope());
assertEquals("西班牙CTT", ctt.getSheetName());
assertTrue(ctt.getRelatedSheet().contains("西班牙CTT邮编分区"));
PayProduct international = product(preview, "DPD国际件");
assertEquals("德国", international.getCountryScope());
assertTrue(international.getRelatedSheet().contains("岛屿"));
assertTrue(international.getSurcharges().stream().noneMatch(fee -> "1.95|2".equals(fee.getCalcParam())));
}
@Test
void upsZonesStayLeftToRightAndColissimoFuelIsMarked() throws Exception
{
PayPreview preview = parse("32、");
PayProduct ups = product(preview, "德国UPS");
String[] codes = {
"DE",
"BE", "LU", "NL", "AT", "SI",
"CZ", "PL", "SK",
"DK", "FI", "FR", "SE", "ES", "IT", "PT", "HU",
"GR", "IE", "BG", "HR", "EE", "LT", "LV", "RO"
};
String[] zones = {
"Zone 1",
"Zone 2", "Zone 2", "Zone 2", "Zone 2", "Zone 2",
"Zone 3", "Zone 3", "Zone 3",
"Zone 4", "Zone 4", "Zone 4", "Zone 4", "Zone 4", "Zone 4", "Zone 4", "Zone 4",
"Zone 5", "Zone 5", "Zone 5", "Zone 5", "Zone 5", "Zone 5", "Zone 5", "Zone 5"
};
String[] prices = {
"3.9",
"7.58", "7.58", "7.58", "7.58", "7.58",
"7.72", "7.72", "7.72",
"8.41", "8.41", "8.41", "8.41", "8.41", "8.41", "8.41", "8.41",
"9.45", "9.45", "9.45", "9.45", "9.45", "9.45", "9.45", "9.45"
};
assertEquals(codes.length, ups.getWeightBands().size());
for (int i = 0; i < codes.length; i++)
{
PayWeightBand band = ups.getWeightBands().get(i);
assertEquals(zones[i], band.getZoneName(), codes[i]);
assertEquals(codes[i], band.getCountryCode());
assertEquals(codes[i], band.getCountryName());
assertTrue(decimal(band, prices[i]), codes[i] + " " + prices[i]);
}
assertTrue(ups.getSurcharges().stream().anyMatch(fee -> "报价说明".equals(fee.getFeeName())
&& fee.getChargeText().contains("不含管理费") && fee.getChargeText().contains("不含燃油")));
PaySurcharge handling = ups.getSurcharges().stream()
.filter(fee -> fee.getFeeName().startsWith("额外处理费") && (fee.getSectionName() == null || !fee.getSectionName().contains("高峰")))
.findFirst().orElseThrow();
assertTrue(handling.getChargeText().contains("23.65") && handling.getChargeText().contains("26.7"));
assertTrue(handling.getFeeDesc().contains("是否加收燃油:是"));
PaySurcharge peak = ups.getSurcharges().stream()
.filter(fee -> "高峰附加费".equals(fee.getSectionName()) && fee.getFeeName().startsWith("额外处理费"))
.findFirst().orElseThrow();
assertTrue(peak.getChargeText().contains("7.35"));
assertEquals(java.time.LocalDate.of(2026, 9, 28),
peak.getEffectiveStart().toInstant().atZone(java.time.ZoneId.of("GMT+8")).toLocalDate());
assertEquals(java.time.LocalDate.of(2027, 1, 17),
peak.getEffectiveEnd().toInstant().atZone(java.time.ZoneId.of("GMT+8")).toLocalDate());
PayProduct colissimo = product(preview, "Colissimo");
PaySurcharge fuel = colissimo.getSurcharges().stream().filter(fee -> fee.getFeeName().contains("燃油")).findFirst().orElseThrow();
assertTrue(fuel.getChargeText().contains("不含燃油"));
assertTrue(fuel.getChargeText().contains("不含去碳"));
assertTrue(colissimo.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().startsWith("去碳附加费") && decimalFee(fee, "0.05")));
assertTrue(colissimo.getWeightBands().stream().anyMatch(band -> decimal(band, "3.03")));
assertTrue(colissimo.getWeightBands().stream().noneMatch(band -> band.getChargeName() != null && band.getChargeName().contains("燃油")));
}
@Test
void imilePostcodesResolveZoneAndGermanCoverage() throws Exception
{
PayPreview preview = parse("32、");
PayProduct italy = product(preview, "IT-iMile");
assertTrue(italy.getRelatedSheet().contains("iMile邮编分区"));
assertTrue(italy.getExtraLines().size() > 4000);
assertTrue(italy.getExtraLines().stream().anyMatch(line -> "邮编".equals(line.getLineType()) && line.getLineText().contains("00010") && line.getLineText().contains("Province") && line.getLineText().contains("ROMA")));
assertTrue(italy.getExtraLines().stream().anyMatch(line -> line.getLineText().endsWith(" | City")));
assertTrue(italy.getExtraLines().stream().anyMatch(line -> line.getLineText().endsWith(" | Rural")));
assertTrue(italy.getWeightBands().stream().anyMatch(band -> "City".equals(band.getZoneName()) && decimal(band, "2.85")));
assertTrue(italy.getWeightBands().stream().anyMatch(band -> "Rural".equals(band.getZoneName()) && decimal(band, "3.68")));
PayProduct germany = product(preview, "DE-iMile");
assertTrue(germany.getRelatedSheet().contains("iMile邮编分区"));
assertTrue(germany.getExtraLines().size() > 7000);
assertTrue(germany.getExtraLines().stream().anyMatch(line -> line.getLineText().contains("01001") && line.getLineText().contains("Active")));
assertTrue(germany.getExtraLines().stream().noneMatch(line -> line.getLineText().contains("Lazio") || line.getLineText().contains("City")));
}
@Test
void gweFileOverridesOnlyPanEuroSupplier() throws Exception
{
PayPreview preview = parse("8.1");
PayProduct dhl = product(preview, "DHL泛欧");
assertEquals("GWE", dhl.getSupplierName());
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> decimal(band, "9.79")),
dhl.getParseStatus() + " sur=" + dhl.getSurchargeCount() + " extra=" + dhl.getExtraCount() + " " + dhl.getRemark() + " "
+ dhl.getWeightBands().stream().limit(8)
.map(band -> band.getCountryName() + "/" + band.getWeightLabel() + "/" + band.getChargeName() + "/" + band.getPrice()).toList());
PayProduct ctt = product(preview, "CTT");
assertEquals("威狮", ctt.getSupplierName());
}
@Test
void wholeWorkbookPreviewSerializes() throws Exception
{
PayPreview preview = parse("9、");
String json = tools.jackson.databind.json.JsonMapper.builder().build().writeValueAsString(preview);
assertTrue(json.contains("\"productName\":\"Evri 48H\""));
assertTrue(json.contains("2026-10-01") || json.contains("effectiveDate"));
}
@Test
void yideCatalogIncludesPostcodeProducts() throws Exception
{
PayPreview preview = parse("26、");
assertEquals(10, preview.getProducts().size());
assertTrue(preview.getProducts().stream().anyMatch(item -> item.getExtraCount() != null && item.getExtraCount() > 0));
PayProduct evri = preview.getProducts().stream().filter(item -> item.getSheetName().contains("英国evri")).findFirst().orElseThrow();
assertFalse(evri.getWeightBands().isEmpty());
assertFalse(evri.getSurcharges().isEmpty());
}
@Test
void dictMatchReadsNamedSheetAndSkipsOthers() throws Exception
{
Path file = priceCard("Evri 48H.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "Evri 48H",
List.of("Evri 48H", "DHL Paket"));
assertEquals(1, preview.getProducts().size());
PayProduct evri = preview.getProducts().get(0);
assertEquals("Evri 48H", evri.getDictValue());
assertEquals("Evri 48H", evri.getSheetName());
assertFalse(evri.getWeightBands().isEmpty());
assertTrue(evri.getWeightBands().stream().allMatch(band -> "派送费".equals(band.getChargeName())));
assertTrue(evri.getWeightBands().stream().noneMatch(band -> bandText(band).contains("偏远") || bandText(band).contains("Highlands")));
assertTrue(evri.getWeightBands().stream().anyMatch(band -> decimal(band, "1.51") && band.getZoneName().contains("Mainland")));
PaySurcharge highlands = evri.getSurcharges().stream()
.filter(fee -> "偏远附加费".equals(fee.getFeeName()) && fee.getZoneName().contains("Highlands") && decimalFee(fee, "5.95"))
.findFirst().orElseThrow();
assertEquals("ZONE", highlands.getCalcRule());
assertTrue(highlands.getWeightMax() != null && highlands.getWeightMax().compareTo(java.math.BigDecimal.ONE) == 0);
}
@Test
void panEuroKeepsTwoChargesPerCountryAndEverySurcharge() throws Exception
{
Path file = Path.of("D:/payment/DHL泛欧.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayProduct dhl = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "DHL泛欧",
List.of("DHL泛欧")).getProducts().get(0);
assertPanEuroPrices(dhl);
assertEquals(15, dhl.getSurcharges().size(), dhl.getSurcharges().stream()
.map(fee -> fee.getFeeName() + "/" + fee.getChargeText()).toList().toString());
}
@Test
void panEuroSheetInCatalogDoesNotCopyPricesAndKeepsHighRisk() throws Exception
{
Path dir = Path.of("D:/应付供应商");
Assumptions.assumeTrue(Files.isDirectory(dir));
Path file;
try (Stream<Path> files = Files.list(dir))
{
file = files.filter(path -> path.getFileName().toString().startsWith("8、") && path.toString().endsWith(".xlsx"))
.findFirst().orElseThrow();
}
PayProduct dhl = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "DHL泛欧",
List.of("DHL泛欧")).getProducts().get(0);
assertPanEuroPrices(dhl);
assertTrue(dhl.getRelatedSheet() != null && dhl.getRelatedSheet().contains("高风险"));
PaySurcharge afghan = dhl.getSurcharges().stream()
.filter(fee -> "高风险地区附加费".equals(fee.getFeeName()) && "AF".equals(fee.getCalcParam())).findFirst().orElseThrow();
assertEquals("Afghanistan", afghan.getZoneName());
assertTrue(decimalFee(afghan, "10.4"));
assertTrue(dhl.getSurcharges().stream().filter(fee -> "高风险地区附加费".equals(fee.getFeeName()) && "AF".equals(fee.getCalcParam())).count() == 1);
}
@Test
void nlDhlVolumeStaysOnTheWeightBand() throws Exception
{
Path file = Path.of("D:/payment/NL-DHL.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "NL-DHL",
List.of("NL-DHL"));
PayProduct dhl = preview.getProducts().get(0);
assertEquals(10, dhl.getWeightBands().size(), dhl.getWeightBands().stream()
.map(band -> band.getWeightLabel() + "/" + band.getChargeName() + "/" + band.getZoneName() + "/" + band.getPrice()
+ "/" + band.getVolumeText())
.toList().toString());
assertTrue(dhl.getWeightBands().stream().noneMatch(band -> textOf(band.getChargeName()).contains("长")
|| textOf(band.getZoneName()).contains("长")));
assertTrue(dhl.getWeightBands().stream().allMatch(band -> band.getVolumeText() != null && band.getVolumeText().endsWith("cm³")));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "0<W≤5".equals(band.getWeightLabel())
&& decimal(band, "3.49") && "10000cm³".equals(band.getVolumeText())
&& "处理费".equals(band.getChargeName()) && "EUR/ITEM".equals(band.getPriceUnit())));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "20<W≤31.5".equals(band.getWeightLabel())
&& decimal(band, "11.38") && "432000cm³".equals(band.getVolumeText())));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "0<W≤5".equals(band.getWeightLabel())
&& decimal(band, "4.93") && "10000cm³".equals(band.getVolumeText())
&& band.getCountryName().contains("比利时") && "BE".equals(band.getCountryCode())));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "20<W≤31.5".equals(band.getWeightLabel())
&& decimal(band, "19.88") && "432000cm³".equals(band.getVolumeText())));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().startsWith("超尺寸附加费")));
}
@Test
void royalMailReadsRemoteAreaTableAndSkipsRemarks() throws Exception
{
Path file = priceCard("英国RM.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "英国RM", List.of("英国RM"));
assertEquals(1, preview.getProducts().size());
PayProduct rm = preview.getProducts().get(0);
assertEquals(4, rm.getWeightBands().size());
assertTrue(rm.getWeightBands().stream().anyMatch(band -> "0-5".equals(band.getWeightLabel()) && decimal(band, "1.62")
&& band.getWeightMin() != null && band.getWeightMin().compareTo(java.math.BigDecimal.ZERO) == 0
&& band.getWeightMax() != null && band.getWeightMax().compareTo(new java.math.BigDecimal("5")) == 0
&& "每票".equals(band.getChargeName()) && "GBP/票".equals(band.getPriceUnit())));
assertTrue(rm.getWeightBands().stream().anyMatch(band -> "15.001-20".equals(band.getWeightLabel()) && decimal(band, "5.18")
&& band.getWeightMin() != null && band.getWeightMin().compareTo(new java.math.BigDecimal("15.001")) == 0
&& band.getWeightMax() != null && band.getWeightMax().compareTo(new java.math.BigDecimal("20")) == 0));
List<PaySurcharge> remote = rm.getSurcharges().stream().filter(fee -> "偏远地区附加费".equals(fee.getFeeName())).toList();
assertEquals(3, remote.size(), remote.stream().map(fee -> fee.getChargeText() + " / " + fee.getFeeDesc()).toList().toString());
assertTrue(remote.stream().anyMatch(fee -> fee.getChargeText().contains("1.80") && fee.getFeeDesc().contains("Area A")
&& fee.getFeeDesc().contains("Highlands") && fee.getFeeDesc().contains("IV")));
assertTrue(remote.stream().anyMatch(fee -> fee.getChargeText().contains("1.80") && fee.getFeeDesc().contains("Isle of Man")
&& fee.getFeeDesc().contains("ZE")));
assertTrue(remote.stream().anyMatch(fee -> fee.getChargeText().contains("1.00") && fee.getFeeDesc().contains("Area B")
&& fee.getFeeDesc().contains("BT")));
assertTrue(remote.stream().allMatch(fee -> "POSTCODE".equals(fee.getCalcRule())));
assertTrue(remote.stream().anyMatch(fee -> fee.getCalcParam() != null && fee.getCalcParam().contains("IV")));
assertTrue(remote.stream().anyMatch(fee -> fee.getCalcParam() != null && fee.getCalcParam().contains("BT")));
assertTrue(rm.getSurcharges().stream().anyMatch(fee -> "签名费".equals(fee.getFeeName()) && fee.getChargeText().contains("0.7")
&& "MANUAL".equals(fee.getCalcRule())));
assertTrue(rm.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("超大附加费") && fee.getFeeDesc().contains("61")
&& "SIZE".equals(fee.getCalcRule()) && fee.getCalcParam() != null && fee.getCalcParam().contains("box:61")));
assertTrue(rm.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("超重")
&& "WEIGHT".equals(fee.getCalcRule()) && "20".equals(fee.getCalcParam())));
assertTrue(rm.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("重复单号") && fee.getChargeText().contains("23")));
assertTrue(rm.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("未预报") && fee.getChargeText().contains("1.50")));
assertTrue(rm.getSurcharges().stream().noneMatch(fee -> fee.getFeeDesc() != null && (fee.getFeeDesc().contains("计费重量")
|| fee.getFeeDesc().contains("赔偿条款") || fee.getFeeDesc().contains("royalmail.com"))));
assertTrue(rm.getServiceNote() == null || rm.getServiceNote().isBlank());
assertTrue(rm.getExtraLines().isEmpty());
}
@Test
void royalMail48hRemoteRowsMatchPostcodePrefixes() throws Exception
{
Path file = priceCard("Royal Mail 48H.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayProduct rm = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "Royal Mail 48H",
List.of("Royal Mail 48H")).getProducts().get(0);
List<PaySurcharge> remote = rm.getSurcharges().stream().filter(fee -> fee.getFeeName() != null && fee.getFeeName().contains("偏远")).toList();
assertFalse(remote.isEmpty(), rm.getSurcharges().stream().map(fee -> fee.getFeeName() + "/" + fee.getCalcRule() + "/" + fee.getCalcParam()).toList().toString());
assertTrue(remote.stream().allMatch(fee -> "POSTCODE".equals(fee.getCalcRule())),
remote.stream().map(fee -> fee.getFeeName() + "/" + fee.getCalcParam() + "/" + fee.getFeeDesc()).toList().toString());
assertTrue(remote.stream().anyMatch(fee -> fee.getCalcParam() != null && fee.getCalcParam().contains("IV") && !fee.getCalcParam().contains("邮编")));
assertTrue(rm.getWeightBands().stream().noneMatch(band -> bandText(band).contains("偏远")));
}
@Test
void evriOtherFeesKeepOneRowPerRegion() throws Exception
{
Path file = Path.of("D:/payment/英国evri.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "英国evri", List.of("英国evri"));
PayProduct evri = preview.getProducts().get(0);
List<PaySurcharge> oversize = evri.getSurcharges().stream()
.filter(fee -> "超尺寸费".equals(fee.getFeeName())).toList();
assertEquals(6, oversize.size(), oversize.stream().map(fee -> fee.getZoneName() + "=" + fee.getChargeText()).toList().toString());
assertTrue(oversize.stream().anyMatch(fee -> fee.getZoneName().contains("英国大陆") && fee.getChargeText().contains("6.99")));
assertTrue(oversize.stream().anyMatch(fee -> fee.getZoneName().contains("高地") && fee.getChargeText().contains("9.67")));
assertTrue(oversize.stream().anyMatch(fee -> fee.getZoneName().contains("马恩岛") && fee.getChargeText().contains("9.67")));
assertTrue(evri.getSurcharges().stream().anyMatch(fee -> "周末服务费".equals(fee.getFeeName())
&& fee.getZoneName().contains("北爱尔兰") && fee.getChargeText().contains("3.02")));
assertTrue(evri.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().startsWith("旺季附加费")
&& fee.getZoneName().contains("英国大陆") && fee.getChargeText().contains("0.15")));
assertTrue(evri.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().startsWith("旺季附加费")
&& fee.getZoneName().contains("马恩岛") && fee.getChargeText().contains("5.5")));
assertTrue(evri.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null
&& (fee.getFeeName().contains("服务说明") || fee.getFeeName().contains("索赔"))));
}
@Test
void spainMainlandKeepsAreasAndTwoSurcharges() throws Exception
{
Path file = Path.of("D:/payment/西邮-西班牙(主岛).xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "西邮-西班牙(主岛)",
List.of("西邮-西班牙(主岛)"));
PayProduct spain = preview.getProducts().get(0);
assertEquals(160, spain.getWeightBands().size());
assertTrue(spain.getWeightBands().stream().anyMatch(band -> band.getWeightLabel().contains("0.01-0.25")
&& "MAD-BCN Dense".equals(band.getZoneName()) && decimal(band, "2.27") && "每件".equals(band.getChargeName())
&& "EUR/件".equals(band.getPriceUnit())));
assertTrue(spain.getWeightBands().stream().anyMatch(band -> band.getWeightLabel().contains("0.01-0.25")
&& "Rural Resto".equals(band.getZoneName()) && decimal(band, "3.12")));
assertTrue(spain.getWeightBands().stream().anyMatch(band -> band.getWeightLabel().contains("29-30")
&& "Denso Resto".equals(band.getZoneName()) && decimal(band, "10.08")));
assertTrue(spain.getSurcharges().stream().anyMatch(fee -> "重新贴尾程标签附加费".equals(fee.getFeeName())
&& fee.getChargeText().contains("0.35") && fee.getFeeDesc().contains("重新贴尾程标签")));
List<PaySurcharge> oversize = spain.getSurcharges().stream().filter(fee -> "特大包裹收费".equals(fee.getFeeName())).toList();
assertEquals(2, oversize.size(), oversize.stream().map(PaySurcharge::getChargeText).toList().toString());
assertTrue(oversize.stream().anyMatch(fee -> fee.getChargeText().contains("35%") && fee.getFeeDesc().contains("120")
&& "SIZE".equals(fee.getCalcRule()) && fee.getCalcParam() != null && fee.getCalcParam().contains("join:or")));
assertTrue(oversize.stream().anyMatch(fee -> fee.getChargeText().contains("70%")
&& "SIZE".equals(fee.getCalcRule()) && fee.getCalcParam() != null && fee.getCalcParam().contains("join:and")));
assertTrue(spain.getSurcharges().stream().anyMatch(fee -> "重新贴尾程标签附加费".equals(fee.getFeeName())
&& "MANUAL".equals(fee.getCalcRule())));
assertTrue(spain.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null
&& (fee.getFeeName().contains("禁运") || fee.getFeeName().contains("条款"))));
}
@Test
void spainGlsSplitsCountryCodesAndServiceFees() throws Exception
{
Path file = Path.of("D:/payment/西班牙GLS.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "西班牙GLS",
List.of("西班牙GLS"));
PayProduct gls = preview.getProducts().get(0);
assertEquals("OK", gls.getParseStatus());
assertEquals(476, gls.getWeightBands().size());
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "DE".equals(band.getCountryCode()) && "德国".equals(band.getCountryName())
&& "A".equals(band.getZoneName()) && "1KG".equals(band.getWeightLabel()) && decimal(band, "5.12")
&& band.getWeightMin().compareTo(java.math.BigDecimal.ZERO) == 0
&& band.getWeightMax().compareTo(java.math.BigDecimal.ONE) == 0
&& "每件".equals(band.getChargeName()) && "EUR/件".equals(band.getPriceUnit())));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "NL".equals(band.getCountryCode()) && "荷兰".equals(band.getCountryName())
&& "A".equals(band.getZoneName()) && decimal(band, "5.12")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "PL".equals(band.getCountryCode()) && "波兰".equals(band.getCountryName())));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "FR".equals(band.getCountryCode()) && "法国".equals(band.getCountryName())
&& "B".equals(band.getZoneName()) && "1KG".equals(band.getWeightLabel()) && decimal(band, "6.38")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "HU".equals(band.getCountryCode()) && "匈牙利".equals(band.getCountryName())
&& "D".equals(band.getZoneName()) && "3KG".equals(band.getWeightLabel()) && decimal(band, "8.15")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "GB".equals(band.getCountryCode()) && "英国".equals(band.getCountryName())
&& "E".equals(band.getZoneName()) && "1KG".equals(band.getWeightLabel()) && decimal(band, "7.11")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "FR2".equals(band.getCountryCode()) && "法国-科西嘉岛".equals(band.getCountryName())
&& "G".equals(band.getZoneName()) && "1KG".equals(band.getWeightLabel()) && decimal(band, "16.01")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "CY".equals(band.getCountryCode()) && "塞浦路斯".equals(band.getCountryName())
&& "H".equals(band.getZoneName()) && decimal(band, "13.01")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "IT".equals(band.getCountryCode()) && "Ad.kg.".equals(band.getWeightLabel())
&& decimal(band, "1.07") && "每公斤".equals(band.getChargeName()) && "EUR/公斤".equals(band.getPriceUnit())
&& band.getWeightMin() == null));
assertEquals(4, gls.getSurcharges().size());
assertTrue(gls.getSurcharges().stream().anyMatch(fee -> "取还服务".equals(fee.getFeeName()) && fee.getChargeText().contains("7")
&& fee.getFeeDesc().contains("送货上门") && decimalFee(fee, "7")));
assertTrue(gls.getSurcharges().stream().anyMatch(fee -> "取货发货服务".equals(fee.getFeeName()) && decimalFee(fee, "7")));
assertTrue(gls.getSurcharges().stream().anyMatch(fee -> "现金服务".equals(fee.getFeeName()) && fee.getFeeDesc().contains("交货付款")
&& decimalFee(fee, "6")));
assertTrue(gls.getSurcharges().stream().anyMatch(fee -> "灵活配送服务".equals(fee.getFeeName()) && "free".equalsIgnoreCase(fee.getChargeText())
&& decimalFee(fee, "0") && fee.getFeeDesc().contains("灵活性")));
assertTrue(gls.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null && (fee.getFeeName().contains("格陵兰")
|| fee.getFeeName().contains("转账") || fee.getFeeName().contains("备注") || fee.getFeeName().contains("能源"))));
assertTrue(gls.getServiceNote() == null || gls.getServiceNote().isBlank());
}
@Test
void ctt48hFileKeepsSurchargeTablesOutOfWeightBands() throws Exception
{
Path file = priceCard("CTT 48H.xlsx");
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "CTT 48H",
List.of("CTT 48H"));
PayProduct ctt = preview.getProducts().get(0);
assertTrue(ctt.getWeightBands().stream().allMatch(band -> "派送费".equals(band.getChargeName())),
ctt.getWeightBands().stream().map(band -> band.getChargeName() + "/" + band.getZoneName() + "/" + band.getPrice()).toList().toString());
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> decimal(band, "2.05") && band.getZoneName().contains("马德里")));
assertTrue(ctt.getWeightBands().stream().noneMatch(band -> bandText(band).contains("偏远") || bandText(band).contains("额外")
|| bandText(band).toLowerCase().contains("line-haul")));
PaySurcharge balearic = ctt.getSurcharges().stream()
.filter(fee -> "偏远附加费".equals(fee.getFeeName()) && fee.getZoneName().contains("Balearic") && decimalFee(fee, "7.1"))
.findFirst().orElseThrow();
assertTrue(balearic.getFeeDesc().contains("首重"));
assertEquals("POSTCODE", balearic.getCalcRule());
assertTrue(balearic.getCalcParam() != null && balearic.getCalcParam().contains("07"));
assertTrue(balearic.getWeightMax() != null && balearic.getWeightMax().compareTo(new java.math.BigDecimal("1")) == 0);
PaySurcharge canary = ctt.getSurcharges().stream()
.filter(fee -> "偏远附加费".equals(fee.getFeeName()) && fee.getZoneName().contains("Canary") && decimalFee(fee, "4.5")
&& fee.getChargeText().contains("KG"))
.findFirst().orElseThrow();
assertEquals("EXTRA_KG", canary.getCalcRule());
assertEquals("1", canary.getCalcParam());
assertTrue(canary.getPostcodeParam() != null && canary.getPostcodeParam().contains("35") && canary.getPostcodeParam().contains("38"));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("额外线路运输费") && "MANUAL".equals(fee.getCalcRule())));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("额外线路运输费") && decimalFee(fee, "0.09")));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("额外线路运输费")
&& fee.getFeeDesc().contains("800公里")));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "超重附加费".equals(fee.getFeeName()) && decimalFee(fee, "5.98")));
}
@Test
void spainCttMainlandReadsBillableConcepts() throws Exception
{
Path file = priceCard("西班牙CTT带电专线(主要地区).xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(),
"西班牙CTT带电专线(主要地区)", List.of("西班牙CTT带电专线(主要地区)"));
PayProduct ctt = preview.getProducts().get(0);
assertEquals("OK", ctt.getParseStatus());
assertEquals("单票价", ctt.getChargeType());
assertTrue(ctt.getExtraPerKg() == null || ctt.getExtraPerKg().isBlank());
assertEquals(150, ctt.getWeightBands().size());
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "1".equals(band.getWeightLabel())
&& "Mad High Density".equals(band.getZoneName()) && decimal(band, "2.11")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "30".equals(band.getWeightLabel())
&& "Portugal".equals(band.getZoneName()) && decimal(band, "11.96")));
assertEquals(15, ctt.getSurcharges().size());
assertEquals(5, ctt.getSurcharges().stream().filter(fee -> "Extra Line-Haul (*)".equals(fee.getFeeName())).count());
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "Extra Line-Haul (*)".equals(fee.getFeeName())
&& "Mad High Density".equals(fee.getZoneName()) && decimalFee(fee, "0.12") && "EUR".equals(fee.getCurrency())
&& "MANUAL".equals(fee.getCalcRule())));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "Extra Line-Haul Additional Kg (**)".equals(fee.getFeeName())
&& "Portugal".equals(fee.getZoneName()) && decimalFee(fee, "0.08")));
List<PaySurcharge> pudo = ctt.getSurcharges().stream().filter(fee -> "Pudo".equals(fee.getFeeName())).toList();
assertEquals(1, pudo.size());
assertTrue(pudo.get(0).getChargeText().contains("0.10") && pudo.get(0).getChargeText().contains("欧元"));
assertTrue(decimalFee(pudo.get(0), "0.10"));
assertEquals("", pudo.get(0).getZoneName() == null ? "" : pudo.get(0).getZoneName());
assertEquals(1, ctt.getSurcharges().stream().filter(fee -> fee.getFeeName().contains("COD")).count());
assertEquals(1, ctt.getSurcharges().stream().filter(fee -> "Reverse Logistics".equals(fee.getFeeName())).count());
assertEquals(1, ctt.getSurcharges().stream().filter(fee -> "Fuel rate".equals(fee.getFeeName())).count());
assertEquals(1, ctt.getSurcharges().stream().filter(fee -> "Volumetric weight".equals(fee.getFeeName())).count());
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("COD")
&& fee.getChargeText().contains("1%") && fee.getAmount() == null));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "Volumetric weight".equals(fee.getFeeName())
&& fee.getChargeText().contains("90cm") && fee.getAmount() == null && "MANUAL".equals(fee.getCalcRule())));
assertTrue(ctt.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null && (fee.getFeeName().contains("计费重量")
|| fee.getFeeName().contains("4.95") || fee.getFeeName().contains("丢货") || fee.getFeeName().contains("退件"))));
}
@Test
void spainCttIslandsReadsFiveRateTables() throws Exception
{
Path file = Path.of("D:/payment/西班牙CTT带电专线(岛屿+其他地区).xlsx");
if (!Files.exists(file))
{
file = Path.of("D:/A-payment/西班牙CTT带电专线(岛屿+其他地区).xlsx");
}
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(),
"西班牙CTT带电专线(岛屿+其他地区)", List.of("西班牙CTT带电专线(岛屿+其他地区)"));
PayProduct ctt = preview.getProducts().get(0);
assertEquals("OK", ctt.getParseStatus());
assertEquals("EUR", ctt.getCurrency());
assertEquals("续重计费", ctt.getChargeType());
assertTrue(covers(ctt.getExtraPerKg(), "1.86"));
assertTrue(covers(ctt.getExtraPerKg(), "5.79"));
assertEquals(24, ctt.getWeightBands().size());
assertEquals(24, ctt.getSurcharges().stream().filter(fee -> "每公斤续重".equals(fee.getFeeName())).count());
assertEquals(4, ctt.getSurcharges().stream().filter(fee -> "Cubic Capacity".equals(fee.getFeeName())).count());
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "2. INSULAR RATES / STANDARD".equals(band.getSectionName())
&& "Balearic Islands Economy 48H / Majorca".equals(band.getZoneName()) && "西班牙".equals(band.getCountryName()) && "ES".equals(band.getCountryCode())
&& "Up to 1 kg".equals(band.getWeightLabel()) && decimal(band, "3.39") && "每件".equals(band.getChargeName())
&& "EUR/件".equals(band.getPriceUnit()) && band.getWeightMax().compareTo(java.math.BigDecimal.ONE) == 0));
assertTrue(ctt.getWeightBands().stream().noneMatch(band -> band.getWeightLabel() != null && band.getWeightLabel().contains("Additional")));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "每公斤续重".equals(fee.getFeeName())
&& "2. INSULAR RATES / STANDARD".equals(fee.getSectionName())
&& "Canary Islands / Minor Islands".equals(fee.getZoneName()) && decimalFee(fee, "1.86")
&& "EUR".equals(fee.getCurrency()) && "EUR/KG".equals(fee.getPriceUnit())
&& "EXTRA_KG".equals(fee.getCalcRule()) && "1".equals(fee.getCalcParam())));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "2. INSULAR RATES / EXPRESS".equals(band.getSectionName())
&& "Canary Islands / Mayor Islands".equals(band.getZoneName()) && "Up to 1 kg".equals(band.getWeightLabel())
&& decimal(band, "7.46")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Price WITH CUSTOMS CLEARANCE / STANDARD".equals(band.getSectionName())
&& "Canary Islands (*) / Mayor Islands".equals(band.getZoneName()) && decimal(band, "5.33")));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "每公斤续重".equals(fee.getFeeName())
&& "Price WITH CUSTOMS CLEARANCE / EXPRESS".equals(fee.getSectionName())
&& "Portuguese Island / Island".equals(fee.getZoneName()) && decimalFee(fee, "5.79")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> band.getSectionName().contains("CEUTA")
&& "Ceuta".equals(band.getZoneName()) && "Up to 1 kg".equals(band.getWeightLabel()) && decimal(band, "7.61")));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "每公斤续重".equals(fee.getFeeName())
&& fee.getSectionName().contains("CEUTA") && "Andorra".equals(fee.getZoneName()) && decimalFee(fee, "2.85")));
assertTrue(ctt.getSurcharges().stream().filter(fee -> "Cubic Capacity".equals(fee.getFeeName()))
.allMatch(fee -> fee.getAmount() == null && "MANUAL".equals(fee.getCalcRule())));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "Cubic Capacity".equals(fee.getFeeName())
&& "2. INSULAR RATES / STANDARD".equals(fee.getSectionName())
&& fee.getChargeText().contains("167 kg/m3") && fee.getChargeText().contains("333 kg/m3")
&& fee.getFeeDesc().contains("Balearic Islands Economy 48H / Majorca")
&& fee.getFeeDesc().contains("Canary Islands / Interislands")));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "Cubic Capacity".equals(fee.getFeeName())
&& "2. INSULAR RATES / EXPRESS".equals(fee.getSectionName())
&& fee.getFeeDesc().contains("Portuguese Island / Island")));
assertTrue(ctt.getSurcharges().stream().noneMatch(fee -> "Cubic Capacity".equals(fee.getFeeName())
&& fee.getSectionName() != null && fee.getSectionName().contains("CEUTA")));
assertTrue(ctt.getWeightBands().stream().noneMatch(band -> band.getZoneName() != null && band.getZoneName().contains("19E")));
assertTrue(ctt.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null && (fee.getFeeName().contains("计费重量")
|| fee.getFeeName().contains("4.95") || fee.getFeeName().contains("退货"))));
PayOrderCostResult oneKg = PayCostCalculator.calculate(ctt, new java.math.BigDecimal("1"), null, null, null, null,
"Balearic Islands Economy 48H / Majorca", null, 1);
assertEquals(new java.math.BigDecimal("3.39"), oneKg.getPayTotal());
assertTrue(oneKg.getFees().stream().noneMatch(fee -> "每公斤续重".equals(fee.getFeeName())));
PayOrderCostResult twoKg = PayCostCalculator.calculate(ctt, new java.math.BigDecimal("2"), null, null, null, null,
"Balearic Islands Economy 48H / Majorca", null, 1);
assertEquals(new java.math.BigDecimal("4.24"), twoKg.getPayTotal());
assertEquals(1, twoKg.getFees().stream().filter(fee -> "每公斤续重".equals(fee.getFeeName())
&& fee.getAmount().compareTo(new java.math.BigDecimal("0.85")) == 0).count());
PayOrderCostResult island = PayCostCalculator.calculate(ctt, new java.math.BigDecimal("2"), null, null, null, null,
"Portuguese Island / Island", null, 1);
assertEquals(new java.math.BigDecimal("12.41"), island.getPayTotal());
assertEquals(1, island.getFees().stream().filter(fee -> "每公斤续重".equals(fee.getFeeName())
&& fee.getAmount().compareTo(new java.math.BigDecimal("3.34")) == 0).count());
}
@Test
void spainCttFillsCountryAndContinuedWeight() throws Exception
{
Path file = Path.of("D:/payment/西班牙CTT.xlsx");
if (!Files.exists(file))
{
file = Path.of("D:/A-payment/西班牙CTT.xlsx");
}
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "西班牙CTT",
List.of("西班牙CTT"));
PayProduct ctt = preview.getProducts().get(0);
assertEquals("OK", ctt.getParseStatus());
assertEquals("西班牙", ctt.getCountryScope());
assertEquals("续重计费", ctt.getChargeType());
assertTrue(covers(ctt.getExtraPerKg(), "0.82"));
assertEquals(60, ctt.getWeightBands().size());
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "西班牙".equals(band.getCountryName()) && "ES".equals(band.getCountryCode())
&& "Mainland / Mad High Density".equals(band.getZoneName()) && "1kg".equals(band.getWeightLabel())
&& decimal(band, "2.1") && "每件".equals(band.getChargeName())
&& band.getWeightMin().compareTo(java.math.BigDecimal.ZERO) == 0
&& band.getWeightMax().compareTo(java.math.BigDecimal.ONE) == 0));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Mainland / Portugal".equals(band.getZoneName())
&& "5kg".equals(band.getWeightLabel()) && decimal(band, "3.95")
&& band.getWeightMin().compareTo(new java.math.BigDecimal("4")) == 0));
assertTrue(ctt.getWeightBands().stream().noneMatch(band -> band.getWeightLabel() != null
&& (band.getWeightLabel().contains("Kg.ad") || band.getWeightLabel().contains("预估"))));
assertTrue(ctt.getWeightBands().stream().noneMatch(band -> "每公斤".equals(band.getChargeName())));
assertEquals(12, ctt.getSurcharges().stream().filter(fee -> "每公斤续重".equals(fee.getFeeName())).count());
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "每公斤续重".equals(fee.getFeeName())
&& "Balearic Islands Economy 48H / Majorca".equals(fee.getZoneName()) && decimalFee(fee, "0.82")
&& "EUR".equals(fee.getCurrency()) && "EUR/KG".equals(fee.getPriceUnit())
&& "EXTRA_KG".equals(fee.getCalcRule()) && "5".equals(fee.getCalcParam())
&& fee.getFeeDesc().contains("超过5kg")));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "每公斤续重".equals(fee.getFeeName())
&& "Canary Islands / Minor Islands".equals(fee.getZoneName()) && decimalFee(fee, "1.83")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Canary Islands / Minor Islands".equals(band.getZoneName())
&& "1kg".equals(band.getWeightLabel()) && decimal(band, "13.11")));
}
@Test
void posteItalianeKeepsTwoDeliveryServicesAndSizeRules() throws Exception
{
Path file = Path.of("D:/payment/PosteItaliane.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), null, List.of("PosteItaliane"));
PayProduct poste = preview.getProducts().get(0);
assertEquals("OK", poste.getParseStatus());
assertEquals(18, poste.getWeightBands().size(), poste.getWeightBands().stream()
.map(band -> band.getChargeName() + "/" + band.getZoneName() + "/" + band.getWeightLabel() + "/" + band.getPrice())
.toList().toString());
assertEquals(9, poste.getWeightBands().stream().filter(band -> "派送费 Crono Express".equals(band.getChargeName())).count());
assertEquals(9, poste.getWeightBands().stream().filter(band -> "派送费 Crono".equals(band.getChargeName())).count());
assertTrue(poste.getWeightBands().stream().anyMatch(band -> "派送费 Crono Express".equals(band.getChargeName())
&& "City".equals(band.getZoneName()) && band.getWeightLabel().contains("0") && decimal(band, "3")
&& "EUR/ITEM".equals(band.getPriceUnit()) && "意大利(IT)".equals(band.getCountryName()) && "IT".equals(band.getCountryCode())));
assertTrue(poste.getWeightBands().stream().anyMatch(band -> "派送费 Crono Express".equals(band.getChargeName())
&& "Province".equals(band.getZoneName()) && band.getWeightLabel().contains("5") && decimal(band, "6.63")));
assertTrue(poste.getWeightBands().stream().anyMatch(band -> "派送费 Crono".equals(band.getChargeName())
&& "Rural".equals(band.getZoneName()) && band.getWeightLabel().contains("2") && decimal(band, "4.38")
&& "意大利(IT)".equals(band.getCountryName())));
assertTrue(poste.getWeightBands().stream().anyMatch(band -> "派送费 Crono".equals(band.getChargeName())
&& "Province".equals(band.getZoneName()) && band.getWeightLabel().contains("5") && decimal(band, "5.83")));
assertTrue(poste.getExtraLines().stream().anyMatch(line -> "配置".equals(line.getLineType())
&& line.getLineText().contains("尺寸限制") && line.getLineText().contains("长+宽+高≤130cm")
&& line.getLineText().contains("最长边≤60cm")));
assertTrue(poste.getExtraLines().stream().anyMatch(line -> line.getLineText().contains("计费说明") && line.getLineText().contains("计费重")));
assertTrue(poste.getSurcharges().stream().anyMatch(fee -> "销毁费".equals(fee.getFeeName()) && decimalFee(fee, "0.2")
&& "EUR/ITEM".equals(fee.getPriceUnit()) && "产生时收取".equals(fee.getChargeTiming())));
assertTrue(poste.getSurcharges().stream().anyMatch(fee -> "退件重派费".equals(fee.getFeeName())
&& fee.getChargeText().contains("同正向运费") && "产生时收取".equals(fee.getChargeTiming())));
assertTrue(poste.getSurcharges().stream().filter(fee -> "产生时收取".equals(fee.getChargeTiming())).count() >= 6);
}
private PayPreview parse(String prefix) throws Exception
{
Path dir = Path.of("D:/应付供应商");
Path file;
try (Stream<Path> files = Files.list(dir))
{
file = files.filter(path -> path.getFileName().toString().startsWith(prefix) && path.toString().endsWith(".xlsx"))
.findFirst().orElseThrow();
}
return parser.parse(Files.readAllBytes(file), file.getFileName().toString());
}
private void assertPanEuroPrices(PayProduct dhl)
{
assertEquals(52, dhl.getWeightBands().size(), dhl.getWeightBands().stream()
.map(band -> band.getCountryCode() + "/" + band.getChargeName() + "/" + band.getPrice()).toList().toString());
assertEquals(26, dhl.getWeightBands().stream().map(PayWeightBand::getCountryCode).distinct().count());
assertEquals(26, dhl.getWeightBands().stream().filter(band -> "处理费".equals(band.getChargeName())).count());
assertEquals(26, dhl.getWeightBands().stream().filter(band -> "速递运费".equals(band.getChargeName())).count());
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "DK".equals(band.getCountryCode())
&& "处理费".equals(band.getChargeName()) && decimal(band, "9.71") && "EUR/ITEM".equals(band.getPriceUnit())));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "DK".equals(band.getCountryCode())
&& "速递运费".equals(band.getChargeName()) && decimal(band, "0.23") && "EUR/KG".equals(band.getPriceUnit())));
assertTrue(dhl.getServiceNote() != null && dhl.getServiceNote().contains("报价已含燃油"));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "其他费用".equals(fee.getSectionName())
&& "道路通行费".equals(fee.getFeeName()) && decimalFee(fee, "0.19")
&& "EUR".equals(fee.getCurrency()) && "EUR/ITEM".equals(fee.getPriceUnit())));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "旺季附加费".equals(fee.getFeeName()) && decimalFee(fee, "0.19")
&& fee.getEffectiveStart() != null));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "高峰旺季附加费".equals(fee.getFeeName()) && decimalFee(fee, "0.5")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "超尺寸超重费".equals(fee.getFeeName()) && decimalFee(fee, "14")
&& "产生时收取".equals(fee.getChargeTiming())));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "路由码选择附加费".equals(fee.getFeeName()) && decimalFee(fee, "0.49")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "重量信息修正附加费".equals(fee.getFeeName()) && decimalFee(fee, "0.99")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "未预报附加费".equals(fee.getFeeName())
&& fee.getChargeText().contains("31.5kg")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "托收附加费".equals(fee.getFeeName()) && decimalFee(fee, "2.2")
&& textOf(fee.getFeeDesc()).contains("Supplemental")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "提供虚假信息罚款费".equals(fee.getFeeName()) && decimalFee(fee, "18")
&& textOf(fee.getFeeDesc()).contains("故意")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "高风险地区附加费".equals(fee.getFeeName())
&& textOf(fee.getChargeText()).contains("高风险")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "退件费".equals(fee.getFeeName()) && decimalFee(fee, "20")
&& textOf(fee.getFeeDesc()).contains("空运")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "退件费".equals(fee.getFeeName()) && decimalFee(fee, "10")
&& textOf(fee.getFeeDesc()).contains("陆运")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "重派费".equals(fee.getFeeName())
&& textOf(fee.getChargeText()).contains("同正向运费")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "退件操作费".equals(fee.getFeeName()) && decimalFee(fee, "0.5")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "重派操作费".equals(fee.getFeeName()) && decimalFee(fee, "1")));
}
private PayProduct product(PayPreview preview, String name)
{
return preview.getProducts().stream().filter(item -> name.equals(item.getProductName())).findFirst().orElseThrow();
}
private boolean covers(String extraPerKg, String price)
{
if (extraPerKg == null || extraPerKg.isBlank())
{
return false;
}
java.math.BigDecimal value = new java.math.BigDecimal(price);
String[] parts = extraPerKg.split("~");
java.math.BigDecimal low = new java.math.BigDecimal(parts[0]);
java.math.BigDecimal high = new java.math.BigDecimal(parts[parts.length - 1]);
return low.compareTo(value) <= 0 && high.compareTo(value) >= 0;
}
private String textOf(String value)
{
return value == null ? "" : value;
}
private boolean decimal(PayWeightBand band, String price)
{
return band.getPrice() != null && band.getPrice().compareTo(new java.math.BigDecimal(price)) == 0;
}
private boolean decimalFee(PaySurcharge fee, String amount)
{
return fee.getAmount() != null && fee.getAmount().compareTo(new java.math.BigDecimal(amount)) == 0;
}
private static Path priceCard(String name)
{
Path current = Path.of("D:/A-payment").resolve(name);
if (Files.exists(current))
{
return current;
}
return Path.of("D:/payment").resolve(name);
}
private String bandText(PayWeightBand band)
{
return (band.getChargeName() == null ? "" : band.getChargeName()) + " "
+ (band.getSectionName() == null ? "" : band.getSectionName()) + " "
+ (band.getZoneName() == null ? "" : band.getZoneName());
}
private String zoneTail(PayWeightBand band)
{
String zone = band.getZoneName() == null ? "" : band.getZoneName();
return zone.contains("英国大陆") ? "英国大陆" : zone;
}
}
@@ -1,72 +0,0 @@
package com.bms.price.pay;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import com.bms.price.domain.PayPostcodeLine;
class PayZoneMatcherTest
{
private static final List<String> ZONES = List.of(
"UK Mainland\n英国大陆",
"Highlands& Islands\n高地和岛屿",
"Channel Islands\n(海峡群岛,包括Isle of Scilly锡利岛)",
"Northern Ireland\n北爱尔兰",
"BFPO\n(军事地址)",
"Isle of Man\n马恩岛");
@Test
void welshPostcodeFallsBackToMainland()
{
PayZoneMatcher.Decision decision = PayZoneMatcher.resolve(ZONES, evriLines(), "LL22 8PJ");
assertNull(decision.getMessage());
assertEquals("UK Mainland\n英国大陆", decision.getZoneName());
}
@Test
void northernIrelandAndHighlandsComeFromThePostcodeBook()
{
assertEquals("Northern Ireland\n北爱尔兰", PayZoneMatcher.resolve(ZONES, evriLines(), "BT1 1AA").getZoneName());
assertEquals("Highlands& Islands\n高地和岛屿", PayZoneMatcher.resolve(ZONES, evriLines(), "IV1 1AA").getZoneName());
assertEquals("Channel Islands\n(海峡群岛,包括Isle of Scilly锡利岛)",
PayZoneMatcher.resolve(ZONES, evriLines(), "TR21 1AA").getZoneName());
assertEquals("Isle of Man\n马恩岛", PayZoneMatcher.resolve(ZONES, evriLines(), "IM1 1AA").getZoneName());
assertEquals("BFPO\n(军事地址)", PayZoneMatcher.resolve(ZONES, evriLines(), "BF1 0AA").getZoneName());
}
@Test
void productNameKeepsTheCarrierToken()
{
assertEquals(List.of("evri"), PayZoneMatcher.searchTokens("英国evri"));
assertEquals(List.of("ubi", "evri"), PayZoneMatcher.searchTokens("UBI英国EVRi普"));
}
@Test
void missingPostcodeIsRejected()
{
PayZoneMatcher.Decision decision = PayZoneMatcher.resolve(ZONES, evriLines(), " ");
assertTrue(decision.getMessage().contains("没有邮编"));
}
private static List<PayPostcodeLine> evriLines()
{
return List.of(
line("Channel Islands", "GY"),
line("Isle of Man", "IM"),
line("Northern Ireland", "BT"),
line("Scilly Isles", "TR21"),
line("Scottish H&I (Finance)", "IV"),
line("Scottish H&I (Finance)", "HS"),
line("Shetlands", "ZE"));
}
private static PayPostcodeLine line(String region, String postcode)
{
PayPostcodeLine line = new PayPostcodeLine();
line.setRegionName(region);
line.setPostcode(postcode);
return line;
}
}
@@ -1,83 +0,0 @@
package com.bms.report;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.lang.reflect.Proxy;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import com.bms.ar.domain.ArBalanceWriteoff;
import com.bms.ar.domain.ArBill;
import com.bms.ar.mapper.ArCommissionMapper;
import com.bms.ar.profit.ArCommissionRate;
import com.bms.ar.profit.ArCommissionResult;
import com.bms.ar.profit.ArCommissionService;
class ArCommissionServiceTest
{
@Test
void snapshotsTheRateOnceAtWriteoffAndPreservesMissingRates()
{
List<String> calls = new ArrayList<>();
AtomicReference<Object[]> inserted = new AtomicReference<>();
AtomicReference<ArCommissionRate> effective = new AtomicReference<>();
ArCommissionMapper mapper = (ArCommissionMapper) Proxy.newProxyInstance(getClass().getClassLoader(),
new Class<?>[] { ArCommissionMapper.class }, (proxy, method, args) -> {
calls.add(method.getName());
return switch (method.getName()) {
case "lockSalesUser" -> 7L;
case "selectEffective" -> effective.get();
case "insertWriteoffRate" -> { inserted.set(args.clone()); yield 1; }
default -> throw new AssertionError(method.getName());
};
});
ArCommissionService service = new ArCommissionService(mapper, null, null, null);
ArBill bill = new ArBill();
bill.setProfitSalesUserId(7L);
ArBalanceWriteoff writeoff = new ArBalanceWriteoff();
writeoff.setWriteoffId(18L);
ArCommissionRate rate = new ArCommissionRate();
rate.setRateId(3L);
rate.setRate(new BigDecimal("10"));
effective.set(rate);
service.snapshotWriteoff(bill, writeoff);
assertEquals(List.of("lockSalesUser", "selectEffective", "insertWriteoffRate"), calls);
assertEquals(3L, inserted.get()[2]);
assertEquals(new BigDecimal("10"), inserted.get()[3]);
calls.clear();
effective.set(null);
service.snapshotWriteoff(bill, writeoff);
assertNull(inserted.get()[2]);
assertNull(inserted.get()[3]);
}
@Test
void savesOnlyCalculatedAmountsForReadyRows()
{
ArCommissionResult ready = new ArCommissionResult();
ready.setResultStatus("READY");
ready.setGrossProfit(new BigDecimal("-40"));
ready.setWeightedRate(new BigDecimal("1000"));
ready.setBillAmount(new BigDecimal("100"));
ArCommissionResult missing = new ArCommissionResult();
missing.setResultStatus("MISSING_PRICE");
List<ArCommissionResult> saved = new ArrayList<>();
ArCommissionMapper mapper = (ArCommissionMapper) Proxy.newProxyInstance(getClass().getClassLoader(),
new Class<?>[] { ArCommissionMapper.class }, (proxy, method, args) -> {
if ("selectSettlementInputs".equals(method.getName())) return List.of(ready, missing);
if ("insertResults".equals(method.getName())) {
@SuppressWarnings("unchecked") List<ArCommissionResult> batch = (List<ArCommissionResult>) args[0];
saved.addAll(batch);
return batch.size();
}
throw new AssertionError(method.getName());
});
new ArCommissionService(mapper, null, null, null).createFinalResults(1L, "finance");
assertEquals(new BigDecimal("-4.00"), saved.get(0).getCommissionAmount());
assertNull(saved.get(1).getCommissionAmount());
}
}
@@ -1,68 +0,0 @@
package com.bms.report;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.InputStream;
import java.util.Map;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import com.bms.ar.profit.ArProfitQuery;
import com.bms.ar.profit.ArProfitScope;
import com.bms.ar.profit.ArCommissionResult;
class ArProfitMapperSqlTest
{
@Test
void parsesReportAndCommissionMappersAndScopesEachReportQuery() throws Exception
{
Configuration configuration = new Configuration();
for (String resource : new String[] { "mapper/ar/ArProfitMapper.xml", "mapper/ar/ArCommissionMapper.xml" })
{
try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource))
{
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
}
for (String id : new String[] { "selectBills", "selectSummary", "selectBillDetails", "selectFees" })
{
String name = "com.bms.ar.mapper.ArProfitMapper." + id;
String self = configuration.getMappedStatement(name).getBoundSql(Map.of(
"query", new ArProfitQuery(), "scope", new ArProfitScope(false, true, false, 8L, 10L))).getSql();
String manager = configuration.getMappedStatement(name).getBoundSql(Map.of(
"query", new ArProfitQuery(), "scope", new ArProfitScope(false, false, true, 8L, 10L))).getSql();
assertTrue(self.contains("b.profit_sales_user_id = ?"));
assertFalse(self.contains("find_in_set"));
assertTrue(manager.contains("find_in_set"));
assertTrue(manager.contains("b.status in"));
}
ArProfitQuery orderFilter = new ArProfitQuery();
orderFilter.setOrderNo("ORDER-1");
String bills = configuration.getMappedStatement("com.bms.ar.mapper.ArProfitMapper.selectBills")
.getBoundSql(Map.of("query", orderFilter, "scope", new ArProfitScope(true, false, false, 8L, 10L))).getSql();
assertTrue(bills.contains("from ar_bill b"));
assertTrue(bills.contains("exists (select 1 from ar_bill_detail matched"));
assertFalse(bills.contains("and d.order_no like"));
String billCommission = configuration.getMappedStatement("com.bms.ar.mapper.ArCommissionMapper.selectCommissionSummary")
.getBoundSql(Map.of("billId", 9L)).getSql();
assertTrue(billCommission.contains("r.ar_bill_id = ?"));
assertTrue(billCommission.contains("then null"));
var salesSql = configuration.getMappedStatement("com.bms.ar.mapper.ArProfitMapper.selectSalesOptions")
.getBoundSql(Map.of("keyword", "sales"));
assertTrue(salesSql.getSql().contains("from sys_user u"));
assertTrue(salesSql.getSql().contains("u.nick_name like"));
assertTrue(salesSql.getSql().contains("u.user_name like"));
assertFalse(salesSql.getSql().contains("ar_bill"));
assertFalse(salesSql.getSql().contains("sys_role"));
assertTrue(salesSql.getSql().contains("limit 50"));
String settlement = configuration.getMappedStatement("com.bms.ar.mapper.ArCommissionMapper.selectSettlementInputs")
.getBoundSql(Map.of("billId", 1L)).getSql();
assertTrue(settlement.contains("w.status = 'EFFECTIVE'"));
assertTrue(settlement.contains("b.status = 'WRITTEN_OFF'"));
var insert = configuration.getMappedStatement("com.bms.ar.mapper.ArCommissionMapper.insertResults")
.getBoundSql(Map.of("items", java.util.List.of(new ArCommissionResult(), new ArCommissionResult()), "operator", "test"));
assertTrue(insert.getSql().contains("ar_profit_commission_result"));
assertTrue(insert.getParameterMappings().size() == 16);
}
}
@@ -1,52 +0,0 @@
package com.bms.report;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import com.bms.ar.profit.ArProfitScopeResolver;
import com.bms.common.core.domain.entity.SysRole;
import com.bms.common.core.domain.entity.SysUser;
import com.bms.common.core.domain.model.LoginUser;
import com.bms.common.exception.ServiceException;
class ArProfitScopeResolverTest
{
private static final String PERMISSION = "report:arProfit:list";
private LoginUser actor(String... keys)
{
SysUser user = new SysUser();
user.setUserId(99L);
user.setStatus("0");
user.setDelFlag("0");
user.setRoles(java.util.Arrays.stream(keys).map(key -> {
SysRole role = new SysRole();
role.setRoleKey(key);
role.setStatus("0");
role.setPermissions(Set.of(PERMISSION));
return role;
}).toList());
return new LoginUser(99L, 10L, user, Set.of(PERMISSION));
}
@Test
void allowedRolesHaveOnlyTheirApprovedScope()
{
ArProfitScopeResolver resolver = new ArProfitScopeResolver();
var sales = resolver.resolve(actor("ORA_6041"), PERMISSION);
assertTrue(sales.self());
assertFalse(sales.all());
var manager = resolver.resolve(actor("ORA_6040"), PERMISSION);
assertTrue(manager.department());
assertFalse(manager.all());
assertTrue(resolver.resolve(actor("ORA_6042"), PERMISSION).all());
assertTrue(resolver.resolve(actor("ORA_6043"), PERMISSION).all());
assertThrows(ServiceException.class, () -> resolver.resolve(actor("ORA_6163"), PERMISSION));
assertThrows(ServiceException.class, () -> resolver.resolve(actor("UNKNOWN"), PERMISSION));
assertThrows(ServiceException.class, () -> resolver.resolve(actor("ORA_6042"), "other:permission"));
}
}
@@ -1,59 +0,0 @@
package com.bms.report;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import java.util.List;
import com.bms.ar.profit.CommissionCalculator;
import com.bms.ar.profit.ArCommissionResult;
import com.bms.ar.profit.CommissionCalculator.Writeoff;
import org.junit.jupiter.api.Test;
class CommissionCalculatorTest
{
private static BigDecimal n(String value) { return new BigDecimal(value); }
@Test
void waitsForFullSettlementAndUsesEachWriteoffRate()
{
var first = new Writeoff(n("50"), n("10"));
var partial = CommissionCalculator.calculate(n("100"), n("20"), List.of(first));
assertEquals(n("1.00"), partial.estimated());
assertNull(partial.formal());
var complete = CommissionCalculator.calculate(n("100"), n("20"),
List.of(first, new Writeoff(n("50"), n("20"))));
assertEquals(n("3.00"), complete.formal());
}
@Test
void deductsNegativeProfitAndKeepsMissingRatesUnknown()
{
assertEquals(n("-4.00"), CommissionCalculator.calculate(n("100"), n("-40"),
List.of(new Writeoff(n("100"), n("10")))).formal());
assertNull(CommissionCalculator.calculate(n("100"), n("20"),
List.of(new Writeoff(n("100"), null))).formal());
assertThrows(IllegalArgumentException.class, () -> CommissionCalculator.calculate(n("100"), n("20"),
List.of(new Writeoff(n("101"), n("10")))));
}
@Test
void allocatesRoundingRemainderAcrossOrderResults()
{
List<ArCommissionResult> orders = new java.util.ArrayList<>();
for (int i = 0; i < 3; i++)
{
ArCommissionResult order = new ArCommissionResult();
order.setResultStatus("READY");
order.setBillAmount(n("100"));
order.setGrossProfit(n("0.05"));
order.setWeightedRate(n("1000"));
orders.add(order);
}
CommissionCalculator.allocateFinalAmounts(orders);
assertEquals(n("0.01"), orders.get(0).getCommissionAmount());
assertEquals(n("0.00"), orders.get(1).getCommissionAmount());
assertEquals(n("0.01"), orders.get(2).getCommissionAmount());
}
}
@@ -1,53 +0,0 @@
package com.bms.report;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.lang.reflect.Proxy;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicInteger;
import com.bms.common.exception.ServiceException;
import com.bms.price.domain.vo.PrcProfitReportQuery;
import com.bms.price.mapper.PrcProfitReportMapper;
import com.bms.price.service.impl.PrcProfitReportServiceImpl;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
class ProfitAreaRuleTest
{
@Test
void reportQueriesResolveRuleOnServerAndRejectUnknownAreas()
{
AtomicInteger calls = new AtomicInteger();
PrcProfitReportMapper mapper = (PrcProfitReportMapper) Proxy.newProxyInstance(
getClass().getClassLoader(), new Class<?>[] { PrcProfitReportMapper.class },
(proxy, method, args) -> {
calls.incrementAndGet();
return Collections.emptyList();
});
PrcProfitReportServiceImpl service = new PrcProfitReportServiceImpl();
ReflectionTestUtils.setField(service, "profitReportMapper", mapper);
PrcProfitReportQuery query = new PrcProfitReportQuery();
query.setCustomerArea(" GZ ");
query.setProfitRule("SHENZHEN");
service.selectSummary(query);
assertEquals("GZ", query.getCustomerArea());
assertEquals("GUANGZHOU", query.getProfitRule());
query.setCustomerArea("C");
query.setProfitRule("GUANGZHOU");
service.selectOrderRows(query);
assertEquals("SHENZHEN", query.getProfitRule());
service.selectProductRows(query);
assertEquals(3, calls.get());
query.setCustomerArea("UNKNOWN");
assertThrows(ServiceException.class, () -> service.selectProductRows(query));
query.setCustomerArea("");
assertThrows(ServiceException.class, () -> service.selectSummary(query));
assertEquals(3, calls.get());
assertEquals("SHENZHEN", service.selectAreaRules().get("C"));
assertEquals("GUANGZHOU", service.selectAreaRules().get("GZ"));
}
}
@@ -1,209 +0,0 @@
package com.bms.web.controller.system;
import com.bms.common.constant.CacheConstants;
import com.bms.common.constant.Constants;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.domain.entity.SysUser;
import com.bms.common.core.domain.entity.SysRole;
import com.bms.common.core.domain.model.LoginUser;
import com.bms.common.core.redis.RedisCache;
import com.bms.common.exception.ServiceException;
import com.bms.framework.web.service.TokenService;
import com.bms.framework.web.service.UserDetailsServiceImpl;
import com.bms.framework.config.FastJson2JsonRedisSerializer;
import com.bms.system.service.ISysUserService;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class SysImpersonationTest {
private final ISysUserService users = mock(ISysUserService.class);
private final UserDetailsServiceImpl details = mock(UserDetailsServiceImpl.class);
private final TokenService tokens = mock(TokenService.class);
private final SysImpersonationController controller = new SysImpersonationController(users, details, tokens);
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void onlySuperAdministratorCanStart() {
authenticate(loginUser(2L, "sales"));
assertThrows(ServiceException.class, () -> controller.start(3L));
verifyNoInteractions(users, details, tokens);
}
@Test
void rejectsDisabledAndAdministratorTargets() {
authenticate(loginUser(1L, "admin"));
SysUser disabled = user(2L, "sales");
disabled.setStatus("1");
when(users.selectUserById(2L)).thenReturn(disabled);
assertThrows(ServiceException.class, () -> controller.start(2L));
assertThrows(ServiceException.class, () -> controller.start(1L));
verifyNoInteractions(details, tokens);
}
@Test
void issuedSessionUsesTargetIdentityAndRetainsAdministrator() {
LoginUser admin = loginUser(1L, "admin");
admin.setToken("parent-session");
authenticate(admin);
SysUser target = user(2L, "sales");
LoginUser targetLogin = new LoginUser(2L, 10L, target, Collections.singleton("bill:list"));
when(users.selectUserById(2L)).thenReturn(target);
when(details.createLoginUser(target)).thenReturn(targetLogin);
when(tokens.createToken(targetLogin)).thenReturn("target-jwt");
long started = System.currentTimeMillis();
AjaxResult result = controller.start(2L);
assertEquals("target-jwt", result.get(Constants.TOKEN));
assertEquals(2L, targetLogin.getUserId());
assertEquals(1L, targetLogin.getImpersonatorId());
assertEquals("parent-session", targetLogin.getImpersonatorToken());
assertTrue(targetLogin.getImpersonationExpireTime() >= started + 29 * 60_000L);
assertTrue(targetLogin.getImpersonationExpireTime() <= System.currentTimeMillis() + 30 * 60_000L);
verify(users).checkUserDataScope(2L);
}
@Test
void rejectsTargetWithAdministratorRole() {
authenticate(loginUser(1L, "admin"));
SysUser target = user(2L, "other-admin");
SysRole role = new SysRole();
role.setRoleId(1L);
target.setRoles(Collections.singletonList(role));
when(users.selectUserById(2L)).thenReturn(target);
assertThrows(ServiceException.class, () -> controller.start(2L));
verifyNoInteractions(details, tokens);
}
@Test
void impersonationContextSurvivesRedisSerialization() {
LoginUser acting = loginUser(2L, "sales");
acting.setImpersonatorId(1L);
acting.setImpersonatorName("admin");
acting.setImpersonatorToken("parent");
acting.setImpersonationExpireTime(System.currentTimeMillis() + 60_000L);
FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
LoginUser restored = (LoginUser) serializer.deserialize(serializer.serialize(acting));
assertTrue(restored.isImpersonating());
assertEquals(1L, restored.getImpersonatorId());
assertEquals("parent", restored.getImpersonatorToken());
assertEquals(acting.getImpersonationExpireTime(), restored.getImpersonationExpireTime());
}
@Test
void rejectsSessionWhenAdministratorSessionWasRevoked() {
RedisCache redis = mock(RedisCache.class);
TokenService service = tokenService(redis);
LoginUser acting = loginUser(2L, "sales");
acting.setImpersonatorId(1L);
acting.setImpersonatorToken("parent");
acting.setImpersonationExpireTime(System.currentTimeMillis() + 60_000L);
when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + "acting")).thenReturn(acting);
when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + "parent")).thenReturn(null);
assertNull(service.getLoginUser(request("acting")));
verify(redis).deleteObject(CacheConstants.LOGIN_TOKEN_KEY + "acting");
}
@Test
void rejectsExpiredSessionEvenWhenAdministratorIsStillLoggedIn() {
RedisCache redis = mock(RedisCache.class);
TokenService service = tokenService(redis);
LoginUser acting = loginUser(2L, "sales");
acting.setImpersonatorId(1L);
acting.setImpersonatorToken("parent");
acting.setImpersonationExpireTime(System.currentTimeMillis() - 1);
when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + "acting")).thenReturn(acting);
when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + "parent")).thenReturn(loginUser(1L, "admin"));
assertNull(service.getLoginUser(request("acting")));
verify(redis).deleteObject(CacheConstants.LOGIN_TOKEN_KEY + "acting");
}
@Test
void exitRevokesOnlyTheImpersonationSession() {
LoginUser acting = loginUser(2L, "sales");
acting.setToken("acting");
acting.setImpersonatorId(1L);
acting.setImpersonatorToken("parent");
authenticate(acting);
assertEquals(200, controller.exit().get("code"));
verify(tokens).delLoginUser("acting");
verify(tokens, never()).delLoginUser("parent");
}
@Test
void refreshCannotExtendPastAbsoluteDeadline() {
RedisCache redis = mock(RedisCache.class);
TokenService service = tokenService(redis);
LoginUser acting = loginUser(2L, "sales");
acting.setToken("acting");
acting.setImpersonatorId(1L);
long deadline = System.currentTimeMillis() + 45_000L;
acting.setImpersonationExpireTime(deadline);
service.refreshToken(acting);
assertEquals(deadline, acting.getExpireTime());
ArgumentCaptor<Integer> ttl = ArgumentCaptor.forClass(Integer.class);
verify(redis).setCacheObject(eq(CacheConstants.LOGIN_TOKEN_KEY + "acting"), same(acting), ttl.capture(), eq(TimeUnit.SECONDS));
assertTrue(ttl.getValue() > 0 && ttl.getValue() <= 45);
}
private static final String SECRET = "test-secret-for-impersonation-verification-012345678901234567890123456789";
private TokenService tokenService(RedisCache redis) {
TokenService service = new TokenService();
ReflectionTestUtils.setField(service, "redisCache", redis);
ReflectionTestUtils.setField(service, "header", "Authorization");
ReflectionTestUtils.setField(service, "secret", SECRET);
ReflectionTestUtils.setField(service, "expireTime", 1440);
return service;
}
private MockHttpServletRequest request(String uuid) {
String jwt = Jwts.builder().setClaims(Map.of(Constants.LOGIN_USER_KEY, uuid))
.signWith(SignatureAlgorithm.HS512, SECRET).compact();
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer " + jwt);
return request;
}
private LoginUser loginUser(long id, String name) {
SysUser user = user(id, name);
return new LoginUser(id, 10L, user, Collections.emptySet());
}
private SysUser user(long id, String name) {
SysUser user = new SysUser(id);
user.setUserName(name);
user.setStatus("0");
user.setDelFlag("0");
return user;
}
private void authenticate(LoginUser user) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()));
}
}
@@ -75,24 +75,6 @@ public class LoginUser implements UserDetails
*/
private SysUser user;
/** 代登录的实际操作人及其原会话标识,仅保存在服务端登录态中。 */
private Long impersonatorId;
private String impersonatorName;
private String impersonatorToken;
private Long impersonationExpireTime;
public Long getImpersonatorId() { return impersonatorId; }
public void setImpersonatorId(Long impersonatorId) { this.impersonatorId = impersonatorId; }
public String getImpersonatorName() { return impersonatorName; }
public void setImpersonatorName(String impersonatorName) { this.impersonatorName = impersonatorName; }
public String getImpersonatorToken() { return impersonatorToken; }
public void setImpersonatorToken(String impersonatorToken) { this.impersonatorToken = impersonatorToken; }
public Long getImpersonationExpireTime() { return impersonationExpireTime; }
public void setImpersonationExpireTime(Long impersonationExpireTime) { this.impersonationExpireTime = impersonationExpireTime; }
@JSONField(serialize = false)
public boolean isImpersonating() { return impersonatorId != null; }
public LoginUser()
{
}
@@ -168,35 +168,6 @@ public class StringUtils extends org.apache.commons.lang3.StringUtils
return (str == null ? "" : str.trim());
}
/**
* Normalize destination postcode for zone match / persist.
* Removes all Unicode whitespace (incl. NBSP / U+2005) and ASCII spaces;
* strips Excel-style trailing ".0" on pure digits.
*/
public static String normalizePostcode(String postcode)
{
if (isEmpty(postcode))
{
return postcode;
}
StringBuilder sb = new StringBuilder(postcode.length());
for (int i = 0; i < postcode.length(); )
{
int cp = postcode.codePointAt(i);
if (!Character.isWhitespace(cp))
{
sb.appendCodePoint(cp);
}
i += Character.charCount(cp);
}
String n = sb.toString();
if (n.endsWith(".0") && n.matches("\\d+\\.0"))
{
return n.substring(0, n.length() - 2);
}
return n;
}
/**
* 替换指定字符串的指定区间内字符为"*"
*
@@ -1,358 +0,0 @@
package com.bms.common.utils.poi;
import java.math.BigDecimal;
import java.util.Date;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Hyperlink;
import org.apache.poi.xssf.usermodel.XSSFHyperlink;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.CellRangeAddress;
import com.bms.common.utils.DateUtils;
/**
* 把一个工作表读成文本网格。
* 合并单元格只保留左上角的原文,不把国家、处理费、超重费再填到每一段重量上。
*/
public class ExcelGrid
{
/** 单表最多读取的行数,避免样式撑满的空行把内存打满 */
public static final int MAX_ROWS = 2500;
/** 单表最多读取的列数。价卡分区列不会超过这个宽度 */
public static final int MAX_COLS = 60;
private final String[][] text;
private final Double[][] number;
private final String[][] linkSheet;
/** 合并区域左上角横向占几列,其余单元格为 0 */
private final int[][] colSpan;
/** 纵向合并时,被盖住的格子指向左上角行;没有纵向合并是 -1 */
private final int[][] rowAnchor;
private final int rows;
private final int cols;
private ExcelGrid(String[][] text, Double[][] number, String[][] linkSheet, int[][] colSpan, int[][] rowAnchor, int rows, int cols)
{
this.text = text;
this.number = number;
this.linkSheet = linkSheet;
this.colSpan = colSpan;
this.rowAnchor = rowAnchor;
this.rows = rows;
this.cols = cols;
}
/**
* 读取工作表。日期写成 yyyy-MM-dd,不把日期序列号当成金额。
*/
public static ExcelGrid read(Sheet sheet)
{
return read(sheet, MAX_ROWS);
}
/**
* 按指定行数上限读取。邮编分区表会超过价卡默认的 2500 行。
*/
public static ExcelGrid read(Sheet sheet, int maxRows)
{
return read(sheet, maxRows, null);
}
/**
* 读取工作表,并在扫描、填值时回报行号。progress 为空时与 {@link #read(Sheet, int)} 相同。
* phase 0 是统计列宽,phase 1 是读取单元格。
*/
public static ExcelGrid read(Sheet sheet, int maxRows, ReadProgress progress)
{
int limit = maxRows < 1 ? MAX_ROWS : maxRows;
int lastRow = sheet == null || sheet.getLastRowNum() < 0 ? -1 : Math.min(sheet.getLastRowNum(), limit - 1);
int cols = 0;
if (sheet != null)
{
for (int r = 0; r <= lastRow; r++)
{
Row row = sheet.getRow(r);
if (row == null || row.getLastCellNum() <= 0)
{
continue;
}
int lastCell = Math.min(row.getLastCellNum(), MAX_COLS);
for (int c = 0; c < lastCell; c++)
{
Cell cell = row.getCell(c);
if (hasValue(cell) && c + 1 > cols)
{
cols = c + 1;
}
}
reportRow(progress, 0, r, lastRow + 1);
}
}
int rows = lastRow + 1;
String[][] text = new String[Math.max(rows, 0)][Math.max(cols, 0)];
Double[][] number = new Double[Math.max(rows, 0)][Math.max(cols, 0)];
String[][] linkSheet = new String[Math.max(rows, 0)][Math.max(cols, 0)];
int[][] colSpan = new int[Math.max(rows, 0)][Math.max(cols, 0)];
int[][] rowAnchor = new int[Math.max(rows, 0)][Math.max(cols, 0)];
for (int r = 0; r < rowAnchor.length; r++)
{
for (int c = 0; c < rowAnchor[r].length; c++)
{
rowAnchor[r][c] = -1;
}
}
if (sheet != null && rows > 0 && cols > 0)
{
for (int r = 0; r < rows; r++)
{
Row row = sheet.getRow(r);
if (row == null)
{
continue;
}
for (int c = 0; c < cols; c++)
{
fill(row.getCell(c), text, number, linkSheet, r, c);
}
reportRow(progress, 1, r, rows);
}
for (CellRangeAddress region : sheet.getMergedRegions())
{
int r0 = Math.max(region.getFirstRow(), 0);
int c0 = Math.max(region.getFirstColumn(), 0);
if (r0 >= rows || c0 >= cols)
{
continue;
}
int width = region.getLastColumn() - region.getFirstColumn() + 1;
if (width > 1)
{
colSpan[r0][c0] = width;
}
int bottom = Math.min(region.getLastRow(), rows - 1);
int right = Math.min(region.getLastColumn(), cols - 1);
if (bottom > r0)
{
for (int r = r0 + 1; r <= bottom; r++)
{
for (int c = c0; c <= right; c++)
{
rowAnchor[r][c] = r0;
}
}
}
}
}
return new ExcelGrid(text, number, linkSheet, colSpan, rowAnchor, rows, cols);
}
/** 读取进度。大约每 20 行回报一次,避免大表回调过密。 */
private static void reportRow(ReadProgress progress, int phase, int row, int total)
{
if (progress == null || total < 1)
{
return;
}
if (row % 20 != 0 && row != total - 1)
{
return;
}
progress.onRow(phase, row, total);
}
/** 工作表读取进度。phase 0 统计列宽,phase 1 读取单元格。 */
@FunctionalInterface
public interface ReadProgress
{
void onRow(int phase, int row, int total);
}
public int rows()
{
return rows;
}
public int cols()
{
return cols;
}
public String text(int row, int col)
{
if (row < 0 || col < 0 || row >= rows || col >= cols)
{
return "";
}
return text[row][col] == null ? "" : text[row][col];
}
/** 本格有字就用本格。纵向合并盖住的空格,用合并区左上角的原文。 */
public String mergedText(int row, int col)
{
String own = text(row, col);
if (!own.isEmpty() || row < 0 || col < 0 || row >= rows || col >= cols)
{
return own;
}
int anchor = rowAnchor[row][col];
return anchor < 0 ? "" : text(anchor, col);
}
public Double number(int row, int col)
{
if (row < 0 || col < 0 || row >= rows || col >= cols)
{
return null;
}
return number[row][col];
}
/** 单元格内部超链接指向的工作表名,没有链接时返回空串 */
public String linkSheet(int row, int col)
{
if (row < 0 || col < 0 || row >= rows || col >= cols || linkSheet[row][col] == null)
{
return "";
}
return linkSheet[row][col];
}
/** 该格是横向合并的左上角时,返回合并了几列;普通格返回 1 */
public int colSpan(int row, int col)
{
if (row < 0 || col < 0 || row >= rows || col >= cols)
{
return 1;
}
return colSpan[row][col] > 1 ? colSpan[row][col] : 1;
}
public boolean blankRow(int row)
{
if (row < 0 || row >= rows)
{
return true;
}
for (int c = 0; c < cols; c++)
{
if (!isBlank(text[row][c]))
{
return false;
}
}
return true;
}
private static void fill(Cell cell, String[][] text, Double[][] number, String[][] linkSheet, int r, int c)
{
if (cell == null)
{
return;
}
linkSheet[r][c] = sheetName(cell.getHyperlink());
CellType type = cell.getCellType();
if (type == CellType.FORMULA)
{
type = cell.getCachedFormulaResultType();
}
if (type == CellType.NUMERIC)
{
if (DateUtil.isCellDateFormatted(cell))
{
Date date = cell.getDateCellValue();
text[r][c] = date == null ? "" : DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD, date);
return;
}
double raw = cell.getNumericCellValue();
number[r][c] = raw;
text[r][c] = BigDecimal.valueOf(raw).stripTrailingZeros().toPlainString();
return;
}
if (type == CellType.STRING)
{
text[r][c] = clean(cell.getStringCellValue());
number[r][c] = plainNumber(text[r][c]);
return;
}
if (type == CellType.BOOLEAN)
{
text[r][c] = Boolean.toString(cell.getBooleanCellValue());
}
}
private static boolean hasValue(Cell cell)
{
if (cell == null || cell.getCellType() == CellType.BLANK)
{
return false;
}
if (cell.getCellType() == CellType.STRING)
{
return !isBlank(cell.getStringCellValue());
}
return true;
}
private static Double plainNumber(String text)
{
if (text == null || !text.matches("-?\\d+(\\.\\d+)?"))
{
return null;
}
try
{
return Double.valueOf(text);
}
catch (NumberFormatException ex)
{
return null;
}
}
/** 内部链接形如 目录!A1 或 'evri 偏远'!A1,这里只留工作表名 */
static String sheetName(Hyperlink link)
{
if (link == null)
{
return "";
}
String location = link instanceof XSSFHyperlink ? ((XSSFHyperlink) link).getLocation() : "";
if (isBlank(location))
{
location = link.getAddress();
}
if (isBlank(location) || !location.contains("!"))
{
return "";
}
String name = location.substring(0, location.lastIndexOf('!')).trim();
if (name.startsWith("'") && name.endsWith("'") && name.length() >= 2)
{
name = name.substring(1, name.length() - 1).replace("''", "'");
}
return name.trim();
}
private static String clean(String value)
{
if (value == null)
{
return "";
}
return value.replace('\u00A0', ' ').replace('\r', ' ').trim();
}
private static boolean isBlank(String value)
{
return value == null || value.trim().isEmpty();
}
}
@@ -101,7 +101,7 @@ public class LogAspect
operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255));
if (loginUser != null)
{
operLog.setOperName(loginUser.isImpersonating() ? loginUser.getImpersonatorName() : loginUser.getUsername());
operLog.setOperName(loginUser.getUsername());
SysUser currentUser = loginUser.getUser();
if (StringUtils.isNotNull(currentUser) && StringUtils.isNotNull(currentUser.getDept()))
{
@@ -122,11 +122,6 @@ public class LogAspect
operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
// 处理设置注解上的参数
getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
if (loginUser != null && loginUser.isImpersonating())
{
String target = "代登录目标=" + loginUser.getUsername() + "(ID=" + loginUser.getUserId() + "); ";
operLog.setOperParam(StringUtils.substring(target + StringUtils.defaultString(operLog.getOperParam()), 0, PARAM_MAX_LENGTH));
}
// 设置消耗时间
operLog.setCostTime(System.currentTimeMillis() - TIME_THREADLOCAL.get());
// 保存数据库
@@ -11,16 +11,10 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.bms.common.core.domain.model.LoginUser;
import com.bms.common.utils.SecurityUtils;
import com.bms.common.utils.StringUtils;
import com.bms.framework.web.service.TokenService;
import com.bms.framework.manager.AsyncManager;
import com.bms.framework.manager.factory.AsyncFactory;
import com.bms.system.domain.SysOperLog;
import com.bms.common.utils.ip.IpUtils;
/**
* token过滤器 验证token有效性
@@ -30,7 +24,6 @@ import com.bms.common.utils.ip.IpUtils;
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
@Autowired
private TokenService tokenService;
@@ -46,46 +39,6 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
if (loginUser == null || !loginUser.isImpersonating())
{
chain.doFilter(request, response);
return;
}
long started = System.currentTimeMillis();
boolean failed = false;
try
{
chain.doFilter(request, response);
}
catch (IOException | ServletException | RuntimeException e)
{
failed = true;
throw e;
}
finally
{
// 记录所有代登录请求的真实操作人和目标账号,不记录令牌、请求参数或响应正文。
try
{
SysOperLog accessLog = new SysOperLog();
accessLog.setTitle("代登录访问");
accessLog.setBusinessType(0);
accessLog.setOperatorType(1);
accessLog.setOperName(loginUser.getImpersonatorName());
accessLog.setOperIp(IpUtils.getIpAddr(request));
accessLog.setOperUrl(StringUtils.substring(request.getRequestURI(), 0, 255));
accessLog.setRequestMethod(request.getMethod());
accessLog.setMethod("impersonation");
accessLog.setOperParam("实际管理员ID=" + loginUser.getImpersonatorId() + ", 目标用户="
+ loginUser.getUsername() + "(ID=" + loginUser.getUserId() + "), HTTP状态=" + response.getStatus());
accessLog.setStatus(failed || response.getStatus() >= 400 ? 1 : 0);
accessLog.setCostTime(System.currentTimeMillis() - started);
AsyncManager.me().execute(AsyncFactory.recordOper(accessLog));
}
catch (Exception e)
{
log.error("记录代登录访问日志失败", e);
}
}
chain.doFilter(request, response);
}
}
@@ -74,19 +74,6 @@ public class TokenService
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
String userKey = getTokenKey(uuid);
LoginUser user = redisCache.getCacheObject(userKey);
if (user != null && user.isImpersonating())
{
LoginUser operator = StringUtils.isEmpty(user.getImpersonatorToken()) ? null
: redisCache.getCacheObject(getTokenKey(user.getImpersonatorToken()));
if (user.getImpersonationExpireTime() == null
|| user.getImpersonationExpireTime() <= System.currentTimeMillis()
|| operator == null || operator.isImpersonating()
|| !user.getImpersonatorId().equals(operator.getUserId()))
{
redisCache.deleteObject(userKey);
return null;
}
}
return user;
}
catch (Exception e)
@@ -163,29 +150,10 @@ public class TokenService
public void refreshToken(LoginUser loginUser)
{
loginUser.setLoginTime(System.currentTimeMillis());
long expiry = loginUser.getLoginTime() + expireTime * MILLIS_MINUTE;
if (loginUser.isImpersonating())
{
if (loginUser.getImpersonationExpireTime() == null
|| loginUser.getImpersonationExpireTime() <= loginUser.getLoginTime())
{
delLoginUser(loginUser.getToken());
return;
}
expiry = Math.min(expiry, loginUser.getImpersonationExpireTime());
}
loginUser.setExpireTime(expiry);
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
if (loginUser.isImpersonating())
{
int ttlSeconds = (int) Math.max(1, (expiry - loginUser.getLoginTime() + 999) / 1000);
redisCache.setCacheObject(userKey, loginUser, ttlSeconds, TimeUnit.SECONDS);
}
else
{
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
/**
@@ -36,13 +36,6 @@ public class ArBill extends BaseEntity {
private Long customerId;
private String customerCode;
private String customerName;
/** 创建账单时的客户区域和负责人快照。 */
private String profitCustomerArea;
private Long profitSalesUserId;
private String profitSalesUserCode;
private String profitSalesUserName;
/** NEW=生成时固化;BACKFILLED=历史补录,来源为补录时可取得的数据。 */
private String profitSnapshotSource;
@DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd")
/** 账单所属期开始日期,导出模板展示用。 */
@@ -1,68 +0,0 @@
package com.bms.ar.domain.vo;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import com.bms.common.annotation.Excel;
import com.bms.price.domain.PrcOrderBill;
import lombok.Data;
/** 应收账单生成预览结果 Excel 导出行。 */
@Data
public class ArBillGeneratePreviewExportRow implements Serializable {
private static final long serialVersionUID = 1L;
@Excel(name = "订单号", sort = 0, width = 24)
private String orderNo;
@Excel(name = "运单号", sort = 1, width = 22)
private String trackingNo;
@Excel(name = "转单号", sort = 2, width = 24)
private String transferNo;
@Excel(name = "客户", sort = 3, width = 24)
private String customerName;
@Excel(name = "计费时间", sort = 4, dateFormat = "yyyy-MM-dd HH:mm:ss", width = 22)
private Date bizDate;
@Excel(name = "收货时间", sort = 4, dateFormat = "yyyy-MM-dd HH:mm:ss", width = 22)
private Date pickupDate;
@Excel(name = "产品", sort = 5, width = 22)
private String productName;
@Excel(name = "国家/地区", sort = 6, width = 18)
private String countryName;
@Excel(name = "实际重(kg)", sort = 7, width = 16)
private BigDecimal actualWeight;
@Excel(name = "计费重(kg)", sort = 8, width = 16)
private BigDecimal chargeWeight;
@Excel(name = "应收金额", sort = 9, scale = 2, width = 16)
private BigDecimal incomeTotal;
@Excel(name = "币种", sort = 10, width = 12)
private String currency;
public static ArBillGeneratePreviewExportRow from(PrcOrderBill bill) {
ArBillGeneratePreviewExportRow row = new ArBillGeneratePreviewExportRow();
row.setOrderNo(bill.getOrderNo());
row.setTrackingNo(bill.getTrackingNo());
row.setTransferNo(bill.getTransferNo());
row.setCustomerName(bill.getCustomerName());
row.setBizDate(bill.getBizDate());
row.setPickupDate(bill.getPickupDate());
row.setProductName(bill.getProductName());
row.setCountryName(bill.getCountryName());
row.setActualWeight(bill.getActualWeight());
row.setChargeWeight(bill.getChargeWeight());
row.setIncomeTotal(bill.getIncomeTotal());
row.setCurrency(bill.getCurrency());
return row;
}
}
@@ -1,7 +1,6 @@
package com.bms.ar.domain.vo;
import java.util.Date;
import java.util.List;
import org.springframework.format.annotation.DateTimeFormat;
import com.bms.ar.domain.ArBill;
import lombok.Data;
@@ -14,9 +13,6 @@ public class ArBillQuery extends ArBill
{
private static final long serialVersionUID = 1L;
/** 多选账单状态;为空时与旧版 status 参数保持兼容并表示不按状态筛选。 */
private List<String> statuses;
/** 账单日期查询起始日。 */
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date billDateStart;
@@ -1,30 +0,0 @@
package com.bms.ar.mapper;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.ar.profit.ArCommissionRate;
import com.bms.ar.profit.ArWriteoffRate;
import com.bms.ar.profit.ArCommissionResult;
import com.bms.ar.profit.ArCommissionSummary;
public interface ArCommissionMapper
{
List<ArCommissionRate> selectRates();
ArCommissionRate selectEffective(@Param("salesUserId") Long salesUserId, @Param("at") Date at);
int insertRate(ArCommissionRate rate);
Long lockSalesUser(@Param("salesUserId") Long salesUserId);
int insertWriteoffRate(@Param("writeoffId") Long writeoffId, @Param("salesUserId") Long salesUserId,
@Param("rateId") Long rateId, @Param("rate") java.math.BigDecimal rate);
int reverseResults(@Param("billId") Long billId, @Param("operator") String operator);
List<ArCommissionResult> selectSettlementInputs(@Param("billId") Long billId);
int insertResults(@Param("items") List<ArCommissionResult> items, @Param("operator") String operator);
List<ArWriteoffRate> selectMissingRates();
List<ArWriteoffRate> selectWriteoffRates(@Param("billId") Long billId);
Long selectBillIdByWriteoffId(@Param("writeoffId") Long writeoffId);
int countMissingRates(@Param("billId") Long billId);
int confirmMissingRate(@Param("writeoffId") Long writeoffId, @Param("rate") java.math.BigDecimal rate,
@Param("operator") String operator, @Param("remark") String remark);
List<ArCommissionResult> selectResults(@Param("billId") Long billId, @Param("includeReversed") boolean includeReversed);
List<ArCommissionSummary> selectCommissionSummary(@Param("billId") Long billId);
}
@@ -1,20 +0,0 @@
package com.bms.ar.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.ar.profit.ArProfitQuery;
import com.bms.ar.profit.ArProfitRow;
import com.bms.ar.profit.ArProfitScope;
import com.bms.ar.profit.ArProfitSummary;
import com.bms.ar.profit.ArProfitSalesOption;
import com.bms.ar.domain.ArBillDetailFee;
import com.bms.ar.domain.ArBillDetail;
public interface ArProfitMapper
{
List<ArProfitRow> selectBills(@Param("query") ArProfitQuery query, @Param("scope") ArProfitScope scope);
List<ArProfitSummary> selectSummary(@Param("query") ArProfitQuery query, @Param("scope") ArProfitScope scope);
List<ArProfitSalesOption> selectSalesOptions(@Param("keyword") String keyword);
List<ArBillDetail> selectBillDetails(@Param("query") ArProfitQuery query, @Param("scope") ArProfitScope scope);
List<ArBillDetailFee> selectFees(@Param("query") ArProfitQuery query, @Param("scope") ArProfitScope scope);
}
@@ -1,22 +0,0 @@
package com.bms.ar.profit;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
@Data
public class ArCommissionRate
{
private Long rateId;
private Long salesUserId;
private String salesUserCode;
private String salesUserName;
private BigDecimal rate;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date effectiveAt;
private String createBy;
private String remark;
}
@@ -1,27 +0,0 @@
package com.bms.ar.profit;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
import com.bms.common.annotation.Excel;
@Data
public class ArCommissionResult
{
private Long resultId;
private Long arBillId;
private Long detailId;
private Long salesUserId;
@Excel(name = "账单号", width = 20) private String billNo;
@Excel(name = "订单号", width = 22) private String orderNo;
@Excel(name = "销售员", width = 16) private String salesUserName;
@Excel(name = "币种", width = 10) private String currency;
@Excel(name = "销售毛利", scale = 4, width = 16) private BigDecimal grossProfit;
@Excel(name = "正式提成", scale = 2, width = 16) private BigDecimal commissionAmount;
@Excel(name = "状态", readConverterExp = "READY=已计算,MISSING_RATE=比例待确认,MISSING_PRICE=价格缺失,MISSING_SNAPSHOT=归属或区域缺失,WRITEOFF_MISMATCH=核销金额不一致,REVERSED=已冲回", width = 20) private String resultStatus;
private Date createTime;
private String reversedBy;
private Date reversedTime;
private BigDecimal weightedRate;
private BigDecimal billAmount;
}
@@ -1,148 +0,0 @@
package com.bms.ar.profit;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.bms.ar.domain.ArBalanceWriteoff;
import com.bms.ar.domain.ArBill;
import com.bms.ar.mapper.ArCommissionMapper;
import com.bms.ar.mapper.ArBillMapper;
import com.bms.ar.enums.ArBillStatus;
import com.bms.ar.enums.ArBillGenerationStatus;
import com.bms.ar.security.ArBillRoleKeys;
import com.bms.common.exception.ServiceException;
import com.bms.common.core.domain.entity.SysUser;
import com.bms.common.utils.SecurityUtils;
import com.bms.system.mapper.SysUserMapper;
@Service
public class ArCommissionService
{
private final ArCommissionMapper mapper;
private final ArProfitScopeResolver scopeResolver;
private final SysUserMapper userMapper;
private final ArBillMapper billMapper;
public ArCommissionService(ArCommissionMapper mapper, ArProfitScopeResolver scopeResolver, SysUserMapper userMapper,
ArBillMapper billMapper)
{
this.mapper = mapper;
this.scopeResolver = scopeResolver;
this.userMapper = userMapper;
this.billMapper = billMapper;
}
public List<ArCommissionRate> selectRates()
{
requireFinance();
return mapper.selectRates();
}
@Transactional(rollbackFor = Exception.class)
public void addRate(ArCommissionRate rate)
{
requireFinance();
SysUser sales = rate == null || rate.getSalesUserId() == null ? null : userMapper.selectUserById(rate.getSalesUserId());
if (sales == null || !"0".equals(sales.getStatus()) || !"0".equals(sales.getDelFlag())
|| sales.getRoles() == null || sales.getRoles().stream().noneMatch(role -> role != null
&& "0".equals(role.getStatus()) && ArBillRoleKeys.SALES.equals(role.getRoleKey())))
throw new ServiceException("请选择有效销售员");
if (rate.getRate() == null || rate.getRate().signum() < 0 || rate.getRate().compareTo(new BigDecimal("100")) > 0
|| rate.getRate().scale() > 4)
throw new ServiceException("提成比例须为 0 至 100,最多四位小数");
if (rate.getRemark() != null && rate.getRemark().length() > 500)
throw new ServiceException("备注不能超过 500 字");
mapper.lockSalesUser(rate.getSalesUserId());
Date now = new Date();
if (rate.getEffectiveAt() == null) rate.setEffectiveAt(now);
if (rate.getEffectiveAt().before(now)) throw new ServiceException("提成比例生效时间不能早于当前时间");
rate.setCreateBy(SecurityUtils.getUsername());
mapper.insertRate(rate);
}
/** 在核销事务内固化该次操作时的比例;未配置时保留空值,待财务处理。 */
public void snapshotWriteoff(ArBill bill, ArBalanceWriteoff writeoff)
{
Long salesUserId = bill.getProfitSalesUserId();
if (salesUserId != null) mapper.lockSalesUser(salesUserId);
ArCommissionRate rate = salesUserId == null ? null : mapper.selectEffective(salesUserId, new Date());
mapper.insertWriteoffRate(writeoff.getWriteoffId(), salesUserId,
rate == null ? null : rate.getRateId(), rate == null ? null : rate.getRate());
}
/** 完全核销后生成逐单结果;由外部账单锁及核销事务保证并发一致。 */
public void createFinalResults(Long billId, String operator)
{
List<ArCommissionResult> items = mapper.selectSettlementInputs(billId);
CommissionCalculator.allocateFinalAmounts(items);
for (int offset = 0; offset < items.size(); offset += 300)
{
mapper.insertResults(items.subList(offset, Math.min(offset + 300, items.size())), operator);
}
}
/** 反核销保留原记录并标记冲回。 */
public void reverseFinalResults(Long billId, String operator)
{
mapper.reverseResults(billId, operator);
}
public List<ArWriteoffRate> missingRates()
{
requireFinance();
return mapper.selectMissingRates();
}
public List<ArCommissionResult> results(Long billId, boolean includeReversed)
{
requireFinance();
return mapper.selectResults(billId, includeReversed);
}
public List<ArCommissionSummary> summary(Long billId)
{
requireFinance();
return mapper.selectCommissionSummary(billId);
}
public List<ArWriteoffRate> writeoffRates(Long billId)
{
requireFinance();
return mapper.selectWriteoffRates(billId);
}
@Transactional(rollbackFor = Exception.class)
public void confirmMissingRate(ArWriteoffRate input)
{
requireFinance();
if (input == null || input.getWriteoffId() == null || input.getRate() == null
|| input.getRate().signum() < 0 || input.getRate().compareTo(new BigDecimal("100")) > 0
|| input.getRate().scale() > 4 || input.getRemark() == null || input.getRemark().isBlank()
|| input.getRemark().length() > 500)
throw new ServiceException("请填写 0 至 100 的提成比例及核对说明");
Long billId = mapper.selectBillIdByWriteoffId(input.getWriteoffId());
ArBill bill = billId == null ? null : billMapper.selectArBillByIdForUpdate(billId);
if (bill == null || bill.getProfitSalesUserId() == null)
throw new ServiceException("核销记录无效或账单销售员快照缺失");
if (!ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())
|| !(ArBillStatus.CONFIRMED.getCode().equals(bill.getStatus())
|| ArBillStatus.PARTIAL_WRITTEN_OFF.getCode().equals(bill.getStatus())
|| ArBillStatus.WRITTEN_OFF.getCode().equals(bill.getStatus())))
throw new ServiceException("账单未生成完成或状态不允许确认提成比例");
String operator = SecurityUtils.getUsername();
if (mapper.confirmMissingRate(input.getWriteoffId(), input.getRate(), operator, input.getRemark().trim()) != 1)
throw new ServiceException("比例已确认或核销状态已变化,请刷新");
if (ArBillStatus.WRITTEN_OFF.getCode().equals(bill.getStatus()) && mapper.countMissingRates(billId) == 0)
{
mapper.reverseResults(billId, operator);
createFinalResults(billId, operator);
}
}
private void requireFinance()
{
if (!scopeResolver.resolve().all()) throw new ServiceException("仅财务和系统管理员可查看或维护提成");
}
}
@@ -1,15 +0,0 @@
package com.bms.ar.profit;
import java.math.BigDecimal;
import lombok.Data;
@Data
public class ArCommissionSummary
{
private Long salesUserId;
private String salesUserName;
private String currency;
private Long orderCount;
private Long pendingCount;
private BigDecimal commissionAmount;
}
@@ -1,21 +0,0 @@
package com.bms.ar.profit;
import java.util.Date;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
@Data
public class ArProfitQuery
{
private Long customerId;
private String billNo;
private String orderNo;
private String status;
private Long salesUserId;
private Long arBillId;
private Long detailId;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date beginBillDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date endBillDate;
}
@@ -1,30 +0,0 @@
package com.bms.ar.profit;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.bms.common.annotation.Excel;
@Data
public class ArProfitRow
{
private Long arBillId;
@Excel(name = "账单号", width = 20) private String billNo;
@Excel(name = "客户代码", width = 16) private String customerCode;
@Excel(name = "客户名称", width = 24) private String customerName;
private String customerArea;
@Excel(name = "归属来源", readConverterExp = "NEW=生成时快照,BACKFILLED=历史补录", width = 14) private String snapshotSource;
@Excel(name = "销售员", width = 16) private String salesUserName;
@Excel(name = "币种", width = 10) private String currency;
@Excel(name = "账单状态", readConverterExp = "CONFIRMED=已确认,PARTIAL_WRITTEN_OFF=部分核销,WRITTEN_OFF=已核销", width = 14) private String status;
@Excel(name = "账单日期", dateFormat = "yyyy-MM-dd", width = 15)
@JsonFormat(pattern = "yyyy-MM-dd")
private Date billDate;
@Excel(name = "销售价", scale = 4, width = 16) private BigDecimal salesAmount;
@Excel(name = "产品价", scale = 4, width = 16) private BigDecimal productAmount;
@Excel(name = "销售毛利", scale = 4, width = 16) private BigDecimal salesGrossProfit;
@Excel(name = "销售毛利率(%)", scale = 4, width = 18) private BigDecimal salesGrossMarginRate;
@Excel(name = "账单已核销", scale = 2, width = 16) private BigDecimal writtenOffAmount;
@Excel(name = "账单未核销", scale = 2, width = 16) private BigDecimal unwrittenAmount;
}
@@ -1,11 +0,0 @@
package com.bms.ar.profit;
import lombok.Data;
@Data
public class ArProfitSalesOption
{
private Long userId;
private String name;
private String code;
}
@@ -1,11 +0,0 @@
package com.bms.ar.profit;
/** 后端构造的数据范围,不接收前端传入的权限参数。 */
public record ArProfitScope(boolean all, boolean self, boolean department, Long userId, Long deptId)
{
public boolean isAll() { return all; }
public boolean isSelf() { return self; }
public boolean isDepartment() { return department; }
public Long getUserId() { return userId; }
public Long getDeptId() { return deptId; }
}
@@ -1,58 +0,0 @@
package com.bms.ar.profit;
import java.util.Arrays;
import java.util.Set;
import org.springframework.stereotype.Component;
import com.bms.ar.security.ArBillRoleKeys;
import com.bms.common.constant.Constants;
import com.bms.common.core.domain.entity.SysRole;
import com.bms.common.core.domain.entity.SysUser;
import com.bms.common.core.domain.model.LoginUser;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.PermissionContext;
import com.bms.common.utils.SecurityUtils;
@Component
public class ArProfitScopeResolver
{
public ArProfitScope resolve()
{
return resolve(SecurityUtils.getLoginUser(), PermissionContext.get());
}
public ArProfitScope resolve(LoginUser actor, String permission)
{
if (actor == null || actor.getUser() == null || actor.getUserId() == null)
{
throw new ServiceException("登录状态无效,请重新登录", 401);
}
SysUser user = actor.getUser();
if (!"0".equals(user.getStatus()) || !"0".equals(user.getDelFlag()))
{
return new ArProfitScope(false, false, false, actor.getUserId(), actor.getDeptId());
}
if (user.isAdmin()) return new ArProfitScope(true, false, false, actor.getUserId(), actor.getDeptId());
boolean all = false, self = false, department = false;
if (permission != null && !permission.isBlank() && user.getRoles() != null)
{
for (SysRole role : user.getRoles())
{
if (role == null || !"0".equals(role.getStatus()) || !permits(role.getPermissions(), permission)) continue;
String key = role.getRoleKey();
all |= ArBillRoleKeys.ALL_DATA_ROLES.contains(key == null ? "" : key);
self |= ArBillRoleKeys.SALES.equals(key);
department |= ArBillRoleKeys.SALES_MANAGER.equals(key);
}
}
if (!all && !self && !department) throw new ServiceException("无权访问应收账单毛利分析");
return new ArProfitScope(all, self && actor.getUserId() > 0,
department && actor.getDeptId() != null && actor.getDeptId() > 0,
actor.getUserId(), actor.getDeptId());
}
private boolean permits(Set<String> permissions, String required)
{
return permissions != null && (permissions.contains(Constants.ALL_PERMISSION)
|| Arrays.stream(required.split(Constants.PERMISSION_DELIMITER)).map(String::trim).anyMatch(permissions::contains));
}
}
@@ -1,59 +0,0 @@
package com.bms.ar.profit;
import java.util.List;
import org.springframework.stereotype.Service;
import com.bms.ar.mapper.ArProfitMapper;
import com.bms.ar.domain.ArBillDetailFee;
import com.bms.ar.domain.ArBillDetail;
@Service
public class ArProfitService
{
private final ArProfitMapper mapper;
private final ArProfitScopeResolver scopeResolver;
public ArProfitService(ArProfitMapper mapper, ArProfitScopeResolver scopeResolver)
{
this.mapper = mapper;
this.scopeResolver = scopeResolver;
}
public List<ArProfitRow> selectBills(ArProfitQuery query)
{
return mapper.selectBills(query == null ? new ArProfitQuery() : query, scopeResolver.resolve());
}
public List<ArProfitSummary> selectSummary(ArProfitQuery query)
{
return mapper.selectSummary(query == null ? new ArProfitQuery() : query, scopeResolver.resolve());
}
public List<ArProfitSalesOption> selectSalesOptions(String keyword)
{
ArProfitScope scope = scopeResolver.resolve();
if (!scope.all() && !scope.self() && !scope.department())
{
return List.of();
}
if (keyword != null)
{
keyword = keyword.trim();
if (keyword.length() > 100) keyword = keyword.substring(0, 100);
}
return mapper.selectSalesOptions(keyword);
}
public List<ArBillDetailFee> selectFees(Long detailId)
{
ArProfitQuery query = new ArProfitQuery();
query.setDetailId(detailId);
return mapper.selectFees(query, scopeResolver.resolve());
}
public List<ArBillDetail> selectBillDetails(Long billId)
{
ArProfitQuery query = new ArProfitQuery();
query.setArBillId(billId);
return mapper.selectBillDetails(query, scopeResolver.resolve());
}
}
@@ -1,18 +0,0 @@
package com.bms.ar.profit;
import java.math.BigDecimal;
import lombok.Data;
@Data
public class ArProfitSummary
{
private String currency;
private Long billCount;
private Long invalidBillCount;
private BigDecimal salesAmount;
private BigDecimal productAmount;
private BigDecimal salesGrossProfit;
private BigDecimal salesGrossMarginRate;
private BigDecimal writtenOffAmount;
private BigDecimal unwrittenAmount;
}
@@ -1,23 +0,0 @@
package com.bms.ar.profit;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
@Data
public class ArWriteoffRate
{
private Long writeoffId;
private Long arBillId;
private String billNo;
private String customerName;
private Long salesUserId;
private String salesUserName;
private BigDecimal writeoffAmount;
private Date writeoffDate;
private BigDecimal rate;
private String remark;
private String status;
private String confirmedBy;
private Date confirmedTime;
}
@@ -1,88 +0,0 @@
package com.bms.ar.profit;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
/** 根据账单有效核销及当时比例计算单个账单订单的提成。 */
public final class CommissionCalculator
{
private static final BigDecimal HUNDRED = new BigDecimal("100");
private CommissionCalculator() {}
/** 比例以百分数表示,10 代表 10%;缺失比例不得按零处理。 */
public record Writeoff(BigDecimal amount, BigDecimal rate) {}
public record Result(BigDecimal estimated, BigDecimal formal, boolean complete) {}
public static Result calculate(BigDecimal billAmount, BigDecimal orderProfit, List<Writeoff> writeoffs)
{
if (billAmount == null || billAmount.signum() <= 0 || orderProfit == null || writeoffs == null)
{
return new Result(null, null, false);
}
BigDecimal written = BigDecimal.ZERO;
BigDecimal weightedRates = BigDecimal.ZERO;
boolean hasMissingRate = false;
for (Writeoff writeoff : writeoffs)
{
if (writeoff == null || writeoff.amount() == null || writeoff.amount().signum() <= 0)
{
throw new IllegalArgumentException("核销金额必须大于零");
}
written = written.add(writeoff.amount());
if (writeoff.rate() == null)
{
hasMissingRate = true;
}
else
{
if (writeoff.rate().signum() < 0 || writeoff.rate().compareTo(HUNDRED) > 0)
{
throw new IllegalArgumentException("提成比例必须在 0 至 100 之间");
}
weightedRates = weightedRates.add(writeoff.amount().multiply(writeoff.rate()));
}
}
if (written.compareTo(billAmount) > 0)
{
throw new IllegalArgumentException("有效核销金额超过账单金额");
}
boolean complete = written.compareTo(billAmount) == 0;
if (hasMissingRate)
{
return new Result(null, null, complete);
}
BigDecimal amount = orderProfit.multiply(weightedRates)
.divide(billAmount.multiply(HUNDRED), 2, RoundingMode.HALF_UP);
return new Result(amount, complete ? amount : null, complete);
}
/** 对同一账单的结果按明细顺序分配舍入尾差,使逐单金额之和等于该账单精确总额的四舍五入。 */
public static void allocateFinalAmounts(List<ArCommissionResult> items)
{
BigDecimal cumulativeNumerator = BigDecimal.ZERO;
BigDecimal allocated = BigDecimal.ZERO;
BigDecimal billAmount = null;
BigDecimal weightedRate = null;
for (ArCommissionResult item : items)
{
if (!"READY".equals(item.getResultStatus())) continue;
if (item.getBillAmount() == null || item.getBillAmount().signum() <= 0
|| item.getGrossProfit() == null || item.getWeightedRate() == null)
throw new IllegalArgumentException("正式提成计算数据不完整");
if (billAmount == null)
{
billAmount = item.getBillAmount();
weightedRate = item.getWeightedRate();
}
else if (billAmount.compareTo(item.getBillAmount()) != 0 || weightedRate.compareTo(item.getWeightedRate()) != 0)
throw new IllegalArgumentException("同一账单的提成计算依据不一致");
cumulativeNumerator = cumulativeNumerator.add(item.getGrossProfit().multiply(item.getWeightedRate()));
BigDecimal cumulativeAmount = cumulativeNumerator.divide(billAmount.multiply(HUNDRED), 2, RoundingMode.HALF_UP);
item.setCommissionAmount(cumulativeAmount.subtract(allocated));
allocated = cumulativeAmount;
}
}
}
@@ -24,7 +24,6 @@ import com.bms.ar.enums.ArLogResult;
import com.bms.ar.mapper.ArBalanceWriteoffMapper;
import com.bms.ar.mapper.ArBillMapper;
import com.bms.ar.security.ArBillScopeResolver;
import com.bms.ar.profit.ArCommissionService;
import com.bms.ar.service.IArBalanceWriteoffService;
import com.bms.ar.service.IArBillLogService;
import com.bms.common.exception.ServiceException;
@@ -50,8 +49,6 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
private ArBillMapper billMapper;
@Autowired
private ArBillScopeResolver billScope;
@Autowired
private ArCommissionService commissionService;
@Autowired
private ArBalanceWriteoffMapper writeoffMapper;
@@ -144,7 +141,6 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
writeoff.setCreateUserId(operatorUserId);
writeoff.setCreateBy(operator);
writeoffMapper.insertArBalanceWriteoff(writeoff);
commissionService.snapshotWriteoff(bill, writeoff);
// 3. 账单核销口径变更(2026-09-23):核销不再变动账户余额与已用额度,
// 仅更新账单已核销/未核销金额与状态,资金变动由收款/计费入口产生。
@@ -157,10 +153,6 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
throw new ServiceException("账单状态已发生变化,请刷新后重试");
}
if (ArBillStatus.WRITTEN_OFF.getCode().equals(nextStatus)) {
commissionService.createFinalResults(bill.getArBillId(), operator);
}
recordLog(bill.getArBillId(), writeoff.getWriteoffId(), ArLogOperationType.BALANCE_WRITEOFF,
"余额核销账单 " + amount.toPlainString() + " " + currency,
fromStatus, nextStatus, operator, requestId);
@@ -215,7 +207,6 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
ArBalanceWriteoffStatus.CANCELLED.getCode(), ArBalanceWriteoffStatus.EFFECTIVE.getCode()) != 1) {
throw new ServiceException("核销记录状态已变化,请刷新后重试");
}
commissionService.reverseFinalResults(bill.getArBillId(), operator);
recordLog(bill.getArBillId(), writeoffId, ArLogOperationType.CANCEL_BALANCE_WRITEOFF,
"反核销取消核销:" + cancelRemark,
fromStatus, nextStatus, operator, requestId);
@@ -73,10 +73,6 @@ import com.bms.ar.mapper.ArBillLogMapper;
import com.bms.ar.mapper.ArBillMapper;
import com.bms.ar.security.ArBillScopeResolver;
import com.bms.customer.customerinfo.domain.BmsCustomerOption;
import com.bms.customer.customerinfo.domain.BmsCustomer;
import com.bms.customer.customerinfo.mapper.BmsCustomerMapper;
import com.bms.common.core.domain.entity.SysUser;
import com.bms.system.mapper.SysUserMapper;
import com.bms.ar.mapper.ArBillGenerateTaskMapper;
import com.bms.ar.mapper.ArBankAccountMapper;
import com.bms.ar.mapper.ArExportTemplateMapper;
@@ -189,10 +185,6 @@ public class ArBillServiceImpl implements IArBillService {
@Autowired
private ArBillMapper billMapper;
@Autowired
private BmsCustomerMapper customerMapper;
@Autowired
private SysUserMapper sysUserMapper;
@Autowired
private ArBillScopeResolver billScope;
@Autowired
private ArBillGenerateTaskMapper generateTaskMapper;
@@ -449,21 +441,6 @@ public class ArBillServiceImpl implements IArBillService {
String customerName = StringUtils.trim(request.getCustomerName());
bill.setCustomerCode(customerCode == null ? "" : customerCode);
bill.setCustomerName(customerName == null ? "" : customerName);
bill.setProfitSnapshotSource("NEW");
// 固定账单生成时的客户归属;无法确定的用户保持为空,由财务核对。
BmsCustomer snapshotCustomer = customerMapper.selectBmsCustomerByCustomerId(request.getCustomerId());
if (snapshotCustomer != null) {
bill.setProfitCustomerArea(snapshotCustomer.getCustomerArea());
String salesId = snapshotCustomer.getSalesUserId();
if (salesId != null && salesId.matches("[0-9]{1,18}")) {
SysUser salesUser = sysUserMapper.selectUserById(Long.valueOf(salesId));
if (salesUser != null) {
bill.setProfitSalesUserId(salesUser.getUserId());
bill.setProfitSalesUserCode(salesUser.getUserName());
bill.setProfitSalesUserName(salesUser.getNickName());
}
}
}
bill.setBillPeriodStart(request.getBillPeriodEnd());
bill.setBillPeriodEnd(request.getBillPeriodEnd());
bill.setBillDate(request.getBillDate() == null ? now : request.getBillDate());
@@ -1756,7 +1733,7 @@ public class ArBillServiceImpl implements IArBillService {
case CATALOG_COUNTRY -> "产品价未匹配国家";
case CATALOG_WEIGHT -> "产品价未匹配重量段";
case NO_CUSTOMER_PACKAGE -> "未绑定套餐";
case NO_ACTIVE_PACKAGE_VERSION -> "套餐未启用/无生效版本";
case NO_ACTIVE_PACKAGE_VERSION -> "无生效套餐版本";
case ALREADY_IN_AR_BILL -> "已生成应收账单";
case PRODUCT_ARCHIVE_MISSING -> "产品档案缺失";
case PRICE_NOT_HIT -> "未命中价格";
@@ -47,9 +47,9 @@ public class BmsCustomer extends BaseEntity
@Excel(name = "业务类型", readConverterExp = "0=直客,1=同行,2=集运")
private String customerType;
/** 客户区域(字典 customer_area) */
@Excel(name = "客户区域", dictType = "customer_area")
private String customerArea;
/** 区域类型(字典 data_area_type) */
@Excel(name = "区域类型", dictType = "data_area_type")
private String areaType;
/** 启用状态(0正常 1停用) */
@Excel(name = "启用状态", readConverterExp = "0=正常,1=停用")
@@ -45,10 +45,10 @@ public class BmsCustomerLogServiceImpl implements IBmsCustomerLogService
private static final ThreadLocal<SimpleDateFormat> DATE_FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
/** 客户区域走字典 customer_area;缓存未命中时保留原编码。 */
private static String customerAreaLabel(String value)
/** 区域类型走字典 data_area_type;缓存未命中时保留原编码。 */
private static String areaTypeLabel(String value)
{
String label = DictUtils.getDictLabel("customer_area", value);
String label = DictUtils.getDictLabel("data_area_type", value);
return StringUtils.isEmpty(label) ? value : label;
}
@@ -77,7 +77,7 @@ public class BmsCustomerLogServiceImpl implements IBmsCustomerLogService
/**
* 需要记录日志的主表字段(顺序即展示顺序)。
* requireNonBlank 为 true 的字段对应 update SQL 的 &lt;if test="x!=null and x!=''"&gt;,
* 其余动态字段仅在新值非 null 时落库;业务员/结算员/客服、客户区域为无条件写入。
* 其余动态字段仅在新值非 null 时落库;业务员/结算员/客服、区域类型为无条件写入。
*/
private static final List<FieldDef> FIELD_DEFS = List.of(
new FieldDef("customerCode", "客户编码", BmsCustomer::getCustomerCode, true, Function.identity()),
@@ -85,7 +85,7 @@ public class BmsCustomerLogServiceImpl implements IBmsCustomerLogService
new FieldDef("customerCodeDw", "对外代码", BmsCustomer::getCustomerCodeDw, false, Function.identity()),
new FieldDef("creditCode", "统一社会信用代码", BmsCustomer::getCreditCode, false, Function.identity()),
new FieldDef("customerType", "业务类型", BmsCustomer::getCustomerType, false, CUSTOMER_TYPE_MAP::get),
new FieldDef("customerArea", "客户区域", BmsCustomer::getCustomerArea, false, BmsCustomerLogServiceImpl::customerAreaLabel),
new FieldDef("areaType", "区域类型", BmsCustomer::getAreaType, false, BmsCustomerLogServiceImpl::areaTypeLabel),
new FieldDef("isEnable", "启用状态", BmsCustomer::getIsEnable, false, ENABLE_MAP::get),
new FieldDef("salesUserId", "业务员", c -> userDisplay(c.getSalesUserId(), c.getSalesUser()), false, Function.identity()),
new FieldDef("ed", "资金额度(元)", BmsCustomer::getEd, false, BmsCustomerLogServiceImpl::numText),
@@ -208,8 +208,8 @@ public class BmsCustomerLogServiceImpl implements IBmsCustomerLogService
{
// 与 update SQL 的动态条件保持一致:未提交(null/空串)的字段本次不落库,不参与比对
Object raw = def.rawGetter.apply(newCustomer);
// 合同起止、客户区域允许清空;其余动态字段未提交时不落库,也不记日志
if (raw == null && !isContractDateField(def.field) && !"customerArea".equals(def.field))
// 合同起止、区域类型允许清空;其余动态字段未提交时不落库,也不记日志
if (raw == null && !isContractDateField(def.field) && !"areaType".equals(def.field))
{
continue;
}
@@ -450,11 +450,6 @@ public class BmsCustomerLogServiceImpl implements IBmsCustomerLogService
realChanges.add(c);
}
}
// Rebind/update with no field diff: skip empty UPDATE noise (full package save upsert)
if (existing != null && realChanges.isEmpty())
{
return;
}
saveLog(bind.getCustomerId(), customer == null ? null : customer.getCustomerCode(),
customer == null ? null : customer.getCustomerName(),
BmsCustomerLog.MODULE_PRICE_SET, newSet.getPriceSetName(),
@@ -60,7 +60,6 @@ public class CustomerSyncService
private static final int LEN_CODE = 50;
private static final int LEN_NAME = 100;
private static final int LEN_CODE_DW = 50;
private static final int LEN_CUSTOMER_AREA = 32;
private static final int LEN_USER = 50;
private static final int LEN_REMARK = 500;
/** 告警明细最多打印条数 */
@@ -179,7 +178,6 @@ public class CustomerSyncService
c.setDelFlag("0");
c.setCustomerCodeDw(truncate(getStr(row, "CUSTOMER_OUTER_CODE"), LEN_CODE_DW, code, "customer_code_dw", warnings));
c.setCustomerArea(truncate(getStr(row, "CUSTOMER_AREA"), LEN_CUSTOMER_AREA, code, "customer_area", warnings));
String sourceSalesCode = clean(getStr(row, "BELONG_SALER"));
c.setSourceSalesCode(sourceSalesCode);
c.setSalesUserId(resolveSalesUserId(sourceSalesCode, userIdsByLogin,
@@ -3,7 +3,6 @@ package com.bms.masterdata.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.bms.common.annotation.Excel;
import lombok.Data;
/**
@@ -15,136 +14,72 @@ import lombok.Data;
@Data
public class LineProduct
{
/** 源主键 ID。列表、表单和导出都不展示。 */
/** 源主键 ID */
private String id;
/** 产品(线路)编码 */
@Excel(name = "产品编码")
private String lineCode;
/** 产品(线路)名称 */
@Excel(name = "产品名称")
private String lineName;
@Excel(name = "产品类型")
private String productType;
@Excel(name = "线路类型")
private String lineType;
/** 是否可收货(1是 0否) */
@Excel(name = "是否可收货", readConverterExp = "1=是,0=否")
private String blCargo;
@Excel(name = "货物名称")
private String cargoName;
@Excel(name = "仓储服务名称")
private String productStorage;
@Excel(name = "头程服务名称")
private String productFirst;
@Excel(name = "清关服务名称")
private String productClearance;
@Excel(name = "仓储服务编码")
private String productStorageCode;
@Excel(name = "头程服务编码")
private String productFirstCode;
@Excel(name = "清关服务编码")
private String productClearanceCode;
/** 是否启用(1启用 0停用) */
@Excel(name = "是否启用", readConverterExp = "1=启用,0=停用")
private String blOpen;
/** 是否分段(1是 0否) */
@Excel(name = "是否分段", readConverterExp = "1=是,0=否")
private String blSection;
@Excel(name = "派送商编码")
private String dispCode;
@Excel(name = "派送商名称")
private String dispName;
@Excel(name = "费用类别")
private String feeClass;
@Excel(name = "发货人ID")
private String shipperId;
/** 发货规则 */
@Excel(name = "发货规则")
private String shipmentRule;
/** 限重(kg) */
@Excel(name = "限重(kg)")
private BigDecimal weight;
@Excel(name = "货物类型")
private String cargoType;
@Excel(name = "产品分类")
private String productCategory;
/** 体积基数(抛货系数) */
@Excel(name = "体积基数")
private BigDecimal volumeBasis;
@Excel(name = "重量类型")
private String weightType;
@Excel(name = "服务类型")
private String serviceType;
@Excel(name = "发货类型")
private String shipmentType;
@Excel(name = "税费金额")
private BigDecimal taxAmount;
@Excel(name = "税费币种")
private String taxCurrency;
@Excel(name = "电池类型")
private String batteryType;
@Excel(name = "备注")
private String remark;
/** 源创建人,页面不展示 */
private String createName;
/** 源创建人工号,页面不展示 */
private String createManCode;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Excel(name = "创建时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date createDate;
@Excel(name = "修改人")
private String modifier;
@Excel(name = "修改人工号")
private String modifierCode;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Excel(name = "修改时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date modifyDate;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Excel(name = "GMT创建时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date gmtCreate;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Excel(name = "GMT修改时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date gmtModified;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Excel(name = "同步时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date syncTime;
}
@@ -1,26 +0,0 @@
package com.bms.masterdata.mapper;
import java.util.List;
import com.bms.masterdata.domain.LineProduct;
/**
* 路线产品 srv_line_product
*/
public interface LineProductMapper
{
List<LineProduct> selectLineProductList(LineProduct query);
LineProduct selectLineProductById(String id);
LineProduct selectLineProductByCode(String lineCode);
int countProductChannelByProductId(String productId);
int insertLineProduct(LineProduct product);
int updateLineProduct(LineProduct product);
int updateLineProductStatus(LineProduct product);
int deleteLineProductByIds(String[] ids);
}
@@ -27,11 +27,6 @@ public interface ServiceChannelMapper
*/
ServiceChannel selectServiceChannelByCode(String channelCode);
/**
* 按渠道名称精确查询,重名时返回多条供调用方拒绝。
*/
List<ServiceChannel> selectServiceChannelByName(String channelName);
/**
* 产品渠道关系中引用该渠道编码的数量
*/
@@ -1,22 +0,0 @@
package com.bms.masterdata.service;
import java.util.List;
import com.bms.masterdata.domain.LineProduct;
/**
* 路线产品
*/
public interface ILineProductService
{
List<LineProduct> selectLineProductList(LineProduct query);
LineProduct selectLineProductById(String id);
int insertLineProduct(LineProduct product);
int updateLineProduct(LineProduct product);
int updateLineProductStatus(LineProduct product);
int deleteLineProductByIds(String[] ids);
}
@@ -1,228 +0,0 @@
package com.bms.masterdata.service.impl;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.StringUtils;
import com.bms.common.utils.uuid.IdUtils;
import com.bms.masterdata.domain.LineProduct;
import com.bms.masterdata.mapper.LineProductMapper;
import com.bms.masterdata.service.ILineProductService;
/**
* 路线产品
*/
@Service
public class LineProductServiceImpl implements ILineProductService
{
@Autowired
private LineProductMapper lineProductMapper;
@Override
public List<LineProduct> selectLineProductList(LineProduct query)
{
return lineProductMapper.selectLineProductList(query);
}
@Override
public LineProduct selectLineProductById(String id)
{
return lineProductMapper.selectLineProductById(id);
}
@Override
public int insertLineProduct(LineProduct product)
{
prepare(product, true);
product.setId(IdUtils.fastSimpleUUID());
assertCodeUnique(product);
return lineProductMapper.insertLineProduct(product);
}
@Override
public int updateLineProduct(LineProduct product)
{
if (product == null || StringUtils.isEmpty(product.getId()))
{
throw new ServiceException("路线产品不存在");
}
LineProduct existing = lineProductMapper.selectLineProductById(product.getId());
if (existing == null)
{
throw new ServiceException("路线产品不存在或已被删除");
}
prepare(product, false);
assertCodeUnique(product);
return lineProductMapper.updateLineProduct(product);
}
@Override
public int updateLineProductStatus(LineProduct product)
{
if (product == null || StringUtils.isEmpty(product.getId()))
{
throw new ServiceException("路线产品不存在");
}
if (!"0".equals(product.getBlOpen()) && !"1".equals(product.getBlOpen()))
{
throw new ServiceException("启用状态只能是启用或停用");
}
LineProduct existing = lineProductMapper.selectLineProductById(product.getId());
if (existing == null)
{
throw new ServiceException("路线产品不存在或已被删除");
}
return lineProductMapper.updateLineProductStatus(product);
}
@Override
public int deleteLineProductByIds(String[] ids)
{
if (ids == null || ids.length == 0)
{
throw new ServiceException("请选择要删除的路线产品");
}
for (String id : ids)
{
if (StringUtils.isEmpty(id))
{
continue;
}
LineProduct existing = lineProductMapper.selectLineProductById(id);
if (existing == null)
{
continue;
}
if (lineProductMapper.countProductChannelByProductId(id) > 0)
{
throw new ServiceException("路线产品「" + existing.getLineName() + "」已被渠道关系引用,不能删除,请改为停用");
}
}
return lineProductMapper.deleteLineProductByIds(ids);
}
private void prepare(LineProduct product, boolean creating)
{
if (product == null)
{
throw new ServiceException("路线产品不能为空");
}
product.setLineCode(StringUtils.trim(product.getLineCode()));
product.setLineName(StringUtils.trim(product.getLineName()));
product.setProductType(limit(product.getProductType(), 20, "产品类型"));
product.setLineType(limit(product.getLineType(), 50, "线路类型"));
product.setCargoName(limit(product.getCargoName(), 100, "货物名称"));
product.setProductStorage(trimToNull(product.getProductStorage()));
product.setProductFirst(trimToNull(product.getProductFirst()));
product.setProductClearance(trimToNull(product.getProductClearance()));
product.setProductStorageCode(limit(product.getProductStorageCode(), 1000, "仓储服务编码"));
product.setProductFirstCode(limit(product.getProductFirstCode(), 1000, "头程服务编码"));
product.setProductClearanceCode(limit(product.getProductClearanceCode(), 1000, "清关服务编码"));
product.setDispCode(limit(product.getDispCode(), 60, "派送商编码"));
product.setDispName(limit(product.getDispName(), 100, "派送商名称"));
product.setFeeClass(limit(product.getFeeClass(), 100, "费用类别"));
product.setShipperId(limit(product.getShipperId(), 64, "发货人ID"));
product.setShipmentRule(flagOrNull(product.getShipmentRule(), "发货规则"));
product.setCargoType(limit(product.getCargoType(), 64, "货物类型"));
product.setProductCategory(limit(product.getProductCategory(), 100, "产品分类"));
product.setWeightType(flagOrNull(product.getWeightType(), "重量类型"));
product.setServiceType(flagOrNull(product.getServiceType(), "服务类型"));
product.setShipmentType(limit(product.getShipmentType(), 32, "发货类型"));
product.setTaxCurrency(limit(product.getTaxCurrency(), 16, "税费币种"));
product.setBatteryType(limit(product.getBatteryType(), 64, "电池类型"));
product.setRemark(limit(product.getRemark(), 500, "备注"));
product.setBlCargo(yesNo(product.getBlCargo(), "是否可收货"));
product.setBlSection(yesNo(product.getBlSection(), "是否分段"));
if (StringUtils.isEmpty(product.getLineCode()))
{
throw new ServiceException("产品编码不能为空");
}
if (product.getLineCode().length() > 64)
{
throw new ServiceException("产品编码不能超过64个字符");
}
if (StringUtils.isEmpty(product.getLineName()))
{
throw new ServiceException("产品名称不能为空");
}
if (product.getLineName().length() > 200)
{
throw new ServiceException("产品名称不能超过200个字符");
}
if (StringUtils.isEmpty(product.getBlOpen()))
{
if (creating)
{
product.setBlOpen("1");
}
else
{
throw new ServiceException("请选择启用状态");
}
}
if (!"0".equals(product.getBlOpen()) && !"1".equals(product.getBlOpen()))
{
throw new ServiceException("启用状态只能是启用或停用");
}
if (product.getWeight() != null && product.getWeight().compareTo(BigDecimal.ZERO) < 0)
{
throw new ServiceException("限重不能为负数");
}
if (product.getVolumeBasis() != null && product.getVolumeBasis().compareTo(BigDecimal.ZERO) < 0)
{
throw new ServiceException("体积基数不能为负数");
}
if (product.getTaxAmount() != null && product.getTaxAmount().compareTo(BigDecimal.ZERO) < 0)
{
throw new ServiceException("税费金额不能为负数");
}
}
private void assertCodeUnique(LineProduct product)
{
LineProduct same = lineProductMapper.selectLineProductByCode(product.getLineCode());
if (same != null && !StringUtils.equals(same.getId(), product.getId()))
{
throw new ServiceException("产品编码「" + product.getLineCode() + "」已存在");
}
}
private String yesNo(String value, String label)
{
String text = trimToNull(value);
if (text != null && !"0".equals(text) && !"1".equals(text))
{
throw new ServiceException(label + "只能是是或否");
}
return text;
}
/** 单字符标记:空则清空,有值时只能是一个字符。 */
private String flagOrNull(String value, String label)
{
String text = trimToNull(value);
if (text != null && text.length() > 1)
{
throw new ServiceException(label + "只能填写一个字符");
}
return text;
}
private String limit(String value, int max, String label)
{
String text = trimToNull(value);
if (text != null && text.length() > max)
{
throw new ServiceException(label + "不能超过" + max + "个字符");
}
return text;
}
private String trimToNull(String value)
{
String text = StringUtils.trim(value);
return StringUtils.isEmpty(text) ? null : text;
}
}
@@ -1,40 +0,0 @@
package com.bms.price.airbill.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/**
* 空运提单费用明细 srv_bill_air_fee。
*/
@Data
public class SrvBillAirFee
{
private Long id;
private String billCode;
/** 字典 bms_fee_type */
private String feeType;
private BigDecimal price;
/** 字典 bms_currency */
private String currency;
private String remark;
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
private String updateBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
/** 0未删除 1已删除 */
private String delFlag;
}
@@ -1,64 +0,0 @@
package com.bms.price.airbill.domain;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/**
* 空运提单费用操作日志。
*/
@Data
public class SrvBillAirFeeLog
{
public static final String INSERT = "1";
public static final String UPDATE = "2";
public static final String DELETE = "3";
private Long logId;
private String billCode;
private Long feeId;
private String operationType;
private String summary;
private String changeContent;
private String createBy;
private String createByName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
private List<FieldChange> changes;
@Data
public static class FieldChange
{
private String field;
private String fieldLabel;
private String oldValue;
private String newValue;
public FieldChange()
{
}
public FieldChange(String field, String fieldLabel, String oldValue, String newValue)
{
this.field = field;
this.fieldLabel = fieldLabel;
this.oldValue = oldValue;
this.newValue = newValue;
}
}
}
@@ -1,20 +0,0 @@
package com.bms.price.airbill.domain;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* 保存提单费用时只带有变动的行。未出现的费用保持原样。
*/
@Data
public class SrvBillAirFeeSave
{
private String billCode;
private List<SrvBillAirFee> inserts = new ArrayList<>();
private List<SrvBillAirFee> updates = new ArrayList<>();
private List<Long> deletes = new ArrayList<>();
}
@@ -20,11 +20,6 @@ public class BillingProperties
private int consumerThreads = 8;
/** Concurrent threads for monitor order-source sync */
private int orderSourceSyncThreads = 8;
/**
* Concurrent threads for order-list local rebill (2~24).
* Keep below Druid maxActive with headroom for other traffic.
*/
private int localRebillThreads = 16;
/** Night window auto-retry of prc_billing_task (all statuses) */
private NightRetry nightRetry = new NightRetry();
private Oracle oracle = new Oracle();
@@ -89,16 +84,6 @@ public class BillingProperties
this.orderSourceSyncThreads = orderSourceSyncThreads;
}
public int getLocalRebillThreads()
{
return localRebillThreads;
}
public void setLocalRebillThreads(int localRebillThreads)
{
this.localRebillThreads = localRebillThreads;
}
public NightRetry getNightRetry()
{
return nightRetry;
@@ -11,7 +11,6 @@ public class BatchAddIncomeBillFeeTask implements Serializable
public static final String STATUS_RUNNING = "RUNNING";
public static final String STATUS_SUCCESS = "SUCCESS";
public static final String STATUS_FAIL = "FAIL";
public static final String STATUS_CANCELLED = "CANCELLED";
private String taskId;
private String status;
@@ -1,35 +0,0 @@
package com.bms.price.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/**
* 渠道和产品的收付关系。payType 取字典 bms_pay_type。
*/
@Data
public class ChannelProductMap
{
private Long id;
/** 渠道表主键 srv_service_channel.id */
private String channelId;
private Long productId;
/** 应收套餐 prc_price_set.price_set_id */
private Long priceSetId;
/** AR 应收,AP 应付 */
private String payType;
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
private String updateBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}
@@ -1,61 +0,0 @@
package com.bms.price.domain;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/** 应付账单。作废后仍保留。 */
@Data
public class PayBill
{
private Long billId;
private String billNo;
private Long supplierId;
private String supplierName;
private String currency;
/** ORDER 订单号,TIME 计费日,LADING 提单 */
private String sourceType;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date periodStart;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date periodEnd;
private Integer orderCount;
private BigDecimal totalAmount;
/** DRAFT CONFIRMED PAID VOID */
private String status;
private String voidReason;
private String voidBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date voidTime;
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
private String updateBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
private String remark;
private List<PayBillLine> lines;
private List<PayBillLog> logs;
}
@@ -1,35 +0,0 @@
package com.bms.price.domain;
import lombok.Data;
/** 查询或生成应付账单。订单号和提单号可用逗号、空格、换行分隔。 */
@Data
public class PayBillCommand
{
/** 取数方式:ORDER 订单号,TIME 计费日,LADING 提单号 */
private String sourceType;
/** 订单号原文,可用逗号、空格或换行分隔 */
private String orderText;
/** 计费日起,yyyy-MM-dd。sourceType 为 TIME 时必填 */
private String beginDate;
/** 计费日止,yyyy-MM-dd,含当天 */
private String endDate;
/** 提单号原文,可用逗号、空格或换行分隔 */
private String ladingText;
/** 供应商ID。生成账单时必填,预览不用 */
private Long supplierId;
/** 账单备注 */
private String remark;
/** 作废原因。作废时必填 */
private String voidReason;
/** 要作废的应付账单ID */
private Long billId;
}
@@ -1,41 +0,0 @@
package com.bms.price.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/** 应付账单上的一条费用快照。 */
@Data
public class PayBillLine
{
private Long lineId;
private Long billId;
private String orderBillId;
private String orderNo;
private String ladingNo;
private Long ladingFeeId;
private String feeName;
private BigDecimal amount;
private String currency;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date priceDate;
private Integer versionNo;
private String remoteFlag;
private String densityLevel;
/** ORDER 订单费用,LADING 留在主单上的费用 */
private String lineSource;
}
@@ -1,27 +0,0 @@
package com.bms.price.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/** 应付账单操作记录。作废后不删除。 */
@Data
public class PayBillLog
{
private Long logId;
private Long billId;
private String operationType;
private String fromStatus;
private String toStatus;
private String operationDesc;
private String operatorName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
}
@@ -1,48 +0,0 @@
package com.bms.price.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/** 生成应付账单前查到的一票订单。 */
@Data
public class PayBillOrder
{
private String billId;
private String orderNo;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date bizDate;
private BigDecimal payTotal;
private String currency;
private String ladingNo;
private String trackingNo;
private String channelName;
private String productName;
private String supplierName;
private Integer pieces;
private BigDecimal chargeWeight;
private String countryCode;
private String countryName;
private String destPostcode;
/** 已算出的应付费用,名称和金额用分号拼在一起 */
private String feeText;
/** 已挂在未作废账单上时的账单号 */
private String occupiedBillNo;
}
@@ -1,22 +0,0 @@
package com.bms.price.domain;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/** 应付账单生成前的查询结果。 */
@Data
public class PayBillPreview
{
/** 还没进未作废账单、可以生成的订单 */
private List<PayBillOrder> orders = new ArrayList<>();
/** 按订单号查询时没有找到的单号 */
private List<String> missingNos = new ArrayList<>();
/** 已经挂在未作废账单上的订单 */
private List<PayBillOrder> occupied = new ArrayList<>();
/** 给页面的提示,例如未找到订单或超过单次票数 */
private String message;
}
@@ -1,21 +0,0 @@
package com.bms.price.domain;
import java.math.BigDecimal;
import lombok.Data;
/** 一条算出的应付费用。 */
@Data
public class PayCostLine
{
private String feeType;
private String feeName;
private Long scId;
private BigDecimal amount;
private String currency;
private String remark;
}
@@ -1,21 +0,0 @@
package com.bms.price.domain;
import lombok.Data;
/**
* 产品报价表里的邮编或其他不能落进重量段、附加费的配置原文。
*/
@Data
public class PayExtraLine
{
private Long lineId;
private Long productId;
/** 邮编 或 配置 */
private String lineType;
private String lineText;
private Integer sortNo;
}
@@ -1,32 +0,0 @@
package com.bms.price.domain;
import java.math.BigDecimal;
import java.util.List;
import lombok.Data;
/** 按订单生成应付的结果。skipped 是价卡里有、但这票算不了的附加费。 */
@Data
public class PayOrderCostResult
{
private String billId;
private String channelId;
private String channelName;
private Long productId;
private String productName;
private Integer versionNo;
private String currency;
private BigDecimal payTotal;
private String message;
private List<PayCostLine> fees;
private List<String> skipped;
}
@@ -1,62 +0,0 @@
package com.bms.price.domain;
/**
* 应付价卡解析进度。给导入弹框轮询,不入库。
*/
public class PayParseProgress
{
public static final String PENDING = "PENDING";
public static final String RUNNING = "RUNNING";
public static final String SUCCESS = "SUCCESS";
public static final String FAIL = "FAIL";
private int percent;
private String message;
private String status;
public PayParseProgress()
{
}
public PayParseProgress(int percent, String message, String status)
{
this.percent = percent;
this.message = message;
this.status = status;
}
public int getPercent()
{
return percent;
}
public void setPercent(int percent)
{
this.percent = percent;
}
public String getMessage()
{
return message;
}
public void setMessage(String message)
{
this.message = message;
}
public String getStatus()
{
return status;
}
public void setStatus(String status)
{
this.status = status;
}
}
@@ -1,64 +0,0 @@
package com.bms.price.domain;
import java.util.ArrayList;
import java.util.List;
import com.bms.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 应付邮编配置的一条分区或一条合并后的附加费。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class PayPostcodeGroup extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long groupId;
private String sourceFile;
private String fileMd5;
private String sheetName;
private String blockTitle;
/** ZONE 邮编分区,FEE 附加费 */
private String configKind;
/** REACH 可达,UNREACH 不可达,REMOTE 偏远。普通分区为空 */
private String coverage;
/** 渠道名称,从表头识别,可在页面修改 */
private String channelName;
/** 目录里的服务类型,和报价产品对应 */
private String serviceType;
/** 目录里的国家。邮编目的国仍在 countryName,偏远表里两者可以不同 */
private String catalogCountry;
/** 目录里的销售产品 */
private String productName;
/** 目录里的报价工作表 */
private String quoteSheet;
private String countryName;
private String countryCode;
private String feeName;
private String feeText;
private Integer lineCount;
/** 查询条件:邮编 */
private String postcode;
/** 导入时挂上的明细,列表查询不填充 */
private List<PayPostcodeLine> lines = new ArrayList<>();
}
@@ -1,23 +0,0 @@
package com.bms.price.domain;
import java.io.Serializable;
import lombok.Data;
/** 一次 Excel 导入的汇总,不把全部邮编返回给页面。 */
@Data
public class PayPostcodeImportResult implements Serializable
{
private static final long serialVersionUID = 1L;
private String sourceFile;
private int groupCount;
private int feeCount;
private int zoneCount;
private int lineCount;
private String message;
}
@@ -1,41 +0,0 @@
package com.bms.price.domain;
import java.io.Serializable;
import lombok.Data;
/**
* 一条邮编。除末尾星号外,地区、分区和金额都保持 Excel 原文。
*/
@Data
public class PayPostcodeLine implements Serializable
{
private static final long serialVersionUID = 1L;
private Long lineId;
private Long groupId;
private String countryName;
private String countryCode;
private String regionName;
private String regionNameCn;
private String cityName;
private String regionCode;
private String zoneName;
private String postcode;
private String postcodeRaw;
private String postcodeEnd;
private String extraText;
private Integer sortNo;
}
@@ -1,31 +0,0 @@
package com.bms.price.domain;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/**
* 导入预览。此时还没有写入数据库。
*/
@Data
public class PayPreview
{
private String sourceFile;
/** 本次价卡在 OSS 上的地址,保存时写到每个产品版本 */
private String filePath;
private String fileMd5;
private String supplierName;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date effectiveDate;
private List<PayProduct> products = new ArrayList<>();
/** 没有对上字典、因此没有读取的工作表 */
private List<String> skippedSheets = new ArrayList<>();
}
@@ -1,17 +0,0 @@
package com.bms.price.domain;
import java.util.List;
import lombok.Data;
/** 旧版和新版价格对照。 */
@Data
public class PayPriceCompare
{
private PayProduct older;
private PayProduct newer;
private List<PayPriceDiff> weightDiffs;
private List<PayPriceDiff> surchargeDiffs;
}
@@ -1,19 +0,0 @@
package com.bms.price.domain;
import lombok.Data;
/** 两个价格版本里的一行差异。 */
@Data
public class PayPriceDiff
{
private String name;
private String zoneName;
/** 相同、变更、新增、删除。新增和删除相对新版本而言 */
private String change;
private String oldValue;
private String newValue;
}
@@ -1,149 +0,0 @@
package com.bms.price.domain;
import java.util.Date;
import java.util.List;
import com.bms.common.annotation.Excel;
import com.bms.common.core.domain.BaseEntity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 应付价卡产品。目录里的一个产品对应一行,重量段和附加费在子表。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class PayProduct extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long productId;
@Excel(name = "产品名称", width = 24)
private String productName;
/** 支付类型,字典 bms_pay_type:AR应收 AP应付 */
private String productType;
/** 字典 payment_product_type 的键值,和报价工作表对应 */
private String dictValue;
/** 复制来源。空表示这是公共模板 */
private Long originId;
/** 服务渠道 ID。空表示公共模板 */
private String channelId;
/** 版本号。模板是 1,渠道另存从 2 起 */
private Integer versionNo;
/** 列表只看公共模板 */
private Boolean templateOnly;
/** 列表每个产品只显示当前生效版;没有生效版时显示最新一版 */
private Boolean currentOnly;
/** 当前价格版本对应的记录。列表上的 productId 是不变的产品编号 */
private Long versionProductId;
/** 当前版本的有效期记录 */
private Long termId;
/** 0 生效,1 失效 */
private String termStatus;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date termStart;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date termEnd;
@Excel(name = "国家/口岸", width = 18)
private String countryScope;
/** 字典 order_charge_type:续重计费、单票价、常规计重价 */
@Excel(name = "费用类型", width = 14)
private String chargeType;
/** 续重时每公斤加价。各分区相同是一个金额,不同是最小~最大 */
@Excel(name = "每公斤加价", width = 14)
private String extraPerKg;
@Excel(name = "服务类型", width = 16)
private String serviceType;
@Excel(name = "分类", width = 16)
private String classification;
@Excel(name = "目录备注", width = 28)
private String catalogRemark;
@Excel(name = "更新内容", width = 18)
private String updateContent;
@Excel(name = "重量限制", width = 14)
private String weightLimit;
@Excel(name = "报价工作表", width = 24)
private String sheetName;
@Excel(name = "关联分区表", width = 24)
private String relatedSheet;
@Excel(name = "来源文件", width = 36)
private String sourceFile;
/** 源文件在 OSS 上的地址,形如 oss://bucket/key。历史记录按它下载 */
private String filePath;
private String fileMd5;
@Excel(name = "价卡归属", width = 12)
private String supplierName;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
@Excel(name = "生效日期", width = 14, dateFormat = "yyyy-MM-dd")
private Date effectiveDate;
@Excel(name = "币种", width = 10)
private String currency;
@Excel(name = "服务说明", width = 40)
private String serviceNote;
@Excel(name = "识别结果", width = 12)
private String parseStatus;
@Excel(name = "重量段数", width = 12)
private Integer weightCount;
@Excel(name = "附加费数", width = 12)
private Integer surchargeCount;
@Excel(name = "其他配置数", width = 12)
private Integer extraCount;
/** 查询条件:生效日起,格式 yyyy-MM-dd */
private String effectiveBegin;
/** 查询条件:生效日止 */
private String effectiveEnd;
/** 查询条件:附加费名称、收费标准、费用说明或时效原文 */
private String surchargeName;
/** 查询条件:重量段、费用项、分区或国家 */
private String weightKeyword;
/** 预览时标记库里是否已有同一文件、同一工作表、同一产品 */
private Boolean duplicated;
/** 已存在记录的主键,覆盖保存时使用 */
private Long existsId;
private List<PayWeightBand> weightBands;
private List<PaySurcharge> surcharges;
private List<PayExtraLine> extraLines;
}
@@ -1,48 +0,0 @@
package com.bms.price.domain;
import java.util.Date;
import com.bms.common.core.domain.BaseEntity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 应付产品某一版价格的有效期。同一产品多次导入,每版一行。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class PayProductTerm extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long termId;
/** 字典 payment_product_type,同一产品的各版本用它归组 */
private String dictValue;
private Long productId;
private Integer versionNo;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date effectiveStart;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date effectiveEnd;
/** 0 生效,1 失效 */
private String status;
private String productName;
private String sourceFile;
/** 这一版源文件的 OSS 地址,空表示导入时还没有存文件 */
private String filePath;
private Integer weightCount;
private Integer surchargeCount;
private String currency;
}
@@ -1,17 +0,0 @@
package com.bms.price.domain;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* 预览确认后的保存请求。只保存前端勾选并回传的产品。
*/
@Data
public class PaySaveCommand
{
/** 为 true 时,同文件同表同名的旧产品先删后插 */
private boolean overwrite;
private List<PayProduct> products = new ArrayList<>();
}
@@ -1,70 +0,0 @@
package com.bms.price.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/**
* 应付产品附加费。生效开始和结束都空时,表示跟随价卡生效日,不另设窗口。
*/
@Data
public class PaySurcharge
{
private Long surchargeId;
private Long productId;
/** 所属价卡版本,跟产品版本一起变 */
private Integer versionNo;
private String sectionName;
private String feeName;
private String zoneName;
/** 收费标准原文 */
private String chargeText;
/** 从收费标准里解析出的金额;百分比时这里是百分数 */
private BigDecimal amount;
/** 币种代码,计费时和订单币种比对 */
private String currency;
/** 货币和计费单位,与重量段单位同一写法,如 EUR/ITEM、RMB/KG、% */
private String priceUnit;
/** 费用说明 */
private String feeDesc;
/** 时效原文,解析不出日期时仍保留 */
private String periodText;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date effectiveStart;
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date effectiveEnd;
/** 固定收取、产生时收取、旺季期间 */
private String chargeTiming;
/** ALWAYS、POSTCODE、ZONE、WEIGHT、SIZE、RETURN、EXTRA_KG、MANUAL */
private String calcRule;
/** 邮编列表、重量阈值或尺寸表达式。续重时这里是公斤阈值 */
private String calcParam;
/** 偏远邮编规则。续重行的阈值在 calcParam,邮编放这里,避免互相覆盖 */
private String postcodeParam;
/** 适用重量下限,空表示不限。首重从 0 开始 */
private BigDecimal weightMin;
/** 适用重量上限,空表示不限。分区价按这个区间只收一档 */
private BigDecimal weightMax;
private Integer sortNo;
}
@@ -1,48 +0,0 @@
package com.bms.price.domain;
import java.math.BigDecimal;
import lombok.Data;
/**
* 应付产品上的一行重量段、首重续重或分区单价。
*/
@Data
public class PayWeightBand
{
private Long bandId;
private Long productId;
/** 所属价卡版本,跟产品版本一起变 */
private Integer versionNo;
/** 报价区块,例如派送费、偏远附加费 */
private String sectionName;
private String countryName;
private String countryCode;
/** 价格列名称,例如英国大陆、马德里高密度 */
private String zoneName;
/** 重量段原文 */
private String weightLabel;
private BigDecimal weightMin;
private BigDecimal weightMax;
/** 体积上限,数字加单位,如 10000cm³。空表示这档不看体积 */
private String volumeText;
/** 处理费、派送费、续重等 */
private String chargeName;
private BigDecimal price;
/** EUR/ITEM、GBP/KG 等 */
private String priceUnit;
private Integer sortNo;
}
@@ -23,9 +23,6 @@ public class PrcPayBillFee implements Serializable
private String currency;
private String roundMode;
private String remark;
/** CALC 计算,LADING 提单分摊,MANUAL 人工 */
private String feeSource;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
}
@@ -15,19 +15,12 @@ public class PrcPriceSet extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** Ordinary package (default). */
public static final String CATEGORY_PACKAGE = "PACKAGE";
/** Boss exclusive quote. */
public static final String CATEGORY_EXCLUSIVE = "EXCLUSIVE";
private Long priceSetId;
private String priceSetCode;
private String priceSetName;
private String priceSetNote;
/** FIXED / DISCOUNT */
private String setType;
/** PACKAGE=ordinary set / EXCLUSIVE=boss exclusive quote */
private String setCategory;
private BigDecimal defaultDiscount;
private String defaultType;
private String calculateSign;
@@ -40,10 +40,6 @@ public class BatchAddIncomeBillFeeRequest implements Serializable
private String beginBizDate;
private String endBizDate;
/** Receive/pickup time range (pickup_date), inclusive. */
private String beginPickupDate;
private String endPickupDate;
/** Surcharge config id (prc_surcharge.sc_id). */
@NotNull(message = "scId required")
private Long scId;
@@ -25,7 +25,4 @@ public class ChargeWeightTemplateRequest implements Serializable
private List<Integer> orderStatuses;
private String beginBizDate;
private String endBizDate;
/** Receive/pickup time range (pickup_date), inclusive. */
private String beginPickupDate;
private String endPickupDate;
}
@@ -56,7 +56,7 @@ public class PrcBillingBillListExportRow implements Serializable
@Excel(name = "计费时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date bizDate;
@Excel(name = "收货时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "提货时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date pickupDate;
@Excel(name = "销售员", width = 12)
@@ -43,4 +43,6 @@ public class PrcProfitReportProductRow implements Serializable
@Excel(name = "未计算提成订单数", width = 20)
private Long commissionMissingOrderCount;
*/
@Excel(name = "产品价或价格缺失订单数", width = 22)
private Long incompleteOrderCount;
}
@@ -24,10 +24,6 @@ public class PrcProfitReportQuery extends BaseEntity
private String customerCode;
/** 客户代码拆分后的多值查询条件。 */
private List<String> customerCodes;
/** 按客户当前区域筛选。 */
private String customerArea;
/** 服务端根据客户区域确定的计算规则。 */
private String profitRule;
private String customerName;
/** 客户名称拆分后的多值查询条件。 */
private List<String> customerNames;
@@ -39,6 +35,6 @@ public class PrcProfitReportQuery extends BaseEntity
private String arBillStatus;
/** 是否按产品维度分组值下钻订单明细,允许分组字段为空。 */
private Boolean productDrilldown;
/** 仅用于拒绝已暂停的提成未计算订单下钻请求。 */
/** 订单明细弹窗的筛选类型:ALL、INCOMPLETE;COMMISSION_MISSING 暂停使用。 */
private String detailType;
}
@@ -11,6 +11,7 @@ public class PrcProfitReportSummary implements Serializable
private static final long serialVersionUID = 1L;
private Long orderCount;
private Long incompleteOrderCount;
private String currency;
private BigDecimal costAmount;
private BigDecimal redlineAmount;
@@ -31,7 +31,4 @@ public class RecalcBillPriceRequest implements Serializable
private List<Integer> billingFailStatuses;
private String beginBizDate;
private String endBizDate;
/** Receive/pickup time range (pickup_date), inclusive. */
private String beginPickupDate;
private String endPickupDate;
}
@@ -43,11 +43,6 @@ public class TabOrderMonitorQuery implements Serializable
* 空则服务端回落默认 3,4,5,6,7,8,11(与历史硬编码一致)。
*/
private List<Integer> orderStatuses;
/**
* 是否收货 BL_PICKUP。1 已收货,0 未收货。
* 空则服务端回落 1,与历史查询一致。
*/
private Integer blPickup;
/** 分批同步计费每批条数 */
private Integer batchSize;
/** Excel export field names (Java props; export only). */
@@ -66,7 +66,7 @@ public enum BillTaskFailCode
{
return NO_CUSTOMER_PACKAGE.code;
}
if (containsAny(msg, "无生效套餐版本", "套餐未启用", "noActivePackageVersion", "NO_ACTIVE_PACKAGE_VERSION", "packageNotEnabled"))
if (containsAny(msg, "无生效套餐版本", "noActivePackageVersion", "NO_ACTIVE_PACKAGE_VERSION"))
{
return NO_ACTIVE_PACKAGE_VERSION.code;
}

Some files were not shown because too many files have changed in this diff Show More