✨ 新增应收账单毛利分析与提成管理

This commit is contained in:
caojia
2026-11-09 10:50:29 +08:00
parent 489f99dc7b
commit cd25055159
30 changed files with 2018 additions and 2 deletions
@@ -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());
}
}