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:
+135
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,35 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换指定字符串的指定区间内字符为"*"
|
||||
*
|
||||
|
||||
@@ -36,6 +36,13 @@ 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")
|
||||
/** 账单所属期开始日期,导出模板展示用。 */
|
||||
|
||||
+2
-2
@@ -37,10 +37,10 @@ public class ArBillGeneratePreviewExportRow implements Serializable {
|
||||
@Excel(name = "国家/地区", sort = 6, width = 18)
|
||||
private String countryName;
|
||||
|
||||
@Excel(name = "实际重(kg)", sort = 7, scale = 2, width = 16)
|
||||
@Excel(name = "实际重(kg)", sort = 7, width = 16)
|
||||
private BigDecimal actualWeight;
|
||||
|
||||
@Excel(name = "计费重(kg)", sort = 8, scale = 2, width = 16)
|
||||
@Excel(name = "计费重(kg)", sort = 8, width = 16)
|
||||
private BigDecimal chargeWeight;
|
||||
|
||||
@Excel(name = "应收金额", sort = 9, scale = 2, width = 16)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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("仅财务和系统管理员可查看或维护提成");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.bms.ar.profit;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ArProfitSalesOption
|
||||
{
|
||||
private Long userId;
|
||||
private String name;
|
||||
private String code;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -24,6 +24,7 @@ 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;
|
||||
@@ -49,6 +50,8 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
|
||||
private ArBillMapper billMapper;
|
||||
@Autowired
|
||||
private ArBillScopeResolver billScope;
|
||||
@Autowired
|
||||
private ArCommissionService commissionService;
|
||||
|
||||
@Autowired
|
||||
private ArBalanceWriteoffMapper writeoffMapper;
|
||||
@@ -141,6 +144,7 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
|
||||
writeoff.setCreateUserId(operatorUserId);
|
||||
writeoff.setCreateBy(operator);
|
||||
writeoffMapper.insertArBalanceWriteoff(writeoff);
|
||||
commissionService.snapshotWriteoff(bill, writeoff);
|
||||
|
||||
// 3. 账单核销口径变更(2026-09-23):核销不再变动账户余额与已用额度,
|
||||
// 仅更新账单已核销/未核销金额与状态,资金变动由收款/计费入口产生。
|
||||
@@ -153,6 +157,10 @@ 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);
|
||||
@@ -207,6 +215,7 @@ 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,6 +73,10 @@ 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;
|
||||
@@ -185,6 +189,10 @@ 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;
|
||||
@@ -441,6 +449,21 @@ 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());
|
||||
|
||||
@@ -16,6 +16,11 @@ public enum ProfitAreaRule
|
||||
this.customerArea = customerArea;
|
||||
}
|
||||
|
||||
public String getCustomerArea()
|
||||
{
|
||||
return customerArea;
|
||||
}
|
||||
|
||||
public static ProfitAreaRule fromCustomerArea(String customerArea)
|
||||
{
|
||||
if (customerArea != null)
|
||||
|
||||
@@ -47,7 +47,7 @@ public class OracleOrderBillMapper
|
||||
req.setCountryCode(firstStr(row, "COUNTRY"));
|
||||
String countryName = firstStr(row, "COUNTRY_NAME", "COUNTRY_EINGTH_NAME", "COUNTRY");
|
||||
req.setCountryName(countryName);
|
||||
req.setDestPostcode(firstStr(row, "CONSIGNEE_POSTCODE"));
|
||||
req.setDestPostcode(StringUtils.normalizePostcode(firstStr(row, "CONSIGNEE_POSTCODE")));
|
||||
// Prefer CONFIRM_WEIGHT (customer confirmed); fallback DIMENSIONAL_WEIGHT_FACTOR (charge weight).
|
||||
BigDecimal weight = firstPositiveDecimal(row, "CONFIRM_WEIGHT", "DIMENSIONAL_WEIGHT_FACTOR");
|
||||
req.setWeight(weight);
|
||||
|
||||
@@ -69,8 +69,8 @@ public class EsBillSearchStore implements IBillSearchStore
|
||||
doc.put("bizId", bizId.trim());
|
||||
doc.put("status", StringUtils.isEmpty(status) ? EsBizLogSupport.STATUS_SUCCESS : status.trim());
|
||||
doc.put("error", StringUtils.isEmpty(errorMessage) ? null : errorMessage);
|
||||
doc.put("request", parseOrRaw(requestJson));
|
||||
// Normalize response.ok boolean -> 1/0: index mapping typed response.ok as long.
|
||||
// Index dynamic mapping is strict on a few fields; coerce before PUT.
|
||||
doc.put("request", normalizeRequestForEs(parseOrRaw(requestJson)));
|
||||
doc.put("response", normalizeResponseForEs(parseOrRaw(responseJson)));
|
||||
String ts = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(now);
|
||||
doc.put("updatedAt", ts);
|
||||
@@ -86,7 +86,26 @@ public class EsBillSearchStore implements IBillSearchStore
|
||||
}
|
||||
}
|
||||
|
||||
/** Index historically mapped response.ok as long; coerce boolean to 1/0. */
|
||||
/**
|
||||
* request.bizDate is mapped as date (ISO / epoch only); bill writes "yyyy-MM-dd HH:mm:ss".
|
||||
* request.taskId is mapped as long; UUID strings must not stay under taskId.
|
||||
*/
|
||||
private Object normalizeRequestForEs(Object request)
|
||||
{
|
||||
if (!(request instanceof JSONObject))
|
||||
{
|
||||
return request;
|
||||
}
|
||||
JSONObject obj = (JSONObject) request;
|
||||
normalizeBizDateField(obj);
|
||||
relocateNonNumericTaskId(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* response.ok mapped as long; response.success as boolean; response.taskId as long.
|
||||
* Local rebill DONE used UUID taskId + numeric success and was rejected by ES.
|
||||
*/
|
||||
private Object normalizeResponseForEs(Object response)
|
||||
{
|
||||
if (!(response instanceof JSONObject))
|
||||
@@ -99,9 +118,54 @@ public class EsBillSearchStore implements IBillSearchStore
|
||||
{
|
||||
obj.put("ok", Boolean.TRUE.equals(ok) ? Integer.valueOf(1) : Integer.valueOf(0));
|
||||
}
|
||||
Object success = obj.get("success");
|
||||
if (success instanceof Number)
|
||||
{
|
||||
obj.put("success", Boolean.valueOf(((Number) success).intValue() != 0));
|
||||
}
|
||||
relocateNonNumericTaskId(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
private void normalizeBizDateField(JSONObject obj)
|
||||
{
|
||||
Object raw = obj.get("bizDate");
|
||||
if (!(raw instanceof String))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String bizDate = ((String) raw).trim();
|
||||
// "yyyy-MM-dd HH:mm:ss" -> ISO-local so ES date mapper accepts it.
|
||||
if (bizDate.length() >= 19 && bizDate.charAt(10) == ' ')
|
||||
{
|
||||
obj.put("bizDate", bizDate.substring(0, 10) + "T" + bizDate.substring(11));
|
||||
}
|
||||
}
|
||||
|
||||
private void relocateNonNumericTaskId(JSONObject obj)
|
||||
{
|
||||
Object taskId = obj.get("taskId");
|
||||
if (!(taskId instanceof String))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String text = ((String) taskId).trim();
|
||||
if (text.isEmpty())
|
||||
{
|
||||
obj.remove("taskId");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
obj.put("taskId", Long.valueOf(Long.parseLong(text)));
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
obj.remove("taskId");
|
||||
obj.put("taskIdStr", text);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BillSyncSnapshot get(String logType, String bizId)
|
||||
{
|
||||
|
||||
+1
-1
@@ -3324,7 +3324,7 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
|
||||
bill.setPkgHeight(request.getPkgHeight());
|
||||
bill.setShipperName(request.getShipperName());
|
||||
bill.setConsigneeName(request.getConsigneeName());
|
||||
bill.setDestPostcode(request.getDestPostcode());
|
||||
bill.setDestPostcode(StringUtils.normalizePostcode(request.getDestPostcode()));
|
||||
bill.setWarehouseCode(request.getWarehouseCode());
|
||||
bill.setOrderTime(request.getOrderTime());
|
||||
applyUpstreamOrderStatus(bill, request);
|
||||
|
||||
+11
-3
@@ -40,6 +40,7 @@ import com.bms.price.domain.PrcSurchargeRel;
|
||||
import com.bms.price.mapper.BmsProductCountryMapper;
|
||||
import com.bms.price.mapper.BmsProductMapper;
|
||||
import com.bms.price.mapper.PrcCustomerPriceSetMapper;
|
||||
import com.bms.price.mapper.PrcOrderBillMapper;
|
||||
import com.bms.price.mapper.PrcPriceSetDetailMapper;
|
||||
import com.bms.price.mapper.PrcPriceSetMapper;
|
||||
import com.bms.price.mapper.PrcPriceSetProductMapper;
|
||||
@@ -141,6 +142,9 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
||||
@Autowired
|
||||
private IBmsCustomerService bmsCustomerService;
|
||||
|
||||
@Autowired
|
||||
private PrcOrderBillMapper orderBillMapper;
|
||||
|
||||
@Override
|
||||
public PrcPriceSet selectPrcPriceSetById(Long priceSetId)
|
||||
{
|
||||
@@ -626,7 +630,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
||||
{
|
||||
throw new ServiceException(MessageUtils.message("price.set.version.notExists"));
|
||||
}
|
||||
// Draft / pending: hard delete; EFFECT: soft delete then reopen previous via refreshVersionStatus
|
||||
// Draft / pending / effect may be deleted; expired stays view-only.
|
||||
if (!VER_DRAFT.equals(version.getStatus())
|
||||
&& !VER_PENDING.equals(version.getStatus())
|
||||
&& !VER_EFFECT.equals(version.getStatus()))
|
||||
@@ -634,9 +638,13 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
|
||||
throw new ServiceException(MessageUtils.message("price.set.version.delete.statusNotAllowed"));
|
||||
}
|
||||
requireSet(version.getPriceSetId());
|
||||
wipeVersionChildren(psvId, version.getStatus());
|
||||
// Products / details / surcharge always physical delete.
|
||||
surchargeRelMapper.physicalDeleteByPsvId(psvId);
|
||||
detailMapper.physicalDeleteByPsvId(psvId);
|
||||
setProductMapper.physicalDeleteByPsvId(psvId);
|
||||
// Version: soft-delete when any order bill references psv_id; otherwise hard-delete.
|
||||
int rows;
|
||||
if (VER_EFFECT.equals(version.getStatus()))
|
||||
if (orderBillMapper.countByPsvId(psvId) > 0)
|
||||
{
|
||||
rows = versionMapper.softDeleteById(psvId, updateBy);
|
||||
}
|
||||
|
||||
+1
-9
@@ -783,15 +783,7 @@ public class PrcZoneServiceImpl implements IPrcZoneService
|
||||
|
||||
private static String normalizePostcode(String postcode)
|
||||
{
|
||||
if (StringUtils.isEmpty(postcode))
|
||||
{
|
||||
return postcode;
|
||||
}
|
||||
if (postcode.endsWith(".0") && postcode.matches("\\d+\\.0"))
|
||||
{
|
||||
return postcode.substring(0, postcode.length() - 2);
|
||||
}
|
||||
return postcode;
|
||||
return StringUtils.normalizePostcode(postcode);
|
||||
}
|
||||
|
||||
private static String normalizeCell(String text)
|
||||
|
||||
+6
-11
@@ -388,7 +388,11 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
|
||||
}
|
||||
// Charge weight keeps 3 decimals; freight unit price keeps 2 (see buildFromRates).
|
||||
BigDecimal weight = request.getWeight().setScale(3, RoundingMode.HALF_UP);
|
||||
String destPostcode = emptyToNull(request.getDestPostcode());
|
||||
String destPostcode = emptyToNull(normalizePostcode(request.getDestPostcode()));
|
||||
if (destPostcode != null)
|
||||
{
|
||||
request.setDestPostcode(destPostcode);
|
||||
}
|
||||
String productName = null;
|
||||
if (request instanceof TmsPriceBillRequest)
|
||||
{
|
||||
@@ -992,16 +996,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
|
||||
|
||||
private static String normalizePostcode(String postcode)
|
||||
{
|
||||
if (StringUtils.isEmpty(postcode))
|
||||
{
|
||||
return postcode;
|
||||
}
|
||||
String n = postcode.trim().replace(" ", "");
|
||||
if (n.endsWith(".0") && n.matches("\\d+\\.0"))
|
||||
{
|
||||
return n.substring(0, n.length() - 2);
|
||||
}
|
||||
return n;
|
||||
return StringUtils.normalizePostcode(postcode);
|
||||
}
|
||||
|
||||
private TmsPriceQuoteResult tryFixed(PrcCustomerPriceSet bind, PrcPriceSet set, PrcPriceSetVersion version,
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
<result property="customerId" column="customer_id"/>
|
||||
<result property="customerCode" column="customer_code"/>
|
||||
<result property="customerName" column="customer_name"/>
|
||||
<result property="profitCustomerArea" column="profit_customer_area"/>
|
||||
<result property="profitSalesUserId" column="profit_sales_user_id"/>
|
||||
<result property="profitSalesUserCode" column="profit_sales_user_code"/>
|
||||
<result property="profitSalesUserName" column="profit_sales_user_name"/>
|
||||
<result property="profitSnapshotSource" column="profit_snapshot_source"/>
|
||||
<result property="billPeriodStart" column="bill_period_start"/>
|
||||
<result property="billPeriodEnd" column="bill_period_end"/>
|
||||
<result property="billDate" column="bill_date"/>
|
||||
@@ -48,6 +53,11 @@
|
||||
customer_id,
|
||||
customer_code,
|
||||
customer_name,
|
||||
profit_customer_area,
|
||||
profit_sales_user_id,
|
||||
profit_sales_user_code,
|
||||
profit_sales_user_name,
|
||||
profit_snapshot_source,
|
||||
bill_period_start,
|
||||
bill_period_end,
|
||||
bill_date,
|
||||
@@ -169,13 +179,15 @@
|
||||
</select>
|
||||
|
||||
<insert id="insertArBill" parameterType="ArBill" useGeneratedKeys="true" keyProperty="arBillId">
|
||||
insert into ar_bill (bill_no, bill_name, customer_id, customer_code, customer_name, bill_period_start,
|
||||
insert into ar_bill (bill_no, bill_name, customer_id, customer_code, customer_name,
|
||||
profit_customer_area, profit_sales_user_id, profit_sales_user_code, profit_sales_user_name, profit_snapshot_source, bill_period_start,
|
||||
bill_period_end,
|
||||
bill_date, currency, order_count, total_amount, written_off_amount, unwritten_amount,
|
||||
status, generation_status, bill_version, bank_account_name, bank_account_no, bank_name,
|
||||
remark,
|
||||
confirmed_by, confirmed_time, create_by, create_user_id, create_time, update_by, update_time)
|
||||
values (#{billNo}, #{billName}, #{customerId}, #{customerCode}, #{customerName}, #{billPeriodStart},
|
||||
values (#{billNo}, #{billName}, #{customerId}, #{customerCode}, #{customerName},
|
||||
#{profitCustomerArea}, #{profitSalesUserId}, #{profitSalesUserCode}, #{profitSalesUserName}, #{profitSnapshotSource}, #{billPeriodStart},
|
||||
#{billPeriodEnd},
|
||||
#{billDate}, #{currency}, #{orderCount}, #{totalAmount}, #{writtenOffAmount}, #{unwrittenAmount},
|
||||
#{status}, #{generationStatus}, #{billVersion}, #{bankAccountName}, #{bankAccountNo}, #{bankName},
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.bms.ar.mapper.ArCommissionMapper">
|
||||
<resultMap id="rate" type="com.bms.ar.profit.ArCommissionRate">
|
||||
<id property="rateId" column="rate_id"/><result property="salesUserId" column="sales_user_id"/>
|
||||
<result property="salesUserCode" column="sales_user_code"/><result property="salesUserName" column="sales_user_name"/>
|
||||
<result property="rate" column="rate"/><result property="effectiveAt" column="effective_at"/>
|
||||
<result property="createBy" column="create_by"/><result property="remark" column="remark"/>
|
||||
</resultMap>
|
||||
<resultMap id="missingRate" type="com.bms.ar.profit.ArWriteoffRate">
|
||||
<id property="writeoffId" column="writeoff_id"/><result property="arBillId" column="ar_bill_id"/>
|
||||
<result property="billNo" column="bill_no"/><result property="customerName" column="customer_name"/>
|
||||
<result property="salesUserId" column="sales_user_id"/><result property="salesUserName" column="sales_user_name"/>
|
||||
<result property="writeoffAmount" column="writeoff_amount"/><result property="writeoffDate" column="writeoff_date"/>
|
||||
<result property="rate" column="rate"/>
|
||||
<result property="status" column="status"/><result property="confirmedBy" column="confirmed_by"/>
|
||||
<result property="confirmedTime" column="confirmed_time"/><result property="remark" column="confirm_remark"/>
|
||||
</resultMap>
|
||||
<resultMap id="commissionResult" type="com.bms.ar.profit.ArCommissionResult">
|
||||
<id property="resultId" column="result_id"/><result property="arBillId" column="ar_bill_id"/>
|
||||
<result property="detailId" column="detail_id"/><result property="billNo" column="bill_no"/>
|
||||
<result property="orderNo" column="order_no"/><result property="salesUserName" column="sales_user_name"/>
|
||||
<result property="currency" column="currency"/><result property="grossProfit" column="gross_profit"/>
|
||||
<result property="commissionAmount" column="commission_amount"/><result property="resultStatus" column="result_status"/>
|
||||
<result property="createTime" column="create_time"/>
|
||||
<result property="reversedBy" column="reversed_by"/><result property="reversedTime" column="reversed_time"/>
|
||||
<result property="salesUserId" column="sales_user_id"/><result property="weightedRate" column="weighted_rate"/>
|
||||
<result property="billAmount" column="bill_amount"/>
|
||||
</resultMap>
|
||||
<resultMap id="commissionSummary" type="com.bms.ar.profit.ArCommissionSummary">
|
||||
<result property="salesUserId" column="sales_user_id"/><result property="salesUserName" column="sales_user_name"/>
|
||||
<result property="currency" column="currency"/><result property="orderCount" column="order_count"/>
|
||||
<result property="pendingCount" column="pending_count"/><result property="commissionAmount" column="commission_amount"/>
|
||||
</resultMap>
|
||||
<select id="selectRates" resultMap="rate">
|
||||
select r.rate_id, r.sales_user_id, u.user_name as sales_user_code, u.nick_name as sales_user_name,
|
||||
r.rate, r.effective_at, r.create_by, r.remark
|
||||
from ar_profit_commission_rate r left join sys_user u on u.user_id = r.sales_user_id
|
||||
order by r.sales_user_id, r.effective_at desc, r.rate_id desc
|
||||
</select>
|
||||
<select id="selectEffective" resultMap="rate">
|
||||
select rate_id, sales_user_id, rate, effective_at
|
||||
from ar_profit_commission_rate
|
||||
where sales_user_id = #{salesUserId} and effective_at <= #{at}
|
||||
order by effective_at desc, rate_id desc limit 1 for update
|
||||
</select>
|
||||
<insert id="insertRate" parameterType="com.bms.ar.profit.ArCommissionRate" useGeneratedKeys="true" keyProperty="rateId">
|
||||
insert into ar_profit_commission_rate(sales_user_id, rate, effective_at, create_by, create_time, remark)
|
||||
values(#{salesUserId}, #{rate}, #{effectiveAt}, #{createBy}, sysdate(), #{remark})
|
||||
</insert>
|
||||
<select id="lockSalesUser" resultType="long">
|
||||
select user_id from sys_user where user_id = #{salesUserId} for update
|
||||
</select>
|
||||
<insert id="insertWriteoffRate">
|
||||
insert into ar_profit_writeoff_rate(writeoff_id, sales_user_id, rate_id, rate, create_time)
|
||||
values(#{writeoffId}, #{salesUserId}, #{rateId}, #{rate}, sysdate())
|
||||
</insert>
|
||||
<select id="selectMissingRates" resultMap="missingRate">
|
||||
select w.writeoff_id, b.ar_bill_id, b.bill_no, b.customer_name,
|
||||
b.profit_sales_user_id as sales_user_id, b.profit_sales_user_name as sales_user_name,
|
||||
w.writeoff_amount, w.writeoff_date, r.rate
|
||||
from ar_balance_writeoff w join ar_bill b on b.ar_bill_id = w.ar_bill_id
|
||||
left join ar_profit_writeoff_rate r on r.writeoff_id = w.writeoff_id
|
||||
where w.status = 'EFFECTIVE' and (r.rate is null or r.rate < 0 or r.rate > 100)
|
||||
and b.status in ('CONFIRMED', 'PARTIAL_WRITTEN_OFF', 'WRITTEN_OFF')
|
||||
and b.generation_status = 'READY'
|
||||
order by w.writeoff_date desc, w.writeoff_id desc
|
||||
</select>
|
||||
<select id="selectWriteoffRates" resultMap="missingRate">
|
||||
select w.writeoff_id, b.ar_bill_id, b.bill_no, b.customer_name,
|
||||
b.profit_sales_user_id as sales_user_id, b.profit_sales_user_name as sales_user_name,
|
||||
w.writeoff_amount, w.writeoff_date, w.status, r.rate, r.confirmed_by, r.confirmed_time, r.confirm_remark
|
||||
from ar_balance_writeoff w join ar_bill b on b.ar_bill_id = w.ar_bill_id
|
||||
left join ar_profit_writeoff_rate r on r.writeoff_id = w.writeoff_id
|
||||
where b.ar_bill_id = #{billId} and b.generation_status = 'READY'
|
||||
and b.status in ('CONFIRMED', 'PARTIAL_WRITTEN_OFF', 'WRITTEN_OFF')
|
||||
order by w.create_time, w.writeoff_id
|
||||
</select>
|
||||
<select id="selectBillIdByWriteoffId" resultType="long">
|
||||
select ar_bill_id from ar_balance_writeoff where writeoff_id = #{writeoffId} and status = 'EFFECTIVE'
|
||||
</select>
|
||||
<select id="countMissingRates" resultType="int">
|
||||
select count(*) from ar_balance_writeoff w
|
||||
left join ar_profit_writeoff_rate r on r.writeoff_id = w.writeoff_id
|
||||
where w.ar_bill_id = #{billId} and w.status = 'EFFECTIVE' and (r.rate is null or r.rate < 0 or r.rate > 100)
|
||||
</select>
|
||||
<update id="confirmMissingRate">
|
||||
update ar_profit_writeoff_rate r
|
||||
join ar_balance_writeoff w on w.writeoff_id = r.writeoff_id and w.status = 'EFFECTIVE'
|
||||
join ar_bill b on b.ar_bill_id = w.ar_bill_id
|
||||
set r.sales_user_id = b.profit_sales_user_id, r.rate = #{rate}, r.confirmed_by = #{operator},
|
||||
r.confirmed_time = sysdate(), r.confirm_remark = #{remark}
|
||||
where r.writeoff_id = #{writeoffId} and r.rate is null and b.profit_sales_user_id is not null
|
||||
</update>
|
||||
<select id="selectResults" resultMap="commissionResult">
|
||||
select r.result_id, r.ar_bill_id, r.detail_id, b.bill_no, d.order_no,
|
||||
b.profit_sales_user_name as sales_user_name, r.currency, r.gross_profit,
|
||||
r.commission_amount, r.result_status, r.create_time, r.reversed_by, r.reversed_time
|
||||
from ar_profit_commission_result r join ar_bill b on b.ar_bill_id = r.ar_bill_id
|
||||
join ar_bill_detail d on d.detail_id = r.detail_id
|
||||
where 1 = 1
|
||||
<choose>
|
||||
<when test="includeReversed">and (r.result_status = 'REVERSED' or (b.status = 'WRITTEN_OFF' and b.generation_status = 'READY'))</when>
|
||||
<otherwise>and r.result_status != 'REVERSED' and b.status = 'WRITTEN_OFF' and b.generation_status = 'READY'</otherwise>
|
||||
</choose>
|
||||
<if test="billId != null">and r.ar_bill_id = #{billId}</if>
|
||||
order by r.result_id desc
|
||||
</select>
|
||||
<select id="selectCommissionSummary" resultMap="commissionSummary">
|
||||
select r.sales_user_id, max(b.profit_sales_user_name) as sales_user_name, r.currency,
|
||||
count(*) as order_count,
|
||||
sum(case when r.result_status != 'READY' then 1 else 0 end) as pending_count,
|
||||
<choose>
|
||||
<when test="billId != null">
|
||||
case when sum(case when r.result_status != 'READY' then 1 else 0 end) > 0 then null
|
||||
else sum(case when r.result_status = 'READY' then r.commission_amount else null end) end
|
||||
</when>
|
||||
<otherwise>sum(case when r.result_status = 'READY' then r.commission_amount else null end)</otherwise>
|
||||
</choose> as commission_amount
|
||||
from ar_profit_commission_result r join ar_bill b on b.ar_bill_id = r.ar_bill_id
|
||||
where r.result_status != 'REVERSED' and b.status = 'WRITTEN_OFF' and b.generation_status = 'READY'
|
||||
<if test="billId != null">and r.ar_bill_id = #{billId}</if>
|
||||
group by r.sales_user_id, r.currency
|
||||
order by sales_user_name, r.currency
|
||||
</select>
|
||||
<update id="reverseResults">
|
||||
update ar_profit_commission_result
|
||||
set result_status = 'REVERSED', reversed_by = #{operator}, reversed_time = sysdate()
|
||||
where ar_bill_id = #{billId} and result_status != 'REVERSED'
|
||||
</update>
|
||||
|
||||
<!-- 当且仅当账单全部核销时,以当次费用快照及每笔有效核销的比例生成正式记录。 -->
|
||||
<select id="selectSettlementInputs" resultMap="commissionResult">
|
||||
<bind name="shenzhenArea" value="@com.bms.price.enums.ProfitAreaRule@SHENZHEN.getCustomerArea()"/>
|
||||
<bind name="guangzhouArea" value="@com.bms.price.enums.ProfitAreaRule@GUANGZHOU.getCustomerArea()"/>
|
||||
select x.ar_bill_id, x.detail_id, x.profit_sales_user_id as sales_user_id, x.currency, x.gross_profit,
|
||||
x.weighted_rate, x.total_amount as bill_amount, coalesce(x.issue, 'READY') as result_status
|
||||
from (
|
||||
select b.ar_bill_id, d.detail_id, b.profit_sales_user_id, d.currency, b.total_amount, w.weighted_rate,
|
||||
case when f.currency_count = 1 and f.fee_currency = d.currency and d.currency = b.currency and f.sales_amount = d.total_amount and b.profit_customer_area = #{shenzhenArea} and f.product_complete = 1
|
||||
then f.sales_amount - f.product_amount
|
||||
when f.currency_count = 1 and f.fee_currency = d.currency and d.currency = b.currency and f.sales_amount = d.total_amount and b.profit_customer_area = #{guangzhouArea} and f.redline_complete = 1
|
||||
then f.sales_amount - f.redline_amount else null end as gross_profit,
|
||||
case when b.profit_sales_user_id is null or b.profit_customer_area is null
|
||||
or b.profit_customer_area not in (#{shenzhenArea},#{guangzhouArea}) then 'MISSING_SNAPSHOT'
|
||||
when w.bill_id is null or w.written_amount != b.total_amount then 'WRITEOFF_MISMATCH'
|
||||
when w.missing_rates > 0 then 'MISSING_RATE'
|
||||
when b.total_amount is null or b.total_amount <= 0 or d.total_amount is null
|
||||
or d.currency is null or b.currency is null or f.sales_amount is null
|
||||
or f.currency_count != 1 or f.fee_currency != d.currency or d.currency != b.currency
|
||||
or f.sales_amount != d.total_amount
|
||||
or (b.profit_customer_area = #{shenzhenArea} and coalesce(f.product_complete, 0) = 0)
|
||||
or (b.profit_customer_area = #{guangzhouArea} and coalesce(f.redline_complete, 0) = 0)
|
||||
then 'MISSING_PRICE'
|
||||
else null end as issue
|
||||
from ar_bill b inner join ar_bill_detail d on d.ar_bill_id = b.ar_bill_id
|
||||
left join (
|
||||
select fee.detail_id, case when count(fee.amount) = count(*) then sum(fee.amount) else null end as sales_amount,
|
||||
case when count(fee.currency) = count(*) then count(distinct fee.currency) else 0 end as currency_count, min(fee.currency) as fee_currency,
|
||||
case when count(fee.product_amount) = count(*) then sum(fee.product_amount) else null end as product_amount,
|
||||
case when count(fee.redline_amount) = count(*) then sum(fee.redline_amount) else null end as redline_amount,
|
||||
case when count(fee.product_amount) = count(*) then 1 else 0 end as product_complete,
|
||||
case when count(fee.redline_amount) = count(*) then 1 else 0 end as redline_complete
|
||||
from ar_bill_detail_fee fee join ar_bill_detail detail on detail.detail_id = fee.detail_id
|
||||
where detail.ar_bill_id = #{billId} group by fee.detail_id
|
||||
) f on f.detail_id = d.detail_id
|
||||
left join (
|
||||
select w.ar_bill_id as bill_id, sum(w.writeoff_amount) as written_amount,
|
||||
sum(case when r.rate is null or r.rate < 0 or r.rate > 100 then 1 else 0 end) as missing_rates,
|
||||
sum(w.writeoff_amount * r.rate) as weighted_rate
|
||||
from ar_balance_writeoff w left join ar_profit_writeoff_rate r on r.writeoff_id = w.writeoff_id
|
||||
where w.status = 'EFFECTIVE' and w.ar_bill_id = #{billId}
|
||||
group by w.ar_bill_id
|
||||
) w on w.bill_id = b.ar_bill_id
|
||||
where b.ar_bill_id = #{billId} and b.status = 'WRITTEN_OFF' and b.generation_status = 'READY'
|
||||
) x order by x.detail_id
|
||||
</select>
|
||||
<insert id="insertResults">
|
||||
insert into ar_profit_commission_result
|
||||
(ar_bill_id, detail_id, sales_user_id, currency, gross_profit, commission_amount, result_status, create_by, create_time)
|
||||
values
|
||||
<foreach collection="items" item="item" separator=",">
|
||||
(#{item.arBillId}, #{item.detailId}, #{item.salesUserId}, #{item.currency}, #{item.grossProfit},
|
||||
#{item.commissionAmount}, #{item.resultStatus}, #{operator}, sysdate())
|
||||
</foreach>
|
||||
</insert>
|
||||
</mapper>
|
||||
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.bms.ar.mapper.ArProfitMapper">
|
||||
<resultMap id="row" type="com.bms.ar.profit.ArProfitRow">
|
||||
<id property="arBillId" column="ar_bill_id"/>
|
||||
<result property="billNo" column="bill_no"/><result property="billDate" column="bill_date"/>
|
||||
<result property="customerCode" column="customer_code"/><result property="customerName" column="customer_name"/>
|
||||
<result property="customerArea" column="customer_area"/><result property="snapshotSource" column="snapshot_source"/>
|
||||
<result property="salesUserName" column="sales_user_name"/><result property="currency" column="currency"/>
|
||||
<result property="status" column="status"/><result property="salesAmount" column="sales_amount"/>
|
||||
<result property="productAmount" column="product_amount"/><result property="salesGrossProfit" column="sales_gross_profit"/>
|
||||
<result property="salesGrossMarginRate" column="sales_gross_margin_rate"/>
|
||||
<result property="writtenOffAmount" column="written_off_amount"/><result property="unwrittenAmount" column="unwritten_amount"/>
|
||||
</resultMap>
|
||||
<resultMap id="summary" type="com.bms.ar.profit.ArProfitSummary">
|
||||
<result property="currency" column="currency"/><result property="billCount" column="bill_count"/>
|
||||
<result property="invalidBillCount" column="invalid_bill_count"/>
|
||||
<result property="salesAmount" column="sales_amount"/><result property="productAmount" column="product_amount"/>
|
||||
<result property="salesGrossProfit" column="sales_gross_profit"/><result property="salesGrossMarginRate" column="sales_gross_margin_rate"/>
|
||||
<result property="writtenOffAmount" column="written_off_amount"/><result property="unwrittenAmount" column="unwritten_amount"/>
|
||||
</resultMap>
|
||||
<resultMap id="detail" type="com.bms.ar.domain.ArBillDetail">
|
||||
<id property="detailId" column="detail_id"/><result property="orderNo" column="order_no"/>
|
||||
<result property="productName" column="product_name"/><result property="currency" column="currency"/>
|
||||
<result property="totalAmount" column="total_amount"/>
|
||||
</resultMap>
|
||||
<resultMap id="fee" type="com.bms.ar.domain.ArBillDetailFee">
|
||||
<id property="detailFeeId" column="detail_fee_id"/><result property="detailId" column="detail_id"/>
|
||||
<result property="feeName" column="fee_name"/><result property="feeType" column="fee_type"/>
|
||||
<result property="amount" column="amount"/><result property="productAmount" column="product_amount"/>
|
||||
<result property="redlineAmount" column="redline_amount"/><result property="currency" column="currency"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="scopeCondition">
|
||||
<choose>
|
||||
<when test="scope.all"/>
|
||||
<when test="scope.self or scope.department">
|
||||
and (1 = 0
|
||||
<if test="scope.self">or b.profit_sales_user_id = #{scope.userId}</if>
|
||||
<if test="scope.department">
|
||||
or exists (select 1 from sys_user u inner join sys_dept dept on dept.dept_id = u.dept_id
|
||||
where u.user_id = b.profit_sales_user_id and u.del_flag = '0'
|
||||
and (dept.dept_id = #{scope.deptId} or find_in_set(#{scope.deptId}, dept.ancestors)))
|
||||
</if>
|
||||
)
|
||||
</when>
|
||||
<otherwise>and 1 = 0</otherwise>
|
||||
</choose>
|
||||
</sql>
|
||||
<!-- 订单号仅用于筛选账单,不限制该账单内参与计算的明细。 -->
|
||||
<sql id="billConditions">
|
||||
where b.status in ('CONFIRMED', 'PARTIAL_WRITTEN_OFF', 'WRITTEN_OFF') and b.generation_status = 'READY'
|
||||
<include refid="scopeCondition"/>
|
||||
<if test="query.customerId != null">and b.customer_id = #{query.customerId}</if>
|
||||
<if test="query.billNo != null and query.billNo != ''">and b.bill_no like concat('%', #{query.billNo}, '%')</if>
|
||||
<if test="query.arBillId != null">and b.ar_bill_id = #{query.arBillId}</if>
|
||||
<if test="query.orderNo != null and query.orderNo != ''">
|
||||
and exists (select 1 from ar_bill_detail matched where matched.ar_bill_id = b.ar_bill_id
|
||||
and matched.order_no like concat('%', #{query.orderNo}, '%'))
|
||||
</if>
|
||||
<if test="query.status != null and query.status != ''">and b.status = #{query.status}</if>
|
||||
<if test="query.salesUserId != null">and b.profit_sales_user_id = #{query.salesUserId}</if>
|
||||
<if test="query.beginBillDate != null">and b.bill_date >= #{query.beginBillDate}</if>
|
||||
<if test="query.endBillDate != null">and b.bill_date <= #{query.endBillDate}</if>
|
||||
</sql>
|
||||
|
||||
<!-- 按费用快照先聚合到订单,再聚合到完整账单,避免账单金额及费用重复。 -->
|
||||
<sql id="billRows">
|
||||
<bind name="shenzhenArea" value="@com.bms.price.enums.ProfitAreaRule@SHENZHEN.getCustomerArea()"/>
|
||||
<bind name="guangzhouArea" value="@com.bms.price.enums.ProfitAreaRule@GUANGZHOU.getCustomerArea()"/>
|
||||
select b.ar_bill_id, b.bill_no, b.bill_date, b.customer_code, b.customer_name,
|
||||
b.profit_customer_area as customer_area, b.profit_snapshot_source as snapshot_source,
|
||||
b.profit_sales_user_name as sales_user_name, b.currency, b.status,
|
||||
b.total_amount as sales_amount, b.written_off_amount, b.unwritten_amount,
|
||||
case when a.detail_count > 0 and a.invalid_count = 0 and b.total_amount is not null
|
||||
and a.detail_total = b.total_amount then a.product_total else null end as product_amount,
|
||||
case when a.detail_count > 0 and a.invalid_count = 0 and b.total_amount is not null
|
||||
and a.detail_total = b.total_amount then b.total_amount - a.product_total else null end as sales_gross_profit,
|
||||
case when a.detail_count > 0 and a.invalid_count = 0 and b.total_amount is not null
|
||||
and a.detail_total = b.total_amount and b.total_amount != 0
|
||||
then round((b.total_amount - a.product_total) * 100 / b.total_amount, 4) else null end as sales_gross_margin_rate,
|
||||
case when a.detail_count > 0 and a.invalid_count = 0 and b.total_amount is not null
|
||||
and a.detail_total = b.total_amount then 0 else 1 end as profit_incomplete
|
||||
from ar_bill b
|
||||
left join (
|
||||
select d.ar_bill_id, count(*) as detail_count, sum(d.total_amount) as detail_total,
|
||||
sum(case when d.total_amount is not null and d.currency is not null and b.currency is not null
|
||||
and d.currency = b.currency and f.currency_count = 1 and f.fee_currency = d.currency
|
||||
and f.sales_amount is not null and f.sales_amount = d.total_amount
|
||||
and ((b.profit_customer_area = #{shenzhenArea} and f.product_complete = 1)
|
||||
or (b.profit_customer_area = #{guangzhouArea} and f.redline_complete = 1))
|
||||
then 0 else 1 end) as invalid_count,
|
||||
sum(case when b.profit_customer_area = #{shenzhenArea} then f.product_amount
|
||||
when b.profit_customer_area = #{guangzhouArea} then f.redline_amount else null end) as product_total
|
||||
from ar_bill_detail d join ar_bill b on b.ar_bill_id = d.ar_bill_id
|
||||
left join (
|
||||
select fee.detail_id,
|
||||
case when count(fee.amount) = count(*) then sum(fee.amount) else null end as sales_amount,
|
||||
case when count(fee.currency) = count(*) then count(distinct fee.currency) else 0 end as currency_count,
|
||||
min(fee.currency) as fee_currency,
|
||||
case when count(fee.product_amount) = count(*) then sum(fee.product_amount) else null end as product_amount,
|
||||
case when count(fee.redline_amount) = count(*) then sum(fee.redline_amount) else null end as redline_amount,
|
||||
case when count(fee.product_amount) = count(*) then 1 else 0 end as product_complete,
|
||||
case when count(fee.redline_amount) = count(*) then 1 else 0 end as redline_complete
|
||||
from ar_bill_detail_fee fee
|
||||
join ar_bill_detail d on d.detail_id = fee.detail_id
|
||||
join ar_bill b on b.ar_bill_id = d.ar_bill_id
|
||||
<include refid="billConditions"/>
|
||||
group by fee.detail_id
|
||||
) f on f.detail_id = d.detail_id
|
||||
<include refid="billConditions"/>
|
||||
group by d.ar_bill_id
|
||||
) a on a.ar_bill_id = b.ar_bill_id
|
||||
<include refid="billConditions"/>
|
||||
</sql>
|
||||
<select id="selectBills" resultMap="row">
|
||||
<include refid="billRows"/>
|
||||
order by b.bill_date desc, b.ar_bill_id desc
|
||||
</select>
|
||||
<select id="selectSummary" resultMap="summary">
|
||||
select t.currency, count(*) as bill_count, sum(t.profit_incomplete) as invalid_bill_count,
|
||||
sum(t.sales_amount) as sales_amount, sum(t.product_amount) as product_amount,
|
||||
sum(t.sales_gross_profit) as sales_gross_profit,
|
||||
case when sum(case when t.profit_incomplete = 0 then t.sales_amount else null end) = 0 then null
|
||||
else round(sum(t.sales_gross_profit) * 100 /
|
||||
sum(case when t.profit_incomplete = 0 then t.sales_amount else null end), 4) end as sales_gross_margin_rate,
|
||||
sum(t.written_off_amount) as written_off_amount, sum(t.unwritten_amount) as unwritten_amount
|
||||
from (<include refid="billRows"/>) t group by t.currency order by t.currency
|
||||
</select>
|
||||
<select id="selectSalesOptions" resultType="com.bms.ar.profit.ArProfitSalesOption">
|
||||
select u.user_id as userId, u.nick_name as name, u.user_name as code
|
||||
from sys_user u
|
||||
where u.del_flag = '0' and u.status = '0'
|
||||
<if test="keyword != null and keyword != ''">
|
||||
and (u.nick_name like concat('%', #{keyword}, '%')
|
||||
or u.user_name like concat('%', #{keyword}, '%'))
|
||||
</if>
|
||||
order by u.nick_name, u.user_id
|
||||
limit 50
|
||||
</select>
|
||||
<select id="selectBillDetails" resultMap="detail">
|
||||
select d.detail_id, d.order_no, d.product_name, d.currency, d.total_amount
|
||||
from ar_bill_detail d join ar_bill b on b.ar_bill_id = d.ar_bill_id
|
||||
where b.ar_bill_id = #{query.arBillId}
|
||||
and b.status in ('CONFIRMED', 'PARTIAL_WRITTEN_OFF', 'WRITTEN_OFF') and b.generation_status = 'READY'
|
||||
<include refid="scopeCondition"/>
|
||||
order by d.detail_no, d.detail_id
|
||||
</select>
|
||||
<select id="selectFees" resultMap="fee">
|
||||
select f.* from ar_bill_detail_fee f
|
||||
join ar_bill_detail d on d.detail_id = f.detail_id
|
||||
join ar_bill b on b.ar_bill_id = d.ar_bill_id
|
||||
where d.detail_id = #{query.detailId}
|
||||
and b.status in ('CONFIRMED', 'PARTIAL_WRITTEN_OFF', 'WRITTEN_OFF') and b.generation_status = 'READY'
|
||||
<include refid="scopeCondition"/>
|
||||
order by f.fee_no, f.detail_fee_id
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -1077,4 +1077,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
update_time = sysdate()
|
||||
where bill_id = #{billId}
|
||||
</update>
|
||||
|
||||
<select id="countByPsvId" resultType="int">
|
||||
select count(1) from prc_order_bill where psv_id = #{psvId}
|
||||
</select>
|
||||
</mapper>
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,339 @@
|
||||
-- 应收账单毛利分析:增量结构与历史客户归属快照。
|
||||
-- MySQL 5.7+;停用后端写入并备份数据库,再执行本文件,最后部署新版本并重新登录。
|
||||
-- DDL 自动提交;数据与菜单授权使用事务。失败后检查已创建对象并修复结构,再重试完整文件。
|
||||
-- 历史区域及销售员来自当前客户资料,不代表能还原历史真实归属。
|
||||
-- 已有快照不会覆盖。账单来源订单销售员与当前客户销售员不一致时不自动回填销售员。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS bms_ar_profit_upgrade_20261105_v1;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE bms_ar_profit_upgrade_20261105_v1()
|
||||
BEGIN
|
||||
DECLARE n INT DEFAULT 0;
|
||||
DECLARE report_id BIGINT DEFAULT NULL;
|
||||
DECLARE page_id BIGINT DEFAULT NULL;
|
||||
DECLARE export_id BIGINT DEFAULT NULL;
|
||||
DECLARE commission_id BIGINT DEFAULT NULL;
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
RESIGNAL;
|
||||
END;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name IN ('ar_bill','ar_bill_detail','ar_bill_detail_fee','ar_balance_writeoff','ctr_customer','prc_order_bill','sys_user','sys_dept','sys_role','sys_menu','sys_role_menu');
|
||||
IF n <> 11 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '应收毛利升级前置表缺失'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM sys_menu WHERE parent_id = 0 AND menu_type = 'M' AND menu_name = '报表中心';
|
||||
IF n > 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '报表中心目录不唯一'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM (SELECT perms FROM sys_menu WHERE perms IN
|
||||
('report:arProfit:list','report:arProfit:export','report:arProfit:commission') GROUP BY perms HAVING COUNT(*) > 1) conflicts;
|
||||
IF n > 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '应收毛利菜单权限重复'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM (SELECT role_key FROM sys_role WHERE role_key IN
|
||||
('ORA_6040','ORA_6041','ORA_6042','ORA_6043') AND del_flag = '0' GROUP BY role_key HAVING COUNT(*) > 1) conflicts;
|
||||
IF n > 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '应收毛利角色标识重复'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'ar_bill' AND column_name = 'profit_customer_area';
|
||||
IF n = 0 THEN ALTER TABLE ar_bill ADD COLUMN profit_customer_area VARCHAR(50) NULL COMMENT '账单客户区域快照'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'ar_bill' AND column_name = 'profit_sales_user_id';
|
||||
IF n = 0 THEN ALTER TABLE ar_bill ADD COLUMN profit_sales_user_id BIGINT NULL COMMENT '账单销售员用户ID快照'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'ar_bill' AND column_name = 'profit_sales_user_code';
|
||||
IF n = 0 THEN ALTER TABLE ar_bill ADD COLUMN profit_sales_user_code VARCHAR(64) NULL COMMENT '账单销售员工号快照'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'ar_bill' AND column_name = 'profit_sales_user_name';
|
||||
IF n = 0 THEN ALTER TABLE ar_bill ADD COLUMN profit_sales_user_name VARCHAR(64) NULL COMMENT '账单销售员姓名快照'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'ar_bill' AND column_name = 'profit_snapshot_source';
|
||||
IF n = 0 THEN ALTER TABLE ar_bill ADD COLUMN profit_snapshot_source VARCHAR(16) NULL COMMENT 'NEW生成时快照/BACKFILLED历史补录'; END IF;
|
||||
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'ar_bill'
|
||||
AND is_nullable = 'YES'
|
||||
AND ((column_name = 'profit_customer_area' AND data_type = 'varchar' AND character_maximum_length = 50)
|
||||
OR (column_name = 'profit_sales_user_id' AND data_type = 'bigint')
|
||||
OR (column_name IN ('profit_sales_user_code','profit_sales_user_name') AND data_type = 'varchar' AND character_maximum_length = 64)
|
||||
OR (column_name = 'profit_snapshot_source' AND data_type = 'varchar' AND character_maximum_length = 16));
|
||||
IF n <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '账单快照字段结构冲突'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||
AND table_name = 'ar_bill' AND index_name = 'idx_ar_profit_sales_date';
|
||||
IF n = 0 THEN ALTER TABLE ar_bill ADD INDEX idx_ar_profit_sales_date (profit_sales_user_id, bill_date, ar_bill_id); END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||
AND table_name = 'ar_bill' AND index_name = 'idx_ar_profit_sales_date'
|
||||
AND ((seq_in_index = 1 AND column_name = 'profit_sales_user_id')
|
||||
OR (seq_in_index = 2 AND column_name = 'bill_date')
|
||||
OR (seq_in_index = 3 AND column_name = 'ar_bill_id'));
|
||||
IF n <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '账单毛利索引结构冲突'; END IF;
|
||||
|
||||
-- 提成比例版本及每次核销时的比例快照;历史核销未录入比例时保留空值待财务确认。
|
||||
CREATE TABLE IF NOT EXISTS ar_profit_commission_rate (
|
||||
rate_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '提成比例记录ID',
|
||||
sales_user_id BIGINT NOT NULL COMMENT '销售员用户ID',
|
||||
rate DECIMAL(7,4) NOT NULL COMMENT '提成比例,百分数,例如10表示10%',
|
||||
effective_at DATETIME NOT NULL COMMENT '比例生效时间',
|
||||
create_by VARCHAR(64) NOT NULL COMMENT '创建人',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间',
|
||||
remark VARCHAR(500) NULL COMMENT '备注',
|
||||
PRIMARY KEY (rate_id),
|
||||
KEY idx_ar_profit_rate_effective (sales_user_id, effective_at, rate_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账单毛利销售员提成比例版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ar_profit_writeoff_rate (
|
||||
writeoff_id BIGINT NOT NULL COMMENT '核销记录ID',
|
||||
sales_user_id BIGINT NULL COMMENT '销售员用户ID',
|
||||
rate_id BIGINT NULL COMMENT '提成比例记录ID',
|
||||
rate DECIMAL(7,4) NULL COMMENT '提成比例,百分数,例如10表示10%',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间',
|
||||
confirmed_by VARCHAR(64) NULL COMMENT '历史比例确认人',
|
||||
confirmed_time DATETIME NULL COMMENT '历史比例确认时间',
|
||||
confirm_remark VARCHAR(500) NULL COMMENT '历史比例确认说明',
|
||||
PRIMARY KEY (writeoff_id),
|
||||
KEY idx_ar_profit_writeoff_rate_sales (sales_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账单核销时提成比例快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ar_profit_commission_result (
|
||||
result_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '提成结果ID',
|
||||
ar_bill_id BIGINT NOT NULL COMMENT '应收账单ID',
|
||||
detail_id BIGINT NOT NULL COMMENT '应收账单明细ID',
|
||||
sales_user_id BIGINT NULL COMMENT '销售员用户ID',
|
||||
currency VARCHAR(10) NOT NULL COMMENT '币种',
|
||||
gross_profit DECIMAL(18,4) NULL COMMENT '销售毛利',
|
||||
commission_amount DECIMAL(18,2) NULL COMMENT '提成金额',
|
||||
result_status VARCHAR(24) NOT NULL COMMENT 'READY/MISSING_RATE/MISSING_PRICE/MISSING_SNAPSHOT/WRITEOFF_MISMATCH/REVERSED',
|
||||
create_by VARCHAR(64) NOT NULL COMMENT '创建人',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间',
|
||||
reversed_by VARCHAR(64) NULL COMMENT '冲回操作人',
|
||||
reversed_time DATETIME NULL COMMENT '冲回时间',
|
||||
PRIMARY KEY (result_id),
|
||||
KEY idx_ar_profit_result_bill (ar_bill_id, result_status, detail_id),
|
||||
KEY idx_ar_profit_result_sales (sales_user_id, result_status, create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账单完全核销时逐单正式提成及冲回记录';
|
||||
|
||||
-- 检查已有表结构,避免 IF NOT EXISTS 掩盖不兼容的旧结构。
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'ar_profit_commission_rate'
|
||||
AND column_name IN ('rate_id','sales_user_id','rate','effective_at','create_by','create_time','remark');
|
||||
IF n <> 7 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '提成比例表结构不完整'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'ar_profit_writeoff_rate'
|
||||
AND column_name IN ('writeoff_id','sales_user_id','rate_id','rate','create_time','confirmed_by','confirmed_time','confirm_remark');
|
||||
IF n <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '核销比例表结构不完整'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'ar_profit_commission_result'
|
||||
AND column_name IN ('result_id','ar_bill_id','detail_id','sales_user_id','currency','gross_profit','commission_amount','result_status','create_by','create_time','reversed_by','reversed_time');
|
||||
IF n <> 12 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '逐单提成表结构不完整'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.tables WHERE table_schema = DATABASE()
|
||||
AND table_name IN ('ar_profit_commission_rate','ar_profit_writeoff_rate','ar_profit_commission_result') AND engine = 'InnoDB';
|
||||
IF n <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '提成相关表必须使用 InnoDB'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns WHERE table_schema = DATABASE()
|
||||
AND table_name IN ('ar_profit_commission_rate','ar_profit_writeoff_rate') AND column_name = 'rate' AND column_type = 'decimal(7,4)';
|
||||
IF n <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '提成比例字段类型不匹配'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns WHERE table_schema = DATABASE()
|
||||
AND ((table_name = 'ar_profit_commission_rate' AND column_name IN ('rate_id','sales_user_id') AND data_type = 'bigint' AND is_nullable = 'NO')
|
||||
OR (table_name = 'ar_profit_commission_rate' AND column_name = 'effective_at' AND data_type = 'datetime' AND is_nullable = 'NO')
|
||||
OR (table_name = 'ar_profit_writeoff_rate' AND column_name = 'writeoff_id' AND data_type = 'bigint' AND is_nullable = 'NO')
|
||||
OR (table_name = 'ar_profit_writeoff_rate' AND column_name IN ('sales_user_id','rate_id') AND data_type = 'bigint' AND is_nullable = 'YES')
|
||||
OR (table_name = 'ar_profit_writeoff_rate' AND column_name = 'rate' AND is_nullable = 'YES'));
|
||||
IF n <> 7 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '提成比例关键字段类型或空值约束不匹配'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'ar_profit_commission_result'
|
||||
AND ((column_name = 'gross_profit' AND column_type = 'decimal(18,4)')
|
||||
OR (column_name = 'commission_amount' AND column_type = 'decimal(18,2)')
|
||||
OR (column_name = 'result_status' AND data_type = 'varchar' AND character_maximum_length = 24)
|
||||
OR (column_name IN ('result_id','ar_bill_id','detail_id','sales_user_id') AND data_type = 'bigint'));
|
||||
IF n <> 7 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '逐单提成字段类型不匹配'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'ar_profit_writeoff_rate'
|
||||
AND index_name = 'PRIMARY' AND column_name = 'writeoff_id' AND seq_in_index = 1;
|
||||
IF n <> 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '核销比例表主键不匹配'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'ar_profit_writeoff_rate' AND index_name = 'PRIMARY';
|
||||
IF n <> 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '核销比例表主键必须仅包含 writeoff_id'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'ar_profit_commission_rate'
|
||||
AND index_name = 'PRIMARY' AND column_name = 'rate_id' AND seq_in_index = 1;
|
||||
IF n <> 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '提成比例表主键不匹配'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'ar_profit_commission_result'
|
||||
AND index_name = 'PRIMARY' AND column_name = 'result_id' AND seq_in_index = 1;
|
||||
IF n <> 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '逐单提成表主键不匹配'; END IF;
|
||||
SELECT COUNT(*) INTO n FROM information_schema.statistics WHERE table_schema = DATABASE()
|
||||
AND ((table_name = 'ar_profit_commission_rate' AND index_name = 'PRIMARY')
|
||||
OR (table_name = 'ar_profit_commission_result' AND index_name = 'PRIMARY'));
|
||||
IF n <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '提成表主键结构不匹配'; END IF;
|
||||
|
||||
-- 旧版本已经建表时,只补充空注释;按实际元数据保留字段类型、字符集和空值约束。
|
||||
-- 对无法安全重建的默认值、生成列或其他额外属性先中止,不擅自改写字段定义。
|
||||
SELECT COUNT(*) INTO n FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND column_comment = ''
|
||||
AND ((table_name = 'ar_profit_commission_rate' AND column_name IN ('rate_id','sales_user_id','rate','effective_at','create_by','create_time','remark'))
|
||||
OR (table_name = 'ar_profit_writeoff_rate' AND column_name IN ('writeoff_id','sales_user_id','rate_id','rate','create_time','confirmed_by','confirmed_time','confirm_remark'))
|
||||
OR (table_name = 'ar_profit_commission_result' AND column_name IN ('result_id','ar_bill_id','detail_id','sales_user_id','currency','gross_profit','commission_amount','result_status','create_by','create_time','reversed_by','reversed_time')))
|
||||
AND (column_default IS NOT NULL OR extra NOT IN ('','auto_increment') OR data_type NOT IN ('bigint','decimal','datetime','varchar'));
|
||||
IF n > 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '提成表待补注释字段存在特殊定义,请检查后再执行'; END IF;
|
||||
|
||||
BEGIN
|
||||
DECLARE v_table VARCHAR(64);
|
||||
DECLARE v_table_index INT DEFAULT 1;
|
||||
DECLARE v_old_concat_limit BIGINT UNSIGNED;
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
SET SESSION group_concat_max_len = v_old_concat_limit;
|
||||
RESIGNAL;
|
||||
END;
|
||||
SET v_old_concat_limit = @@SESSION.group_concat_max_len;
|
||||
SET SESSION group_concat_max_len = 65535;
|
||||
WHILE v_table_index <= 3 DO
|
||||
SET v_table = CASE v_table_index
|
||||
WHEN 1 THEN 'ar_profit_commission_rate'
|
||||
WHEN 2 THEN 'ar_profit_writeoff_rate'
|
||||
ELSE 'ar_profit_commission_result' END;
|
||||
SELECT GROUP_CONCAT(CONCAT(
|
||||
'MODIFY COLUMN `', REPLACE(column_name, '`', '``'), '` ', column_type,
|
||||
IF(character_set_name IS NULL, '', CONCAT(' CHARACTER SET ', character_set_name, ' COLLATE ', collation_name)),
|
||||
IF(is_nullable = 'YES', ' NULL DEFAULT NULL', ' NOT NULL'),
|
||||
IF(extra = 'auto_increment', ' AUTO_INCREMENT', ''),
|
||||
' COMMENT ', QUOTE(CASE column_name
|
||||
WHEN 'rate_id' THEN '提成比例记录ID'
|
||||
WHEN 'sales_user_id' THEN '销售员用户ID'
|
||||
WHEN 'rate' THEN '提成比例,百分数,例如10表示10%'
|
||||
WHEN 'effective_at' THEN '比例生效时间'
|
||||
WHEN 'create_by' THEN '创建人'
|
||||
WHEN 'create_time' THEN '创建时间'
|
||||
WHEN 'remark' THEN '备注'
|
||||
WHEN 'writeoff_id' THEN '核销记录ID'
|
||||
WHEN 'confirmed_by' THEN '历史比例确认人'
|
||||
WHEN 'confirmed_time' THEN '历史比例确认时间'
|
||||
WHEN 'confirm_remark' THEN '历史比例确认说明'
|
||||
WHEN 'result_id' THEN '提成结果ID'
|
||||
WHEN 'ar_bill_id' THEN '应收账单ID'
|
||||
WHEN 'detail_id' THEN '应收账单明细ID'
|
||||
WHEN 'currency' THEN '币种'
|
||||
WHEN 'gross_profit' THEN '销售毛利'
|
||||
WHEN 'commission_amount' THEN '提成金额'
|
||||
WHEN 'result_status' THEN 'READY/MISSING_RATE/MISSING_PRICE/MISSING_SNAPSHOT/WRITEOFF_MISMATCH/REVERSED'
|
||||
WHEN 'reversed_by' THEN '冲回操作人'
|
||||
WHEN 'reversed_time' THEN '冲回时间'
|
||||
END)) ORDER BY ordinal_position SEPARATOR ', ')
|
||||
INTO @bms_ar_profit_comment_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = v_table AND column_comment = ''
|
||||
AND ((table_name = 'ar_profit_commission_rate' AND column_name IN ('rate_id','sales_user_id','rate','effective_at','create_by','create_time','remark'))
|
||||
OR (table_name = 'ar_profit_writeoff_rate' AND column_name IN ('writeoff_id','sales_user_id','rate_id','rate','create_time','confirmed_by','confirmed_time','confirm_remark'))
|
||||
OR (table_name = 'ar_profit_commission_result' AND column_name IN ('result_id','ar_bill_id','detail_id','sales_user_id','currency','gross_profit','commission_amount','result_status','create_by','create_time','reversed_by','reversed_time')));
|
||||
IF @bms_ar_profit_comment_columns IS NOT NULL THEN
|
||||
SET @bms_ar_profit_comment_ddl = CONCAT('ALTER TABLE `', REPLACE(v_table, '`', '``'), '` ', @bms_ar_profit_comment_columns);
|
||||
PREPARE bms_ar_profit_comment_stmt FROM @bms_ar_profit_comment_ddl;
|
||||
EXECUTE bms_ar_profit_comment_stmt;
|
||||
DEALLOCATE PREPARE bms_ar_profit_comment_stmt;
|
||||
END IF;
|
||||
SET v_table_index = v_table_index + 1;
|
||||
END WHILE;
|
||||
SET SESSION group_concat_max_len = v_old_concat_limit;
|
||||
END;
|
||||
|
||||
SELECT MAX(menu_id) INTO report_id FROM sys_menu WHERE parent_id = 0 AND menu_type = 'M' AND menu_name = '报表中心';
|
||||
SELECT MAX(menu_id) INTO page_id FROM sys_menu WHERE perms = 'report:arProfit:list';
|
||||
IF page_id IS NOT NULL THEN
|
||||
SELECT COUNT(*) INTO n FROM sys_menu WHERE menu_id = page_id AND parent_id = report_id AND component = 'report/arProfit/index' AND menu_type = 'C';
|
||||
IF n <> 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '现有应收毛利菜单与预期不符'; END IF;
|
||||
END IF;
|
||||
SELECT MAX(menu_id) INTO export_id FROM sys_menu WHERE perms = 'report:arProfit:export';
|
||||
IF export_id IS NOT NULL THEN
|
||||
SELECT COUNT(*) INTO n FROM sys_menu WHERE menu_id = export_id AND parent_id = page_id AND menu_type = 'F';
|
||||
IF n <> 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '现有应收毛利导出权限与预期不符'; END IF;
|
||||
END IF;
|
||||
SELECT MAX(menu_id) INTO commission_id FROM sys_menu WHERE perms = 'report:arProfit:commission';
|
||||
IF commission_id IS NOT NULL THEN
|
||||
SELECT COUNT(*) INTO n FROM sys_menu WHERE menu_id = commission_id AND parent_id = page_id AND menu_type = 'F';
|
||||
IF n <> 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '现有应收毛利提成权限与预期不符'; END IF;
|
||||
END IF;
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- 只读检查:不能关联客户、区域为空或销售员无效的历史账单。
|
||||
SELECT b.ar_bill_id, b.bill_no, b.customer_id, c.customer_area, c.sales_user_id
|
||||
FROM ar_bill b LEFT JOIN ctr_customer c ON c.customer_id = b.customer_id
|
||||
LEFT JOIN sys_user u ON CONVERT(CAST(u.user_id AS CHAR) USING utf8mb4) COLLATE utf8mb4_unicode_ci = c.sales_user_id AND u.del_flag = '0'
|
||||
WHERE (b.profit_customer_area IS NULL AND (c.customer_area IS NULL OR TRIM(c.customer_area) = ''))
|
||||
OR (b.profit_sales_user_id IS NULL AND u.user_id IS NULL);
|
||||
|
||||
-- 检查来源订单销售员与当前客户负责人不一致的账单;下方不自动回填这些销售归属。
|
||||
SELECT DISTINCT b.ar_bill_id, b.bill_no, p.belong_saler_code AS order_saler_code,
|
||||
u.user_id AS customer_sales_user_id, u.user_name AS customer_sales_code
|
||||
FROM ar_bill b
|
||||
JOIN ctr_customer c ON c.customer_id = b.customer_id
|
||||
JOIN sys_user u ON CONVERT(CAST(u.user_id AS CHAR) USING utf8mb4) COLLATE utf8mb4_unicode_ci = c.sales_user_id AND u.del_flag = '0'
|
||||
JOIN ar_bill_detail d ON d.ar_bill_id = b.ar_bill_id
|
||||
JOIN prc_order_bill p ON p.bill_id = d.prc_order_bill_id
|
||||
WHERE b.profit_sales_user_id IS NULL AND (b.profit_snapshot_source IS NULL OR b.profit_snapshot_source = 'BACKFILLED')
|
||||
AND p.belong_saler_code IS NOT NULL
|
||||
AND TRIM(p.belong_saler_code) <> ''
|
||||
AND TRIM(p.belong_saler_code) <> u.user_name COLLATE utf8mb4_unicode_ci
|
||||
AND TRIM(p.belong_saler_code) <> CONVERT(CAST(u.user_id AS CHAR) USING utf8mb4) COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 此处仅补空值。客户区域来自客户当前资料,并非出账时的历史事实。
|
||||
UPDATE ar_bill b JOIN ctr_customer c ON c.customer_id = b.customer_id
|
||||
SET b.profit_customer_area = c.customer_area
|
||||
WHERE b.profit_customer_area IS NULL AND (b.profit_snapshot_source IS NULL OR b.profit_snapshot_source = 'BACKFILLED')
|
||||
AND c.customer_area IS NOT NULL AND TRIM(c.customer_area) <> '';
|
||||
|
||||
UPDATE ar_bill b
|
||||
JOIN ctr_customer c ON c.customer_id = b.customer_id
|
||||
JOIN sys_user u ON CONVERT(CAST(u.user_id AS CHAR) USING utf8mb4) COLLATE utf8mb4_unicode_ci = c.sales_user_id AND u.del_flag = '0'
|
||||
SET b.profit_sales_user_id = u.user_id,
|
||||
b.profit_sales_user_code = u.user_name,
|
||||
b.profit_sales_user_name = u.nick_name
|
||||
WHERE b.profit_sales_user_id IS NULL AND (b.profit_snapshot_source IS NULL OR b.profit_snapshot_source = 'BACKFILLED')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ar_bill_detail d JOIN prc_order_bill p ON p.bill_id = d.prc_order_bill_id
|
||||
WHERE d.ar_bill_id = b.ar_bill_id AND p.belong_saler_code IS NOT NULL
|
||||
AND TRIM(p.belong_saler_code) <> ''
|
||||
AND TRIM(p.belong_saler_code) <> u.user_name COLLATE utf8mb4_unicode_ci
|
||||
AND TRIM(p.belong_saler_code) <> CONVERT(CAST(u.user_id AS CHAR) USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
);
|
||||
|
||||
UPDATE ar_bill SET profit_snapshot_source = 'BACKFILLED'
|
||||
WHERE profit_snapshot_source IS NULL AND (profit_customer_area IS NOT NULL OR profit_sales_user_id IS NOT NULL);
|
||||
|
||||
-- 历史核销比例不可追溯,不能使用当前比例替代。
|
||||
INSERT INTO ar_profit_writeoff_rate (writeoff_id, sales_user_id, rate_id, rate, create_time)
|
||||
SELECT w.writeoff_id, b.profit_sales_user_id, NULL, NULL, NOW()
|
||||
FROM ar_balance_writeoff w JOIN ar_bill b ON b.ar_bill_id = w.ar_bill_id
|
||||
LEFT JOIN ar_profit_writeoff_rate s ON s.writeoff_id = w.writeoff_id
|
||||
WHERE s.writeoff_id IS NULL;
|
||||
|
||||
IF report_id IS NULL THEN
|
||||
INSERT INTO sys_menu (menu_name,parent_id,order_num,path,component,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time)
|
||||
VALUES ('报表中心',0,8,'report',NULL,1,0,'M','0','0','','chart','admin',NOW());
|
||||
SET report_id = LAST_INSERT_ID();
|
||||
END IF;
|
||||
IF page_id IS NULL THEN
|
||||
INSERT INTO sys_menu (menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time)
|
||||
VALUES ('应收账单毛利分析',report_id,2,'arProfit','report/arProfit/index','ArProfitReport',1,0,'C','0','0','report:arProfit:list','chart','admin',NOW());
|
||||
SET page_id = LAST_INSERT_ID();
|
||||
END IF;
|
||||
IF export_id IS NULL THEN
|
||||
INSERT INTO sys_menu (menu_name,parent_id,order_num,path,component,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time)
|
||||
VALUES ('导出',page_id,1,'',NULL,1,0,'F','0','0','report:arProfit:export','#','admin',NOW());
|
||||
SET export_id = LAST_INSERT_ID();
|
||||
END IF;
|
||||
IF commission_id IS NULL THEN
|
||||
INSERT INTO sys_menu (menu_name,parent_id,order_num,path,component,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time)
|
||||
VALUES ('提成管理',page_id,2,'',NULL,1,0,'F','0','0','report:arProfit:commission','#','admin',NOW());
|
||||
SET commission_id = LAST_INSERT_ID();
|
||||
END IF;
|
||||
INSERT INTO sys_role_menu (role_id,menu_id)
|
||||
SELECT r.role_id, m.menu_id FROM sys_role r JOIN sys_menu m ON m.menu_id IN (report_id,page_id,export_id)
|
||||
WHERE r.del_flag = '0' AND r.status = '0' AND (r.role_id = 1 OR r.role_key IN ('ORA_6040','ORA_6041','ORA_6042','ORA_6043'))
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id);
|
||||
INSERT INTO sys_role_menu (role_id,menu_id)
|
||||
SELECT r.role_id, commission_id FROM sys_role r
|
||||
WHERE r.del_flag = '0' AND r.status = '0' AND (r.role_id = 1 OR r.role_key IN ('ORA_6042','ORA_6043'))
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = r.role_id AND rm.menu_id = commission_id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
SELECT 'AR_PROFIT_UPGRADE_COMPLETED' AS upgrade_status,
|
||||
(SELECT COUNT(*) FROM ar_bill WHERE profit_customer_area IS NULL) AS missing_area,
|
||||
(SELECT COUNT(*) FROM ar_bill WHERE profit_sales_user_id IS NULL) AS missing_sales,
|
||||
(SELECT COUNT(*) FROM ar_bill WHERE generation_status IS NULL AND status IN ('CONFIRMED','PARTIAL_WRITTEN_OFF','WRITTEN_OFF')) AS unknown_generation_status,
|
||||
(SELECT COUNT(*) FROM ar_profit_writeoff_rate r JOIN ar_balance_writeoff w ON w.writeoff_id = r.writeoff_id
|
||||
JOIN ar_bill b ON b.ar_bill_id = w.ar_bill_id
|
||||
WHERE (r.rate IS NULL OR r.rate < 0 OR r.rate > 100) AND w.status = 'EFFECTIVE' AND b.generation_status = 'READY' AND b.status IN ('CONFIRMED','PARTIAL_WRITTEN_OFF','WRITTEN_OFF')) AS missing_writeoff_rates,
|
||||
(SELECT COUNT(*) FROM ar_bill b WHERE b.status = 'WRITTEN_OFF' AND NOT EXISTS
|
||||
(SELECT 1 FROM ar_balance_writeoff w WHERE w.ar_bill_id = b.ar_bill_id AND w.status = 'EFFECTIVE')) AS paid_bills_without_writeoffs;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL bms_ar_profit_upgrade_20261105_v1();
|
||||
DROP PROCEDURE IF EXISTS bms_ar_profit_upgrade_20261105_v1;
|
||||
@@ -1,29 +1,75 @@
|
||||
-- Exclusive quote module menus
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- Top-level directory
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2600, N'专属报价', 0, 55, 'exclusive', NULL, '', '', 1, 0, 'M', '0', '0', '', 'money', 'admin', sysdate(), N'老板客户专属报价'
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2600 OR (path = 'exclusive' AND parent_id = 0 AND menu_type = 'M'));
|
||||
-- Exclusive package menus under package management (套餐管理).
|
||||
-- Idempotent: create if missing, rename/reparent if already present, drop obsolete top directory 专属报价.
|
||||
|
||||
SET @exParentId := (SELECT menu_id FROM sys_menu WHERE menu_id = 2600 LIMIT 1);
|
||||
SET @exParentId := IFNULL(@exParentId, (
|
||||
SELECT menu_id FROM sys_menu WHERE path = 'exclusive' AND menu_type = 'M' AND parent_id = 0 ORDER BY menu_id ASC LIMIT 1
|
||||
));
|
||||
SET @pkgParentId := (
|
||||
SELECT menu_id FROM sys_menu
|
||||
WHERE menu_id = 2200
|
||||
OR (menu_name = N'套餐管理' AND parent_id = 0 AND menu_type = 'M')
|
||||
ORDER BY CASE WHEN menu_id = 2200 THEN 0 ELSE 1 END, menu_id
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- List page
|
||||
-- Drop obsolete top-level directory (and role links)
|
||||
DELETE FROM sys_role_menu WHERE menu_id = 2600;
|
||||
DELETE FROM sys_menu
|
||||
WHERE menu_id = 2600
|
||||
OR (path = 'exclusive' AND parent_id = 0 AND menu_type = 'M' AND menu_name = N'专属报价');
|
||||
|
||||
-- List: 专属套餐
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2601, N'报价列表', @exParentId, 1, 'quote', 'price/exclusive/exclusiveQuote', '', 'ExclusiveQuote', 1, 0, 'C', '0', '0', 'price:exclusive:list', 'list', 'admin', sysdate(), N'专属报价列表'
|
||||
FROM DUAL WHERE @exParentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2601 OR perms = 'price:exclusive:list');
|
||||
SELECT 2601, N'专属套餐', @pkgParentId, 2, 'quote', 'price/exclusive/exclusiveQuote', '', 'ExclusiveQuote', 1, 0, 'C', '0', '0', 'price:exclusive:list', 'list', 'admin', sysdate(), N'专属套餐列表'
|
||||
FROM DUAL WHERE @pkgParentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2601 OR perms = 'price:exclusive:list');
|
||||
|
||||
UPDATE sys_menu
|
||||
SET menu_name = N'专属套餐',
|
||||
parent_id = @pkgParentId,
|
||||
order_num = 2,
|
||||
path = 'quote',
|
||||
component = 'price/exclusive/exclusiveQuote',
|
||||
route_name = 'ExclusiveQuote',
|
||||
remark = N'专属套餐列表',
|
||||
update_by = 'admin',
|
||||
update_time = sysdate()
|
||||
WHERE @pkgParentId IS NOT NULL AND (menu_id = 2601 OR perms = 'price:exclusive:list');
|
||||
|
||||
-- Hidden create / detail
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2609, N'报价新建', @exParentId, 2, 'quoteCreate', 'price/exclusive/create', '', 'ExclusiveQuoteCreate', 1, 0, 'C', '1', '0', 'price:exclusive:add', '#', 'admin', sysdate(), N'专属报价新建/编辑'
|
||||
FROM DUAL WHERE @exParentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2609 OR path = 'quoteCreate');
|
||||
SELECT 2609, N'专属套餐新建', @pkgParentId, 5, 'quoteCreate', 'price/exclusive/create', '', 'ExclusiveQuoteCreate', 1, 0, 'C', '1', '0', 'price:exclusive:add', '#', 'admin', sysdate(), N'专属套餐新建/编辑'
|
||||
FROM DUAL WHERE @pkgParentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2609 OR path = 'quoteCreate');
|
||||
|
||||
UPDATE sys_menu
|
||||
SET menu_name = N'专属套餐新建',
|
||||
parent_id = @pkgParentId,
|
||||
order_num = 5,
|
||||
path = 'quoteCreate',
|
||||
component = 'price/exclusive/create',
|
||||
route_name = 'ExclusiveQuoteCreate',
|
||||
remark = N'专属套餐新建/编辑',
|
||||
update_by = 'admin',
|
||||
update_time = sysdate()
|
||||
WHERE @pkgParentId IS NOT NULL AND (menu_id = 2609 OR path = 'quoteCreate');
|
||||
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2610, N'报价详情', @exParentId, 3, 'quoteDetail', 'price/exclusive/detail', '', 'ExclusiveQuoteDetail', 1, 0, 'C', '1', '0', 'price:exclusive:query', '#', 'admin', sysdate(), N'专属报价详情'
|
||||
FROM DUAL WHERE @exParentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2610 OR path = 'quoteDetail');
|
||||
SELECT 2610, N'专属套餐详情', @pkgParentId, 6, 'quoteDetail', 'price/exclusive/detail', '', 'ExclusiveQuoteDetail', 1, 0, 'C', '1', '0', 'price:exclusive:query', '#', 'admin', sysdate(), N'专属套餐详情'
|
||||
FROM DUAL WHERE @pkgParentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2610 OR path = 'quoteDetail');
|
||||
|
||||
UPDATE sys_menu
|
||||
SET menu_name = N'专属套餐详情',
|
||||
parent_id = @pkgParentId,
|
||||
order_num = 6,
|
||||
path = 'quoteDetail',
|
||||
component = 'price/exclusive/detail',
|
||||
route_name = 'ExclusiveQuoteDetail',
|
||||
remark = N'专属套餐详情',
|
||||
update_by = 'admin',
|
||||
update_time = sysdate()
|
||||
WHERE @pkgParentId IS NOT NULL AND (menu_id = 2610 OR path = 'quoteDetail');
|
||||
|
||||
-- Keep ordinary set create/detail order after list pages
|
||||
UPDATE sys_menu SET order_num = 3, update_by = 'admin', update_time = sysdate() WHERE menu_id = 2208;
|
||||
UPDATE sys_menu SET order_num = 4, update_by = 'admin', update_time = sysdate() WHERE menu_id = 2209;
|
||||
|
||||
-- Buttons under list
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
@@ -52,7 +98,7 @@ FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2607 OR (pare
|
||||
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT 1, m.menu_id FROM (
|
||||
SELECT 2600 AS menu_id UNION SELECT 2601 UNION SELECT 2602 UNION SELECT 2603
|
||||
SELECT 2601 AS menu_id UNION SELECT 2602 UNION SELECT 2603
|
||||
UNION SELECT 2604 UNION SELECT 2605 UNION SELECT 2606 UNION SELECT 2607
|
||||
UNION SELECT 2609 UNION SELECT 2610
|
||||
) m
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const listArProfitBills = params => request({ url: '/report/ar-profit/bills', method: 'get', params })
|
||||
export const listArProfitBillDetails = (billId, params) => request({ url: `/report/ar-profit/bills/${billId}/details`, method: 'get', params })
|
||||
export const listArProfitSummary = params => request({ url: '/report/ar-profit/summary', method: 'get', params })
|
||||
export const listArProfitSalesOptions = params => request({ url: '/report/ar-profit/sales-options', method: 'get', params })
|
||||
export const listArProfitAreaRules = () => request({ url: '/report/ar-profit/area-rules', method: 'get' })
|
||||
export const listArProfitFees = detailId => request({ url: `/report/ar-profit/orders/${detailId}/fees`, method: 'get' })
|
||||
export const listArProfitRates = () => request({ url: '/report/ar-profit/rates', method: 'get' })
|
||||
export const addArProfitRate = data => request({ url: '/report/ar-profit/rates', method: 'post', data })
|
||||
export const listArProfitMissingRates = params => request({ url: '/report/ar-profit/writeoffs/missing-rates', method: 'get', params })
|
||||
export const confirmArProfitMissingRate = data => request({ url: '/report/ar-profit/writeoffs/confirm-rate', method: 'post', data })
|
||||
export const listArProfitCommissions = params => request({ url: '/report/ar-profit/commissions', method: 'get', params })
|
||||
export const listArProfitCommissionSummary = params => request({ url: '/report/ar-profit/commissions/summary', method: 'get', params })
|
||||
export const listArProfitWriteoffRates = billId => request({ url: `/report/ar-profit/commissions/${billId}/writeoffs`, method: 'get' })
|
||||
@@ -73,8 +73,8 @@
|
||||
<el-table-column :prop="dateColumnProp" :label="dateColumnLabel" width="170" :formatter="formatDateColumn" />
|
||||
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="countryName" label="国家/地区" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="actualWeight" label="实际重(kg)" width="105" align="right" :formatter="formatNumberColumn" />
|
||||
<el-table-column prop="chargeWeight" label="计费重(kg)" width="105" align="right" :formatter="formatNumberColumn" />
|
||||
<el-table-column prop="actualWeight" label="实际重(kg)" width="105" align="right" :formatter="formatWeightColumn" />
|
||||
<el-table-column prop="chargeWeight" label="计费重(kg)" width="105" align="right" :formatter="formatWeightColumn" />
|
||||
<el-table-column prop="incomeTotal" label="应收金额" width="110" align="right" :formatter="formatNumberColumn" />
|
||||
<el-table-column prop="currency" label="币种" width="75" />
|
||||
</el-table>
|
||||
@@ -541,6 +541,7 @@ function date(value) {
|
||||
}
|
||||
function money(value) { const num = Number(value); return Number.isNaN(num) ? '-' : num.toFixed(2) }
|
||||
function formatDateColumn(_, __, value) { return date(value) }
|
||||
function formatWeightColumn(_, __, value) { return value == null || value === '' ? '-' : value }
|
||||
function formatNumberColumn(_, __, value) { return Number(value || 0).toFixed(2) }
|
||||
function createRequestId() { return globalThis.crypto?.randomUUID?.() || `ar-${Date.now()}-${Math.random().toString(16).slice(2)}` }
|
||||
</script>
|
||||
|
||||
@@ -626,19 +626,6 @@ const searchColumns = [
|
||||
collapseTagsTooltip: true
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '\u8ba1\u8d39\u5931\u8d25',
|
||||
prop: 'billingFailStatuses',
|
||||
component: 'select',
|
||||
options: billingFailStatusOptions,
|
||||
placeholder: '\u8bf7\u9009\u62e9',
|
||||
props: {
|
||||
multiple: true,
|
||||
clearable: true,
|
||||
collapseTags: true,
|
||||
collapseTagsTooltip: true
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '计费时间',
|
||||
prop: 'bizDateRange',
|
||||
@@ -659,6 +646,19 @@ const searchColumns = [
|
||||
clearable: true,
|
||||
defaultTime: [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '\u8ba1\u8d39\u5931\u8d25',
|
||||
prop: 'billingFailStatuses',
|
||||
component: 'select',
|
||||
options: billingFailStatusOptions,
|
||||
placeholder: '\u8bf7\u9009\u62e9',
|
||||
props: {
|
||||
multiple: true,
|
||||
clearable: true,
|
||||
collapseTags: true,
|
||||
collapseTagsTooltip: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -448,8 +448,8 @@ function onCreateVersion() {
|
||||
function onRemoveVersion(row) {
|
||||
if (!row || row.psvId == null) return
|
||||
const msg = isEffectVersion(row)
|
||||
? "确认软删除已生效版本(生效时间:" + (row.effectiveTime || '未设置') + ")?版本及产品、明细将标记为已删除,上一版将自动恢复生效并清空失效时间。"
|
||||
: "确认删除" + (isPendingVersion(row) ? "待生效" : "草稿") + "版本(生效时间:" + (row.effectiveTime || '未设置') + ")?其明细与附加费将一并删除。"
|
||||
? "确认删除该版本(生效时间:" + (row.effectiveTime || '未设置') + ")?若订单已引用该版本则仅软删版本记录;若无订单引用则整版本硬删。产品与明细均会物理删除,且上一版将自动恢复生效。"
|
||||
: "确认删除" + (isPendingVersion(row) ? "待生效" : "草稿") + "版本(生效时间:" + (row.effectiveTime || '未设置') + ")?若订单已引用该版本则仅软删版本记录;若无订单引用则整版本硬删。产品与明细均会物理删除。"
|
||||
proxy.$modal.confirm(msg).then(() => {
|
||||
removingPsvId.value = row.psvId
|
||||
proxy.$modal.loading("正在删除,请稍候...")
|
||||
|
||||
@@ -36,23 +36,23 @@ export function createExclusiveModule(api) {
|
||||
exclusiveQuote: true,
|
||||
onlyImportFresh: true,
|
||||
routes: Object.freeze({
|
||||
list: '/exclusive/quote',
|
||||
create: '/exclusive/quoteCreate',
|
||||
detail: '/exclusive/quoteDetail'
|
||||
list: '/customer/quote',
|
||||
create: '/customer/quoteCreate',
|
||||
detail: '/customer/quoteDetail'
|
||||
}),
|
||||
labels: Object.freeze({
|
||||
entity: '专属报价',
|
||||
entityCode: '报价编码',
|
||||
entityName: '报价名称',
|
||||
createButton: '新建报价',
|
||||
saveProgressTitle: '正在保存专属报价',
|
||||
savedSuccess: '专属报价已保存',
|
||||
noActiveVersion: '该专属报价暂无生效版本',
|
||||
disableConfirm: '停用后该专属报价不再参与计费命中,确认停用【{name}】?',
|
||||
enableConfirm: '启用后该专属报价将重新参与计费命中,确认启用【{name}】?',
|
||||
basicEditTitle: '修改专属报价基本信息',
|
||||
keywordPlaceholder: '报价编码 / 名称',
|
||||
detailTitleFallback: '专属报价详情'
|
||||
entity: '专属套餐',
|
||||
entityCode: '套餐编码',
|
||||
entityName: '套餐名称',
|
||||
createButton: '新建专属套餐',
|
||||
saveProgressTitle: '正在保存专属套餐',
|
||||
savedSuccess: '专属套餐已保存',
|
||||
noActiveVersion: '该专属套餐暂无生效版本',
|
||||
disableConfirm: '停用后该专属套餐不再参与计费命中,确认停用【{name}】?',
|
||||
enableConfirm: '启用后该专属套餐将重新参与计费命中,确认启用【{name}】?',
|
||||
basicEditTitle: '修改专属套餐基本信息',
|
||||
keywordPlaceholder: '套餐编码 / 名称',
|
||||
detailTitleFallback: '专属套餐详情'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -465,8 +465,8 @@ function onCreateVersion() {
|
||||
function onRemoveVersion(row) {
|
||||
if (!row || row.psvId == null) return
|
||||
const msg = isEffectVersion(row)
|
||||
? "确认软删除已生效版本(生效时间:" + (row.effectiveTime || '未设置') + ")?版本及产品、明细将标记为已删除,上一版将自动恢复生效并清空失效时间。"
|
||||
: "确认删除" + (isPendingVersion(row) ? "待生效" : "草稿") + "版本(生效时间:" + (row.effectiveTime || '未设置') + ")?其明细与附加费将一并删除。"
|
||||
? "确认删除该版本(生效时间:" + (row.effectiveTime || '未设置') + ")?若订单已引用该版本则仅软删版本记录;若无订单引用则整版本硬删。产品与明细均会物理删除,且上一版将自动恢复生效。"
|
||||
: "确认删除" + (isPendingVersion(row) ? "待生效" : "草稿") + "版本(生效时间:" + (row.effectiveTime || '未设置') + ")?若订单已引用该版本则仅软删版本记录;若无订单引用则整版本硬删。产品与明细均会物理删除。"
|
||||
proxy.$modal.confirm(msg).then(() => {
|
||||
removingPsvId.value = row.psvId
|
||||
proxy.$modal.loading("正在删除,请稍候...")
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="app-container ar-profit-report">
|
||||
<el-card shadow="never">
|
||||
<el-form :model="query" inline @submit.prevent>
|
||||
<el-form-item label="账单日期">
|
||||
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客户">
|
||||
<customer-select v-model="query.customerId" :fetch-options="listArBillCustomerOptions"
|
||||
placeholder="请输入客户名称或代码搜索" :remote-search="true" :page-size="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="销售员">
|
||||
<el-select v-model="query.salesUserId" placeholder="姓名或工号搜索" clearable filterable remote
|
||||
:remote-method="searchSales" :loading="salesLoading" style="width: 180px"
|
||||
@input="handleSalesInput">
|
||||
<el-option v-for="person in salesOptions" :key="person.userId" :label="person.name || person.code || String(person.userId)" :value="person.userId">
|
||||
<span>{{ person.name || person.code || person.userId }}</span>
|
||||
<span v-if="person.code" class="sales-code">{{ person.code }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="账单号"><el-input v-model="query.billNo" clearable @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderNo" clearable @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" placeholder="全部状态" clearable style="width: 140px">
|
||||
<el-option label="已确认" value="CONFIRMED" /><el-option label="部分核销" value="PARTIAL_WRITTEN_OFF" /><el-option label="已核销" value="WRITTEN_OFF" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="Search" @click="search">搜索</el-button><el-button icon="Refresh" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-for="summary in summaryCards" :key="summary.currency || 'empty'" shadow="never" class="summary-card">
|
||||
<template #header>{{ summary.currency ? summary.currency + ' 汇总' : '汇总' }}</template>
|
||||
<div class="metrics">
|
||||
<div>账单数 <b>{{ summary.billCount ?? '-' }}</b></div>
|
||||
<div>销售价 <b>{{ money(summary.salesAmount) }}</b></div><div>产品价 <b>{{ money(summary.productAmount) }}</b></div>
|
||||
<div>销售毛利 <b>{{ money(summary.salesGrossProfit) }}</b></div>
|
||||
<div>销售毛利率 <b>{{ rate(summary.salesGrossMarginRate) }}</b></div>
|
||||
<div>账单已核销 <b>{{ money(summary.writtenOffAmount) }}</b></div><div>账单未核销 <b>{{ money(summary.unwrittenAmount) }}</b></div>
|
||||
<div>毛利待核对账单 <b>{{ summary.invalidBillCount ?? '-' }}</b></div>
|
||||
</div>
|
||||
<div class="summary-note">广州产品价按红线价展示,深圳产品价按产品价展示;待核对账单不计入产品价、毛利及毛利率汇总。</div>
|
||||
<div v-if="query.orderNo" class="summary-note">订单号用于查找账单,金额按完整账单统计。</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="data-card">
|
||||
<div class="heading">
|
||||
<span class="table-title">账单列表</span>
|
||||
<div>
|
||||
<el-button v-hasPermi="['report:arProfit:export']" icon="Download" @click="exportRows">导出账单</el-button>
|
||||
<el-button v-hasPermi="['report:arProfit:commission']" @click="openCommission()">提成管理</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border stripe>
|
||||
<el-table-column prop="billNo" label="账单号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="billDate" label="账单日期" width="115" />
|
||||
<el-table-column prop="customerCode" label="客户代码" width="110" />
|
||||
<el-table-column prop="customerName" label="客户名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="salesUserName" label="销售员" width="110" />
|
||||
<el-table-column label="归属来源" width="110"><template #default="{ row }">{{ row.snapshotSource === 'NEW' ? '生成时快照' : row.snapshotSource === 'BACKFILLED' ? '历史补录' : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="currency" label="币种" width="80" />
|
||||
<el-table-column label="销售价" min-width="110" align="right"><template #default="{ row }">{{ money(row.salesAmount) }}</template></el-table-column>
|
||||
<el-table-column label="产品价" min-width="110" align="right"><template #default="{ row }">{{ money(row.productAmount) }}</template></el-table-column>
|
||||
<el-table-column label="销售毛利" min-width="110" align="right"><template #default="{ row }">{{ money(row.salesGrossProfit) }}</template></el-table-column>
|
||||
<el-table-column label="销售毛利率" width="110" align="right"><template #default="{ row }">{{ rate(row.salesGrossMarginRate) }}</template></el-table-column>
|
||||
<el-table-column label="已核销" min-width="110" align="right"><template #default="{ row }">{{ money(row.writtenOffAmount) }}</template></el-table-column>
|
||||
<el-table-column label="未核销" min-width="110" align="right"><template #default="{ row }">{{ money(row.unwrittenAmount) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }">{{ statusLabel(row.status) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openBillDetails(row)">账单明细</el-button><el-button v-hasPermi="['report:arProfit:commission']" link type="primary" @click="openBillCommission(row)">查看提成</el-button><el-button v-hasPermi="['report:arProfit:commission']" link type="primary" @click="openWriteoffRates(row)">核销比例</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:page="pageNum" v-model:limit="pageSize" :total="total" @pagination="loadRows" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailsVisible" :title="'账单明细:' + detailBillNo" width="900px" append-to-body>
|
||||
<el-table :data="details" border v-loading="detailsLoading">
|
||||
<el-table-column prop="orderNo" label="订单号" min-width="160" />
|
||||
<el-table-column prop="productName" label="产品名称" min-width="150" />
|
||||
<el-table-column prop="currency" label="币种" width="80" />
|
||||
<el-table-column label="销售价" align="right"><template #default="{ row }">{{ money(row.totalAmount) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="openFees(row)">费用明细</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="detailsTotal > 0" v-model:page="detailsPage" v-model:limit="detailsSize" :total="detailsTotal" @pagination="loadBillDetails" />
|
||||
</el-dialog>
|
||||
<el-dialog v-model="feeVisible" title="本次出账费用明细" width="800px" append-to-body>
|
||||
<el-table :data="fees" border v-loading="feeLoading">
|
||||
<el-table-column prop="feeName" label="费用名称" min-width="140" /><el-table-column prop="currency" label="币种" width="80" />
|
||||
<el-table-column label="销售价" align="right"><template #default="{ row }">{{ money(row.amount) }}</template></el-table-column>
|
||||
<el-table-column label="产品价" align="right"><template #default="{ row }">{{ money(feeProductAmount(row)) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="commissionVisible" title="提成管理" width="1100px" append-to-body>
|
||||
<el-tabs v-model="commissionTab" @tab-change="loadCommissionTab">
|
||||
<el-tab-pane label="正式提成" name="results">
|
||||
<el-alert v-if="commissionBillId" :title="'当前查看账单:' + commissionBillNo" type="info" :closable="false" show-icon>
|
||||
<el-button link type="primary" @click="clearCommissionBill">查看全部账单</el-button>
|
||||
</el-alert>
|
||||
<div v-if="commissionBillId" class="summary-note">账单全部核销后生成正式提成;存在待处理明细时不展示整单提成合计。</div>
|
||||
<el-button icon="Download" style="margin-bottom: 12px" @click="exportCommissions">导出提成明细</el-button>
|
||||
<el-checkbox v-model="includeReversed" style="margin: 0 0 12px 12px" @change="loadCommissionTab">显示已冲回记录</el-checkbox>
|
||||
<el-table :data="commissionSummary" border size="small" style="margin-bottom: 16px">
|
||||
<el-table-column prop="salesUserName" label="销售员" /><el-table-column prop="currency" label="币种" width="80" />
|
||||
<el-table-column prop="orderCount" label="订单明细数" /><el-table-column prop="pendingCount" label="待处理订单数" />
|
||||
<el-table-column label="正式提成合计" align="right"><template #default="{ row }">{{ money(row.commissionAmount) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<el-table :data="results" border size="small" v-loading="commissionLoading">
|
||||
<el-table-column prop="billNo" label="账单号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="150" />
|
||||
<el-table-column prop="salesUserName" label="销售员" width="110" /><el-table-column prop="currency" label="币种" width="75" />
|
||||
<el-table-column label="销售毛利" width="110" align="right"><template #default="{ row }">{{ money(row.grossProfit) }}</template></el-table-column>
|
||||
<el-table-column label="正式提成" width="110" align="right"><template #default="{ row }">{{ money(row.commissionAmount) }}</template></el-table-column>
|
||||
<el-table-column label="状态" min-width="120"><template #default="{ row }">{{ resultLabel(row.resultStatus) }}</template></el-table-column>
|
||||
<el-table-column v-if="includeReversed" prop="reversedBy" label="冲回人" width="110" />
|
||||
<el-table-column v-if="includeReversed" label="冲回时间" width="170"><template #default="{ row }">{{ row.reversedTime ? parseTime(row.reversedTime) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="依据" width="100"><template #default="{ row }"><el-button link type="primary" @click="openWriteoffRates(row)">核销比例</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="commissionTotal > 0" v-model:page="commissionPage" v-model:limit="commissionSize" :total="commissionTotal" @pagination="loadCommissionTab" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="提成比例" name="rates">
|
||||
<el-form inline><el-form-item label="销售员"><user-select v-model="rateForm.salesUserId" placeholder="选择销售员" style="width: 200px" /></el-form-item>
|
||||
<el-form-item label="比例(%)"><el-input-number v-model="rateForm.rate" :min="0" :max="100" :precision="4" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="saveRate">立即生效</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="rates" border size="small" height="400" v-loading="commissionLoading">
|
||||
<el-table-column prop="salesUserName" label="销售员" /><el-table-column prop="salesUserCode" label="销售员编码" />
|
||||
<el-table-column prop="rate" label="比例(%)" /><el-table-column prop="effectiveAt" label="生效时间" /><el-table-column prop="createBy" label="操作人" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="比例待确认" name="missing">
|
||||
<el-table :data="missingRates" border size="small" v-loading="commissionLoading">
|
||||
<el-table-column prop="billNo" label="账单号" min-width="150" /><el-table-column prop="customerName" label="客户" min-width="140" />
|
||||
<el-table-column prop="salesUserName" label="销售员" width="110" /><el-table-column prop="writeoffAmount" label="核销金额" width="110" />
|
||||
<el-table-column prop="writeoffDate" label="核销日期" width="165"><template #default="{ row }">{{ parseTime(row.writeoffDate, '{y}-{m}-{d}') }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="140"><template #default="{ row }"><el-button v-if="row.rate === null || row.rate === undefined" link type="primary" @click="openConfirm(row)">确认比例</el-button><span v-else>比例异常,请核对</span></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="missingTotal > 0" v-model:page="missingPage" v-model:limit="missingSize" :total="missingTotal" @pagination="loadCommissionTab" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="confirmVisible" title="确认历史核销比例" width="480px" append-to-body>
|
||||
<el-form label-width="100px"><el-form-item label="账单号">{{ confirmForm.billNo }}</el-form-item>
|
||||
<el-form-item label="比例(%)"><el-input-number v-model="confirmForm.rate" :min="0" :max="100" :precision="4" /></el-form-item>
|
||||
<el-form-item label="核对说明"><el-input v-model="confirmForm.remark" type="textarea" maxlength="500" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="confirmVisible = false">取消</el-button><el-button type="primary" @click="confirmRate">确认</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="writeoffRatesVisible" title="账单核销与提成比例" width="950px" append-to-body>
|
||||
<el-table :data="writeoffRates" border size="small" v-loading="writeoffRatesLoading">
|
||||
<el-table-column prop="writeoffDate" label="核销日期" width="130"><template #default="{ row }">{{ parseTime(row.writeoffDate, '{y}-{m}-{d}') }}</template></el-table-column>
|
||||
<el-table-column prop="writeoffAmount" label="核销金额" width="120" /><el-table-column prop="rate" label="比例(%)" width="110" />
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }">{{ row.status === 'EFFECTIVE' ? '有效' : '已反核销' }}</template></el-table-column>
|
||||
<el-table-column prop="confirmedBy" label="补录人" width="110" /><el-table-column prop="remark" label="核对说明" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="ArProfitReport">
|
||||
import { listArBillCustomerOptions } from '@/api/ar/bill'
|
||||
import { addArProfitRate, confirmArProfitMissingRate, listArProfitAreaRules, listArProfitBillDetails, listArProfitBills, listArProfitCommissions, listArProfitCommissionSummary, listArProfitFees, listArProfitMissingRates, listArProfitRates, listArProfitSalesOptions, listArProfitSummary, listArProfitWriteoffRates } from '@/api/report/arProfit'
|
||||
import { parseTime } from '@/utils/bms'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const query = reactive({ customerId: null, salesUserId: null, billNo: '', orderNo: '', status: '' })
|
||||
const salesOptions = ref([])
|
||||
const salesLoading = ref(false)
|
||||
let salesSearchVersion = 0
|
||||
const areaRules = ref({})
|
||||
const dateRange = ref([])
|
||||
const pageNum = ref(1), pageSize = ref(10), total = ref(0), rows = ref([]), loading = ref(false)
|
||||
const summaries = ref([]), summaryLoaded = ref(false)
|
||||
const detailsVisible = ref(false), detailsLoading = ref(false), details = ref([]), detailsTotal = ref(0)
|
||||
const detailBillId = ref(null), detailBillNo = ref(''), detailsPage = ref(1), detailsSize = ref(10)
|
||||
let detailsVersion = 0
|
||||
const feeVisible = ref(false), feeLoading = ref(false), feeArea = ref(''), fees = ref([])
|
||||
const summaryCards = computed(() => !summaryLoaded.value ? [] : summaries.value.length ? summaries.value : [{}])
|
||||
let requestVersion = 0
|
||||
let commissionVersion = 0
|
||||
const commissionVisible = ref(false), commissionTab = ref('results'), commissionLoading = ref(false)
|
||||
const commissionBillId = ref(null), commissionBillNo = ref('')
|
||||
const results = ref([]), commissionTotal = ref(0), commissionPage = ref(1), commissionSize = ref(10)
|
||||
const commissionSummary = ref([])
|
||||
const includeReversed = ref(false)
|
||||
const rates = ref([]), rateForm = reactive({ salesUserId: null, rate: null })
|
||||
const missingRates = ref([]), missingTotal = ref(0), missingPage = ref(1), missingSize = ref(10)
|
||||
const confirmVisible = ref(false), confirmForm = reactive({ writeoffId: null, billNo: '', rate: null, remark: '' })
|
||||
const writeoffRatesVisible = ref(false), writeoffRatesLoading = ref(false), writeoffRates = ref([])
|
||||
|
||||
function params() { return { ...query, beginBillDate: dateRange.value?.[0], endBillDate: dateRange.value?.[1] } }
|
||||
function search() { pageNum.value = 1; loadRows(); loadSummary() }
|
||||
function reset() { Object.assign(query, { customerId: null, salesUserId: null, billNo: '', orderNo: '', status: '' }); dateRange.value = []; search() }
|
||||
function handleSalesInput() {
|
||||
salesSearchVersion++
|
||||
salesOptions.value = []
|
||||
salesLoading.value = false
|
||||
}
|
||||
async function searchSales(keyword) {
|
||||
const version = ++salesSearchVersion
|
||||
salesOptions.value = []
|
||||
salesLoading.value = true
|
||||
try {
|
||||
const res = await listArProfitSalesOptions({ keyword: keyword?.trim() || '' })
|
||||
if (version !== salesSearchVersion) return
|
||||
salesOptions.value = res.data || []
|
||||
} finally {
|
||||
if (version === salesSearchVersion) salesLoading.value = false
|
||||
}
|
||||
}
|
||||
function loadRows() {
|
||||
const version = ++requestVersion
|
||||
loading.value = true
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
listArProfitBills({ ...params(), pageNum: pageNum.value, pageSize: pageSize.value }).then(res => {
|
||||
if (version === requestVersion) { rows.value = res.rows || []; total.value = res.total || 0 }
|
||||
}).finally(() => { if (version === requestVersion) loading.value = false })
|
||||
}
|
||||
let summaryVersion = 0
|
||||
function loadSummary() {
|
||||
const version = ++summaryVersion
|
||||
summaryLoaded.value = false
|
||||
listArProfitSummary(params()).then(res => { if (version === summaryVersion) { summaries.value = res.data || []; summaryLoaded.value = true } })
|
||||
}
|
||||
function openBillDetails(row) { detailBillId.value = row.arBillId; detailBillNo.value = row.billNo; feeArea.value = row.customerArea; detailsPage.value = 1; detailsVisible.value = true; loadBillDetails() }
|
||||
async function loadBillDetails() {
|
||||
const version = ++detailsVersion
|
||||
detailsLoading.value = true
|
||||
details.value = []
|
||||
detailsTotal.value = 0
|
||||
try { const res = await listArProfitBillDetails(detailBillId.value, { pageNum: detailsPage.value, pageSize: detailsSize.value }); if (version === detailsVersion) { details.value = res.rows || []; detailsTotal.value = res.total || 0 } }
|
||||
finally { if (version === detailsVersion) detailsLoading.value = false }
|
||||
}
|
||||
async function openFees(row) { feeVisible.value = true; feeLoading.value = true; fees.value = []; try { fees.value = (await listArProfitFees(row.detailId)).data || [] } finally { feeLoading.value = false } }
|
||||
function exportRows() { proxy.download('/report/ar-profit/export', params(), `应收账单毛利分析_${Date.now()}.xlsx`) }
|
||||
function exportCommissions() { proxy.download('/report/ar-profit/commissions/export', { billId: commissionBillId.value }, `应收账单提成明细_${Date.now()}.xlsx`) }
|
||||
function money(v) { return v === null || v === undefined || v === '' || !Number.isFinite(Number(v)) ? '-' : Number(v).toFixed(2) }
|
||||
function rate(v) { return v === null || v === undefined || v === '' || !Number.isFinite(Number(v)) ? '-' : Number(v).toFixed(2) + '%' }
|
||||
function feeProductAmount(row) {
|
||||
if (areaRules.value[feeArea.value] === 'GUANGZHOU') return row.redlineAmount
|
||||
if (areaRules.value[feeArea.value] === 'SHENZHEN') return row.productAmount
|
||||
return null
|
||||
}
|
||||
function statusLabel(v) { return ({ CONFIRMED: '已确认', PARTIAL_WRITTEN_OFF: '部分核销', WRITTEN_OFF: '已核销' })[v] || v || '-' }
|
||||
function resultLabel(v) { return ({ READY: '已计算', MISSING_RATE: '比例待确认', MISSING_PRICE: '价格缺失', MISSING_SNAPSHOT: '归属或区域缺失', WRITEOFF_MISMATCH: '核销金额不一致', REVERSED: '已冲回' })[v] || v || '-' }
|
||||
function openCommission() { commissionBillId.value = null; commissionBillNo.value = ''; commissionPage.value = 1; commissionVisible.value = true; loadCommissionTab() }
|
||||
function openBillCommission(row) { commissionBillId.value = row.arBillId; commissionBillNo.value = row.billNo; commissionPage.value = 1; commissionTab.value = 'results'; commissionVisible.value = true; loadCommissionTab() }
|
||||
function clearCommissionBill() { commissionBillId.value = null; commissionBillNo.value = ''; commissionPage.value = 1; loadCommissionTab() }
|
||||
async function loadCommissionTab() {
|
||||
const version = ++commissionVersion
|
||||
const tab = commissionTab.value
|
||||
const billId = commissionBillId.value
|
||||
commissionLoading.value = true
|
||||
if (tab === 'results') { results.value = []; commissionSummary.value = []; commissionTotal.value = 0 }
|
||||
try {
|
||||
if (tab === 'rates') { const r = await listArProfitRates(); if (version === commissionVersion) rates.value = r.data || [] }
|
||||
else if (tab === 'missing') { const r = await listArProfitMissingRates({ pageNum: missingPage.value, pageSize: missingSize.value }); if (version === commissionVersion) { missingRates.value = r.rows || []; missingTotal.value = r.total || 0 } }
|
||||
else { const [r, summary] = await Promise.all([listArProfitCommissions({ billId, pageNum: commissionPage.value, pageSize: commissionSize.value, includeReversed: includeReversed.value }), listArProfitCommissionSummary({ billId })]); if (version === commissionVersion) { results.value = r.rows || []; commissionTotal.value = r.total || 0; commissionSummary.value = summary.data || [] } }
|
||||
} finally { if (version === commissionVersion) commissionLoading.value = false }
|
||||
}
|
||||
async function saveRate() {
|
||||
if (!rateForm.salesUserId || rateForm.rate === null) return ElMessage.warning('请选择销售员并填写提成比例')
|
||||
await addArProfitRate(rateForm); ElMessage.success('提成比例已保存'); loadCommissionTab()
|
||||
}
|
||||
function openConfirm(row) { Object.assign(confirmForm, { writeoffId: row.writeoffId, billNo: row.billNo, rate: null, remark: '' }); confirmVisible.value = true }
|
||||
async function openWriteoffRates(row) { writeoffRatesVisible.value = true; writeoffRatesLoading.value = true; writeoffRates.value = []; try { writeoffRates.value = (await listArProfitWriteoffRates(row.arBillId)).data || [] } finally { writeoffRatesLoading.value = false } }
|
||||
async function confirmRate() {
|
||||
if (confirmForm.rate === null || !confirmForm.remark.trim()) return ElMessage.warning('请填写比例及核对说明')
|
||||
await confirmArProfitMissingRate(confirmForm); confirmVisible.value = false; ElMessage.success('已确认提成比例'); loadCommissionTab()
|
||||
}
|
||||
listArProfitAreaRules().then(res => { areaRules.value = res.data || {} })
|
||||
search()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-card, .data-card { margin-top: 14px; }
|
||||
.metrics { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
.metrics > div { min-width: 150px; padding: 10px; background: var(--el-fill-color-lighter); color: var(--el-text-color-secondary); }
|
||||
.metrics b { display: block; margin-top: 5px; color: var(--el-text-color-primary); }
|
||||
.summary-note { margin-top: 10px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
|
||||
.table-title { font-size: 16px; font-weight: 500; }
|
||||
.sales-code { float: right; margin-left: 16px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user