Merge branch 'test' into feauture-20261104

# Conflicts:
#	bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java
This commit is contained in:
liuminghui
2026-11-09 15:37:55 +08:00
46 changed files with 2238 additions and 83 deletions
@@ -0,0 +1,135 @@
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), "应收账单毛利分析");
}
}
@@ -0,0 +1,83 @@
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());
}
}
@@ -0,0 +1,68 @@
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);
}
}
@@ -0,0 +1,52 @@
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"));
}
}
@@ -0,0 +1,59 @@
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());
}
}