diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/report/ArProfitController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/report/ArProfitController.java new file mode 100644 index 00000000..68752349 --- /dev/null +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/report/ArProfitController.java @@ -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 excel = new ExcelUtil<>(ArProfitRow.class); + excel.exportExcel(response, report.selectBills(query), "应收账单毛利分析"); + } +} diff --git a/bms-back/bms-admin/src/test/java/com/bms/report/ArCommissionServiceTest.java b/bms-back/bms-admin/src/test/java/com/bms/report/ArCommissionServiceTest.java new file mode 100644 index 00000000..62fa5351 --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/report/ArCommissionServiceTest.java @@ -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 calls = new ArrayList<>(); + AtomicReference inserted = new AtomicReference<>(); + AtomicReference 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 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 batch = (List) 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()); + } +} diff --git a/bms-back/bms-admin/src/test/java/com/bms/report/ArProfitMapperSqlTest.java b/bms-back/bms-admin/src/test/java/com/bms/report/ArProfitMapperSqlTest.java new file mode 100644 index 00000000..738955b5 --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/report/ArProfitMapperSqlTest.java @@ -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); + } +} diff --git a/bms-back/bms-admin/src/test/java/com/bms/report/ArProfitScopeResolverTest.java b/bms-back/bms-admin/src/test/java/com/bms/report/ArProfitScopeResolverTest.java new file mode 100644 index 00000000..d2ae3faf --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/report/ArProfitScopeResolverTest.java @@ -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")); + } +} diff --git a/bms-back/bms-admin/src/test/java/com/bms/report/CommissionCalculatorTest.java b/bms-back/bms-admin/src/test/java/com/bms/report/CommissionCalculatorTest.java new file mode 100644 index 00000000..75e997e0 --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/report/CommissionCalculatorTest.java @@ -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 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()); + } +} diff --git a/bms-back/bms-common/src/main/java/com/bms/common/utils/StringUtils.java b/bms-back/bms-common/src/main/java/com/bms/common/utils/StringUtils.java index d9a86f5d..82628b08 100644 --- a/bms-back/bms-common/src/main/java/com/bms/common/utils/StringUtils.java +++ b/bms-back/bms-common/src/main/java/com/bms/common/utils/StringUtils.java @@ -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; + } + /** * 替换指定字符串的指定区间内字符为"*" * diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBill.java b/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBill.java index fe31a58a..0b887444 100644 --- a/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBill.java +++ b/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBill.java @@ -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") /** 账单所属期开始日期,导出模板展示用。 */ diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/domain/vo/ArBillGeneratePreviewExportRow.java b/bms-back/bms-system/src/main/java/com/bms/ar/domain/vo/ArBillGeneratePreviewExportRow.java index e7f1f235..4f2dab18 100644 --- a/bms-back/bms-system/src/main/java/com/bms/ar/domain/vo/ArBillGeneratePreviewExportRow.java +++ b/bms-back/bms-system/src/main/java/com/bms/ar/domain/vo/ArBillGeneratePreviewExportRow.java @@ -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) diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/mapper/ArCommissionMapper.java b/bms-back/bms-system/src/main/java/com/bms/ar/mapper/ArCommissionMapper.java new file mode 100644 index 00000000..d602a708 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/mapper/ArCommissionMapper.java @@ -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 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 selectSettlementInputs(@Param("billId") Long billId); + int insertResults(@Param("items") List items, @Param("operator") String operator); + List selectMissingRates(); + List 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 selectResults(@Param("billId") Long billId, @Param("includeReversed") boolean includeReversed); + List selectCommissionSummary(@Param("billId") Long billId); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/mapper/ArProfitMapper.java b/bms-back/bms-system/src/main/java/com/bms/ar/mapper/ArProfitMapper.java new file mode 100644 index 00000000..069b6823 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/mapper/ArProfitMapper.java @@ -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 selectBills(@Param("query") ArProfitQuery query, @Param("scope") ArProfitScope scope); + List selectSummary(@Param("query") ArProfitQuery query, @Param("scope") ArProfitScope scope); + List selectSalesOptions(@Param("keyword") String keyword); + List selectBillDetails(@Param("query") ArProfitQuery query, @Param("scope") ArProfitScope scope); + List selectFees(@Param("query") ArProfitQuery query, @Param("scope") ArProfitScope scope); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionRate.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionRate.java new file mode 100644 index 00000000..da8d48ba --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionRate.java @@ -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; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionResult.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionResult.java new file mode 100644 index 00000000..ce732fa4 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionResult.java @@ -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; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionService.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionService.java new file mode 100644 index 00000000..8dfe1acc --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionService.java @@ -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 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 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 missingRates() + { + requireFinance(); + return mapper.selectMissingRates(); + } + + public List results(Long billId, boolean includeReversed) + { + requireFinance(); + return mapper.selectResults(billId, includeReversed); + } + + public List summary(Long billId) + { + requireFinance(); + return mapper.selectCommissionSummary(billId); + } + + public List 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("仅财务和系统管理员可查看或维护提成"); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionSummary.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionSummary.java new file mode 100644 index 00000000..31063205 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArCommissionSummary.java @@ -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; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitQuery.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitQuery.java new file mode 100644 index 00000000..19078556 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitQuery.java @@ -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; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitRow.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitRow.java new file mode 100644 index 00000000..c7cb2718 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitRow.java @@ -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; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitSalesOption.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitSalesOption.java new file mode 100644 index 00000000..0ff4a874 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitSalesOption.java @@ -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; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitScope.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitScope.java new file mode 100644 index 00000000..24d52b1d --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitScope.java @@ -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; } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitScopeResolver.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitScopeResolver.java new file mode 100644 index 00000000..a2290c27 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitScopeResolver.java @@ -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 permissions, String required) + { + return permissions != null && (permissions.contains(Constants.ALL_PERMISSION) + || Arrays.stream(required.split(Constants.PERMISSION_DELIMITER)).map(String::trim).anyMatch(permissions::contains)); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitService.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitService.java new file mode 100644 index 00000000..b14df719 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitService.java @@ -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 selectBills(ArProfitQuery query) + { + return mapper.selectBills(query == null ? new ArProfitQuery() : query, scopeResolver.resolve()); + } + + public List selectSummary(ArProfitQuery query) + { + return mapper.selectSummary(query == null ? new ArProfitQuery() : query, scopeResolver.resolve()); + } + + public List 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 selectFees(Long detailId) + { + ArProfitQuery query = new ArProfitQuery(); + query.setDetailId(detailId); + return mapper.selectFees(query, scopeResolver.resolve()); + } + + public List selectBillDetails(Long billId) + { + ArProfitQuery query = new ArProfitQuery(); + query.setArBillId(billId); + return mapper.selectBillDetails(query, scopeResolver.resolve()); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitSummary.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitSummary.java new file mode 100644 index 00000000..295b2172 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArProfitSummary.java @@ -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; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArWriteoffRate.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArWriteoffRate.java new file mode 100644 index 00000000..cdd49689 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/ArWriteoffRate.java @@ -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; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/profit/CommissionCalculator.java b/bms-back/bms-system/src/main/java/com/bms/ar/profit/CommissionCalculator.java new file mode 100644 index 00000000..c693d320 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/ar/profit/CommissionCalculator.java @@ -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 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 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; + } + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBalanceWriteoffServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBalanceWriteoffServiceImpl.java index 44738f62..ef8b676c 100644 --- a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBalanceWriteoffServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBalanceWriteoffServiceImpl.java @@ -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); diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java index 87499e98..727966ee 100644 --- a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java @@ -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()); diff --git a/bms-back/bms-system/src/main/java/com/bms/price/enums/ProfitAreaRule.java b/bms-back/bms-system/src/main/java/com/bms/price/enums/ProfitAreaRule.java index 5e8629b3..e3f0c7f6 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/enums/ProfitAreaRule.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/enums/ProfitAreaRule.java @@ -16,6 +16,11 @@ public enum ProfitAreaRule this.customerArea = customerArea; } + public String getCustomerArea() + { + return customerArea; + } + public static ProfitAreaRule fromCustomerArea(String customerArea) { if (customerArea != null) diff --git a/bms-back/bms-system/src/main/java/com/bms/price/oracle/OracleOrderBillMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/oracle/OracleOrderBillMapper.java index 0628a1df..dd93978b 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/oracle/OracleOrderBillMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/oracle/OracleOrderBillMapper.java @@ -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); diff --git a/bms-back/bms-system/src/main/java/com/bms/price/search/EsBillSearchStore.java b/bms-back/bms-system/src/main/java/com/bms/price/search/EsBillSearchStore.java index 52f99f25..ed6e0398 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/search/EsBillSearchStore.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/search/EsBillSearchStore.java @@ -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) { diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java index 8614f22d..86bbc07f 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java @@ -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); diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java index 6c05e563..c8b15e81 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java @@ -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); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcZoneServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcZoneServiceImpl.java index 368f60fb..f868cfcd 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcZoneServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcZoneServiceImpl.java @@ -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) diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java index 5f996c1b..33e1bade 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java @@ -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, diff --git a/bms-back/bms-system/src/main/resources/mapper/ar/ArBillMapper.xml b/bms-back/bms-system/src/main/resources/mapper/ar/ArBillMapper.xml index a3e86e24..cc18e807 100644 --- a/bms-back/bms-system/src/main/resources/mapper/ar/ArBillMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/ar/ArBillMapper.xml @@ -11,6 +11,11 @@ + + + + + @@ -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 @@ - 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}, diff --git a/bms-back/bms-system/src/main/resources/mapper/ar/ArCommissionMapper.xml b/bms-back/bms-system/src/main/resources/mapper/ar/ArCommissionMapper.xml new file mode 100644 index 00000000..a9aec461 --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/ar/ArCommissionMapper.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 into ar_profit_writeoff_rate(writeoff_id, sales_user_id, rate_id, rate, create_time) + values(#{writeoffId}, #{salesUserId}, #{rateId}, #{rate}, sysdate()) + + + + + + + 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 ar_profit_commission_result + set result_status = 'REVERSED', reversed_by = #{operator}, reversed_time = sysdate() + where ar_bill_id = #{billId} and result_status != 'REVERSED' + + + + + + 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 + + (#{item.arBillId}, #{item.detailId}, #{item.salesUserId}, #{item.currency}, #{item.grossProfit}, + #{item.commissionAmount}, #{item.resultStatus}, #{operator}, sysdate()) + + + diff --git a/bms-back/bms-system/src/main/resources/mapper/ar/ArProfitMapper.xml b/bms-back/bms-system/src/main/resources/mapper/ar/ArProfitMapper.xml new file mode 100644 index 00000000..251866e6 --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/ar/ArProfitMapper.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + and (1 = 0 + or b.profit_sales_user_id = #{scope.userId} + + 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))) + + ) + + and 1 = 0 + + + + + where b.status in ('CONFIRMED', 'PARTIAL_WRITTEN_OFF', 'WRITTEN_OFF') and b.generation_status = 'READY' + + and b.customer_id = #{query.customerId} + and b.bill_no like concat('%', #{query.billNo}, '%') + and b.ar_bill_id = #{query.arBillId} + + 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}, '%')) + + and b.status = #{query.status} + and b.profit_sales_user_id = #{query.salesUserId} + and b.bill_date >= #{query.beginBillDate} + and b.bill_date <= #{query.endBillDate} + + + + + + + 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 + + group by fee.detail_id + ) f on f.detail_id = d.detail_id + + group by d.ar_bill_id + ) a on a.ar_bill_id = b.ar_bill_id + + + + + + + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml index 2b09758b..521d9087 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml @@ -1077,4 +1077,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update_time = sysdate() where bill_id = #{billId} + + diff --git a/bms-back/bms-system/src/main/resources/templates/ar/ar_bill_template.xlsx b/bms-back/bms-system/src/main/resources/templates/ar/ar_bill_template.xlsx index 9af3491a..df730b35 100644 Binary files a/bms-back/bms-system/src/main/resources/templates/ar/ar_bill_template.xlsx and b/bms-back/bms-system/src/main/resources/templates/ar/ar_bill_template.xlsx differ diff --git a/bms-back/sql/20261105_ar_profit_report.sql b/bms-back/sql/20261105_ar_profit_report.sql new file mode 100644 index 00000000..74f889aa --- /dev/null +++ b/bms-back/sql/20261105_ar_profit_report.sql @@ -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; diff --git a/bms-back/sql/price/exclusive_quote_menu.sql b/bms-back/sql/price/exclusive_quote_menu.sql index e6e831ee..f9aa8a70 100644 --- a/bms-back/sql/price/exclusive_quote_menu.sql +++ b/bms-back/sql/price/exclusive_quote_menu.sql @@ -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 diff --git a/bms-front/src/api/report/arProfit.js b/bms-front/src/api/report/arProfit.js new file mode 100644 index 00000000..96c86759 --- /dev/null +++ b/bms-front/src/api/report/arProfit.js @@ -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' }) diff --git a/bms-front/src/views/ar/bill/components/GenerateBillDialog.vue b/bms-front/src/views/ar/bill/components/GenerateBillDialog.vue index 9607db17..90e65078 100644 --- a/bms-front/src/views/ar/bill/components/GenerateBillDialog.vue +++ b/bms-front/src/views/ar/bill/components/GenerateBillDialog.vue @@ -73,8 +73,8 @@ - - + + @@ -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)}` } diff --git a/bms-front/src/views/price/billing/bill.vue b/bms-front/src/views/price/billing/bill.vue index 6952d845..81005f6e 100644 --- a/bms-front/src/views/price/billing/bill.vue +++ b/bms-front/src/views/price/billing/bill.vue @@ -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 + } } ] diff --git a/bms-front/src/views/price/exclusive/detail.vue b/bms-front/src/views/price/exclusive/detail.vue index ab62b16e..0c6f0ff7 100644 --- a/bms-front/src/views/price/exclusive/detail.vue +++ b/bms-front/src/views/price/exclusive/detail.vue @@ -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("正在删除,请稍候...") diff --git a/bms-front/src/views/price/set/composables/usePriceSetModule.js b/bms-front/src/views/price/set/composables/usePriceSetModule.js index 9e09c06b..81035774 100644 --- a/bms-front/src/views/price/set/composables/usePriceSetModule.js +++ b/bms-front/src/views/price/set/composables/usePriceSetModule.js @@ -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: '专属套餐详情' }) }) } diff --git a/bms-front/src/views/price/set/detail.vue b/bms-front/src/views/price/set/detail.vue index d6856f38..46a9ba09 100644 --- a/bms-front/src/views/price/set/detail.vue +++ b/bms-front/src/views/price/set/detail.vue @@ -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("正在删除,请稍候...") diff --git a/bms-front/src/views/report/arProfit/index.vue b/bms-front/src/views/report/arProfit/index.vue new file mode 100644 index 00000000..fec94a2f --- /dev/null +++ b/bms-front/src/views/report/arProfit/index.vue @@ -0,0 +1,283 @@ + + + + +